test(cli): widen the headless capture timing gap instead of the bound - #1081
Conversation
The three headless_capture_* tests time a whole tokscale process spawn, so every measurement includes startup. The bounds were placed inside gaps narrower than the startup cost on a loaded windows-latest runner, so they measured runner speed rather than the behaviour they are named for. All three failed on run 31196968982 (job 93170461116) with correct behaviour and wrong timings: 11.06s and 11.17s against an 8s bound for the two fast tests, and 21.17s against a >= 10s && < 18s window for the slow one. Raising the bounds again cannot work at the old constants. The fast tests exist to prove the parent did not wait for its 10s deadline, so the discriminating signal was 10s while the noise was ~11s, and no threshold separates the two outcomes. The slow test's upper bound was already widened once, from 14s to 18s, for exactly this reason; 21.17s is past the child's own 20s sleep, so the bound could no longer tell "the parent killed the child at its deadline" from "the parent outlived the child", and raising it to 22s would have kept the test green while making it vacuous. Widen the gap instead. The fast tests now give the parent a 60s deadline and assert elapsed under 30s, leaving 27s of headroom above a healthy run and 30s below the failure mode. The slow test keeps its 10s deadline while fake_codex sleeps 120s instead of 20s, so the bound moves to >= 10s && < 60s with 50s of headroom above the deadline and 60s below the child's sleep. TOKSCALE_NATIVE_TIMEOUT_MS is now a per-test parameter of the command helper, since the fast and slow tests need the parent's deadline on opposite sides of the child's runtime. Every behavioural assertion is unchanged: exit code 17 for fail, 124 for slow, and the byte-for-byte stdout comparisons. The cost is that a genuinely hung parent takes up to 60s to be reported rather than 10s, which is preferable to a gate that blocks unrelated changes.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
…red deadline `headless_capture_slow_command_times_out` bounds one wall-clock measurement taken around a whole `tokscale` process spawn, so its window has to be wide enough to absorb startup: `[10s, 60s)` against a 10s deadline. That proves the parent killed the child rather than outliving its 120s sleep, but it no longer says anything about when the parent killed it. An effective deadline of 50s would still kill the child, still report 124, and still land inside the window. Add `headless_capture_timeout_fires_near_its_deadline`, which subtracts two runs instead of bounding one. Process spawn, dynamic linking, argument parsing, settings load, and spawning the stand-in on PATH are paid by a fast run and a timed-out run alike, so they cancel and the difference is the deadline. The numbers from the failure that forced the window open (run 31196968982, job 93170461116) show the cancellation: absolute elapsed times of 11.16815s, 11.0576809s, and 21.173129s are all ~11s away from any usable threshold, yet their differences recover the configured 10s deadline to within 0.12s on that same runner. The baseline is the minimum of two fast samples because the first spawn in the test is not in steady state: locally it costs 3.5-6.1s while every spawn after it costs ~45ms, and subtracting a cold baseline from a warm timed-out run charges that one-off to the deadline. Elapsed-time noise is one-sided, so the smaller sample is the better estimate of the floor. The accepted band is the configured deadline plus or minus 5s. Locally the measurement lands within 20ms of 10s across repeated runs, including under 2x CPU oversubscription, and 5s is the largest tolerance that still fails when the effective deadline grows by half. Verified by temporarily scaling the deadline in `run_capture_command`: at 5x the new test reports `measured 49.977642376s, accepted 5s..=15s` and at 1.6x it reports `15.986997208s`, while the coarse window passes in both cases. The coarse window stays as it is. The two assertions answer different questions and both are needed.
Pinning the deadline itself, not just "the parent killed the child"A review of this PR found a real gap in it, so here is the follow-up: Widening the gap fixed the flake, but it gave something up. Assert on the difference, because startup is common-modeThe new test, The numbers from the failure that forced the bound open (run 31196968982, job 93170461116) are the argument for it. Against a configured 10s deadline: Every absolute number there is ~11s away from anything a fixed threshold could use — the startup overhead alone is longer than the deadline being measured, which is why no absolute bound survived. The differences recover the deadline to within 0.12s, on the very runner that was too slow for a bound of any width. The ~11s of startup noise is not something the new assertion tolerates; it is something it subtracts away. One thing the design had to learn the hard wayThe overhead is only common-mode in steady state, and the first spawn in the test is not. Measured locally, the first So the baseline is the minimum of two fast samples. Elapsed-time noise is one-sided — nothing makes a run finish faster than the work it has to do — so the smaller sample is the better estimate of the floor, and one unlucky sample no longer moves it. With that in place the measurement is stable: Within 20ms of the configured 10s across repeated runs, and it stays green under 2x CPU oversubscription (20 busy loops on 10 cores). Tolerance:
|
| Injected regression | headless_capture_slow_command_times_out |
headless_capture_timeout_fires_near_its_deadline |
|---|---|---|
timeout * 5 (50s effective) |
passes | FAILS — measured 49.977642376s ... accepted 5s..=15s |
timeout * 8 / 5 (16s effective) |
passes | FAILS — measured 15.986997208s ... accepted 5s..=15s |
| none (reverted) | passes | passes |
The first row is precisely the scenario the review raised: the coarse window happily reports success while the deadline is five times what it was configured to be, and the new assertion says so with the numbers in the failure message.
Why not a unit test on run_capture_command directly
It was considered — run_capture_command takes timeout: Duration and main.rs already has a mod tests, so a direct call would exclude tokscale's own process startup entirely rather than subtracting it. Two reasons it lost:
CARGO_BIN_EXE_fake_codexis only set for integration tests, so a unit test needs a cross-platform sleeping child it can name without guessing at a target directory. The existing fixture comments avoid exactly that guess, and the alternatives (sleep,timeout /t,powershell -Command Start-Sleep) are either missing or awkward on Windows, which is the platform this whole thread of failures came from.- More importantly, a direct call passes a
Durationin and so cannot see the path where a deadline regression is most likely to appear:TOKSCALE_NATIVE_TIMEOUT_MS→Settings::get_native_timeout→ parse →clamp(5_000, 3_600_000)→run_capture_command. The integration test measures the deadline the CLI actually ended up using, config path included.
The narrower measurement was not worth losing the configuration path plus cross-platform coverage.
Kept, not replaced
The coarse [10s, 60s) window stays exactly as it is. It answers "did the parent end this run, or did the child exit on its own after 120s" — which the delta cannot answer, since a parent that ignored its deadline entirely and a parent that honored it both produce a difference the subtraction would have to interpret. The two assertions answer different questions and both are needed. The diff is purely additive: no existing assertion, exit code (17 / 124), or byte-for-byte stdout check (captured ok / captured fail, no trailing newline) was modified.
Test output
running 4 tests
test headless_capture_fast_success_does_not_wait_for_timeout ... ok
test headless_capture_fast_nonzero_preserves_exit_code ... ok
test headless_capture_slow_command_times_out ... ok
test headless_capture_timeout_fires_near_its_deadline ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 150 filtered out; finished in 14.30s
Suite runtime is unchanged — 14.30s against 14.84s before the new test — because the new test's 10s wait overlaps the existing slow test's under cargo's default parallelism.
Full crate, both targets:
test result: ok. 1023 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 8.85s
test result: ok. 154 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 21.99s
cargo fmt --all -- --check is clean.
There was a problem hiding this comment.
All reported issues were addressed across 1 file (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…ertion The baseline in `headless_capture_timeout_fires_near_its_deadline` is the minimum of two samples because elapsed-time noise is one-sided. That argument applies just as well to the timed-out run, which is the side that drives the upper bound: a scheduling hit worth more than the tolerance would report a deadline longer than the one that actually fired. Make that side a minimum of samples too, taken lazily. Each sample there costs a full `HEADLESS_SLOW_TIMEOUT_MS` while a warm baseline sample costs ~45ms, so the second one is taken only when the first disagrees with the configured deadline and the steady-state cost stays at one wait. Sampling lazily cannot hide a regression. A second sample is taken only when the assertion is already failing, and a minimum only moves the estimate down, so the resample can rescue a spike that inflated the measurement and nothing else. Verified by scaling the deadline in `run_capture_command` by 5 again: the test now takes both samples and still fails with `measured 49.99086375s, accepted 5s..=15s`.
|
Valid, and fixed in The noise model is symmetric and I only defended one side of the subtraction. The reason the two sides were not treated the same was cost, not principle: a baseline sample costs ~45ms once warm, while a sample of the timed-out run costs a full So the timed-out side is now a minimum of samples as well, sampled lazily: the second sample is taken only when the first disagrees with the configured deadline. Steady state stays at one wait, and the protection you asked for is there when it is actually needed. Worth spelling out that this cannot hide a regression, since a conditional resample looks like a retry:
The behavioural assertions — exit 124, exit 17, byte-for-byte stdout — are checked on every sample and are never retried. Only the timing measurement is resampled. |
There was a problem hiding this comment.
All reported issues were addressed across 1 file (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
The resample takes the minimum of the samples, so it can only move the measured deadline down. Conditioning it on the whole band therefore spent a second `HEADLESS_SLOW_TIMEOUT_MS` wait on the one case it provably cannot rescue: a measurement already below the band cannot be brought back up into it by a minimum, so the extra wait only reached the failure it had already reached. Condition the resample on the upper bound alone. A measurement that reads too long is still resampled before it can fail, and one that reads too short now fails on the first sample: with the deadline scaled to a quarter in `run_capture_command`, the test reports `measured 2.47093175s, accepted 5s..=15s` and finishes in 8.39s instead of paying for a second timed-out run.
|
Valid, and fixed in You are right, and it follows directly from a property I had already written into the comment two paragraphs above without carrying it through: the resample takes a minimum, so it only ever moves the measurement down. That makes the The resample is now conditioned on Both directions re-verified by scaling the deadline in
So the too-short case now fails roughly an order of magnitude faster, and the too-long case keeps the spike protection it was added for. |
Problem
The three
headless_capture_*tests incrates/tokscale-cli/tests/cli_tests.rsassert wall-clock bounds measured around a fulltokscaleprocess spawn, so every measurement includes process startup. The bounds were placed inside gaps narrower than the startup cost on a loadedwindows-latestrunner, so they measured runner speed rather than the behaviour they are named for.All three failed on the
windows-latestleg of run 31196968982 (job93170461116) with the behaviour under test entirely correct — exit code 17 passed through, 124 returned for the killed child, captured stdout matched — and only the elapsed assertions red:Since #1068 made that leg a hard gate, this is a merge blocker that fires on changes unrelated to the code under test.
Why the gap and not the bound
Raising the bounds again cannot work at the old constants.
The two
fasttests exist to prove the parent did not wait for its 10sTOKSCALE_NATIVE_TIMEOUT_MS, so the discriminating signal is 10 seconds while the measured startup noise was ~11 seconds. There is no value between "returned immediately" and "waited for the deadline" left to place a bound on.The
slowtest's upper bound had already been widened once for exactly this reason — 14s to 18s, after failing at 14.83s in job 92167428621, in a comment noting the bound "was measuring runner speed rather than the behaviour this test is named for". It has now failed at 21.17s, which is past the child's own 20s sleep. At that point the bound can no longer separate "the parent killed the child at its deadline" from "the parent outlived the child", which is the one thing it exists to do, so raising it to 22s would keep the test green while making it vacuous.So this widens the gap between the two outcomes until the signal dominates the noise, rather than widening the bound until it stops firing.
Changes
TOKSCALE_NATIVE_TIMEOUT_MSbecomes a per-test parameter ofheadless_capture_command, because thefastandslowtests need the parent's deadline on opposite sides of the child's runtime and a single shared constant cannot serve both.fast_success/fast_nonzero< 8s→< 30sslow_command_times_out>= 10s && < 18s→>= 10s && < 60sThe slow test's lower bound stays at the parent's own deadline. It is exact by construction — the child cannot exit on its own before then, so anything faster means the parent gave up early.
Every behavioural assertion is untouched: exit code 17 for
fail, 124 forslow, and the byte-for-byte stdout comparisons againstcaptured ok/captured failwith no trailing newline. The existing reasoning in the comments is extended rather than replaced, includingfake_codex.rs's module doc, which now explains why the sleep must exceed the parent's timeout by a wide margin rather than merely exceed it.The trade-off is that a genuinely hung parent now takes up to 60s to be reported instead of 10s. That is clearly preferable to a gate that blocks unrelated PRs.
Verification
cargo test -p tokscale-cli --test cli_tests headless_captureon an M-series Mac:Run individually, the fast tests take 3.22s and 3.55s and the slow test 13.99s — the same order as before, so the change costs no wall-clock time in the passing case.
Full package suite is green:
cargo test -p tokscale-cli→ 1023 unit + 153 integration passed, 0 failed.cargo fmt --all -- --checkandcargo clippy -p tokscale-cli --all-targetsare clean.Red-green evidence
Each bound was checked to still fire, by temporarily sabotaging
run_capture_commandincrates/tokscale-cli/src/main.rsand reverting afterwards. The sabotages are deliberately narrow so the timing assertion is the one that fails, not the exit-code assertion.Parent always sits on its deadline before reaping the child, exit code still passed through (
thread::sleep(timeout)before the wait loop) — both fast tests red:Parent enforces its deadline 90s late, still reporting 124 (
deadline + Duration::from_secs(90)) — the slow test's upper bound red:Parent gives up immediately instead of waiting for its deadline (
let deadline = Instant::now();) — the slow test's lower bound red:All three sabotages were reverted and
crates/tokscale-cli/src/main.rsis byte-identical tomainin this branch; no production code is changed.Closes #1078
Summary by cubic
Stabilizes
headless_capture_*CLI tests by widening the timing gap and refining the deadline check to subtract startup overhead and resample only when needed. Prevents false CI failures onwindows-latest; no production code changed.TOKSCALE_NATIVE_TIMEOUT_MSper-test; fast tests use 60s with< 30s, slow test keeps 10s with>= 10s && < 60s.fake_codexslow sleep to 120s viaFAKE_CODEX_SLOW_SLEEP_SECS; trade-off: a hung parent can take up to 60s to report.headless_capture_timeout_fires_near_its_deadline: subtracts a fast baseline (min of 2) from a timed-out run and asserts10s ± 5s; resamples the timed-out run only when it reads too long, taking the min to filter one-off spikes.Written for commit b527b71. Summary will update on new commits.