Mutation controls, exit 137: what the memory actually did

propflowai#21973 said the mutation-control step died because runControls builds memory across a sequential pass, and labelled that inference, not confirmed. It is now measured, and it is wrong. Here is what the numbers say, and the one instrument that would have answered this in a single reading.

2026-10-23 · dock mutation-selector-oom, row runcontrols-memory-bounded · read-only lane: no code changed, no PR opened on scripts/check-mutation-control.ts (a sibling lane holds that file) · REFUTED

The verdict in one line

Memory does not build across the pass. A pass of 781–786 controls completes cleanly in one process on the same runner shape — fourteen times the 56 that died, five times the wall time — and the exact 56-control pass that died passed on a re-run of the same commit on the same shape twenty minutes later. The kill is a one-off on a box with zero swap, not a ceiling the loop walks into.

The claim being tested

From the issue: “runControls (scripts/check-mutation-control.ts:241) runs them sequentially; memory appears to build across 56 double vitest runs (inference, not confirmed).” If that were the mechanism, two things would follow: a bigger selection would die sooner, and the same selection would die every time.

Evidence 1 — a 14× bigger pass completes, repeatedly

Sampling the last 150 ci.yml pull-request runs and reading the mutation step out of each CI Checks job log (141 logs; 113 ran the step): nine runs selected every control and finished.

JobSelectionVerdict printedStep durationRunner
120133510119mode=all selected=781CONTROLS VERIFIED — 78129m 11sr8gd.xlarge · 4 cores · 31,486 MB · swap 0
120139551553mode=all selected=781CONTROLS VERIFIED — 78136m 10sr7g.xlarge · 4 cores · 31,486 MB · swap 0
120142441146mode=all selected=781CONTROLS VERIFIED — 78128m 55sr8gd.xlarge · 4 cores · 31,486 MB · swap 0
120145959644mode=all selected=781CONTROLS VERIFIED — 78139m 35sr7g.xlarge · 4 cores · 31,486 MB · swap 0
120170956630mode=all selected=784CONTROLS VERIFIED — 78440m 44sr8gd.xlarge · 4 cores · 31,486 MB · swap 0
120174359606mode=all selected=781CONTROLS VERIFIED — 78131m 22sr8g.xlarge · 4 cores · 31,486 MB · swap 0
120179990806mode=all selected=781CONTROLS VERIFIED — 78129m 52sr8gd.xlarge · 4 cores · 31,486 MB · swap 0
120198567291mode=all selected=784CONTROLS VERIFIED — 78428m 14sr8g.xlarge · 4 cores · 31,486 MB · swap 0
120202461535mode=all selected=784CONTROLS VERIFIED — 78428m 08sr8g.xlarge · 4 cores · 31,486 MB · swap 0

Every one of those is the same single npx tsx process, the same for … of loop, the same accumulating lines[], on the same 31,486 MB / zero-swap machine class as the job that died — and each ran four to five times longer than the 7m 28s pass that did. Diff-mode passes of 138 and 139 controls complete on the same shape too.

Positive control on the sweep: the same scanner, run over the dead job’s own log (120198320103), returns sel=('diff','56') exit=137. A zero therefore means “no other 137”, not “the scanner did not look”. Across the 113 sampled jobs that ran the step there is no second exit 137.

Evidence 2 — the same 56 controls passed on a re-run, same box

Run 39887146455 on propflowai#21970 has two attempts. Both landed on r8gd.xlarge, 4 cores, 31,486 MB, Swap: 0.

Attempt 1 (job 120198320103)Attempt 2 (job 120204148798)
Selectionmode=diff selected=56mode=diff selected=56
Step window02:03:21 → 02:10:50 (7m 29s)02:22:26 → 02:29:54 (7m 28s)
Outcomeexit 137CONTROLS VERIFIED — 56
Instancer8gd.xlarge, 31,486 MB, swap 0r8gd.xlarge, 31,486 MB, swap 0
Memory free at job start30,323 MB available30,011 MB available

Identical input, identical machine class, one second apart in duration, opposite outcome. A deterministic build-up cannot produce that. It also means attempt 1 was killed at the very end of its pass, within about two seconds of where attempt 2 finished, not “mid-way”.

Evidence 3 — the parent’s floor is 755 MB, and it is paid before any control runs

