Fix session replay dropping screens inside Throttler window#605
Fix session replay dropping screens inside Throttler window#605tsushanth wants to merge 2 commits into
Conversation
When a draw arrives while isThrottling is true, the existing code silently discards it with no rescheduled snapshot. If the user navigates to a new screen during the throttle window, that screen is completely absent from the replay recording. Add a hasPendingDraw flag. When a draw is discarded (isThrottling=true), set the flag. After the postDelayed snapshot fires, check the flag and call throttle() once more so the latest view tree is always captured. Fixes PostHog#596
|
| // Set to true when a draw arrives while a postDelayed is already in flight. | ||
| // The pending draw is re-captured once the delayed snapshot fires, ensuring | ||
| // that a screen change landing inside the throttle window is not silently lost. | ||
| @Volatile private var hasPendingDraw = false |
There was a problem hiding this comment.
hasPendingDraw uses @Volatile while the existing isThrottling field uses AtomicBoolean, which provides atomic compound operations via getAndSet. The read-then-clear in the postDelayed lambda (val pendingAfter = hasPendingDraw; hasPendingDraw = false) is not atomic. If throttle() is ever called off the main thread (which the AtomicBoolean on isThrottling leaves room for), a draw signal set between the read and the clear would be silently lost. Using AtomicBoolean.getAndSet(false) matches the existing pattern and makes the operation atomic.
| @Volatile private var hasPendingDraw = false | |
| private val hasPendingDraw = AtomicBoolean(false) |
| val pendingAfter = hasPendingDraw | ||
| hasPendingDraw = false | ||
| executeAndReleaseThrottle(runnable) |
There was a problem hiding this comment.
If
hasPendingDraw is changed to AtomicBoolean, this read-then-clear becomes a single atomic getAndSet(false), eliminating the non-atomic window.
| val pendingAfter = hasPendingDraw | |
| hasPendingDraw = false | |
| executeAndReleaseThrottle(runnable) | |
| val pendingAfter = hasPendingDraw.getAndSet(false) | |
| executeAndReleaseThrottle(runnable) |
| } else { | ||
| // A draw arrived while a postDelayed is already scheduled. | ||
| // Mark it so the delayed lambda re-captures the current view tree after it fires. | ||
| hasPendingDraw = true |
There was a problem hiding this comment.
| val pendingAfter = hasPendingDraw | ||
| hasPendingDraw = false | ||
| executeAndReleaseThrottle(runnable) | ||
| // A draw arrived while we were waiting; take one more snapshot so | ||
| // no screen change that occurred inside the throttle window is lost. | ||
| if (pendingAfter) { | ||
| throttle(runnable) | ||
| } | ||
| }, remainingDelayMs) | ||
| } else { | ||
| // A draw arrived while a postDelayed is already scheduled. | ||
| // Mark it so the delayed lambda re-captures the current view tree after it fires. | ||
| hasPendingDraw = true |
There was a problem hiding this comment.
Hi @tsushanth, thanks for the pull request. An agent found the following finding, it's worth verifying:
retry runs after the skipped screen is gone
Test sketch (not executed): at t=1000 draw A; t=1100 draw B; t=1200 draw C; run delayed work at t=2000 and t=3000.
Expected a sequence containing B; current code snapshots current state C at both deadlines, producing A,C,C.
Trigger: ordinary replay with two state changes inside the default 1s throttle window.
Entry: PostHogAndroid.setup → replay onDraw → NextDrawListener → Throttler → generateSnapshot.
Fix: capture/retain immutable pending state before it is replaced, or narrow the claim and issue closure. Add state-based A→B→C, single-pending-draw, continuous-draw, and no-pending-draw tests.
|
Hey @dustinbyrne, thanks for digging into this — the concern is valid and I want to be transparent about what this PR does and doesn't fix. Why the retry still captures C, not B
Result: A, C, C — B is still missing. You're right. What this PR actually fixes The A proper fix Fixing B→C within a window would require I'm happy to either:
Let me know which way you'd like to go. |
Fixes #596
Problem
When a user navigates to a new screen during the throttle window, the screen is silently dropped from the session replay recording.
Throttler.throttle()schedules apostDelayedfor the first draw that arrives after a snapshot and setsisThrottling = true. Any subsequent draws whileisThrottlingis true hit theelsebranch and are silently discarded — no newpostDelayedis queued. If the user navigates A → B → C within a single throttle window:isThrottlingisfalse→postDelayedscheduled,isThrottling = trueisThrottlingistrue→ all silently droppedpostDelayedfires → snapshots whatever is on screen now (C) → produces an A→C diffScreen B never appears in the recording.
Fix
Add a
hasPendingDrawflag. When a draw is discarded (isThrottling == true), set the flag. After the delayed snapshot fires, check the flag and callthrottle()once more so the latest view tree is always captured.1 file changed.