Skip to content

Commit 0c5e973

Browse files
os-litantclaude
andauthored
fix(cli): a closed stderr read end exits 2 instead of dying of an uncaught EPIPE (#14858) (#15558)
* fix(cli): make a failed stderr write non-fatal in the dev shim WIP checkpoint: the candidate fix, committed before the ablation so the restore leg has a HEAD that actually holds it. Measured numbers still to be filled into the docblock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * test(cli): flip the closed-read-end pin to 2 and say why Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0ed66da commit 0c5e973

2 files changed

Lines changed: 98 additions & 42 deletions

File tree

packages/cli/bin/run-dev.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,65 @@ settings.debug = true;
195195
// why the re-assert has to sit on the write path rather than run once here.
196196
keepStderrNonBlocking();
197197

198+
/**
199+
* Make a FAILED stderr write non-fatal, so a caller whose read end is gone
200+
* still gets this CLI's own exit status instead of a crash. #14858.
201+
*
202+
* `process.stderr` is an `EventEmitter`, and an `error` event with nothing
203+
* listening IS an uncaught exception. With the parent's read end DESTROYED
204+
* (`stdio: ['ignore', 'ignore', 'pipe']`, then `child.stderr.destroy()`)
205+
* oclif's `displayWarnings()` makes the first write, the pipe is already gone,
206+
* node raises `write EPIPE` on `process.stderr`, and this process died of an
207+
* uncaught exception — 12 of 12 runs, 938-1174 ms in, well before `run()`
208+
* settles and before `writeStderr()` above is ever called. Traced with a
209+
* `--import` observer that installs NO listener on this stream and wraps no
210+
* write (`uncaughtExceptionMonitor`, which observes without preventing the
211+
* default crash — an `uncaughtException` handler would have changed the very
212+
* thing being read):
213+
*
214+
* uncaughtException code=EPIPE msg=write EPIPE
215+
* at afterWriteDispatched (node:internal/stream_base_commons:159:15)
216+
* exit code=1
217+
*
218+
* Every OTHER reader of the same child answers **2** — drained (1209-1217 ms,
219+
* 147699 bytes delivered) and never-read (16178-16471 ms) both did, in the same
220+
* conditions. 2 is what oclif's `handle()` produces, and #14715 pinned it for
221+
* the never-read reader. So the closed reader was the one shape that could not
222+
* tell "the command failed" from "the CLI crashed", on the only channel it had
223+
* left.
224+
*
225+
* ⛔ Deliberately NOT narrowed to `error.code === 'EPIPE'`, even though EPIPE is
226+
* the only code this path was measured to raise (4 events per run, no other
227+
* code, observed with a listener installed on purpose for that one question).
228+
* The reason to tolerate is not WHICH error it is: every event here means one
229+
* thing — a write to stderr failed — the only channel it could be reported on
230+
* is the stream that just failed, and there is no other action to take. A
231+
* predicate would buy no decision and would keep exactly this crash for
232+
* whatever code turns up next.
233+
*
234+
* ⚠️ What it costs, and it is not nothing. With the write no longer fatal the
235+
* run continues into `writeStderr()`'s drain, which for this path had NEVER
236+
* executed at all. Ablated 2x2 on this file, one contiguous run, shared box —
237+
* so read the ratios, not the absolutes:
238+
*
239+
* this listener write callback exit elapsed
240+
* ------------- --------------- ---- ---------------
241+
* present kept (= HEAD) 2 1237-1312 ms
242+
* present removed 2 16271-16294 ms the 15 s bound
243+
* absent kept 1 983-1028 ms the defect
244+
* absent removed 1 843-1031 ms
245+
*
246+
* Row 1 against row 3 is the whole change, and it is ~250 ms: the child now
247+
* ends the way a drained reader's child ends instead of dying on its first
248+
* write. (The two runs are comparable because their unfixed rows agree — 938-
249+
* 1174 ms above, 983-1028 ms here.) Row 2 is what the drain costs when only the
250+
* no-progress bound can end it — a cost that is REACHABLE now and was not
251+
* before, because rows 3-4 never got there at all.
252+
*/
253+
process.stderr.on('error', () => {
254+
// Nothing to report, and nowhere left to report it.
255+
});
256+
198257
const running = run(process.argv.slice(2), import.meta.url);
199258

200259
// ⚠️ ATTACHED AFTER `run()`, and that order is load-bearing rather than style.

packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts

Lines changed: 39 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -455,60 +455,57 @@ describe('the mirror direction: a reader that is never coming back', () => {
455455
expect(Number(String(declared).replaceAll('_', ''))).toBe(SHIM_DRAIN_STALL_MS);
456456
});
457457

458-
it('a CLOSED read end ends the child on its own — by an uncaught EPIPE, never by the bound', () => {
459-
// ⚠️ This case used to read `elapsedMs < STALL_MS`, and its name used to
460-
// say "released at once … EPIPE reaches the callback". BOTH were wrong
461-
// about this shape, and the trace that settles it is worth more than the
462-
// assertion it replaces.
458+
it('a CLOSED read end ends the child on its own, with the status every other reader gets', () => {
459+
// ⚠️ THE NUMBER BELOW MOVED FROM 1 TO 2, and this case is why it could not
460+
// move quietly. It pinned 1 on purpose — 1 was what the CLI DID, never what
461+
// anyone contracted — and #14858 is the card that changed the CLI. ⛔ This
462+
// was not a broken test and the flip is not a regression.
463463
//
464-
// What the child ACTUALLY does with its read end destroyed: oclif's
464+
// What the child USED TO DO with its read end destroyed: oclif's
465465
// `displayWarnings()` makes the first stderr write, the pipe is already
466466
// gone, node raises `write EPIPE` as an `error` event on `process.stderr`,
467-
// NOTHING IS LISTENING, and the process dies of an uncaught exception —
468-
// exit 1, ~1.4 s in. `writeStderr()` is never called, so the bound this
469-
// case was named after is never armed, let alone paid. Traced on one box
470-
// with a `--import` observer: the shim's own 415-byte write is #175, at
471-
// 9250 ms, behind 174 oclif writes that all EPIPE — 7.8 s after the
472-
// unobserved child is already dead.
467+
// NOTHING WAS LISTENING, and the process died of an uncaught exception —
468+
// exit 1, 938-1174 ms in, 12 of 12 runs, traced with a `--import` observer
469+
// that installed no listener here and wrapped no write. `writeStderr()` was
470+
// never called at all, so the bound this case was once named after was
471+
// never armed, let alone paid. (An earlier version of this case read
472+
// `elapsedMs < STALL_MS` and was named for that bound; the wall clock went
473+
// because its whole measured term is child cold start, which is elastic,
474+
// and because it stayed green through the very ablation it named.)
473475
//
474-
// ⛔ So the wall-clock bound was not merely fragile, it was a PHANTOM: it
475-
// could not fail for the reason it named. Ablated on `bin/run-dev.js`,
476-
// same box, same probe, with the old bound's verdict in brackets:
476+
// `bin/run-dev.js` now attaches a no-op `error` listener to
477+
// `process.stderr` before `run()`. A failed stderr write stops being fatal,
478+
// the run reaches the CLI's own exit path — oclif's `handle()`, status 2 —
479+
// and the closed reader answers what the drained and never-read readers
480+
// already answered (#14715 pinned 2 for the never-read one). Re-measured
481+
// for that change, one contiguous 2x2 ablation of the shim on one box, the
482+
// listener present/absent against the `write` callback kept/removed:
477483
//
478-
// pristine exit 1, 1387-1711 ms [green]
479-
// write callback REMOVED, so a closed path
480-
// could only finish on the bound — the
481-
// regression this case named exit 1, 1517-1633 ms [GREEN]
482-
// EPIPE made non-fatal, callback kept exit 2, 8787-8979 ms [green, 1.2 s spare]
483-
// both, so the path really pays the bound exit 2, 23601-23712 ms [red]
484+
// listener present, callback kept (as shipped) exit 2, 1237-1312 ms
485+
// listener present, callback removed exit 2, 16271-16294 ms
486+
// listener absent, callback kept (the defect) exit 1, 983-1028 ms
487+
// listener absent, callback removed exit 1, 843-1031 ms
484488
//
485-
// The bound moved only on lines 3 and 4, which change the EXIT CODE too;
486-
// against its own regression it stayed green. And its whole measured term
487-
// is child cold start, which is elastic — 1.4 s here, 8.9 s the moment
488-
// anything lets the child run further — judged against 10 s borrowed from
489-
// case 4's parent stall, a number with no relationship to this case.
489+
// ⭐ The exit status is still the observation, and it still carries no load
490+
// term. 1 means the child died on its first write and never reached the
491+
// drain; 2 means it got through to `handle()`, which on this run is only
492+
// reachable THROUGH `writeStderr()` — bound paid or not. BOTH rows that
493+
// remove the listener flip it back to 1, so this line pins the fix and not
494+
// the weather.
490495
//
491-
// ⭐ The exit status IS the observation the wall clock was standing in for,
492-
// and it carries no load term at all. 1 means the child died on its first
493-
// write and never reached the drain; 2 means it got through to `handle()`,
494-
// which is only reachable THROUGH `writeStderr()` — bound paid or not. Every
495-
// ablation above that reaches the drain flips it, including the one the old
496-
// assertion could not see.
497-
//
498-
// ⚠️ 1 is what the CLI DOES, not what anyone contracted: a caller whose
499-
// stderr is closed gets 1 where every other reader gets 2, and cannot tell a
500-
// failed command from a crashed CLI. Filed as #14858. If that is fixed to
501-
// exit 2 this case reds, which is the point — the fixing PR flips the number
502-
// here and says why. ⛔ Do not "repair" a red by loosening this to
503-
// `not.toBeNull()`; that is the phantom check all over again.
496+
// ⛔ Do not "repair" a red here by loosening to `not.toBeNull()`; that is
497+
// the phantom check the wall clock already was. A red means the child is
498+
// dying on a stderr write again — the listener is gone, or something now
499+
// writes to stderr ahead of where it is attached.
504500
const evidence =
505501
`closed-read-end child ran ${closedEnd.elapsedMs} ms (harness cap ${UNREAD_HARD_CAP_MS} ms); ` +
506502
`case 1 measured the same child at ${unbuilt.elapsedMs} ms on this runner minutes earlier`;
507503
expect(closedEnd.signal, `the harness SIGKILLed the child — it was still alive at the ceiling. ${evidence}`).toBeNull();
508504
expect(
509505
closedEnd.code,
510-
`the child did not die on its first stderr write — it reached the shim's drain, so something now ` +
511-
`tolerates EPIPE on stderr (see #14858 and the ablation table above this assertion). ${evidence}`,
512-
).toBe(1);
506+
`the child did not reach the CLI's own exit path — it died on a stderr write, so nothing is making ` +
507+
`EPIPE non-fatal on \`process.stderr\` any more (see #14858 and the ablation table above this ` +
508+
`assertion). ${evidence}`,
509+
).toBe(2);
513510
});
514511
});

0 commit comments

Comments
 (0)