Measured locally by replicating only the import half of loadControls — importing all 759 scripts/mutation-controls/*.control.ts in filename order, sampling process.memoryUsage(), executing no run() and spawning no vitest:

Modules importedRSSHeap usedExternalElapsed
0 (process start)207 MB85 MB4 MB0.0 s
100412 MB162 MB39 MB5.6 s
350523 MB256 MB45 MB19.2 s
600724 MB361 MB54 MB31.2 s
759731 MB386 MB57 MB35.8 s
759 + the controlSources map (20 MB of text)755 MB408 MB56 MB36.0 s

/usr/bin/time -l peak maximum resident set size: 838,975,488 bytes (839 MB), process exit code 0, 37.97 s real. Two things follow. The checker’s own footprint is under a gigabyte on a 31 GB box, so the parent is not what fills the machine. And the growth that does exist is in the load phase, which is unconditional: all 759 modules and all 759 control-plus-guard source texts are held whatever the selection, so narrowing the selection does not move this number at all.

Evidence 4 — what the loop can retain is bounded by construction

So the specific thing #21973 names — accumulation inside runControls — is capped around a hundred megabytes before you measure anything. The measurement agrees with the arithmetic.

Three side-claims in the issue and brief that the log does not support
What did kill it: the signature says the machine went away

Exit 137 is SIGKILL. The step runs with NODE_OPTIONS=--max-old-space-size=6144, so a V8 heap exhaustion in the parent would abort at 134 with a message, not die silently at 137. That leaves a kernel OOM kill on a 31,486 MB box with Swap: 0 — no elasticity whatever, so any transient peak over the line is an instant kill rather than a slowdown — or the instance being taken away.

The timing signature says the instance. scripts/ci/checks-reclaim-signature.sh’s own header names two runs AWS recorded as Server.SpotInstanceTermination that showed nothing but a bare 137 on the job. Reading both of their logs next to this one:

JobStep that took the 137Time into that stepexit 137 at“runner has received a shutdown signal”Gap
117541318845 (AWS: spot termination)npm run check:ddb-escapes25 s00:20:00.02400:20:10.02210.0 s
117543009864 (AWS: spot termination)npm run check:ddb-escapes31 s00:29:27.02200:29:37.02210.0 s
120198320103 (propflowai#21970, this one)npx tsx scripts/check-mutation-control.ts7 m 29 s02:10:50.11202:11:00.0199.9 s

Three for three, the same ten seconds, and in the two AWS-confirmed cases the victim was a step twenty-five seconds old — a step with no memory history to accumulate. The repo’s own classifier agrees on the one we care about: run it over this job’s annotations and it answers reclaimed=true · why=exit 137 with no test failure: treated as a reclaim (negative control: a synthetic test-failure annotation correctly returns reclaimed=false). That is why the step kept going afterwards — steps 26 to 28 completed in the ten-second gap, which a slow systemd shutdown allows and an OOM kill also allows, so it separates nothing on its own. The ten seconds do.

What cannot be said, and why. An out-of-memory kill is not excluded — nothing recorded the memory. The mutation-control step is the only long step in that job with no memory reading. Twenty lines earlier in the same ci.yml job, the tsgo step carries a five-second sampler printing avail_mb, rss_mb and swap_free_mb, and it is the reason the tsgo eviction class is understood rather than argued about. Had it been running here, one log line would have ended this question in a reading instead of a day of archaeology.

Recommendation
  1. Do the instrument, in ci.yml (the step at line 1708). Lift the tsgo step’s sampler onto the mutation-control step in its read-only form — sample and print, no VERDICT=KILL arm — plus a per-control progress line so the log names which control was running when a step dies. This touches no file the mutation-selector work touches, and it is the only change here that buys a fact nobody has — the next 137 on this step gets classified from its own log instead of from three other runs’ timestamps.
  2. Do not build the per-control memory bound #21973 proposes. A fresh process per control, or an explicit pool teardown in runControls (scripts/check-mutation-control.ts:241-275), is the fix for a build-up this measurement says is not there — and it would cost a Node start-up plus a 759-module import, about 36 s, on every control. It would also land in the file a parallel lane is editing. Revisit only if the sampler shows a rising trajectory.
  3. The cheap half of the real exposure is the box, not the loop. 31 GB with zero swap turns any transient peak into a SIGKILL. If 137s recur after the sampler lands, the lever is the runner shape or a capped maxForks for the controls’ vitest children — both one-line, neither inside runControls.
What was run, and what it cost

Sources: propflowai#21973; run 39887146455 attempts 1 and 2 (jobs 120198320103, 120204148798); the nine mode=all jobs listed above; .github/workflows/ci.yml; scripts/check-mutation-control.ts; scripts/lib/mutation-control.ts; vitest.config.ts; scripts/ci/checks-reclaim-signature.sh.

PropFlow Docs