Follow-up to #11808 (which stays open until its own PR lands; this card is deliberately separate and is NOT addressed there). That fix pins --log-order=stream under every guard-wrapped turbo and makes the guard refuse a turbo invocation without it, so the guard's output-flush instrument observes real liveness again. This card records the second, more durable correction that #11808's triage graded as in-scope but which was deferred on measurement: the smaller change restores the behaviour the guard was written against, and the probe carries a failure mode that needs its own design pass rather than a rider.
Why a second signal is still worth having
The guard's only instrument is output flushes. Any future buffering layer between the suite and the guard — a vitest reporter change, a pnpm output-mode change, a new runner that groups logs — silently re-creates the #11808 trap: a healthy long task reads as frozen and is killed deterministically, and the resulting red reproduces on rerun, which the standard flake triage then confirms as "the diff". The refusal shipped with #11808 closes exactly one spelling of that class (turbo without --log-order=stream); a process-level liveness signal would close the class itself.
The raw signal is already in hand: on a declared stall the guard's forensic sampler (sampleGroup() in scripts/run-with-stall-guard.mjs) reads /proc state + cpuTicks for every process in the wrapped group and classifies each as ON-CPU vs idle, and the SIGUSR2 harvest distinguishes a live event loop from a blocked one. Today all of that runs only after the verdict, as forensics. The probe would move a version of it before the verdict.
The hazard that makes this a design task, not a one-liner (do not skip this)
A naive probe — "the group is burning CPU, therefore it is alive, therefore do not kill" — inverts the defect instead of fixing it. A sync-spinning hang (infinite loop, GC thrash: the guard self-test's case 5, and half of the #4250 population) has CPU ticks forever, so cpu-alive-means-no-kill converts "kills healthy suites" into "never fires on spin hangs", which is strictly worse: the job then sits in_progress until the job timeout, which is the exact state the guard was built to abolish — and no green run can distinguish that regression from success.
Any acceptable design must therefore state, in the PR body, what a genuinely frozen suite looks like to the probe, and must extend --self-test so that:
- the idle hang (case 4) still fires,
- the sync-spinning hang (case 5) still fires,
- a healthy silent-but-working shape (the thing the probe exists to protect) does NOT fire.
If those three cannot all hold simultaneously, the probe is wrong, not the cases.
Directions that survive the hazard at first look (to be measured, not assumed): treating CPU as a confirming signal only ever in the kill-delay direction bounded by a hard cap far above --stall-minutes (so a spin hang is still killed, later, with a distinct verdict word); or probing task-level progress (e.g. vitest's per-file lifecycle via a reporter side-channel file the guard watches) instead of CPU at all, which measures the quantity the guard actually cares about.
Scope
Refs: #11808 (mechanism, occurrences, and the measured evidence) · #4250 (the real-stall family) · scripts/run-with-stall-guard.mjs header.
Generated by Claude Code
Follow-up to #11808 (which stays open until its own PR lands; this card is deliberately separate and is NOT addressed there). That fix pins
--log-order=streamunder every guard-wrapped turbo and makes the guard refuse a turbo invocation without it, so the guard's output-flush instrument observes real liveness again. This card records the second, more durable correction that #11808's triage graded as in-scope but which was deferred on measurement: the smaller change restores the behaviour the guard was written against, and the probe carries a failure mode that needs its own design pass rather than a rider.Why a second signal is still worth having
The guard's only instrument is output flushes. Any future buffering layer between the suite and the guard — a vitest reporter change, a pnpm output-mode change, a new runner that groups logs — silently re-creates the #11808 trap: a healthy long task reads as frozen and is killed deterministically, and the resulting red reproduces on rerun, which the standard flake triage then confirms as "the diff". The refusal shipped with #11808 closes exactly one spelling of that class (turbo without
--log-order=stream); a process-level liveness signal would close the class itself.The raw signal is already in hand: on a declared stall the guard's forensic sampler (
sampleGroup()inscripts/run-with-stall-guard.mjs) reads/procstate +cpuTicksfor every process in the wrapped group and classifies each as ON-CPU vs idle, and the SIGUSR2 harvest distinguishes a live event loop from a blocked one. Today all of that runs only after the verdict, as forensics. The probe would move a version of it before the verdict.The hazard that makes this a design task, not a one-liner (do not skip this)
A naive probe — "the group is burning CPU, therefore it is alive, therefore do not kill" — inverts the defect instead of fixing it. A sync-spinning hang (infinite loop, GC thrash: the guard self-test's case 5, and half of the #4250 population) has CPU ticks forever, so cpu-alive-means-no-kill converts "kills healthy suites" into "never fires on spin hangs", which is strictly worse: the job then sits
in_progressuntil the job timeout, which is the exact state the guard was built to abolish — and no green run can distinguish that regression from success.Any acceptable design must therefore state, in the PR body, what a genuinely frozen suite looks like to the probe, and must extend
--self-testso that:If those three cannot all hold simultaneously, the probe is wrong, not the cases.
Directions that survive the hazard at first look (to be measured, not assumed): treating CPU as a confirming signal only ever in the kill-delay direction bounded by a hard cap far above
--stall-minutes(so a spin hang is still killed, later, with a distinct verdict word); or probing task-level progress (e.g. vitest's per-file lifecycle via a reporter side-channel file the guard watches) instead of CPU at all, which measures the quantity the guard actually cares about.Scope
scripts/run-with-stall-guard.mjs(+ its--self-test), possibly a small reporter shim if the task-progress direction wins.--stall-minutesbudgets; ⛔ raising the budget stays a gate weakening on the maintainer's floor (per CI: run-with-stall-guard kills a HEALTHY solo tail task — turbo grouped log order flushes nothing while one long task runs, and cli:test now outgrows the 10-minute silence budget #11808's triage grading) and is not part of this card.Refs: #11808 (mechanism, occurrences, and the measured evidence) · #4250 (the real-stall family) ·
scripts/run-with-stall-guard.mjsheader.Generated by Claude Code