You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
os dev dies of an uncaught write EPIPE (exit 1) when its stderr read end is CLOSED — every other reader gets exit 2, and the drain is never reached #14858
Found while fixing #14716 (the closed-read-end oracle in packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts). Not fixed there: that card's file fence is the test file, and the fix for this is in packages/cli/bin/run-dev.js. Filed unassigned and unlabelled for triage.
What
Spawn the CLI with its stderr piped and destroy the parent's read end (stdio: ['ignore','ignore','pipe'], then child.stderr.destroy()). oclif's displayWarnings() makes the first stderr write, the pipe is already gone, node raises write EPIPE as an error event on process.stderr, nothing is listening, and the process dies of an uncaught exception: exit 1, ~1.4 s in.
Traced with a --import observer that only appends to a file (no listener on process.stderr, no write wrapper), 3 for 3:
1192 uncaughtException code=EPIPE msg=write EPIPE
stack=Error: write EPIPE | at afterWriteDispatched (node:internal/stream_base_commons:159:15)
| at writeGeneric (...) | at Socket._writeGeneric (node:net:966:11)
1192 exit code=1
12 further untraced iterations on the same box: 12/12 code=1 signal=null, 1387-1711 ms.
Why it matters
Every other reader of the same child gets exit 2 — the status oclif uses for a failed command, and the status #14715 pinned for the never-read reader. Measured here with the same probe and flags:
parent's reader
exit
elapsed
drained
2
~8.3 s (145696 bytes delivered)
paused, never read
2
8.4 s / 23.5 s (the second paid the shim's 15 s bound)
read end destroyed
1
1.4 s
So a caller that closes stderr — a supervisor that drops the pipe, os dev 2 with the fd closed — cannot tell "the command failed" from "the CLI crashed", and the exit status is the only channel it has left.
The second consequence: writeStderr() is never reached
bin/run-dev.js has an elaborate drain with a no-progress bound, and its comment says the write callback "also fires on EPIPE, which is what releases the closed-reader paths promptly." That is true of node, but the closed-reader path never gets there — the process is dead ~7.8 s earlier. Traced with an observer that DOES swallow the error event, the shim's own 415-byte write is write #175, at 9250 ms, behind 174 oclif writes that all EPIPE.
Confirmed by ablation on a scratch worktree (mutations verified on disk by blob hash, restored by blob-hash equality under a trap):
bin/run-dev.js state
exit
elapsed
pristine
1
1387-1711 ms
write callback removed, so the closed path can only finish on the bound
Line 3 is the whole finding: one noop error listener is the difference between exit 1 at 1.4 s and the contracted exit 2 at 8.8 s, and only in that state does the shim's carefully written drain run at all for a closed reader.
What a fix owes
Decide whether a closed stderr should still exit 2. If yes, the CLI needs an EPIPE-tolerant process.stderr (a noop error listener is the smallest form, but it changes what the drain does for this path, so it wants the ablation table above re-measured rather than assumed).
⚠️ Whatever lands, packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts pins the CURRENT status (closedEnd.code is 1) precisely so this cannot change silently. The fixing PR flips that number and says why — it is not a broken test.
Found while fixing #14716 (the closed-read-end oracle in
packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts). Not fixed there: that card's file fence is the test file, and the fix for this is inpackages/cli/bin/run-dev.js. Filed unassigned and unlabelled for triage.What
Spawn the CLI with its stderr piped and destroy the parent's read end (
stdio: ['ignore','ignore','pipe'], thenchild.stderr.destroy()). oclif'sdisplayWarnings()makes the first stderr write, the pipe is already gone, node raiseswrite EPIPEas anerrorevent onprocess.stderr, nothing is listening, and the process dies of an uncaught exception: exit 1, ~1.4 s in.Traced with a
--importobserver that only appends to a file (no listener onprocess.stderr, no write wrapper), 3 for 3:12 further untraced iterations on the same box: 12/12
code=1 signal=null, 1387-1711 ms.Why it matters
Every other reader of the same child gets exit 2 — the status oclif uses for a failed command, and the status #14715 pinned for the never-read reader. Measured here with the same probe and flags:
So a caller that closes stderr — a supervisor that drops the pipe,
os dev 2 with the fd closed— cannot tell "the command failed" from "the CLI crashed", and the exit status is the only channel it has left.The second consequence:
writeStderr()is never reachedbin/run-dev.jshas an elaborate drain with a no-progress bound, and its comment says the write callback "also fires on EPIPE, which is what releases the closed-reader paths promptly." That is true of node, but the closed-reader path never gets there — the process is dead ~7.8 s earlier. Traced with an observer that DOES swallow the error event, the shim's own 415-byte write is write #175, at 9250 ms, behind 174 oclif writes that all EPIPE.Confirmed by ablation on a scratch worktree (mutations verified on disk by blob hash, restored by blob-hash equality under a trap):
bin/run-dev.jsstateprocess.stderr.on('error', noop)added, callback keptLine 3 is the whole finding: one
nooperror listener is the difference between exit 1 at 1.4 s and the contracted exit 2 at 8.8 s, and only in that state does the shim's carefully written drain run at all for a closed reader.What a fix owes
process.stderr(anooperror listener is the smallest form, but it changes what the drain does for this path, so it wants the ablation table above re-measured rather than assumed).packages/cli/test/run-dev-unbuilt-workspace.e2e.test.tspins the CURRENT status (closedEnd.codeis 1) precisely so this cannot change silently. The fixing PR flips that number and says why — it is not a broken test.os devin an unbuilt workspace sometimes HANGS instead of exiting 2 when its reader goes away — measured at 180103 ms against a 7046 ms calibration on the same runner #14832. That one is an intermittent HANG of the never-read (paused) reader, 180103 ms against a 7046 ms calibration; this one is deterministic, 12/12, and specific to a DESTROYED read end. Same file, opposite failure modes. Do not merge them.Related
os devin an unbuilt workspace sometimes HANGS instead of exiting 2 when its reader goes away — measured at 180103 ms against a 7046 ms calibration on the same runner #14832 — the intermittent hang in the same file. Different case, different reader, different defect.Generated by Claude Code