Skip to content

Commit addbcc3

Browse files
committed
fix: sleep past the settle quiet deadline instead of onto it (#1306)
runStableCaptureLoop derived its whole cadence from pollMs = min(300, max(25, quietMs)), so pollMs === quietMs for every quietMs in [25, 300]. The loop settles once two identical captures span quietMs, and the gap it measures is exactly one sleep(pollMs) plus capture time — so across that entire range "settle at capture 2" rode a 0ms margin. Node decides that margin, not the UI: setTimeout(n) advances Date.now() by only n-1 in 0.13% of calls idle and 0.63% under load, because libuv times the sleep on the monotonic loop clock while now() reads the wall clock. On an undershoot the loop spends a wasted extra capture and poll before settling. The sleep is now deadline-aware: while the quiet deadline is further away than one poll the cadence is unchanged, so changes are still noticed promptly; once it is within reach, the loop sleeps to just past it. The capture that decides settled always spans the window. Effects: --settle-quiet in [25,300] now settles at capture 2 rather than 2-or-3 by coin flip, and the default 500ms window settles at ~502ms instead of ~600ms with the same 3 captures. No behaviour change beyond timing; the quiet-window semantics are identical.
1 parent bd62502 commit addbcc3

2 files changed

Lines changed: 69 additions & 3 deletions

File tree

src/commands/interaction/runtime/selector-read-stable.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,48 @@ test('runtime wait stable settles after two unchanged captures', async () => {
4343
assert.equal(captures, 3);
4444
});
4545

46+
test('runtime wait stable wakes past the quiet deadline instead of onto it', async () => {
47+
const snapshot = selectorReadSnapshot();
48+
const sleeps: number[] = [];
49+
let elapsed = 0;
50+
const clock = {
51+
now: () => elapsed,
52+
sleep: async (ms: number) => {
53+
sleeps.push(ms);
54+
elapsed += ms;
55+
},
56+
};
57+
let captures = 0;
58+
const device = createAgentDevice({
59+
backend: {
60+
platform: 'ios',
61+
captureSnapshot: async () => {
62+
captures += 1;
63+
return { snapshot };
64+
},
65+
} satisfies AgentDeviceBackend,
66+
artifacts: createLocalArtifactAdapter(),
67+
sessions: createMemorySessionStore([{ name: 'default', snapshot }]),
68+
policy: localCommandPolicy(),
69+
clock,
70+
});
71+
72+
// A 300ms quiet window equals the poll cadence, so the second capture is the
73+
// one that decides `settled`.
74+
const result = await device.selectors.wait({
75+
session: 'default',
76+
target: { kind: 'stable', quietMs: 300, timeoutMs: 10_000 },
77+
});
78+
79+
assert.equal(result.kind, 'stable');
80+
if (result.kind === 'stable') assert.equal(result.captures, 2);
81+
// Strictly past the window, never onto it: sleeping exactly 300 would leave
82+
// the verdict to ~1ms of setTimeout/Date.now() skew (#1306).
83+
assert.equal(sleeps.length, 1);
84+
assert.ok(sleeps[0]! > 300, `settling capture must clear the window, slept ${sleeps[0]}ms`);
85+
assert.equal(captures, 2);
86+
});
87+
4688
test('runtime wait stable hints when it settles on a nearly-empty tree', async () => {
4789
const tinySnapshot = makeSnapshotState([
4890
{

src/commands/interaction/runtime/stable-capture.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ import {
1919
*/
2020

2121
const STABLE_POLL_INTERVAL_MS = 300;
22+
const STABLE_MIN_POLL_MS = 25;
23+
// Wake PAST the quiet deadline rather than exactly on it. `setTimeout(n)` can
24+
// advance `Date.now()` by only n-1 — libuv times the sleep on the monotonic
25+
// loop clock while `now()` reads the wall clock — so a capture landing on the
26+
// deadline decides `settled` by sub-millisecond skew rather than by the UI.
27+
const QUIET_DEADLINE_EPSILON_MS = 2;
2228
export const DEFAULT_STABLE_QUIET_MS = 500;
2329
export const DEFAULT_STABLE_TIMEOUT_MS = 10_000;
2430
// Below this node count a settled tree is suspicious: real app surfaces have
@@ -53,7 +59,7 @@ export async function runStableCaptureLoop(
5359
// Cadence derives from the quiet window (never slower than the default
5460
// poll): a caller asking for a 50ms quiet window should not be forced onto a
5561
// 300ms grid — and tests inject the budget instead of waiting real time.
56-
const pollMs = Math.min(STABLE_POLL_INTERVAL_MS, Math.max(25, quietMs));
62+
const pollMs = Math.min(STABLE_POLL_INTERVAL_MS, Math.max(STABLE_MIN_POLL_MS, quietMs));
5763
let captures = 0;
5864
let lastDigest: string | undefined;
5965
let lastNodeCount = 0;
@@ -89,7 +95,7 @@ export async function runStableCaptureLoop(
8995
quietSinceMs = nowMs;
9096
lastDigest = digest;
9197
lastNodeCount = capture.snapshot.nodes.length;
92-
await sleep(runtime, pollMs);
98+
await sleep(runtime, stableCaptureDelayMs({ nowMs, quietSinceMs, quietMs, pollMs }));
9399
continue;
94100
}
95101
if (digest !== lastDigest) {
@@ -106,7 +112,7 @@ export async function runStableCaptureLoop(
106112
lastCapture,
107113
};
108114
}
109-
await sleep(runtime, pollMs);
115+
await sleep(runtime, stableCaptureDelayMs({ nowMs, quietSinceMs, quietMs, pollMs }));
110116
}
111117
return {
112118
settled: false,
@@ -122,6 +128,24 @@ function isPrivateAxRecovery(verdict: SnapshotQualityVerdict | undefined): boole
122128
return verdict?.state === 'recovered' && verdict.backend === 'private-ax';
123129
}
124130

131+
/**
132+
* How long to wait before the next capture. While the quiet deadline is still
133+
* further away than one poll, keep the cadence so a change is noticed promptly.
134+
* Once it is within reach, sleep to just past it instead — the capture that
135+
* decides `settled` then always spans the window, rather than landing on the
136+
* boundary where clock skew picks the answer (#1306).
137+
*/
138+
function stableCaptureDelayMs(params: {
139+
nowMs: number;
140+
quietSinceMs: number;
141+
quietMs: number;
142+
pollMs: number;
143+
}): number {
144+
const remainingQuietMs = params.quietSinceMs + params.quietMs - params.nowMs;
145+
if (remainingQuietMs > params.pollMs) return params.pollMs;
146+
return Math.max(STABLE_MIN_POLL_MS, remainingQuietMs + QUIET_DEADLINE_EPSILON_MS);
147+
}
148+
125149
// Intentionally does not update the session snapshot: the stable loop captures
126150
// an interactive-only tree purely as a settle signal, and overwriting the
127151
// session's richer cached snapshot with the filtered tree would degrade

0 commit comments

Comments
 (0)