Skip to content

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

Description

@os-trump

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 1 1517-1633 ms
process.stderr.on('error', noop) added, callback kept 2 8787-8979 ms
both 2 23601-23712 ms (one bound paid)

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

  1. 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).
  2. ⚠️ 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.
  3. ⛔ Not the same defect as os dev in 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

Generated by Claude Code

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions