From 654bb7f8ae2dedb4e0f583a3e352bede50581538 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Fri, 25 Sep 2026 17:45:43 +0200 Subject: [PATCH 1/3] fix(daemon): consume the post-gesture record when its capture aborts A wait stable poll bounds each capture with a deadline abort. When that abort landed inside the post-gesture stabilization loop the pending record stayed armed, so every later capture on the session paid the whole loop again and wait stable kept timing out with zero captures. The record is now cleared once the loop has been entered, on settle, timeout and abort alike. Refs #2885. --- .../post-gesture-stabilization.test.ts | 33 +++++++++++ src/daemon/deferred-interaction-outcome.ts | 57 ++++++++++--------- 2 files changed, 64 insertions(+), 26 deletions(-) diff --git a/src/daemon/__tests__/post-gesture-stabilization.test.ts b/src/daemon/__tests__/post-gesture-stabilization.test.ts index 48ee133df3..2050c1db25 100644 --- a/src/daemon/__tests__/post-gesture-stabilization.test.ts +++ b/src/daemon/__tests__/post-gesture-stabilization.test.ts @@ -648,3 +648,36 @@ test('capturePostGestureStabilizedResult still distrusts a same-backend baseline assert.equal(rebased, 0); assert.equal(staleAccepts, 1); }); + +// A `wait stable` poll bounds its capture with a deadline abort. The record is consumed by entering +// the loop: left armed, every later capture on the session would pay the whole loop again and the +// wait would keep timing out with zero captures (#2885). +test('capturePostGestureStabilizedResult clears the pending record when a capture aborts mid-loop', async () => { + vi.useFakeTimers(); + const session = makeSession('ios'); + session.snapshot = pickupSnapshot(500); + markPostGestureStabilization(session, 'scroll'); + assert.ok(session.postGestureStabilization); + + let captureCount = 0; + const capture = vi.fn(async () => { + captureCount += 1; + if (captureCount === 1) return pickupSnapshot(500); + throw new DOMException('Wait deadline exceeded', 'TimeoutError'); + }); + + const resultPromise = capturePostGestureStabilizedResult({ + session, + capture, + readSnapshot: (snapshot) => snapshot, + }).then( + () => 'resolved' as const, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(1_000); + const outcome = await resultPromise; + + assert.ok(outcome instanceof DOMException && outcome.name === 'TimeoutError'); + assert.equal(captureCount, 2); + assert.equal(session.postGestureStabilization, undefined); +}); diff --git a/src/daemon/deferred-interaction-outcome.ts b/src/daemon/deferred-interaction-outcome.ts index c772b48db5..4af36b1925 100644 --- a/src/daemon/deferred-interaction-outcome.ts +++ b/src/daemon/deferred-interaction-outcome.ts @@ -311,7 +311,7 @@ async function capturePostActionSnapshotAttempt( * (`post-gesture-stability.ts`): reads the pending record, supplies the * interaction-surface comparators from interaction-outcome-policy as hooks, * and — as the R7 owner — clears `postGestureStabilization` once the loop - * returns, on settle and timeout alike. + * has run, on settle, timeout and an aborted capture alike. */ export async function capturePostGestureStabilizedResult(params: { session: SessionState | undefined; @@ -325,32 +325,37 @@ export async function capturePostGestureStabilizedResult(params: { return { value: params.initial ?? (await capture()) }; } - const outcome = await runPostGestureStabilityLoop({ - pending: { - action: pending.action, - positionals: pending.positionals ?? [], - baselineSignature: pending.baselineSignature, - baselineBackend: pending.baselineBackend, - }, - needsBaselineDistrust: requiresPostGestureBaselineDistrust(session.device), - initial: params.initial, - hooks: { - capture, - readSurface: (value) => { - const snapshot = readSnapshot(value); - return { - signature: buildInteractionSurfaceSignature(snapshot.nodes), - backend: snapshotSurfaceComparisonKey(snapshot), - }; + // The record is consumed by entering the loop, not by finishing it. A capture the caller's + // deadline aborted mid-loop (a `wait stable` poll) must not leave the record armed, or every later + // capture on the session pays the same loop again and the wait never counts a capture (#2885). + try { + return await runPostGestureStabilityLoop({ + pending: { + action: pending.action, + positionals: pending.positionals ?? [], + baselineSignature: pending.baselineSignature, + baselineBackend: pending.baselineBackend, }, - signaturesStable: areInteractionSurfaceSignaturesStable, - classifyBaselineEvidence: classifyBaselineSurfaceEvidence, - surfacesIdentical: haveIdenticalDiscriminatingSurfaces, - summarizeDivergence: summarizeDiscriminatingSurfaceDivergence, - }, - }); - clearPostGestureStabilization(session); - return outcome; + needsBaselineDistrust: requiresPostGestureBaselineDistrust(session.device), + initial: params.initial, + hooks: { + capture, + readSurface: (value) => { + const snapshot = readSnapshot(value); + return { + signature: buildInteractionSurfaceSignature(snapshot.nodes), + backend: snapshotSurfaceComparisonKey(snapshot), + }; + }, + signaturesStable: areInteractionSurfaceSignaturesStable, + classifyBaselineEvidence: classifyBaselineSurfaceEvidence, + surfacesIdentical: haveIdenticalDiscriminatingSurfaces, + summarizeDivergence: summarizeDiscriminatingSurfaceDivergence, + }, + }); + } finally { + clearPostGestureStabilization(session); + } } /** The stabilized attempt as a capture result: the tree carries the gesture's outcome as its own fact. */ From 206d61c3af7e0b07c613b302fc627c18536e2a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Sat, 26 Sep 2026 18:28:27 +0200 Subject: [PATCH 2/3] docs(daemon): state the consume-on-entry rule at the stabilization owner --- src/daemon/deferred-interaction-outcome.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/daemon/deferred-interaction-outcome.ts b/src/daemon/deferred-interaction-outcome.ts index 4af36b1925..2545bd3f7e 100644 --- a/src/daemon/deferred-interaction-outcome.ts +++ b/src/daemon/deferred-interaction-outcome.ts @@ -325,9 +325,8 @@ export async function capturePostGestureStabilizedResult(params: { return { value: params.initial ?? (await capture()) }; } - // The record is consumed by entering the loop, not by finishing it. A capture the caller's - // deadline aborted mid-loop (a `wait stable` poll) must not leave the record armed, or every later - // capture on the session pays the same loop again and the wait never counts a capture (#2885). + // Entering the loop consumes the record: it is cleared however the loop leaves, settle, timeout + // or a capture that threw, so a later capture on the session never pays this gesture's loop again. try { return await runPostGestureStabilityLoop({ pending: { From c23d1b716951961ba34ba5ef0a92e879b8db8e8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Sat, 26 Sep 2026 18:44:43 +0200 Subject: [PATCH 3/3] docs(daemon): name the exits that clear the stabilization record --- src/daemon/deferred-interaction-outcome.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/daemon/deferred-interaction-outcome.ts b/src/daemon/deferred-interaction-outcome.ts index 2545bd3f7e..896b82b4c3 100644 --- a/src/daemon/deferred-interaction-outcome.ts +++ b/src/daemon/deferred-interaction-outcome.ts @@ -325,8 +325,9 @@ export async function capturePostGestureStabilizedResult(params: { return { value: params.initial ?? (await capture()) }; } - // Entering the loop consumes the record: it is cleared however the loop leaves, settle, timeout - // or a capture that threw, so a later capture on the session never pays this gesture's loop again. + // Entering the loop consumes the record. It is cleared on every exit: a settled surface, the + // stabilization timeout, or a capture that threw. A later capture on the session never pays this + // gesture's loop again. try { return await runPostGestureStabilityLoop({ pending: {