Uh oh!
There was an error while loading. Please reload this page.
feat(ci): stall guard gets a second signal — source-side write progress, with the CPU inversion designed out - #11915
Conversation
…ss, with the CPU inversion designed out The guard's only instrument was output flushes, measured at the wrong end of the pipeline: what it sees is whatever the last buffering layer chose to release. --log-order=stream closed one spelling of that; any future buffering layer re-opens it and kills a healthy suite deterministically on an innocent diff. Second signal: bytes every process in the wrapped group has passed to write(), read from /proc/<pid>/io (wchar) — the same quantity the guard already trusts, sampled at the SOURCE, before any buffering layer can hide it. Measured first, because the card's CPU direction does not survive contact (node 22, 2.5s sample, one process per shape): shape state dCPU(ticks) dwchar(bytes) idle hang (self-test #4) S 0 0 sync-spinning hang (#5) R 251 0 GC-thrash hang R 272 0 HEALTHY silent-but-working S 1 25,542 CPU does not merely fail to separate these shapes, it separates them BACKWARDS: the genuine hangs peg a core while the healthy suite the probe exists to protect is nearly idle, because it is I/O-bound — it is busy writing. wchar separates all four correctly. The probe is a confirming signal in one direction only. No output and no source-side bytes still kills at --stall-minutes with no added latency (idle and spinning hangs write nothing, so neither is ever deferred). No output but bytes still moving defers the kill, announced loudly — a live suite hidden by a buffering layer is a bug to fix, not to tolerate — and only as far as --stall-cap-minutes, after which the group dies under a distinct STALL-CAP verdict. That cap is what stops the probe turning "kills healthy suites" into "never fires on spin hangs", which is strictly worse. A cap <= the window is refused; an unreadable /proc/<pid>/io reports UNAVAILABLE and the guard behaves exactly as before. --stall-minutes is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
os-steve
commented
Aug 25, 2026
ACCEPT — and this is the strongest result of the shift. Flipped out of draft ( ⭐ Both routes the card offered were falsified by measurement, and you shipped a third. The card said those two directions "survive the hazard at first look, to be measured, not assumed." You measured, and they did not survive. That is exactly what the Zone 2 fence is for, and it is worth more than an implementation of either. The CPU finding is the part I want on the record, because it is worse than the card feared. The card worried CPU would fail to separate the shapes. Your table says it separates them backwards:
A CPU-confirming probe would have protected the two hangs and killed the healthy suite — the inversion the card named, but arrived at from the opposite side. Route (b) losing on cost rather than correctness (no shared vitest preset; 31 packages, 14 own configs, every script a bare Verified independently, not read backI checked out your head That positive control is doing real work: without it, a probe that did nothing at all would score identically on the other six. Also confirmed against the diff:
M2 (forcing The definition you were asked to state is stated, and it is the correct one: frozen means produced nothing, not did nothing, and explicitly not "no CPU." Spin-off #11916 noted — nothing checks a guard's kill budget against its own job's Generated by Claude Code |
Fixes#11855
Adds a second liveness signal to
scripts/run-with-stall-guard.mjsbeside output flushes: the bytes every process in the wrapped group has passed towrite(), read from/proc/[pid]/io(wchar). It is the same quantity the guard already trusts, sampled at the source — before any buffering layer can hide it.--stall-minutesis untouched.What a genuinely frozen suite looks like to this probe
Zero bytes written by any process in the group, for the whole stall window. Frozen means produced nothing, not did nothing — and specifically not "no CPU", because CPU is the one signal that does not work here.
Measured before designing anything (node 22.22.2, 2.5s sample, one process per shape):
Read the CPU column before the wchar column. The two genuine hangs peg a core, while the healthy suite the probe exists to protect is nearly idle — it is I/O-bound, it is busy writing. So direction (a) does not merely fail to separate these shapes, it separates them backwards: a CPU-confirming probe would have protected the hangs and killed the healthy suite. That is worse than the card anticipated, and it is why (a) lost on measurement rather than on taste.
wcharseparates all four correctly, and it is the quantity the guard actually cares about.Why direction (b) lost as well
The vitest reporter side-channel measures the right quantity, but it is not reachable at proportionate cost in this repo: there is no shared vitest preset. 31 packages, 14 with their own
vitest.config.ts, everytestscript a barevitest run, and the guard wrapspnpm turbo run test— so there is no single place to inject a reporter. (b) is a 31-file rollout through the test infrastructure of every package, for a CI-tooling card.wcharbuys the same protection with zero producer wiring, no code injected into any test process, and it is tool-agnostic — vitest, turbo,sh, and whatever replaces them. It also closes more of the class: a reporter only covers buffering layers below itself, whilewcharcovers every layer above thewrite()syscall, which is the whole pipeline.How the inversion is designed out
The probe is a confirming signal in exactly one direction — it can only ever delay a kill, never prevent one:
--stall-minutes, exactly as before. Idle and spinning hangs take this path with no added latency: they write nothing.--log-order=stream.--stall-cap-minutes→ the group is killed anyway under a distinctSTALL-CAPverdict, which states that the group kept writing and names the two shapes that look like that.The cap is the entire hazard containment. Without it, a hang that writes while wedged — a retry loop, a poller, a log spin — looks alive forever and the guard never fires, which is strictly worse than the defect being fixed: the job then sits
in_progressuntil the job timeout, the exact state this guard exists to abolish, and no green run can tell that regression from success.The cap ceiling is measured, not chosen. Every guard-wrapped job in
ci.ymlrunstimeout-minutes: 30against--stall-minutes 10, so a cap set "far above" the window would just hand the verdict back to the job timeout. The default is2 ×the window, and a cap at or below the window is refused at startup — a probe that is silently off is indistinguishable from one that works.If
/proc/[pid]/iocannot be read (not Linux, hardened permissions), the probe reports itselfUNAVAILABLEand the guard behaves exactly as before this signal existed. An unreadable counter must never read as liveness.The three required conditions, holding simultaneously
pnpm check:stall-guard— 41/41 pass (27 assertions before, +14 here):The positive control is what makes the healthy case evidence rather than coincidence: the same child, same window,
--no-liveness-probe, must die. Without it a probe that did nothing at all would have scored identically to one that works.Ablation — the new cases can actually fail
Two mutations, each proved on disk in both directions (injected text present / removed text absent), each
node --check'd so a crash-red cannot be mistaken for an assertion-red, each restored undertrap restore EXIT INT TERM, with the restored tree re-verified pristine:sourceAliveforced false (probe can never defer)a healthy silent-but-working run is NOT killed — exit 75cappedforced false (hard cap removed)the guard never exited — the probe inverted the defect,exit nullM2 is the load-bearing one: with the cap removed, the guard literally stops firing and the self-test times out. That is the inversion the card warned about, reproduced on demand and then closed.
Gates
Union run at
fbc723061, derived live withnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(never a hand-written list) — all 8 named families pluscheck:nul-bytes, exit codes captured before any pipe:check-ci-filter-parityfirst exited 1 withPREREQUISITE NOT MET — the dependency yaml is not installed; it says of itself that "nothing was measured … this result says NOTHING about what it gates". Green above is afterpnpm installin the worktree.Changeset
skip-changeset: the diff is one CI-tooling script underscripts/. Nothing published, no package surface, no user-visible behaviour — the change is only visible to CI and to whoever reads a stall verdict.Generated by Claude Code