Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/daemon/__tests__/post-gesture-stabilization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
57 changes: 31 additions & 26 deletions src/daemon/deferred-interaction-outcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(params: {
session: SessionState | undefined;
Expand All @@ -325,32 +325,37 @@ export async function capturePostGestureStabilizedResult<T>(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 {
Comment thread
okwasniewski marked this conversation as resolved.
clearPostGestureStabilization(session);
}
}

/** The stabilized attempt as a capture result: the tree carries the gesture's outcome as its own fact. */
Expand Down
Loading