Skip to content

Commit b90986c

Browse files
committed
test(cli): judge the closed-read-end case by exit status, not a wall clock
`run-dev-unbuilt-workspace.e2e.test.ts` case 6 asserted `closedEnd.elapsedMs < STALL_MS` — a fixed wall-clock bound borrowed from case 4's parent stall, against a term that is entirely elastic. Tracing what produces that timing shows the bound was not merely fragile, it was a phantom. With the read end destroyed, 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 child dies of an uncaught exception at ~1.4 s with exit 1. `writeStderr()` is never reached, so the bound the case was named after is never armed. Traced with a `--import` observer: the shim's own 415-byte write is #175 at 9250 ms, behind 174 oclif writes that all EPIPE — 7.8 s after the real child is already dead. Ablated on `bin/run-dev.js`, same box, same probe (old bound's verdict in brackets): pristine exit 1 at 1387-1711 ms [green]; write callback removed so a closed path could only finish on the bound, which is the regression this case named, exit 1 at 1517-1633 ms [GREEN]; EPIPE made non-fatal, exit 2 at 8787-8979 ms [green, 1.2 s spare]; both, so the path really pays the bound, exit 2 at 23601-23712 ms [red]. The measurement moved only where the exit code moved too, and it stayed green on its own regression. So the exit status is the observation the wall clock was standing in for, and it carries no load term: 1 means the child died on its first write, 2 means it reached `handle()`, which is only reachable through `writeStderr()`. This asserts that directly and keeps the elapsed reading as evidence in the failure message, the same split #14715 made for case 5. Exit 1 is what the CLI does rather than what anyone contracted — filed as #14858, and pinning today's value is what stops that changing silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
1 parent f3ae441 commit b90986c

1 file changed

Lines changed: 58 additions & 12 deletions

File tree

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

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -158,14 +158,12 @@ const STALL_MS = 10_000;
158158
* The shim's own no-progress bound, mirrored from `bin/run-dev.js`
159159
* (`STDERR_DRAIN_STALL_MS`) and held equal to it by a case below rather than
160160
* trusted. Case 5's ceiling no longer budgets it — that ceiling is a constant
161-
* now — but two cases here are still sized against it and would quietly stop
161+
* now — and case 6 no longer reads it either, having stopped judging by a wall
162+
* clock at all. ONE case is still sized against it and would quietly stop
162163
* discriminating if it moved:
163164
*
164165
* • `STALL_MS` above must stay strictly BELOW it, or case 4's stalled reader
165-
* outlasts the shim's own give-up and reds against a WORKING fix;
166-
* • case 6 reads the closed-reader path as released in less than `STALL_MS`,
167-
* which is evidence of a fast path only while `STALL_MS` is itself below
168-
* the bound.
166+
* outlasts the shim's own give-up and reds against a WORKING fix.
169167
*/
170168
const SHIM_DRAIN_STALL_MS = 15_000;
171169

@@ -457,12 +455,60 @@ describe('the mirror direction: a reader that is never coming back', () => {
457455
expect(Number(String(declared).replaceAll('_', ''))).toBe(SHIM_DRAIN_STALL_MS);
458456
});
459457

460-
it('a CLOSED read end is released at once, not held for the bound (EPIPE reaches the callback)', () => {
461-
// Pins the fast path measured alongside the hang: when the reader is gone
462-
// rather than idle, the write callback fires with EPIPE and the wait ends
463-
// immediately. A future change to the bound must not quietly make the
464-
// closed-reader paths pay it.
465-
expect(closedEnd.signal).toBeNull();
466-
expect(closedEnd.elapsedMs).toBeLessThan(STALL_MS);
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.
463+
//
464+
// What the child ACTUALLY does with its read end destroyed: oclif's
465+
// `displayWarnings()` makes the first stderr write, the pipe is already
466+
// 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.
473+
//
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:
477+
//
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+
//
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.
490+
//
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.
504+
const evidence =
505+
`closed-read-end child ran ${closedEnd.elapsedMs} ms (harness cap ${UNREAD_HARD_CAP_MS} ms); ` +
506+
`case 1 measured the same child at ${unbuilt.elapsedMs} ms on this runner minutes earlier`;
507+
expect(closedEnd.signal, `the harness SIGKILLed the child — it was still alive at the ceiling. ${evidence}`).toBeNull();
508+
expect(
509+
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);
467513
});
468514
});

0 commit comments

Comments
 (0)