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..896b82b4c3 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), - }; + // 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: { + 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. */