Skip to content

fix: sleep past the settle quiet deadline instead of onto it (#1306)#1308

Merged
thymikee merged 5 commits into
mainfrom
claude/settle-quiet-deadline-sleep
Jul 16, 2026
Merged

fix: sleep past the settle quiet deadline instead of onto it (#1306)#1308
thymikee merged 5 commits into
mainfrom
claude/settle-quiet-deadline-sleep

Conversation

@thymikee

Copy link
Copy Markdown
Member

Closes #1306.

Summary

runStableCaptureLoop derives its whole cadence from

const pollMs = Math.min(STABLE_POLL_INTERVAL_MS, Math.max(25, quietMs)); // 300 / 25 floor

so pollMs === quietMs for every quietMs in [25, 300]. The loop settles once two identical captures span quietMs, and the gap it measures at capture #2 is exactly one sleep(pollMs) plus capture time. Across that whole range, "settle at capture 2" rides 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 (measured, 4000 samples, darwin/Node 26) — libuv times the sleep on the cached monotonic loop clock while now() reads the wall clock (runtime-common.ts:16-23). On an undershoot the loop just spends a wasted extra capture + poll before settling.

Change

The sleep is now deadline-aware. While the quiet deadline is further away than one poll, the cadence is unchanged — changes are still noticed promptly, which matters because detecting a change early is what starts the quiet window early. Once the deadline is within reach, the loop sleeps to just past it instead, so the capture that decides settled always spans the window.

const remainingQuietMs = quietSinceMs + quietMs - nowMs;
if (remainingQuietMs > pollMs) return pollMs;
return Math.max(STABLE_MIN_POLL_MS, remainingQuietMs + QUIET_DEADLINE_EPSILON_MS);

The STABLE_MIN_POLL_MS floor keeps --settle-quiet 0 from turning into a 2ms hot capture loop against a changing UI.

Effects

  • --settle-quiet in [25, 300] settles at capture 2 deterministically, instead of 2-or-3 by coin flip — one capture cheaper on the 0.13–0.63% of settles that lost the flip.
  • The default 500ms window settles at ~502ms instead of ~600ms, with the same 3 captures.
  • Quiet-window semantics are unchanged: still "two identical captures spanning quietMs". Nothing but timing moves.

Shared by --settle (press/fill/click/longpress) and wait stable, so both benefit.

Tests

New unit test asserts the invariant directly — with quietMs equal to the 300ms poll cadence, the loop must sleep strictly past the window:

assert.ok(sleeps[0]! > 300, `settling capture must clear the window, slept ${sleeps[0]}ms`);

Verified it's a real guard: against unpatched production it fails with settling capture must clear the window, slept 300ms, and passes with the fix. The existing fake-clock capture-count assertions (3 and 4) are unchanged — those windows are wider than one poll, so only their waitedMs improves.

src/commands/interaction/, src/commands/capture/, src/daemon/handlers/__tests__/: 988 tests green. Typecheck, lint, format clean.

Provenance

Surfaced while diagnosing the settle-observation.test.ts CI flake (#1307), where the same skew flipped a test assertion because the fixture scripted an exact capture count. That fix is test-only and already separate; this is the production half, deliberately not bundled with it.

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.8 MB 1.8 MB +321 B
JS gzip 568.0 kB 568.1 kB +117 B
npm tarball 682.7 kB 682.8 kB +120 B
npm unpacked 2.4 MB 2.4 MB +321 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 29.1 ms 28.8 ms -0.3 ms
CLI --help 60.4 ms 59.7 ms -0.7 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/tv-remote.js +321 B +117 B

@thymikee

Copy link
Copy Markdown
Member Author

Exact-head review (addbcc3a): one P2 timeout-boundary regression.

The new quiet-deadline delay is not bounded by the loop’s overall deadline. With an injected exact clock, stable tree, quietMs=300, timeoutMs=301, capture 1 requests a 302ms sleep, wakes after the 301ms deadline, and exits without capture 2; the old 300ms cadence settled. waitedMs can also exceed timeoutMs. This affects both wait stable and --settle, including the recovery-reset branch that uses the same helper.

Make the delay global-budget-aware so the epsilon cannot consume the only remaining capture opportunity. Add timeout boundary cases at/just above the quiet deadline. Also strengthen the motivating regression by modeling sleep(ms) advancing now by ms - 1 and asserting the observable two-capture settle, rather than only asserting the requested delay is >300. All CI is green, but no ready-for-human yet.

@thymikee

Copy link
Copy Markdown
Member Author

Fixed in 59c936353 — both points were correct, and the P2 was a regression I introduced.

Budget boundary. Confirmed exactly as described: quietMs=300, timeoutMs=301, capture 1 asked for 302ms, woke past the 301ms deadline, loop exited with one capture — where the old 300ms cadence settled. The epsilon was spending the very capture it exists to land. stableCaptureDelayMs now takes deadlineMs and never wakes past it:

const lastUsefulWakeMs = params.deadlineMs - params.nowMs - 1;
return lastUsefulWakeMs > 0 ? Math.min(cadenceMs, lastUsefulWakeMs) : cadenceMs;

Where the budget is the tighter constraint it wakes just inside the deadline and takes the capture the plain cadence would have taken; the epsilon still applies whenever there's room. The recovery-reset branch shares the helper and passes its post-reset deadlineMs, so it's covered too.

Strengthened regression test. You were right that asserting delay > 300 tested the request rather than the behaviour. It now models the defect itself — an injected clock whose sleep(ms) advances now by ms - 1 — and asserts the observable two-capture settle. That change matters: the old assertion couldn't have distinguished the fix from the bug I then introduced.

Each test now catches exactly one defect, verified against all three versions:

skew undershoot budget boundary
main (bd6250212) fails passes
addbcc3ae passes fails
59c936353 passes passes

Boundary cases on both sides, as asked: timeoutMs one millisecond past the quiet window (settles at capture 2, waitedMs within budget), and equal to it (cannot settle — the window has to elapse inside the budget, so the deadline-aware sleep must not manufacture one).

990 tests green across src/commands/interaction/, src/commands/capture/, src/daemon/handlers/__tests__/; typecheck, lint, format clean.

@thymikee

Copy link
Copy Markdown
Member Author

Exact-head re-review (59c936353): the 301ms budget case and the 1ms undershoot regression are fixed, but one P2 equal-boundary overrun remains.

stableCaptureDelayMs() computes lastUsefulWakeMs = deadlineMs - nowMs - 1, then returns the unbounded cadenceMs when that value is <= 0. In the new quietMs=300, timeoutMs=300 exact-clock case, capture 1 sleeps 299ms; capture 2 cannot settle, then the helper sleeps another 25ms and exits at waitedMs=324. The previous implementation exited at 300, and this contradicts the new “never wakes past the deadline” contract. The test asserts only rejection, so it misses the overrun.

Terminate or bound the no-useful-wake branch to the remaining deadline, and assert waitedMs <= timeoutMs in the equal-boundary test. Shared wait-stable/settle/recovery behavior remains affected. CI is 23/23 green, but no ready-for-human yet.

@thymikee

Copy link
Copy Markdown
Member Author

Fixed in 38ba4ca16 — correct, and it caught my own contract being violated one branch below where I wrote it. The helper's doc said it "never wakes past the deadline"; the lastUsefulWakeMs <= 0 branch then returned an unbounded cadenceMs. Reproduced at exactly your number: elapsed 324ms.

Bounded the branch. It now sleeps out only what remains of the budget:

if (lastUsefulWakeMs > 0) return Math.min(cadenceMs, lastUsefulWakeMs);
// No further capture can land inside the budget, so sleep out what remains of
// it rather than a full cadence past it.
return Math.max(0, params.deadlineMs - params.nowMs);

The quietMs=300, timeoutMs=300 case now exits at 300, matching the previous implementation, and the contract holds on every path.

Test now sees the overrun. You were right that asserting rejection alone missed it. One wrinkle on the suggested waitedMs <= timeoutMs: the timeout error carries captures, nodeCount, quietMs and timeoutMs, but not waitedMs — so there's nothing to assert from the error without widening the error shape, which felt like the wrong thing to smuggle into this PR. I asserted the injected clock's elapsed time instead, which is the same property and the honest observable here:

AssertionError: loop must not run past its budget, elapsed 324ms

Happy to add waitedMs to the timeout details as a follow-up if you'd rather assert it through the public error shape.

Each test still catches exactly one defect, verified against every version:

undershoot 301-budget equal-boundary overrun
main (bd6250212) fails passes passes
addbcc3ae passes fails passes
59c936353 passes passes fails
38ba4ca16 passes passes passes

990 tests green across src/commands/interaction/, src/commands/capture/, src/daemon/handlers/__tests__/; typecheck, lint, format clean.

@thymikee

Copy link
Copy Markdown
Member Author

Exact-head re-review (38ba4ca16): the equal-boundary overrun is fixed, but one P2 combined undershoot/budget defect remains.

Combine the PR’s own 1ms-undershooting clock with quietMs=300, timeoutMs=301: sleep 1 requests 300ms and advances to 299ms; capture 2 cannot settle; lastUsefulWakeMs is now 1, so the helper requests 1ms; the undershooting clock advances by 0ms. The loop then captures/spins indefinitely at 299ms. In production the same wall-clock/timer skew can produce a hot extra-capture loop at the deadline—the exact skew this PR targets.

Current tests cover undershoot with a roomy budget and the 301ms boundary with an exact clock separately, so they miss the interaction. Make the final-budget branch guarantee time progress under the modeled undershoot and add the combined undershoot + 301ms regression. CI is otherwise green; no ready-for-human yet.

thymikee added 4 commits July 16, 2026 21:14
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.
The review is right: the epsilon could spend the very capture it exists to
land. With quietMs=300 and timeoutMs=301, capture 1 asked for a 302ms sleep,
woke past the 301ms deadline, and the loop exited with one capture — where
the old 300ms cadence settled. waitedMs could exceed timeoutMs too, and the
recovery-reset branch shared the same helper.

stableCaptureDelayMs now takes the deadline and never wakes past it: the loop
only runs again while now < deadline, so where the budget is the tighter
constraint it wakes just inside it and takes the capture the plain cadence
would have taken. The epsilon still applies whenever the budget has room.

The motivating regression test is also strengthened per review, from asserting
the requested delay is > 300 to modelling the defect itself: an injected clock
whose sleep advances now by ms - 1, asserting the observable two-capture
settle. Each test now catches exactly one defect:

  vs main (bd62502):  undershoot FAILS, boundary passes
  vs addbcc3:         undershoot passes, boundary FAILS
  vs this commit:       both pass

Boundary cases added on both sides of the edge: timeoutMs one millisecond past
the quiet window (settles at capture 2, waitedMs within budget), and equal to
it (cannot settle — the window has to elapse inside the budget).
…act (P2)

The review is right, and it caught my own contract being violated one branch
below where I wrote it. stableCaptureDelayMs claimed it "never wakes past the
deadline", then exempted the no-useful-wake case: with quietMs=300 and
timeoutMs=300, capture 2 cannot settle, the helper returned a full 25ms
cadence and the loop exited at waitedMs=324 where the old code exited at 300.

That branch now sleeps out only what remains of the budget. The loop is about
to exit either way; overrunning just reports a wait longer than the caller
asked for.

The equal-boundary test asserted rejection only, so it could not see the
overrun — it now asserts the injected clock's elapsed time against the budget
(the timeout error carries captures and nodeCount, but not waitedMs, so the
clock is the honest observable). It fails on 59c9363 with "elapsed 324ms",
exactly as the review predicted.

Each test still catches exactly one defect:

  vs main (bd62502):  undershoot FAILS
  vs addbcc3:         301-budget boundary FAILS
  vs 59c9363:         equal-boundary overrun FAILS
  vs this commit:       all pass
…not (P2)

The review found the real hazard behind the boundary work: not an overrun but
a hot loop. Combine the 1ms-undershooting clock with quietMs=300 and
timeoutMs=301 and capture 2 lands at 299ms; lastUsefulWakeMs is then 1, the
helper asks for 1ms, and the skew eats all of it. The clock never advances,
the deadline never arrives, and the loop captures forever — hammering the
device at exactly the skew this PR exists to absorb.

A delay that buys no time is the bug. Every delay stableCaptureDelayMs returns
is now at least QUIET_DEADLINE_EPSILON_MS, which is what makes the loop
terminate under the modelled undershoot. Where the budget cannot afford a
delay that both progresses and stays inside it, there is no capture left to
place: it returns 0 and the loop stops instead of spinning.

That also subsumes the previous no-useful-wake branch, so the equal-boundary
case now stops at 299ms rather than sleeping out the remainder.

The undershooting test clock now yields to the event loop like a real sleep.
Without that the spin starves the runner on microtasks and the regression
hangs the whole job instead of failing; with it, the reviewed head fails the
new test cleanly with "Test timed out in 6000ms".

Rebased onto 9b5f333.

  vs main:         undershoot FAILS
  vs b6261da:    301-budget boundary FAILS
  vs 8c963fc:    equal-boundary overrun FAILS
  vs 4f6beb8:    combined undershoot+301 spin FAILS (timeout)
  vs this commit:  all pass
@thymikee thymikee force-pushed the claude/settle-quiet-deadline-sleep branch from 38ba4ca to d14000d Compare July 16, 2026 19:20
@thymikee

Copy link
Copy Markdown
Member Author

Fixed in d14000d39, rebased onto 9b5f333a2. This one was the real hazard behind the boundary work — not an overrun but a hot loop, and you're right that it's the exact skew this PR targets turning on itself.

Confirmed the trace precisely: capture 2 lands at 299ms, lastUsefulWakeMs is 1, the helper asks for 1ms, the skew eats all of it, the clock never advances, and the loop captures forever.

A delay that buys no time is the bug. Every delay stableCaptureDelayMs returns is now at least QUIET_DEADLINE_EPSILON_MS — that floor is what makes the loop terminate under the modelled undershoot. Where the budget cannot afford a delay that both progresses and stays inside it, there is no capture left to place, so it returns 0 and the loop stops rather than burning captures against the device at the deadline:

const lastUsefulWakeMs = params.deadlineMs - params.nowMs - 1;
if (lastUsefulWakeMs < QUIET_DEADLINE_EPSILON_MS) return 0;
return Math.min(cadenceMs, lastUsefulWakeMs);

That subsumes the previous no-useful-wake branch, so the equal-boundary case now stops at 299ms instead of sleeping out the remainder — strictly tighter than both the old code and my last revision.

One thing worth flagging about the test. My first version of the combined regression didn't fail on the broken code — it hung the entire runner. The undershooting clock resolved synchronously, so the spin starved the event loop on microtasks and vitest's timeout could never fire; timeout 200s killed the run with no output at all. The clock now yields via setImmediate like a real sleep, and the reviewed head fails cleanly:

× runtime wait stable stops instead of spinning when an undershooting sleep meets a budget it cannot use 6002ms
  Error: Test timed out in 6000ms.

Worth noting the production shape differs: real sleep is a macrotask, so production wouldn't starve the loop — it would spin productively, issuing captures as fast as the device answers until the deadline. A hot capture loop, not a freeze.

Each test still catches exactly one defect:

undershoot 301-budget equal-boundary combined spin
main fails passes passes passes
b6261da05 passes fails passes passes
8c963fc23 passes passes fails passes
4f6beb87e passes passes passes fails
d14000d39 passes passes passes passes

1014 tests green across src/commands/interaction/, src/commands/capture/, src/daemon/handlers/__tests__/; typecheck, lint, format clean.

@thymikee

Copy link
Copy Markdown
Member Author

Exact-head re-review (d14000d39): the hot loop is fixed, but the new stop condition introduces a P2 premature-timeout edge under the same modeled skew.

At quietMs=300, timeoutMs=301, the 1ms-undershooting clock reaches 299ms after the first sleep. Two milliseconds of budget remain: requesting 2ms would advance that modeled wall clock by 1ms to 300, still strictly inside the 301ms deadline, and capture 3 could validly settle. Instead lastUsefulWakeMs is 1, the < 2 branch returns 0, and the loop reports timeout early. With an exact clock, requesting the same remaining 2ms simply lands on the deadline and exits—so using the remaining budget preserves both cases without spinning.

The new regression currently asserts rejection, cementing the false timeout. Change the final-budget branch to spend the remaining budget in a way that guarantees progress, and make the combined test assert settle under undershoot while the exact-clock equal-boundary case still rejects. No ready-for-human yet.

@thymikee

Copy link
Copy Markdown
Member Author

Fixed in 766fd25a6.

The final skew-sized boundary now spends the full remaining budget instead of stopping early or requesting an ineffective 1ms sleep. At quietMs=300, timeoutMs=301:

  • exact clock settles on capture 2 at 300ms;
  • 1ms-undershooting clock reaches 299ms, spends the remaining 2ms, advances to 300ms, and settles on capture 3.

The exact 300ms boundary still times out within budget, and neither path spins or overruns. The combined public wait stable regression now asserts the valid settle, capture count, and elapsed budget. Independent review is clean. pnpm check:affected --base origin/main --run passed all 7 selected gates, including 130 related files / 1,240 tests.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 16, 2026
@thymikee thymikee merged commit 8eeed1d into main Jul 16, 2026
23 checks passed
@thymikee thymikee deleted the claude/settle-quiet-deadline-sleep branch July 16, 2026 20:16
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-16 20:17 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

settle: quiet-window comparison has a 0ms margin whenever pollMs === quietMs (--settle-quiet 25-300)

1 participant