From b90986c7cfc39e56a044dbaa8effeb1d826ca45a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 05:49:52 +0000 Subject: [PATCH] test(cli): judge the closed-read-end case by exit status, not a wall clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- .../run-dev-unbuilt-workspace.e2e.test.ts | 70 +++++++++++++++---- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts b/packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts index 131331ed37..8c7b8f5606 100644 --- a/packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts +++ b/packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts @@ -158,14 +158,12 @@ const STALL_MS = 10_000; * The shim's own no-progress bound, mirrored from `bin/run-dev.js` * (`STDERR_DRAIN_STALL_MS`) and held equal to it by a case below rather than * trusted. Case 5's ceiling no longer budgets it — that ceiling is a constant - * now — but two cases here are still sized against it and would quietly stop + * now — and case 6 no longer reads it either, having stopped judging by a wall + * clock at all. ONE case is still sized against it and would quietly stop * discriminating if it moved: * * • `STALL_MS` above must stay strictly BELOW it, or case 4's stalled reader - * outlasts the shim's own give-up and reds against a WORKING fix; - * • case 6 reads the closed-reader path as released in less than `STALL_MS`, - * which is evidence of a fast path only while `STALL_MS` is itself below - * the bound. + * outlasts the shim's own give-up and reds against a WORKING fix. */ const SHIM_DRAIN_STALL_MS = 15_000; @@ -457,12 +455,60 @@ describe('the mirror direction: a reader that is never coming back', () => { expect(Number(String(declared).replaceAll('_', ''))).toBe(SHIM_DRAIN_STALL_MS); }); - it('a CLOSED read end is released at once, not held for the bound (EPIPE reaches the callback)', () => { - // Pins the fast path measured alongside the hang: when the reader is gone - // rather than idle, the write callback fires with EPIPE and the wait ends - // immediately. A future change to the bound must not quietly make the - // closed-reader paths pay it. - expect(closedEnd.signal).toBeNull(); - expect(closedEnd.elapsedMs).toBeLessThan(STALL_MS); + it('a CLOSED read end ends the child on its own — by an uncaught EPIPE, never by the bound', () => { + // ⚠️ This case used to read `elapsedMs < STALL_MS`, and its name used to + // say "released at once … EPIPE reaches the callback". BOTH were wrong + // about this shape, and the trace that settles it is worth more than the + // assertion it replaces. + // + // What the child ACTUALLY does with its 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 process dies of an uncaught exception — + // exit 1, ~1.4 s in. `writeStderr()` is never called, so the bound this + // case was named after is never armed, let alone paid. Traced on one box + // 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 + // unobserved child is already dead. + // + // ⛔ So the wall-clock bound was not merely fragile, it was a PHANTOM: it + // could not fail for the reason it named. Ablated on `bin/run-dev.js`, + // same box, same probe, with the old bound's verdict in brackets: + // + // pristine exit 1, 1387-1711 ms [green] + // write callback REMOVED, so a closed path + // could only finish on the bound — the + // regression this case named exit 1, 1517-1633 ms [GREEN] + // EPIPE made non-fatal, callback kept exit 2, 8787-8979 ms [green, 1.2 s spare] + // both, so the path really pays the bound exit 2, 23601-23712 ms [red] + // + // The bound moved only on lines 3 and 4, which change the EXIT CODE too; + // against its own regression it stayed green. And its whole measured term + // is child cold start, which is elastic — 1.4 s here, 8.9 s the moment + // anything lets the child run further — judged against 10 s borrowed from + // case 4's parent stall, a number with no relationship to this case. + // + // ⭐ The exit status IS the observation the wall clock was standing in for, + // and it carries no load term at all. 1 means the child died on its first + // write and never reached the drain; 2 means it got through to `handle()`, + // which is only reachable THROUGH `writeStderr()` — bound paid or not. Every + // ablation above that reaches the drain flips it, including the one the old + // assertion could not see. + // + // ⚠️ 1 is what the CLI DOES, not what anyone contracted: a caller whose + // stderr is closed gets 1 where every other reader gets 2, and cannot tell a + // failed command from a crashed CLI. Filed as #14858. If that is fixed to + // exit 2 this case reds, which is the point — the fixing PR flips the number + // here and says why. ⛔ Do not "repair" a red by loosening this to + // `not.toBeNull()`; that is the phantom check all over again. + const evidence = + `closed-read-end child ran ${closedEnd.elapsedMs} ms (harness cap ${UNREAD_HARD_CAP_MS} ms); ` + + `case 1 measured the same child at ${unbuilt.elapsedMs} ms on this runner minutes earlier`; + expect(closedEnd.signal, `the harness SIGKILLed the child — it was still alive at the ceiling. ${evidence}`).toBeNull(); + expect( + closedEnd.code, + `the child did not die on its first stderr write — it reached the shim's drain, so something now ` + + `tolerates EPIPE on stderr (see #14858 and the ablation table above this assertion). ${evidence}`, + ).toBe(1); }); });