Skip to content

stall guard: add a process-level liveness probe as a second signal beside output flushes — with the naive-CPU inversion hazard designed out #11855

Description

@os-steve

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

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions