Skip to content

test: refuse foreign-pid signals from unit-test workers (Coverage fork death, #1824) - #1854

Merged
thymikee merged 5 commits into
mainfrom
ci/1824-coverage-fork-death
Aug 19, 2026
Merged

test: refuse foreign-pid signals from unit-test workers (Coverage fork death, #1824)#1854
thymikee merged 5 commits into
mainfrom
ci/1824-coverage-fork-death

Conversation

@thymikee

@thymikee thymikee commented Aug 18, 2026

Copy link
Copy Markdown
Member

Summary

Refs #1824 — this closes one of two causes behind that signature; see "A second, independent cause" below. The issue stays open on its 20-run criterion.

The Coverage job's "Worker exited unexpectedly" was not OOM. runner-session.test.ts and runner-request-cancellation.test.ts fabricate runner child pids (makeBackgroundRunner(4242), plus 4141/4343/4444) and mock the liveness reads in src/utils/host-process.ts but not the signal writes, so killRunnerProcessTree delivered real SIGINT/SIGTERM/SIGKILL to those pids and to their process groups (process.kill(-4242, …)) — 146 signals per run of runner-session.test.ts. On the ubuntu runner the sibling vitest forks live in exactly that pid band, so the job periodically killed one of its own forks: one file's results vanish, no test fails, and the pool reports an unhandled error 30–40 s later when the run settles.

The negative-pid half is the sharper edge and the reason this is fixed at the seam rather than by renumbering the fake pids: -4242 addresses a whole process group, so a local pnpm test can deliver SIGKILL to an unrelated group leader's children on a developer machine. Any constant a test invents is someone's live pid somewhere.

Two changes, both at the owning interface:

  • signalProcessGroupBestEffort joins signalPidsBestEffort in src/utils/host-process.ts, and runner-disposal.ts's raw process.kill(-pid, …) moves behind it. Signal writes now live in the same module as the liveness reads, so a test mocks both in one place — which runner-session.test.ts, runner-request-cancellation.test.ts, and runner-disposal.test.ts now do.

  • src/__tests__/hermetic-signal-setup.ts (new, in the shared SETUP_FILES — now exported from vitest.config.ts and imported by vitest.mutation.config.ts, so the Stryker lane cannot drift off it) makes the class impossible rather than trusting each test: a worker may signal only itself and its own direct children. It closes both exits, because the runner-disposal family uses both — process.kill (answered ESRCH) and a spawned kill/pkill/killall (answered ENOENT), whose -P and -f forms reach processes the worker never spawned at all. Best-effort kill paths therefore proceed as if nothing matched, no write leaves the worker, and the sending test fails by name in afterEach/afterAll. Signal 0, the liveness probe, stays free; killing a daemon or Metro fixture the test itself spawned stays allowed.

    The spawned half is not hypothetical: request-router-open.test.ts ran a real pkill -f 'xcodebuild.*AgentDeviceRunner\.env\.session-…' twice per suite run, so on a developer's Mac pnpm test could reach a live runner. It now stubs the Apple tool seam, the way the runner tests stub the signal seam.

runner-session.test.ts was over the 1,000-line tripwire, so its fabricated session/lease/child fixtures moved to runner-session-fixtures.ts (the sibling-fixtures pattern AGENTS.md prescribes) instead of growing the file; the size-ratchet pin ratchets down, 2083 → 2001. docs/agents/testing.md documents the invariant.

Validation

Cause confirmed on CI, before the fix — run 32165517507, Coverage job 95804064013, instrumentation-only head 2711b116f. Sentinel processes were parked on 4141/4242/4343/4444 before pnpm test:coverage:ci:

pid 4242: DEAD   received SIGTERM 17:27:01.909
pid 4141: DEAD   received SIGTERM 17:28:10.302
pid 4343: DEAD   received SIGTERM 17:28:10.387
pid 4444: DEAD   received SIGTERM 17:28:10.407
kernel OOM / kill lines: (none)          # 16 GB runner
runner-session.test.ts: 26x kill -4242 SIGTERM | 27x kill -4242 SIGKILL | 20x kill -4242 SIGINT (+ same to 4242)

The split is itself corroboration: 4242 dies 223 ms after the first unit-core file starts — runner-session.test.ts, which signals only 4242 — and the other three die together 69 s later, which is where runner-request-cancellation.test.ts (4141/4242/4343/4444) runs.

Fix verified with the same probe — run 32222448997, Coverage job 95975387817. Note this is a different base from the before-run (2711b116f84ffcc744, 71 files and +3272 lines of unrelated main between them), so it is the probe that is held constant, not the tree:

pid 4141: ALIVE   pid 4242: ALIVE   pid 4343: ALIVE   pid 4444: ALIVE
Test Files  983 passed (983)    Tests  7238 passed | 3 skipped

Neither runner test appears in the trace at all any more; the only signals left in the whole run are tests killing daemon/Metro children they spawned themselves. (The report line signals aimed at a pid that was also a vitest fork: 0 printed in both runs and proves nothing — planting the sentinels burns ~1977 throwaway pids, which pushes every fork in the instrumented run above 4444. I am not offering it as a contrast.)

What is observed vs inferred. Observed: the tests deliver real signals to those pids, the signals kill whatever holds them, and the fix stops both. Inferred: that the victim in the six catalogued deaths was a sibling fork. The arithmetic behind the inference — in an uninstrumented run the ~1977 planting pids do not exist, so the first src/ file's worker (pid 6128 before, 6121 after) would land near 6128 − 1977 ≈ 4151, inside 4141–4444 — plus the victims being exactly the files alive in that window. No run captures a fork's death certificate, so the mechanism evidence carries this, not a single smoking-gun frame.

Vanished-file forensics — in all six recent deaths (runs 32041468051, 32044136488, 32047734673, 32126085337, 32151259761, 31783654175) the file whose results disappear is one of the three largest unit-core files (snapshot-handler ×3, runner-session ×2, daemon-client ×1) — exactly the files alive in the same scheduling window as runner-session.test.ts, ~50–60 s into the run. The issue's "last ~40 s" was the report-print time, not the death time.

Red proof, direct writes — guard on, tests unfixed: runner-session.test.ts + runner-request-cancellation.test.ts fail 28 of 76, each naming the refused signal, the pid, and the delivery path:

Error: This test tried to send 4 real signal(s) to a pid this vitest worker did not spawn. …
SIGTERM -> pid -4242 (runner session invalidates after runner-fatal ok payloads)
    at signalProcessGroupBestEffort (src/utils/host-process.ts:165:11)
    at killRunnerProcessTree (src/platforms/apple/core/runner/runner-disposal.ts:250:3)
    at disposeRunnerSession (src/platforms/apple/core/runner/runner-disposal.ts:50:11)

With the test-side mocks restored: 76/76 pass.

Red proof, the guard's own holeshermetic-signal-setup.test.ts covers the two ownership gaps re-review found. Reverting either fix reds three of its seven cases:

× authority ends when a child is reaped, so its recycled pid is refused
× a promisified execFile cannot smuggle a pkill past the guard
× a promisified execFile still tracks the child it starts

signalProcessGroupBestEffort also carries its own behavioral tests (delivery to -pid, ESRCH → false, and a refusal to touch process.kill at all for pid ≤ 0, which would otherwise address the caller's own group).

Greenpnpm check:affected --run green on the final head (unit lane 2807/2807 with the guard active). Local full-suite runs under 100–280 host load flaked only on the documented contention files (ios-lifecycle, interaction-response-shape, fuzz/harness), which fail identically on plain origin/main under the same load, and pass in isolation.

Four lines

A second, independent cause (not fixed here)

PR #1866 hit the identical pool error on run 32223038279 after making scripts/fuzz/corpus-replay.test.ts heavier (a large module graph pulled into the V8-instrumented run), and fixed it by shrinking that graph. I checked whether it was my mechanism with the timing shifted. It is not:

  • The vanished file is corpus-replay.test.ts, a subprocess-stub member. That project runs fileParallelism: false, maxWorkers: 1 in a group that starts only after unit-core drains: last unit-core file ✓ 06:27:32, stub files 06:27:38 → 06:27:49, death reported 06:28:43. It was the only file executing.
  • The pid-signalling files finish ~3.5 minutes earlier — in my own instrumented run, runner-session.test.ts ✓ 17:27:02 vs corpus-replay.test.ts 17:30:33. Their forks are gone, and late forks carry pids ~14 000, nowhere near 4141–4444.
  • My kill tracer logged every foreign-pid signal across a whole 983-file run: only those two files, only in the early window. It would have recorded nothing at corpus-replay's death.
  • The log carries [vitest-pool]: Timeout terminating forks worker for … corpus-replay.test.ts and no JavaScript heap out of memory — a silent death of a bloated fork, i.e. an external SIGKILL such as the kernel OOM killer (which prints only to dmesg), not an in-process V8 heap abort.

So there are two death shapes behind #1824: (A) an early unit-core file dies while other files run and the fabricated-pid signals are in flight — that is what the sentinels proved, dead before / alive after, and what this PR fixes; (B) a single heavy instrumented file dies alone in the tail under memory pressure — #1866's, unaffected by this PR. Five of the six deaths I catalogued have shape (A); #1866's has shape (B). I am not claiming this PR closes (B), and the 20-run criterion is what distinguishes a real recurrence from a fluke either way.

Residual risk

The guard covers process.kill and spawned kill/pkill/killall from a test worker. What it cannot see is a signal sent from inside a subprocess a test spawned (that process's own process.kill runs in another runtime); no such sender exists in the repo today. And shape (B) above remains open: if the tail deaths continue, the follow-up is a fork heap ceiling or a coverage shard, not this seam.

Touched files: 14 across the three commits (the earlier "11" counted only the first commit, not the review follow-ups). Scope stayed inside the test harness plus the Apple runner disposal seam.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.31 MB 2.31 MB +97 B
JS gzip 759.3 kB 759.3 kB +12 B
npm tarball 882.6 kB 882.6 kB +10 B
npm unpacked 3.08 MB 3.08 MB +97 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.2 ms 28.1 ms +0.9 ms
CLI --help 69.4 ms 71.4 ms +2.0 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/runner-disposal.js -14 B -4 B

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed exact 2711b116: the Coverage experiment confirms the unsafe collision class. All four fabricated-PID sentinels (4141/4242/4343/4444) died from SIGTERM, and the trace attributes the foreign signals to runner-session / runner-request-cancellation tests. No actual Vitest fork overlapped those PIDs in this run, so this proves the unsafe signaling behavior rather than reproducing the fork death itself. Intentionally not ready: keep this draft and unlabeled until the real fix and regressions replace all temporary global instrumentation, the body is updated, and exact-head CI completes. No device evidence applies.

…n the Coverage job

Temporary instrumentation for #1824. Every vitest fork logs each real
process.kill it sends to a foreign pid (and every kill/pkill it spawns);
the Coverage job parks sentinel processes on the pids the Apple runner
tests fabricate (4141/4242/4343/4444) and reports which of them survive
the run. Reverted before this PR leaves draft.
A vitest worker may signal only itself and the processes it spawned.
src/__tests__/hermetic-signal-setup.ts records any other process.kill,
answers it with ESRCH (so best-effort kill paths proceed as if the pid
were dead), and fails the sending test by name in afterEach.

The senders this catches today are the Apple runner tests, which
fabricate runner child pids (4242, 4141, 4343, 4444) and mocked the
liveness reads in host-process.ts but not the signal writes:
killRunnerProcessTree delivered real SIGINT/SIGTERM/SIGKILL to those
pids and their process groups — 146 signals per run of
runner-session.test.ts. On the CI runner the sibling vitest forks live
in that pid band, so the Coverage job periodically lost one fork
mid-file with no test attributed (issue #1824, 6 of the last 40 red CI
runs).

The group-signal write moves behind signalProcessGroupBestEffort in
host-process.ts, next to signalPidsBestEffort, so the runner tests mock
the signal seam in the same place they already mock the liveness reads.

Refs #1824
@thymikee
thymikee force-pushed the ci/1824-coverage-fork-death branch from 2711b11 to 84ffcc7 Compare August 19, 2026 06:12
@thymikee

Copy link
Copy Markdown
Member Author

CI note (head 3e7e22724, the instrumentation-free head): 27/28 checks green, including the lane this PR is about — Coverage passed 983 files / 7238 tests with no fork death.

The one outstanding check is Smoke Tests (Linux), which is stuck in Install Linux desktop dependencies (apt) and hit the job's 30-minute timeout; a gh run rerun --failed is stuck in the same step. It is a fleet-wide condition today, not this branch: #1866's Linux lane was cancelled identically, and three other branches' lanes were in the same step at the same time. Nothing in this diff reaches that lane — it never gets past apt to run any repo code.

Leaving this in draft until that lane is green rather than undrafting on a partial signal. It needs one more rerun once the mirror recovers; no code change is pending.

…the mutation lane

Review of #1854 found three gaps in the first pass:

- The guard intercepted process.kill only, so the spawned half of the same
  function family was unguarded: runner-disposal spawns `pkill -P <pid>` and
  `pkill -f 'xcodebuild.*AgentDeviceRunner.env.session-...'`, and
  request-router-open.test.ts fired that pattern kill twice per suite run. On a
  developer machine with a live Apple runner, `pnpm test` could reach it. The
  setup file now refuses kill/pkill/killall spawns with ENOENT — which the
  best-effort callers already tolerate — and records them the same way; that
  test stubs the Apple tool seam.
- vitest.mutation.config.ts hard-coded its own setupFiles list, so the Stryker
  lane ran without the guard. SETUP_FILES is now exported from vitest.config.ts
  and imported there, next to the SUBPROCESS_STUB_TESTS import that already
  crossed the same boundary.
- 'processes it spawned' meant direct children only; a grandchild started
  through a shell wrapper was refused with advice that did not fit. The docs and
  the failure message now say direct children and name the remedy.

Synchronous spawns are no longer remembered as own pids: spawnSync and
execFileSync have already exited when they return, so keeping their pids would
license a signal to whatever inherits them next.

Refs #1824
@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact 090cfb9bb: temporary instrumentation is gone, the direct runner signal seam is fixed, and direct spawned kill binaries are now intercepted. Two P1 ownership holes remain:

  1. Async child PIDs stay authorized for the worker lifetime after exit/close. Once reaped, that numeric PID can belong to an unrelated process, recreating the PID-reuse hazard this guard exists to prevent. Remove exited children from authority (or prove process identity/start time) and add a post-exit reuse regression; idempotent cleanup must treat exit as dead.
  2. wrapSpawner copies the original util.promisify.custom from execFile, so promisify(execFile) invokes Node’s original implementation and bypasses both kill-binary refusal and child tracking. Wrap that custom path too and add a promisified-execFile("pkill", …) refusal regression.

Fresh exact-head CI is still pending. The body also needs updating: effective scope is 13 files, not 11, and the setup is 140 LOC, not ~85.

@thymikee

Copy link
Copy Markdown
Member Author

CI note: the two iOS reds on this PR are not from this diff — filed as #1874. Run 32229709270 failed RunnerTests.testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden, which failed identically on the unrelated agent/fix-install-source-network-cause (#1863) in run 32176591415 yesterday; the earlier red on this PR was typed email suffix should be observable, the same bare-type-with-hidden-keyboard behaviour one layer up. This branch touches no Swift and no iOS production code — the diff between its last two heads is test setup, docs and two vitest configs. Rerunning.

@thymikee
thymikee marked this pull request as ready for review August 19, 2026 09:36
@thymikee

Copy link
Copy Markdown
Member Author

Undrafted on green CI. 28/28 checks pass on 090cfb9bb after rerun. The two earlier iOS reds were not from this diff — testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden failed identically on the unrelated #1863 (run 32176591415), now filed as #1874; this branch touches no Swift and no iOS production code. All four adversarial-review findings are addressed: the guard now refuses spawned kill/pkill/killall as well as process.kill (reproduced red first — the daemon test failed by name on both its pkill -f spawns — then clean across a 983-file/7241-test sweep), SETUP_FILES is exported and imported by vitest.mutation.config.ts so the lists cannot drift, the docs say direct children, and the evidence section now states that signals aimed at a fork: 0 prints in both runs and that the fork-was-victim step is an inference with the arithmetic shown.

…xecFile path

Re-review of #1854 found two holes in the guard itself, both the class it
exists to close:

- An async child's pid stayed authorized for the worker's lifetime after it
  was reaped. A pid is a claim on a process-table slot, and the kernel reissues
  that slot once it is free, so 'I spawned this pid once' licensed a signal to
  whatever holds it now. Authority now ends on exit/close. Cleanup that signals
  a child must gate on isProcessAlive, which is what the existing cleanup paths
  already do.
- wrapSpawner copied execFile's original util.promisify.custom onto the
  wrapper, and promisify() resolves through that symbol instead of calling the
  function — so every promisified caller got an unguarded execFile, bypassing
  both the kill-binary refusal and child tracking. That path is now wrapped too.

hermetic-signal-setup.test.ts covers both, plus the allowed cases they could
regress into: a reaped child's pid is refused, a promisified execFile cannot
smuggle a pkill, a promisified child is still tracked, a live foreign pid is
refused, and signal 0 stays free. Reverting either fix reds three of them.

Refs #1824
@thymikee

Copy link
Copy Markdown
Member Author

Both P1s were real, and both were the guard failing at its own premise. Fixed in 10c4db829.

1. Authority now ends at child exit. You're right and my earlier reasoning was backwards: a pid is a claim on a process-table slot, not on a process, and the kernel reissues that slot once it's free — so "I spawned this pid once" was licensing exactly the reuse hazard the file exists to close. rememberChild now drops the pid on exit and close (a child that fails to spawn emits only the latter). I checked the objection I'd raised: the cleanup paths that motivated it — stopProcess in client-metro.test.ts:1091, the finally blocks in daemon-client.test.ts — already gate on isProcessAlive, so treating exit as dead costs nothing and the false-positive I was protecting against doesn't exist in the tree. A full 984-file sweep produced zero refusals.

2. The promisified path is wrapped. promisify(execFile) never calls the function, so copying the original util.promisify.custom across handed every promisified caller an unguarded execFile — both refusal and child tracking bypassed. There are two live callers (help-conformance-bench.test.ts, size-report-post-comment.test.ts), which is why the copy was there in the first place. The wrapper now defines its own custom implementation that refuses kill binaries (rejecting, since that's what an async ENOENT looks like), remembers promise.child, then delegates.

Regressions — new src/__tests__/hermetic-signal-setup.test.ts, 7 cases, both of the ones you asked for plus the allowed cases they could regress into. It drains the guard's refusal buffer through a drainRefusedWritesForTest export so it can drive refusal paths without the afterEach hook failing it. Reverting either fix reds three:

× authority ends when a child is reaped, so its recycled pid is refused
× a promisified execFile cannot smuggle a pkill past the guard
× a promisified execFile still tracks the child it starts

Body facts corrected: 14 files (not 11 — I'd counted only the first commit; 13 was right before this commit added the test file), and the setup file is 170 LOC with 94 LOC of tests, not ~85.

CI: pnpm check:affected --run green on 10c4db829; run 32248983600 is in flight. Two notes on what I saw while validating, neither a code issue: the first gate attempt failed on runner-client.test.ts > ensureXctestrunArtifact aborts only the disconnected request build — a 5 s timeout with zero guard refusals in the whole run; it passes 3/3 in isolation and the rerun was clean. And ios-lifecycle.test.ts flakes on this host regardless of the guard: same tree, guard on → 1 pass / 1 fail, guard off → 2 fails.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 19, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact 10c4db829: both P1 ownership holes are closed. Child PID authority is revoked on exit/close, and the custom promisified execFile path now enforces kill-binary refusal while tracking promise.child. The focused regressions are non-vacuous: reverting either fix reds the named ownership/promisify cases, and the body’s scope/evidence facts are current.

Code review is clean. Exact-head CI is 27/28 green with iOS Smoke still pending; no device evidence applies to this test-harness/host-process change.

@thymikee
thymikee merged commit e4c3b42 into main Aug 19, 2026
28 checks passed
@thymikee
thymikee deleted the ci/1824-coverage-fork-death branch August 19, 2026 11:55
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-19 12:00 UTC

thymikee added a commit that referenced this pull request Aug 24, 2026
The Coverage job intermittently ends with no failing test and one file's
results missing:

    Test Files  1070 passed (1071)
    Errors      1 error
    Error: [vitest-pool]: Worker forks emitted error.
    Caused by: Error: Worker exited unexpectedly

This is shape (B) of #1824 — the half #1854 did not fix. Scanning every
failed Coverage job across the 120 CI runs since #1854 merged finds the
signature five times, and the vanished file is scripts/fuzz/corpus-replay.test.ts
all five (six for six with #1866's occurrence): 23% of Coverage failures
in that window, ~4% of all CI runs.

The ~40s gap before the error is coverage report generation, not test
time — the pool surfaces its AggregateError only once every task settles.
Control, from a green attempt of the same run: the file passes in 3152ms
at 09:37:35.9 and the summary prints at 09:38:12.5. So the file is not
slow in CI, nothing else is in flight when it dies (subprocess-stub is
serialized and unit-core has drained), and neither a missed per-case
budget nor STARTUP_BUDGET_MS is implicated. Partial test counts (3/11 and
9/11 reported) place the death mid-file, inside runCases.

What separates this file from the other thousand is that it builds a
node:worker_threads Worker inside the Vitest worker; what separates the
red lane from the green one is V8 instrumentation. The repo already
treats that combination as hostile — the mutation lane excludes its own
nested-Worker tests because a worker inside a worker kills the runner.

So nested-Worker files get their own serialized project that the coverage
run skips, and a second uninstrumented Vitest invocation in
`test:coverage:ci` runs it, keeping the tests on every PR. Measured
against two full runs, this costs zero coverage: the cases execute in
worker threads, a separate isolate the fork's inspector never
instruments, so the lines reported are identical with and without it.

Also here, correct regardless of the above: a rejected worker handshake
in scripts/fuzz/execute.ts left the thread running, because a
startup-budget miss says nothing about whether the thread is gone.

Refs #1824
thymikee added a commit that referenced this pull request Aug 24, 2026
The Coverage job intermittently ends with no failing test and one file's
results missing:

    Test Files  1070 passed (1071)
    Errors      1 error
    Error: [vitest-pool]: Worker forks emitted error.
    Caused by: Error: Worker exited unexpectedly

This is shape (B) of #1824 — the half #1854 did not fix. Scanning every
failed Coverage job across the 120 CI runs since #1854 merged finds the
signature five times, and the vanished file is
scripts/fuzz/corpus-replay.test.ts all five (six for six with #1866's
occurrence): 23% of Coverage failures in that window, ~4% of all CI runs.

The ~40s gap before the error is coverage report generation, not test
time — the pool surfaces its AggregateError only once every task settles.
Control, from a green attempt of the same run: the file passes in 3152ms
at 09:37:35.9 and the summary prints at 09:38:12.5. So the file is not
slow in CI, nothing else is in flight when it dies (subprocess-stub is
serialized and unit-core has drained), and neither a missed per-case
budget nor STARTUP_BUDGET_MS is implicated. Partial test counts (3/11 and
9/11 reported) place the death mid-file, inside runCases.

So the corpus replay gets its own serialized project that the coverage
run skips, and a second uninstrumented Vitest invocation in
`test:coverage:ci` runs it, keeping the tests on every PR. Measured
against two full runs, this costs zero coverage: the cases execute in
worker threads, a separate isolate the fork's inspector never
instruments, so the lines reported are identical with and without it.

Membership is by demonstrated failure, not by a property of the code:
`session-replay-runtime-maestro.test.ts` also constructs a
node:worker_threads Worker and stays in unit-core, instrumented and
green, so "nests a Worker" is explicitly not the criterion.

Refs #1824
thymikee added a commit that referenced this pull request Aug 24, 2026
The Coverage job intermittently ends with no failing test and one file's
results missing:

    Test Files  1070 passed (1071)
    Errors      1 error
    Error: [vitest-pool]: Worker forks emitted error.
    Caused by: Error: Worker exited unexpectedly

This is shape (B) of #1824 — the half #1854 did not fix. Scanning every
failed Coverage job across the 120 CI runs since #1854 merged finds the
signature five times, and the vanished file is
scripts/fuzz/corpus-replay.test.ts all five (six for six with #1866's
occurrence): 23% of Coverage failures in that window, ~4% of all CI runs.

The ~40s gap before the error is coverage report generation, not test
time — the pool surfaces its AggregateError only once every task settles.
Control, from a green attempt of the same run: the file passes in 3152ms
at 09:37:35.9 and the summary prints at 09:38:12.5. So the file is not
slow in CI, nothing else is in flight when it dies (subprocess-stub is
serialized and unit-core has drained), and neither a missed per-case
budget nor STARTUP_BUDGET_MS is implicated. Partial test counts (3/11 and
9/11 reported) place the death mid-file, inside runCases.

So the corpus replay gets its own serialized project that the coverage
run skips, and a second uninstrumented Vitest invocation in
`test:coverage:ci` runs it, keeping the tests on every PR. Measured
against two full runs, this costs zero coverage: the cases execute in
worker threads, a separate isolate the fork's inspector never
instruments, so the lines reported are identical with and without it.

Membership is by demonstrated failure, not by a property of the code:
`session-replay-runtime-maestro.test.ts` also constructs a
node:worker_threads Worker and stays in unit-core, instrumented and
green, so "nests a Worker" is explicitly not the criterion.

Refs #1824
thymikee added a commit that referenced this pull request Aug 24, 2026
The Coverage job intermittently ends with no failing test and one file's
results missing:

    Test Files  1070 passed (1071)
    Errors      1 error
    Error: [vitest-pool]: Worker forks emitted error.
    Caused by: Error: Worker exited unexpectedly

This is shape (B) of #1824 — the half #1854 did not fix. Scanning every
failed Coverage job across the 120 CI runs since #1854 merged finds the
signature five times, and the vanished file is
scripts/fuzz/corpus-replay.test.ts all five (six for six with #1866's
occurrence): 23% of Coverage failures in that window, ~4% of all CI runs.

The ~40s gap before the error is coverage report generation, not test
time — the pool surfaces its AggregateError only once every task settles.
Control, from a green attempt of the same run: the file passes in 3152ms
at 09:37:35.9 and the summary prints at 09:38:12.5. So the file is not
slow in CI, nothing else is in flight when it dies (subprocess-stub is
serialized and unit-core has drained), and neither a missed per-case
budget nor STARTUP_BUDGET_MS is implicated. Partial test counts (3/11 and
9/11 reported) place the death mid-file, inside runCases.

So the corpus replay gets its own serialized project that the coverage
run skips, and a second uninstrumented Vitest invocation in
`test:coverage:ci` runs it, keeping the tests on every PR. Measured
against two full runs, this costs zero coverage: the cases execute in
worker threads, a separate isolate the fork's inspector never
instruments, so the lines reported are identical with and without it.

Membership is by demonstrated failure, not by a property of the code:
`session-replay-runtime-maestro.test.ts` also constructs a
node:worker_threads Worker and stays in unit-core, instrumented and
green, so "nests a Worker" is explicitly not the criterion.

The second leg runs through `test:fuzz-worker`, which blanks
AGENT_DEVICE_COVERAGE_SHARD and AGENT_DEVICE_COVERAGE_MERGE. #1996 sets
those as job-level env over a single `gate: unit-ci` step, so both legs
would otherwise inherit them. Verified on a local merge of the two
branches: without the blanking the shard fails outright, because Vitest
refuses `--shard=1/2` over this one-file project and the blob reporter
overwrites the instrumented shard's report on the way out. With it, both
shards and the merge job pass and the merged report carries all 1094
files.

Refs #1824
thymikee added a commit that referenced this pull request Aug 24, 2026
The Coverage job intermittently ends with no failing test and one file's
results missing:

    Test Files  1070 passed (1071)
    Errors      1 error
    Error: [vitest-pool]: Worker forks emitted error.
    Caused by: Error: Worker exited unexpectedly

This is shape (B) of #1824 — the half #1854 did not fix. Scanning every
failed Coverage job across the 120 CI runs since #1854 merged finds the
signature five times, and the vanished file is
scripts/fuzz/corpus-replay.test.ts all five (six for six with #1866's
occurrence): 23% of Coverage failures in that window, ~4% of all CI runs.

The ~40s gap before the error is coverage report generation, not test
time — the pool surfaces its AggregateError only once every task settles.
Control, from a green attempt of the same run: the file passes in 3152ms
at 09:37:35.9 and the summary prints at 09:38:12.5. So the file is not
slow in CI, nothing else is in flight when it dies (subprocess-stub is
serialized and unit-core has drained), and neither a missed per-case
budget nor STARTUP_BUDGET_MS is implicated. Partial test counts (3/11 and
9/11 reported) place the death mid-file, inside runCases.

So the corpus replay gets its own serialized project that the coverage
run skips, and a second uninstrumented Vitest invocation in
`test:coverage:ci` runs it, keeping the tests on every PR. Measured
against two full runs, this costs zero coverage: the cases execute in
worker threads, a separate isolate the fork's inspector never
instruments, so the lines reported are identical with and without it.

Membership is by demonstrated failure, not by a property of the code:
`session-replay-runtime-maestro.test.ts` also constructs a
node:worker_threads Worker and stays in unit-core, instrumented and
green, so "nests a Worker" is explicitly not the criterion.

The second leg runs through `test:fuzz-worker`, which blanks
AGENT_DEVICE_COVERAGE_SHARD and AGENT_DEVICE_COVERAGE_MERGE. #1996 sets
those as job-level env over a single `gate: unit-ci` step, so both legs
would otherwise inherit them. Verified on a local merge of the two
branches: without the blanking the shard fails outright, because Vitest
refuses `--shard=1/2` over this one-file project and the blob reporter
overwrites the instrumented shard's report on the way out. With it, both
shards and the merge job pass and the merged report carries all 1094
files.

Refs #1824
thymikee added a commit that referenced this pull request Aug 24, 2026
The Coverage job intermittently ends with no failing test and one file's
results missing:

    Test Files  1070 passed (1071)
    Errors      1 error
    Error: [vitest-pool]: Worker forks emitted error.
    Caused by: Error: Worker exited unexpectedly

This is shape (B) of #1824 — the half #1854 did not fix. Scanning every
failed Coverage job across the 120 CI runs since #1854 merged finds the
signature five times, and the vanished file is
scripts/fuzz/corpus-replay.test.ts all five (six for six with #1866's
occurrence): 23% of Coverage failures in that window, ~4% of all CI runs.

The ~40s gap before the error is coverage report generation, not test
time — the pool surfaces its AggregateError only once every task settles.
Control, from a green attempt of the same run: the file passes in 3152ms
at 09:37:35.9 and the summary prints at 09:38:12.5. So the file is not
slow in CI, nothing else is in flight when it dies (subprocess-stub is
serialized and unit-core has drained), and neither a missed per-case
budget nor STARTUP_BUDGET_MS is implicated. Partial test counts (3/11 and
9/11 reported) place the death mid-file, inside runCases.

So the corpus replay gets its own serialized project that the coverage
run skips, and a second uninstrumented Vitest invocation in
`test:coverage:ci` runs it, keeping the tests on every PR. Measured
against two full runs, this costs zero coverage: the cases execute in
worker threads, a separate isolate the fork's inspector never
instruments, so the lines reported are identical with and without it.

Membership is by demonstrated failure, not by a property of the code:
`session-replay-runtime-maestro.test.ts` also constructs a
node:worker_threads Worker and stays in unit-core, instrumented and
green, so "nests a Worker" is explicitly not the criterion.

The second leg runs through `test:fuzz-worker`, which blanks
AGENT_DEVICE_COVERAGE_SHARD and AGENT_DEVICE_COVERAGE_MERGE. #1996 sets
those as job-level env over a single `gate: unit-ci` step, so both legs
would otherwise inherit them. Verified on a local merge of the two
branches: without the blanking the shard fails outright, because Vitest
refuses `--shard=1/2` over this one-file project and the blob reporter
overwrites the instrumented shard's report on the way out. With it, both
shards and the merge job pass and the merged report carries all 1094
files.

Refs #1824
thymikee added a commit that referenced this pull request Aug 24, 2026
The Coverage job intermittently ends with no failing test and one file's
results missing:

    Test Files  1070 passed (1071)
    Errors      1 error
    Error: [vitest-pool]: Worker forks emitted error.
    Caused by: Error: Worker exited unexpectedly

This is shape (B) of #1824 — the half #1854 did not fix. Scanning every
failed Coverage job across the 120 CI runs since #1854 merged finds the
signature five times, and the vanished file is
scripts/fuzz/corpus-replay.test.ts all five (six for six with #1866's
occurrence): 23% of Coverage failures in that window, ~4% of all CI runs.

The ~40s gap before the error is coverage report generation, not test
time — the pool surfaces its AggregateError only once every task settles.
Control, from a green attempt of the same run: the file passes in 3152ms
at 09:37:35.9 and the summary prints at 09:38:12.5. So the file is not
slow in CI, nothing else is in flight when it dies, and neither a missed
per-case budget nor STARTUP_BUDGET_MS is implicated. Partial test counts
(3/11 and 9/11 reported) place the death mid-file, inside runCases.

So the corpus replay gets its own serialized project that the coverage
run skips, and a second uninstrumented Vitest invocation in
`test:coverage:ci` runs it, keeping the tests on every PR. Measured
against two full runs, this costs zero coverage: the cases execute in
worker threads, a separate isolate the fork's inspector never
instruments, so the lines reported are identical with and without it.

Membership is by demonstrated failure, not by a property of the code:
`session-replay-runtime-maestro.test.ts` also constructs a
node:worker_threads Worker and stays in unit-core, instrumented and
green, so "nests a Worker" is explicitly not the criterion.

The second leg goes through `test:fuzz-worker`, which blanks
AGENT_DEVICE_COVERAGE_SHARD and AGENT_DEVICE_COVERAGE_MERGE. ci.yml sets
those as job-level env over a single `gate: unit-ci` step, so both legs
would otherwise inherit them and the shard would die: Vitest refuses
`--shard=1/2` over this one-file project, and the blob reporter
overwrites the instrumented shard's report on its way out. Verified on
the merged tree — shard 1/2 (549 files), shard 2/2 (548), and the merge
job (1097 files, 90.38% lines) all pass, and the leg still fails without
the blanking.

Refs #1824
thymikee added a commit that referenced this pull request Aug 24, 2026
The Coverage job intermittently ends with no failing test and one file's
results missing:

    Test Files  1070 passed (1071)
    Errors      1 error
    Error: [vitest-pool]: Worker forks emitted error.
    Caused by: Error: Worker exited unexpectedly

This is shape (B) of #1824 — the half #1854 did not fix. Scanning every
failed Coverage job across the 120 CI runs since #1854 merged finds the
signature five times, and the vanished file is
scripts/fuzz/corpus-replay.test.ts all five (six for six with #1866's
occurrence): 23% of Coverage failures in that window, ~4% of all CI runs.

The ~40s gap before the error is coverage report generation, not test
time — the pool surfaces its AggregateError only once every task settles.
Control, from a green attempt of the same run: the file passes in 3152ms
at 09:37:35.9 and the summary prints at 09:38:12.5. So the file is not
slow in CI, nothing else is in flight when it dies, and neither a missed
per-case budget nor STARTUP_BUDGET_MS is implicated. Partial test counts
(3/11 and 9/11 reported) place the death mid-file, inside runCases.

So the corpus replay gets its own serialized project that the coverage
run skips, and a second uninstrumented Vitest invocation in
`test:coverage:ci` runs it, keeping the tests on every PR. Measured
against two full runs, this costs zero coverage: the cases execute in
worker threads, a separate isolate the fork's inspector never
instruments, so the lines reported are identical with and without it.

Membership is by demonstrated failure, not by a property of the code:
`session-replay-runtime-maestro.test.ts` also constructs a
node:worker_threads Worker and stays in unit-core, instrumented and
green, so "nests a Worker" is explicitly not the criterion.

The second leg goes through `test:fuzz-worker`, which blanks
AGENT_DEVICE_COVERAGE_SHARD and AGENT_DEVICE_COVERAGE_MERGE. ci.yml sets
those as job-level env over a single `gate: unit-ci` step, so both legs
would otherwise inherit them and the shard would die: Vitest refuses
`--shard=1/2` over this one-file project, and the blob reporter
overwrites the instrumented shard's report on its way out. Verified on
the merged tree — shard 1/2 (549 files), shard 2/2 (548), and the merge
job (1097 files, 90.38% lines) all pass, and the leg still fails without
the blanking.

Refs #1824
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant