Skip to content

test: stop pinning settle capture counts against a wall-clock loop#1307

Merged
thymikee merged 4 commits into
mainfrom
claude/laughing-goldwasser-b2fdce
Jul 16, 2026
Merged

test: stop pinning settle capture counts against a wall-clock loop#1307
thymikee merged 4 commits into
mainfrom
claude/laughing-goldwasser-b2fdce

Conversation

@thymikee

@thymikee thymikee commented Jul 16, 2026

Copy link
Copy Markdown
Member

Summary

settle-observation.test.ts has been failing on loaded CI runners and passing on rerun (#1260, #1288, #1289). It wasn't generic contention — it was a zero-margin comparison meeting a 1ms clock skew.

runStableCaptureLoop derives pollMs = min(300, max(25, quietMs)), so the test's settleQuietMs: 25 made pollMs === quietMs. Settling needs nowMs - quietSinceMs >= quietMs, and that gap is one sleep(pollMs) plus capture time — i.e. "~25 >= 25", a 0ms margin. Instrumented, sinceQuiet lands at 25, 26, 27, 28, 29 against a threshold of 25.

setTimeout(25) advances Date.now() by only 24ms in 0.13% of calls idle and 0.63% under load (5x) — libuv's timers fire off cached monotonic loop time while now() reads the wall clock (runtime-common.ts:16-23). On a 24, capture #2 misses the window, the loop takes a third capture, the transcript scripted only two, and the throw is swallowed by settle's best-effort catch (settle.ts:113) into settled: false.

Signature: false !== true at the settled assert in ~600ms — far too fast for the 10s budget, which rules out timeout pressure.

What changed (tests only — no production code)

The fixtures scripted an exact capture count, which asserts how many captures fit in a wall-clock window — the runner's speed, not the contract. They now model the surface instead:

  • quiet UI — serves the settled tree to every capture (repeat entry)
  • busy UI — serves a fresh tree per capture (repeat + result factory)

Both make the loop's capture count irrelevant to the verdict. New repeat entries and result factories in the provider-scenario transcript helper, with unit coverage.

Guards kept rather than dropped:

  • Snapshot-floor economy is now a bound (captures >= 2 && captures <= 3) instead of === 2. Under any legitimate timing a quiet UI settles at capture 2, or 3 on skew — an unbounded assertion would let a regression to 5 captures per settle pass unnoticed.
  • The follow-up's "no fresh capture" cost — previously implied by the exact transcript — is now asserted directly by diffing transcript.calls across the call. A repeat entry would otherwise happily serve a stray capture.

Also fixed the never-settled test, which was pinned to exactly 3 captures from both directions (transcript size above, assertComplete() below) on a ~10ms budget margin, and whose 60ms budget a loaded capture could outlast — reporting the stalled hint instead of the kept-changing one.

Validation

Forced-undershoot A/B (CI's exact condition, made deterministic via sleep(pollMs - 6)):

arm result
control (old fixture) 58/60 failed
fix (new fixture) 0/60 failed — capture histogram [[2,1],[3,59]]
real test file 4/4 pass (previously 3/4 failed)

The histogram is why the bound is [2,3]: 59 of 60 runs settle at capture 3 under skew, so === 2 would fail every one.

Also 150 loaded iterations, 0 failures — though noting honestly that the control also passed at that sample size (~39% chance of zero failures at a 0.63% rate), so the A/B above is the load-bearing evidence, not the volume run.

Full provider-integration suite: 33 files / 130 tests green. Typecheck, lint, format clean.

Not in this PR

The same zero margin exists in production for every --settle-quiet in [25, 300] — pollMs === quietMs across that whole range. Production doesn't fail on it; it pays a wasted extra capture + poll on ~0.13–0.63% of settles. A deadline-aware sleep deletes it at the source, but that changes production timing, so it's filed separately as #1306 with the full diagnosis.


Follow-up in this PR: the same bug class, swept

A sweep of every test driving runStableCaptureLoop (the --settle flag and wait stable) found one more instance, now fixed here:

  • test/integration/interaction-contract/direct-ios-selector.contract.test.ts — the settleObservation scenario carries the identical 0ms margin (settleQuietMs: 25pollMs === quietMs) against a consume-once transcript scripting exactly two settle captures, with no injected clock. It hasn't lost the coin flip in CI yet, but it fails the same way when it does.

Everything else came back clean, for reasons worth recording:

  • The other contract scenarios and the interaction runtime tests use clamped capture mocks (captures === 1 ? before : after) that repeat the last snapshot forever, so any capture count is tolerated. runtime-selector.contract.test.ts already bounds with captures >= 2.
  • interaction-settle.test.ts has the same acute margin but its mock clamps rather than throws, and it never asserts an exact count.
  • The fake-clock tests (settle.test.ts, selector-read-stable.test.ts) are correct to pin exact counts — with an injected clock, time only advances via sleep().
  • wait stable is never exercised against a real-timed transcript anywhere.

…e count

settle-observation's "contention flakes" were a zero-margin comparison
meeting a 1ms clock skew, not generic load.

runStableCaptureLoop derives pollMs = min(300, max(25, quietMs)), so the
test's settleQuietMs: 25 made pollMs === quietMs, and the settle check
(one sleep(25) plus capture time, against >= 25) a 0ms margin. Node's
setTimeout(25) advances Date.now() by only 24ms in 0.13% of calls idle
and 0.63% under load, because libuv's timers and Date.now() read
different clocks. On a 24 the loop takes a third capture that the
transcript never scripted, and settle's best-effort catch reports the
resulting throw as settled: false.

The fixtures now model the surface rather than the runner's speed: a
quiet UI serves the settled tree to every capture, a busy one a fresh
tree per capture, via new transcript `repeat` entries and result
factories. The snapshot-floor economy guard survives as a bound (2-3
captures), and the follow-up's "no fresh capture" cost — previously
implied by the exact transcript — is now asserted directly.

Production is untouched: the same zero margin only costs a wasted extra
capture and poll there, filed separately as #1306.
@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.7 MB 1.7 MB -531 B
JS gzip 557.4 kB 557.3 kB -129 B
npm tarball 673.1 kB 672.9 kB -130 B
npm unpacked 2.4 MB 2.4 MB -531 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 28.9 ms 27.6 ms -1.3 ms
CLI --help 59.2 ms 58.1 ms -1.1 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/runtime.js -146 B -35 B
dist/src/selector-runtime.js -96 B -30 B
dist/src/context.js -134 B -23 B
dist/src/tv-remote.js -99 B -21 B
dist/src/internal/daemon.js -1 B 0 B

@thymikee

Copy link
Copy Markdown
Member Author

Exact-head review (213e4f683): the settle-observation fixture changes are sound—the quiet/changing factories model UI state, the 2–3 capture bound preserves economy without asserting runner speed, follow-up no-capture cost is pinned directly, and #1306 correctly remains separate production work.

One P2 helper-contract finding remains. createProviderTranscript() does not actually guarantee that one-shot entries outrank repeats, despite the new test’s name. Unordered lookup takes the first matching pending entry, so a repeat placed before a matching one-shot shadows it forever and leaves the one-shot unreachable. In ordered mode a leading repeat never advances, making every following entry unreachable. The test covers only the favorable one-shot-before-repeat order. Either reject repeats in ordered transcripts and document/enforce terminal-fallback placement, or explicitly search matching one-shots before repeats; add reverse-order and ordered coverage. All CI is green, but fix the reusable helper semantics before ready-for-human.

…rio too

A sweep for the same bug class found direct-ios-selector's settleObservation
scenario carrying the identical 0ms margin: settleQuietMs: 25 (so pollMs ===
quietMs) against a consume-once transcript scripting exactly two settle
captures, with no injected clock. It has not lost the coin flip in CI yet,
but it fails the same way when it does — a third capture finds no entry and
settle's best-effort catch reports settled: false.

Same fix: the fixture models a quiet UI (every settle capture sees the same
tree) instead of scripting how many captures fit in a wall-clock window.

The rest of the sweep was clean. The other contract scenarios and the
interaction runtime tests already use clamped mocks that repeat the last
snapshot, so any capture count is tolerated; the fake-clock tests are correct
to pin exact counts.
@thymikee thymikee changed the title test: model the UI in settle-observation fixtures instead of a capture count test: stop pinning settle capture counts against a wall-clock loop Jul 16, 2026
thymikee added 2 commits July 16, 2026 19:46
The review is right: `one-shot entries still outrank a repeat entry` asserted
a guarantee the lookup did not provide. It passed only because the one-shot
happened to be declared first — unordered lookup took the first match, so a
repeat declared ahead of a matching one-shot shadowed it forever and left it
permanently unconsumed. Both failure modes reproduce; the reverse-order test
added here fails on the previous implementation.

Unordered lookup now searches matching one-shots before repeats, so
outranking holds whatever the declaration order. A repeat is documented as
its command's fallback.

Ordered transcripts now reject repeats at construction: ordered lookup only
ever reads the head, so a repeat there never advances and strands every entry
behind it. Refusing the combination beats failing later as a confusing
"Provider command mismatch".

Coverage added for both: reverse declaration order, and ordered + repeat.
@thymikee

Copy link
Copy Markdown
Member Author

Fixed in 6b22e5b43 — the finding was correct on all three counts, including the sharpest one: the test name asserted a guarantee the lookup didn't provide, and passed only because the one-shot happened to be declared first.

I wrote both failure modes as tests before touching the implementation, and both reproduce on e663af780:

× a repeat declared before a matching one-shot does not shadow it
    AssertionError: Expected values to be strictly deep-equal  (served { ok: 'rest' }, not { ok: 'first' })
× ordered transcripts reject repeat entries outright
    AssertionError: Missing expected exception.

Unordered — took option (b), explicit outranking. findEntryIndex searches matching one-shots first and falls back to repeats, so outranking holds whatever the declaration order rather than depending on placement. That makes the existing test's name true instead of lucky, and keeps the rule statable in one line: a repeat is its command's fallback. Documented on the repeat field.

Ordered — took option (a), rejection. Ordered lookup only ever reads the head, so a repeat there can never advance and strands everything behind it. There's no coherent meaning to give it, so createProviderTranscript now throws at construction rather than surfacing later as a confusing Provider command mismatch.

Coverage added for both gaps: reverse declaration order, and ordered + repeat.

No fixture behaviour changes — every current repeat fixture declares its one-shots first, so they resolve identically; the rule just no longer depends on that. provider-integration + interaction-contract: 41 files / 194 tests green, typecheck and format clean.

@thymikee

Copy link
Copy Markdown
Member Author

Exact-head re-review (6b22e5b43): clean. The prior transcript-helper P2 is fully fixed. Unordered matching now prioritizes request/scope-compatible one-shots before repeat fallbacks regardless of declaration order; ordered transcripts reject repeats at construction. The reverse-order and ordered-rejection tests are revert-sensitive, and existing call sites remain compatible because ordered transcripts do not use repeats. All completed checks are green; iOS Smoke is still in progress. Applying ready-for-human under coordinator policy.

@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 95f838f into main Jul 16, 2026
22 checks passed
@thymikee thymikee deleted the claude/laughing-goldwasser-b2fdce branch July 16, 2026 18:13
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-16 18:13 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.

1 participant