From 729cce198ad23b3ef0e0870549b93068f687bccb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 19:16:20 +0000 Subject: [PATCH 1/4] fix(cli): make a failed stderr write non-fatal on the published entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bin/run.js` writes to `process.stderr` with no `error` listener, so an `error` event there is an uncaught exception. #15564 was filed NOT REPRODUCED, and its fence was explicit: symmetry with `bin/run-dev.js` is not evidence, establish reachability first. Both of the card's probes were re-run before anything was written here and both still read clean — exit 2, no `uncaughtException`, 3/3 each. They were not a guard, they were the wrong lifecycle: everything a failing invocation puts on stderr is written after `run()` has settled, by `handle()`, which exits on top of its own report, and a failing write reports through libuv's completion callback that a synchronous exit is never told about. Widening to a lifecycle that outlives its first failed write reaches it. `os serve` on `examples/app-todo`, read end destroyed: uncaughtException code=EPIPE msg=write EPIPE at afterWriteDispatched (node:internal/stream_base_commons:159:15) exit code=1 3 of 3 runs, 3049-3433 ms in — the same frame and status #14858 traced on the dev shim. The same child read by a draining parent boots, serves, and exits 0 after 7926 bytes over 16.6 s. Pinned with the hazard manufactured inside the published binary's own process, alongside a live positive control that removes the listener in the child and re-crashes it, so the guarded arm's silence is a reading. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --- .changeset/tidy-cars-repeat.md | 19 ++ packages/cli/bin/run.js | 71 ++++++ .../published-entry-stderr-error-probe.mjs | 121 +++++++++ ...ed-entry-stderr-error-listener.e2e.test.ts | 234 ++++++++++++++++++ 4 files changed, 445 insertions(+) create mode 100644 .changeset/tidy-cars-repeat.md create mode 100644 packages/cli/test/fixtures/published-entry-stderr-error-probe.mjs create mode 100644 packages/cli/test/published-entry-stderr-error-listener.e2e.test.ts diff --git a/.changeset/tidy-cars-repeat.md b/.changeset/tidy-cars-repeat.md new file mode 100644 index 0000000000..4123980eba --- /dev/null +++ b/.changeset/tidy-cars-repeat.md @@ -0,0 +1,19 @@ +--- +'@objectstack/cli': patch +--- + +Stop the published CLI from dying of an uncaught `write EPIPE` when its caller's stderr read end is gone. + +`bin/run.js` — the file `bin.objectstack` / `bin.os` point at, and the only thing under `bin/` npm packs — now attaches the same no-op `error` listener to `process.stderr` that the in-repo dev shim has carried since the original finding. `process.stderr` is an `EventEmitter`, so an `error` event with nothing listening is an uncaught exception. + +Measured on the published entry with the read end destroyed (`stdio: ['ignore','ignore','pipe']`, then `child.stderr.destroy()`), traced with an observer that installs no listener and wraps no write: + +``` +uncaughtException code=EPIPE msg=write EPIPE + at afterWriteDispatched (node:internal/stream_base_commons:159:15) +exit code=1 +``` + +3 of 3 runs, 3049-3433 ms in, on `os serve` over `examples/app-todo`. Read by a draining parent the same child boots and serves and exits 0, having written 7926 bytes over 16.6 s — so the crash was costing the run at its first diagnostic line and 20 of its 21 stderr writes. Failing invocations do not reach it: everything they put on stderr is written after `run()` has settled, by a handler that exits on top of its own report. + +Behaviour change worth knowing about: a long-running command (`os serve`, `os dev`, `os start`) whose reader has gone now keeps running and reports its own exit status, instead of dying on its first diagnostic write. A supervisor that destroyed the read end and relied on that crash to end the child needs to end it itself. diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js index eb9d6c9dac..3915e1e95e 100755 --- a/packages/cli/bin/run.js +++ b/packages/cli/bin/run.js @@ -70,6 +70,77 @@ try { // Unbuilt or half-built tree — nothing to install and nothing to say. } +/** + * Make a FAILED stderr write non-fatal, so a caller whose read end is gone + * still gets this CLI's own exit status instead of a crash. #14858, reached on + * THIS entry point by the #15564 measurement. + * + * `process.stderr` is an `EventEmitter`, and an `error` event with nothing + * listening IS an uncaught exception. `bin/run-dev.js` has carried this + * listener since #14858; the published entry did not, and #15564 was filed + * NOT REPRODUCED because the two probes that had been run against it — a + * bad command id, and `OBJECTSTACK_DEBUG=1` over an unbuilt `@objectstack/spec` + * — both answered exit 2 with no `uncaughtException`. Re-run here, they still + * do (3/3 each, 57 and 35528 bytes drained). ⭐ They were not a guard; they + * were the wrong lifecycle, and the difference is measurable rather than + * arguable: + * + * leg (bin/run.js, read end destroyed) stderr writes exit + * --------------------------------------- ------------- ---------------- + * `definitely-not-a-command` 1 @ 3231 ms 2, no crash + * OBJECTSTACK_DEBUG=1 + unbuilt spec 60 @ 932-960 ms 2, no crash + * `serve objectstack.config.ts` 21 @ 3180 ms on 1, `write EPIPE` + * 3/3 + * + * Two things separate the last row, and BOTH are needed: + * + * • an event-loop TURN between the failing write and `process.exit`. A + * failing write reports through libuv's completion callback, so a write + * followed by a synchronous exit is never told. Both probe legs are that + * shape: everything they put on stderr is written after `run()` has already + * settled, by `handle()`, which exits on top of its own report — measured + * at one write 1 ms before exit, and at 59 warning blocks whose EPIPE + * arrives synchronously inside the write. + * • a RAW `process.stderr.write`. Node's `console.error` carries + * `ignoreErrors`, which parks a temporary `error` listener across the write + * — so oclif's warning blocks cannot crash this process at any size + * (measured: 1 MiB through `console.error` does not, one line through + * `process.stderr.write` does, 3/3 each). + * + * `os serve` is both: `printDiagnostic` in `src/commands/serve.ts` writes + * straight to stderr (#7915) and the boot around it is asynchronous, so the + * process is alive across the whole sequence. Measured on `examples/app-todo` + * through this file, read end destroyed (`stdio: ['ignore','ignore','pipe']`, + * then `child.stderr.destroy()`), traced with a `--import` observer that + * installs NO listener here and wraps no write: + * + * uncaughtException code=EPIPE msg=write EPIPE + * at afterWriteDispatched (node:internal/stream_base_commons:159:15) + * exit code=1 + * + * 3 of 3 runs, 3049-3433 ms in — the same frame and the same status #14858 + * traced on the dev shim. The same child read by a draining parent boots and + * serves, exit 0 at a 20 s SIGTERM, having written 7926 bytes over 16.6 s. So + * the crash costs the run at its FIRST diagnostic line and 20 of its 21 stderr + * writes, on the entry point a customer's install actually runs (`files` names + * only `dist`, but npm packs a `bin` target regardless — #14874). + * + * ⛔ Deliberately NOT narrowed to `error.code === 'EPIPE'`, for the reason + * `bin/run-dev.js` records: the reason to tolerate is not WHICH error it is. + * Every event here means one thing — a write to stderr failed — the only + * channel it could be reported on is the stream that just failed, and there is + * no other action to take. + * + * ⚠️ What it costs: a long-running command whose reader has gone now keeps + * running instead of dying on its first diagnostic. That is the point (the + * server is still serving, and its caller still gets the CLI's own status), but + * it is a real behaviour change for a supervisor that destroyed the read end + * and relied on the crash to end the child. + */ +process.stderr.on('error', () => { + // Nothing to report, and nowhere left to report it. +}); + await run(process.argv.slice(2), import.meta.url) .then(async (result) => { flush(); diff --git a/packages/cli/test/fixtures/published-entry-stderr-error-probe.mjs b/packages/cli/test/fixtures/published-entry-stderr-error-probe.mjs new file mode 100644 index 0000000000..ffc5f1299d --- /dev/null +++ b/packages/cli/test/fixtures/published-entry-stderr-error-probe.mjs @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The #14858 crash class, manufactured INSIDE the published entry point's own + * process — driven by `published-entry-stderr-error-listener.e2e.test.ts`. + * + * Loaded with `node --import bin/run.js …` against a read end the parent + * has destroyed, so everything below runs in the same process as the shipped + * CLI, on the same open file description, after `bin/run.js` has had its chance + * to attach the `error` listener. + * + * ## Why the failing write is manufactured rather than taken from a command + * + * The field reproduction is `os serve`: `printDiagnostic` writes straight to + * stderr (#7915), the boot around it is asynchronous, and #15564 measured the + * published entry dying there — `write EPIPE` at `afterWriteDispatched`, exit + * 1, 3 of 3 runs, 3049-3433 ms in. Reproducing THAT needs a fixture app, a + * database, a bound port and four seconds per leg, and it pins the crash to one + * command that could stop writing raw tomorrow. What the entry point owes is + * narrower and does not move: **a failed stderr write in this process must not + * be fatal.** One raw write to a destroyed pipe is the whole of that hazard, + * and it costs milliseconds. + * + * ⛔ The write is deliberately `process.stderr.write` and NOT `console.error`. + * Node's `console.error` carries `ignoreErrors`, which parks a temporary + * `error` listener across the write, so it cannot crash a process at any + * payload size — measured at 1 MiB, 0 of 3, against one line through + * `process.stderr.write` at 3 of 3. A probe written with `console.error` would + * be green with the listener REMOVED, which is the one thing it must not be. + * + * ## The two arms, and why the unguarded one is not an ablation + * + * `OS_PUBLISHED_ENTRY_ERROR_PROBE_ARM=unguarded` makes this probe remove the + * entry's listener in its own process before writing. That is the harness's + * LIVE POSITIVE CONTROL: it shows, in the same run and against the same tree, + * that this instrument can still see the crash — so the guarded arm's silence + * is a reading rather than a zero. Nothing on disk is touched, so it costs no + * restore and cannot leave a mutated tree behind. + * + * Markers go to a file: stderr is the thing under test and, on the arm that is + * supposed to fail, the thing that is already broken. + * + * env: `OS_PUBLISHED_ENTRY_ERROR_PROBE_MARKS` — the marker file. + * env: `OS_PUBLISHED_ENTRY_ERROR_PROBE_ARM` — `guarded` (default) | `unguarded`. + */ + +import { appendFileSync } from 'node:fs'; + +const MARKS = process.env.OS_PUBLISHED_ENTRY_ERROR_PROBE_MARKS; +const UNGUARDED = process.env.OS_PUBLISHED_ENTRY_ERROR_PROBE_ARM === 'unguarded'; + +const mark = (line) => appendFileSync(MARKS, `${line}\n`); + +/** + * How long to wait for `bin/run.js` to attach its listener before proceeding + * anyway. + * + * A CONSTANT, and far above anything the attach legitimately needs: it happens + * at the top of `bin/run.js`, after one dynamic `import()` of a dependency-free + * module, and every `@oclif/core` byte is written later, inside `run()`. The + * bound exists only so an absent listener is REPORTED rather than waited on + * forever — it is not an oracle over how fast the attach is, and the harness + * asserts the mark this produces rather than the number in it. + */ +const ATTACH_WAIT_MS = 15_000; + +/** Comfortably finer than anything being timed. */ +const POLL_MS = 10; + +/** + * ⛔ This probe installs NO `error` listener of its own on `process.stderr`. + * `uncaughtExceptionMonitor` observes the default action without preventing it, + * so an unguarded run still dies exactly as it would unobserved — an + * `uncaughtException` handler would have changed the very thing being read. + */ +process.on('uncaughtExceptionMonitor', (error) => { + mark(`UNCAUGHT code=${error?.code} msg=${error?.message}`); +}); + +process.on('exit', (code) => mark(`EXIT code=${code}`)); + +function writeAndOutliveIt() { + // ONE raw write. The read end is already gone, so this fails; whether that + // failure is fatal is the entire subject. + process.stderr.write('published-entry-stderr-error-probe: one line to a read end that is gone\n'); + mark('WROTE'); + + // ⚠️ The turn is the point, not the delay. A failing write reports through + // libuv's completion callback, so a write followed by a SYNCHRONOUS exit is + // never told at all — which is exactly why the two probes on #15564's card + // read clean, and why a probe that exited here would reproduce their zero + // reading instead of testing anything. + // + // ⛔ NOT unref'd: this timer is what keeps the process alive across that + // turn, and an unref'd one would let the CLI's own exit race it away. + setTimeout(() => { + mark('SURVIVED'); + // A distinctive status, so "ended on its own past the write" is evidence + // about THIS probe rather than about any process that happens to exit 0. + process.exit(7); + }, 250); +} + +let waited = 0; +const poll = setInterval(() => { + const attached = process.stderr.listenerCount('error') > 0; + if (!attached && waited < ATTACH_WAIT_MS) { + waited += POLL_MS; + return; + } + clearInterval(poll); + mark(attached ? `LISTENER ATTACHED after ${waited} ms` : `LISTENER ABSENT after ${waited} ms`); + if (UNGUARDED) { + // The live positive control — in this process only, never on disk. + process.stderr.removeAllListeners('error'); + mark(`ARM unguarded listeners=${process.stderr.listenerCount('error')}`); + } else { + mark(`ARM guarded listeners=${process.stderr.listenerCount('error')}`); + } + writeAndOutliveIt(); +}, POLL_MS); diff --git a/packages/cli/test/published-entry-stderr-error-listener.e2e.test.ts b/packages/cli/test/published-entry-stderr-error-listener.e2e.test.ts new file mode 100644 index 0000000000..442ec579dc --- /dev/null +++ b/packages/cli/test/published-entry-stderr-error-listener.e2e.test.ts @@ -0,0 +1,234 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A failed stderr write must not kill the PUBLISHED CLI. #14858 for the class, + * #15564 for the measurement that reached it on this entry point. + * + * ## What #15564 asked, and what the answer turned out to be + * + * `bin/run-dev.js` has carried a no-op `error` listener on `process.stderr` + * since #14858; `bin/run.js` — the file `bin.objectstack` / `bin.os` point at, + * and the only thing under `bin/` npm packs (#14874) — did not. The card was + * filed **NOT REPRODUCED** on purpose and fenced the cheap conclusion: two + * probes against the published entry with the read end destroyed had answered + * exit 2 with no `uncaughtException`, so "the sibling has one" was explicitly + * not evidence. + * + * Both were re-run before anything was written here, and both still read clean + * — 3 of 3 each, `definitely-not-a-command` (57 bytes) and `OBJECTSTACK_DEBUG=1` + * over an unbuilt `@objectstack/spec` (35528 bytes). ⭐ They were not a guard. + * They were the wrong LIFECYCLE, and the difference is measurable: + * + * leg (bin/run.js, read end destroyed) stderr writes exit + * -------------------------------------- -------------- ----------------- + * `definitely-not-a-command` 1 @ 3231 ms 2, no crash + * OBJECTSTACK_DEBUG=1 + unbuilt spec 60 @ 932-960 ms 2, no crash + * `serve objectstack.config.ts` 21 @ 3180 ms on 1, `write EPIPE` + * 3/3 + * + * uncaughtException code=EPIPE msg=write EPIPE + * at afterWriteDispatched (node:internal/stream_base_commons:159:15) + * exit code=1 + * + * — the same frame and status #14858 traced on the dev shim, at 3049-3433 ms, + * on `examples/app-todo`. The same child read by a DRAINING parent boots and + * serves, exit 0 at a 20 s SIGTERM after 7926 bytes over 16.6 s. So the crash + * costs the run at its first diagnostic line and 20 of its 21 stderr writes. + * + * Two conditions separate that row, and a probe needs BOTH: + * + * • an event-loop TURN between the failing write and `process.exit` — a + * failing write reports through libuv's completion callback, so a + * synchronous exit on top of it is never told. Both clean legs are that + * shape: everything they write lands after `run()` has settled, and + * `handle()` exits on top of its own report; + * • a RAW `process.stderr.write`. `console.error` carries `ignoreErrors`, + * which parks a temporary `error` listener across the write, so oclif's + * warning blocks cannot crash this process at any size (1 MiB through + * `console.error`: 0 of 3; one line through `process.stderr.write`: 3 of 3). + * + * ## What this file pins, and what it deliberately leaves out + * + * It pins the entry point's own obligation — **a failed stderr write in this + * process is not fatal** — with the hazard manufactured inside the published + * binary's process (the pattern its neighbour + * `published-entry-stderr-nonblocking.e2e.test.ts` already uses, and for the + * same reason: a real `os serve` leg needs a fixture app, a database and a + * bound port to assert something narrower that does not move). + * + * ⛔ It does NOT assert that `bin/run.js` and `bin/run-dev.js` agree. Symmetry + * between the two entries is what #15564 refused to accept as evidence, and a + * case asserting it would smuggle that reading back in. + * + * The reachability half is not left unheld either — case 3 keeps the premise + * the measurement rests on: `serve` still writes to stderr RAW. + */ + +import { spawn } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { maskComments } from '../../../scripts/js-comment-mask.mjs'; +import { childEnv, requireBuiltCli } from './helpers/serve-process.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +/** `packages/cli` — this package's own root, never another package's. */ +const PACKAGE_ROOT = resolve(HERE, '..'); +const RUN_JS = join(PACKAGE_ROOT, 'bin', 'run.js'); +const SERVE_COMMAND = join(PACKAGE_ROOT, 'src', 'commands', 'serve.ts'); +const PROBE = join(HERE, 'fixtures', 'published-entry-stderr-error-probe.mjs'); + +/** + * Why a `bin/run.js` child needs `packages/cli/dist`, in this file's own terms. + * + * ⚠️ The mechanism is the caller's to supply and must be TRUE OF THIS CALLER — + * a borrowed sentence is a false explanation attached to a true refusal. + */ +const PUBLISHED_ENTRY_NEEDS_DIST = + 'This file drives bin/run.js with NODE_ENV unset, so oclif resolves the command from dist/ and the ' + + 'entry point reaches its own prologue; on an unbuilt tree the child answers "command not found" before ' + + 'the probe can read anything, and every assertion below would be about a run that never happened.'; + +/** + * The one ceiling, a CONSTANT for the reason both neighbouring files record: a + * ceiling derived from a calibration is a prediction about contention that a + * shared runner will not honour. It detects what any finite ceiling detects — + * a process that will never end — and sits far above what a healthy run needs + * (the probe fires within ~20 ms of the entry's prologue and waits 250 ms). + */ +const HARD_CAP_MS = 90_000; + +interface Arm { + code: number | null; + signal: NodeJS.Signals | null; + elapsedMs: number; + marks: string; +} + +let dir: string; +let guarded: Arm; +let unguarded: Arm; + +/** + * Run the published entry point under the probe, with the read end DESTROYED, + * and report only how it ended plus what the marker file caught. + * + * `--version` is the argv because it is the cheapest real invocation there is: + * the subject is the process, not the command, and every command goes through + * the same `bin/run.js` prologue. + */ +function runPublishedEntry(arm: 'guarded' | 'unguarded'): Promise { + const marks = join(dir, `${arm}.marks`); + writeFileSync(marks, ''); + const readMarks = (): string => { + try { + return readFileSync(marks, 'utf8'); + } catch { + return ''; + } + }; + return new Promise((resolvePromise) => { + const child = spawn(process.execPath, ['--import', pathToFileURL(PROBE).href, RUN_JS, '--version'], { + env: childEnv({ + NO_COLOR: '1', + OS_PUBLISHED_ENTRY_ERROR_PROBE_MARKS: marks, + OS_PUBLISHED_ENTRY_ERROR_PROBE_ARM: arm, + }), + stdio: ['ignore', 'ignore', 'pipe'], + }); + // ⭐ The condition under test. `pause()` would only stop READING — the + // kernel's buffer and node's own would absorb the write and nothing would + // fail. Destroying the read end is what makes the write fail. + child.stderr?.destroy(); + + const started = Date.now(); + const cap = setTimeout(() => child.kill('SIGKILL'), HARD_CAP_MS); + child.once('exit', (code, signal) => { + clearTimeout(cap); + resolvePromise({ code, signal, elapsedMs: Date.now() - started, marks: readMarks() }); + }); + }); +} + +beforeAll(async () => { + requireBuiltCli(PUBLISHED_ENTRY_NEEDS_DIST); + dir = mkdtempSync(join(tmpdir(), 'os-published-entry-stderr-error-')); + guarded = await runPublishedEntry('guarded'); + unguarded = await runPublishedEntry('unguarded'); +}, HARD_CAP_MS * 3); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('the published entry point survives a failed stderr write', () => { + it('attaches the listener before anything of its own can write', () => { + // The probe reports what it SAW rather than being assumed to have found it: + // `LISTENER ABSENT` is the reading when `bin/run.js` stops attaching one, + // and it is a different sentence from "the probe never ran". + expect( + guarded.marks, + `the probe never reached the listener check, so it measured NOTHING — a zero reading, not a pass. Markers:\n${guarded.marks}`, + ).toMatch(/LISTENER (ATTACHED|ABSENT)/); + expect( + guarded.marks, + `bin/run.js no longer attaches an \`error\` listener to process.stderr — a failed stderr write is an ` + + `uncaught exception again on the entry point a customer's install runs (#14858, #15564). Markers:\n${guarded.marks}`, + ).toContain('LISTENER ATTACHED'); + }); + + it('outlives a failed write and ends with its own status, not a crash', () => { + const evidence = `ceiling ${HARD_CAP_MS} ms (constant, load-independent by design); this child ran ${guarded.elapsedMs} ms. Markers:\n${guarded.marks}`; + expect(guarded.marks, `the probe never made its write. ${evidence}`).toContain('WROTE'); + expect( + guarded.marks, + `the child died of an uncaught exception on a stderr write — see the ablation in the header. ${evidence}`, + ).not.toContain('UNCAUGHT'); + expect(guarded.marks, `the child did not outlive its own failed write. ${evidence}`).toContain('SURVIVED'); + expect(guarded.signal, `the harness SIGKILLed the child — it was still alive at the ceiling. ${evidence}`).toBeNull(); + expect(guarded.code, `the child did not exit with the probe's own status. ${evidence}`).toBe(7); + }); + + it('still crashes with the listener taken away — the live positive control', () => { + // ⛔ Without this the case above is not evidence. A probe whose write had + // silently stopped FAILING — a `pause()` that never destroys, a + // `console.error` that swallows its own errors, a node that stopped + // reporting EPIPE here — would be green on the guarded arm forever. This + // arm removes the listener IN THE CHILD's process (nothing on disk), so the + // hazard is re-armed against the same tree in the same run. + const evidence = `this child ran ${unguarded.elapsedMs} ms. Markers:\n${unguarded.marks}`; + expect(unguarded.marks, `the unguarded arm never made its write. ${evidence}`).toContain('WROTE'); + expect( + unguarded.marks, + `the write did not fail with the listener removed, so the GUARDED arm above proves nothing — this ` + + `instrument has stopped discriminating and the case it controls is vacuous. ${evidence}`, + ).toContain('UNCAUGHT code=EPIPE'); + expect(unguarded.marks, `the unguarded arm survived, so nothing was being guarded against. ${evidence}`).not.toContain('SURVIVED'); + expect(unguarded.code, `the unguarded arm did not die of its uncaught exception. ${evidence}`).toBe(1); + }); +}); + +describe('the premise the measurement rests on', () => { + it('keeps a RAW stderr write on a long-lived published command', () => { + // ⚠️ The reachability half, held rather than assumed. `serve` is what + // #15564 reproduced on, and only because two things are true of it at once: + // it writes to stderr WITHOUT `console.error`'s `ignoreErrors` guard, and + // it stays alive across the write. If every raw write here were ever routed + // through `console.error`, the measurement in this file's header would no + // longer describe the tree — re-measure before reading the pins above as + // covering a live hazard. + // + // Comments are masked so the docblocks that DISCUSS `process.stderr.write` + // — including the one at the call site — cannot answer for the call itself. + const code = maskComments(readFileSync(SERVE_COMMAND, 'utf8')); + expect( + code, + `${SERVE_COMMAND} no longer writes to stderr directly. That is not automatically a defect, but it ` + + `removes the reproduction #15564 measured, so the header above needs re-measuring rather than trusting.`, + ).toContain('process.stderr.write('); + }); +}); From f07368b41031d7ecc10f5e35a269e9dc985b5206 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 19:20:23 +0000 Subject: [PATCH 2/4] fix(cli): name the stderr error listener so the pin can see it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first ablation of this change caught a phantom check in its own pin: a probe polling `process.stderr.listenerCount('error') > 0` reported `LISTENER ATTACHED after 20 ms` against a tree with the whole listener block deleted. Node parks an anonymous `once('error', noop)` on this stream for the duration of every `console.error` (`ignoreErrors`), so a count cannot tell this listener apart from that one, and the poll doubled as the probe's only synchronisation point — a slow box would have written before the attach and red a working tree. The listener is named, the probe waits for that name, and a parity case holds the probe's mirror equal to the entry's spelling. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --- packages/cli/bin/run.js | 9 ++++- .../published-entry-stderr-error-probe.mjs | 26 ++++++++++++++- ...ed-entry-stderr-error-listener.e2e.test.ts | 33 ++++++++++++++++--- 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js index 3915e1e95e..7d9324d7b0 100755 --- a/packages/cli/bin/run.js +++ b/packages/cli/bin/run.js @@ -137,7 +137,14 @@ try { * it is a real behaviour change for a supervisor that destroyed the read end * and relied on the crash to end the child. */ -process.stderr.on('error', () => { +// ⚠️ NAMED, and not for tidiness. Node parks an anonymous `once('error')` on +// this stream for the duration of a `console.error` (`ignoreErrors`), so +// "something is listening" is briefly true in any process and cannot tell this +// listener apart from that one — a pin that polled the COUNT passed against a +// tree with this whole block deleted, measured. The name is what +// `published-entry-stderr-error-listener.e2e.test.ts` waits for and asserts on; +// it also puts a legible frame in any listener dump. +process.stderr.on('error', function objectstackStderrErrorIsNotFatal() { // Nothing to report, and nowhere left to report it. }); diff --git a/packages/cli/test/fixtures/published-entry-stderr-error-probe.mjs b/packages/cli/test/fixtures/published-entry-stderr-error-probe.mjs index ffc5f1299d..ca6d272b4e 100644 --- a/packages/cli/test/fixtures/published-entry-stderr-error-probe.mjs +++ b/packages/cli/test/fixtures/published-entry-stderr-error-probe.mjs @@ -40,6 +40,16 @@ * Markers go to a file: stderr is the thing under test and, on the arm that is * supposed to fail, the thing that is already broken. * + * ## Why it waits for a NAMED listener and not for a count + * + * Node parks an anonymous `once('error', noop)` on this stream for the duration + * of every `console.error` (`ignoreErrors`), so `listenerCount('error') > 0` is + * briefly true in any process. An earlier version of this probe polled the + * count, and under the ablation that deletes the entry's listener entirely it + * still reported `LISTENER ATTACHED after 20 ms` — a green reading against a + * tree with nothing guarding it. The name is the only thing that identifies + * THIS listener, so it is what the poll waits for. + * * env: `OS_PUBLISHED_ENTRY_ERROR_PROBE_MARKS` — the marker file. * env: `OS_PUBLISHED_ENTRY_ERROR_PROBE_ARM` — `guarded` (default) | `unguarded`. */ @@ -67,6 +77,18 @@ const ATTACH_WAIT_MS = 15_000; /** Comfortably finer than anything being timed. */ const POLL_MS = 10; +/** + * The listener `bin/run.js` attaches, by name. Mirrored rather than imported — + * that file runs the CLI at module top, so there is nothing to import from it — + * and held equal to the entry's spelling by a case in the driving suite, the + * same discipline `run-dev-unbuilt-workspace.e2e.test.ts` uses for the shim's + * drain bound. + */ +const LISTENER_NAME = 'objectstackStderrErrorIsNotFatal'; + +/** Is the entry's OWN listener on the stream right now? */ +const guardAttached = () => process.stderr.listeners('error').some((fn) => fn?.name === LISTENER_NAME); + /** * ⛔ This probe installs NO `error` listener of its own on `process.stderr`. * `uncaughtExceptionMonitor` observes the default action without preventing it, @@ -103,13 +125,15 @@ function writeAndOutliveIt() { let waited = 0; const poll = setInterval(() => { - const attached = process.stderr.listenerCount('error') > 0; + const attached = guardAttached(); if (!attached && waited < ATTACH_WAIT_MS) { waited += POLL_MS; return; } clearInterval(poll); mark(attached ? `LISTENER ATTACHED after ${waited} ms` : `LISTENER ABSENT after ${waited} ms`); + // The raw count too, as EVIDENCE in a failure message — never as the oracle. + mark(`LISTENERS count=${process.stderr.listenerCount('error')}`); if (UNGUARDED) { // The live positive control — in this process only, never on disk. process.stderr.removeAllListeners('error'); diff --git a/packages/cli/test/published-entry-stderr-error-listener.e2e.test.ts b/packages/cli/test/published-entry-stderr-error-listener.e2e.test.ts index 442ec579dc..9f6bb72615 100644 --- a/packages/cli/test/published-entry-stderr-error-listener.e2e.test.ts +++ b/packages/cli/test/published-entry-stderr-error-listener.e2e.test.ts @@ -60,8 +60,8 @@ * between the two entries is what #15564 refused to accept as evidence, and a * case asserting it would smuggle that reading back in. * - * The reachability half is not left unheld either — case 3 keeps the premise - * the measurement rests on: `serve` still writes to stderr RAW. + * The reachability half is not left unheld either — the last case below keeps + * the premise the measurement rests on: `serve` still writes to stderr RAW. */ import { spawn } from 'node:child_process'; @@ -166,21 +166,46 @@ afterAll(() => { }); describe('the published entry point survives a failed stderr write', () => { - it('attaches the listener before anything of its own can write', () => { + it('attaches its OWN listener before anything of its own can write', () => { // The probe reports what it SAW rather than being assumed to have found it: // `LISTENER ABSENT` is the reading when `bin/run.js` stops attaching one, // and it is a different sentence from "the probe never ran". + // + // ⚠️ It looks for the listener BY NAME, and that is a correction rather + // than a flourish: node parks an anonymous `once('error', noop)` across + // every `console.error`, so a count-based version of this case reported + // `LISTENER ATTACHED after 20 ms` against a tree with the whole block + // deleted — measured, under the ablation below. The count is carried in + // the markers as evidence and decides nothing. expect( guarded.marks, `the probe never reached the listener check, so it measured NOTHING — a zero reading, not a pass. Markers:\n${guarded.marks}`, ).toMatch(/LISTENER (ATTACHED|ABSENT)/); expect( guarded.marks, - `bin/run.js no longer attaches an \`error\` listener to process.stderr — a failed stderr write is an ` + + `bin/run.js no longer attaches its \`error\` listener to process.stderr — a failed stderr write is an ` + `uncaught exception again on the entry point a customer's install runs (#14858, #15564). Markers:\n${guarded.marks}`, ).toContain('LISTENER ATTACHED'); }); + it("keeps the probe's mirror of the listener name equal to the entry's own", () => { + // The probe cannot import the name — `bin/run.js` runs the CLI at module + // top — so it mirrors it, and a mirror with nothing holding it is how a + // synchronisation point ends up waiting for a spelling that moved. Same + // discipline as `run-dev-unbuilt-workspace.e2e.test.ts` keeps over the + // shim's drain bound. ⛔ A renamed listener would not red the cases above: + // the probe would simply time out and write early, which on a fast box + // still survives. + const mirrored = /const LISTENER_NAME = '([A-Za-z0-9_$]+)';/.exec(readFileSync(PROBE, 'utf8'))?.[1]; + expect(mirrored, `no LISTENER_NAME declaration found in ${PROBE}`).toBeDefined(); + const entry = maskComments(readFileSync(RUN_JS, 'utf8')); + expect( + entry, + `${RUN_JS} does not attach a listener named ${mirrored}, which is the name the probe waits for — ` + + `either the entry renamed it or it stopped attaching one at all.`, + ).toContain(`function ${mirrored}(`); + }); + it('outlives a failed write and ends with its own status, not a crash', () => { const evidence = `ceiling ${HARD_CAP_MS} ms (constant, load-independent by design); this child ran ${guarded.elapsedMs} ms. Markers:\n${guarded.marks}`; expect(guarded.marks, `the probe never made its write. ${evidence}`).toContain('WROTE'); From edb7c2bcb4bf93dceea2179ba8307a1f9c28ecf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 21:12:32 +0000 Subject: [PATCH 3/4] test(cli): pin the attach ORDER, the real writer, and a named control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review-adopted fixes to the #15564 pin, none of which change `bin/run.js`'s behaviour. F1 — the order case pinned PRESENCE, not order. `LISTENER ATTACHED after N ms` is true for any N inside the probe's 15 s wait, so an attach moved below `await run(…)` would keep every case green while the entry's own claim ("BEFORE `run()`, and that order is the whole point") had stopped being true. The runtime cases cannot see it — one process, `--version` settles oclif in a few hundred ms, both have happened by the time the poll looks — so the order is now read STRUCTURALLY from the entry's comment-masked source, in a case of its own, and the presence case is renamed to claim only what it pins. F2 — the premise case asserted that `serve.ts` contains `process.stderr.write(`. That file holds about a dozen such sites, so it stayed green even if `printDiagnostic` — the one writer the reproduction ran through (#7915) — moved to `console.error` and stopped being able to crash anything. It now anchors on `printDiagnostic`'s own body, located by symbol and brace-matched, with a length bound so a desynchronised match reds instead of reporting green about some other writer. F3 — the unguarded (positive-control) arm called `removeAllListeners('error')`. Equivalent today, but it measures "no listener at all" rather than "the entry's listener absent"; it now removes the guard BY NAME, and marks the resulting `guard=` reading. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --- .../published-entry-stderr-error-probe.mjs | 13 +- ...ed-entry-stderr-error-listener.e2e.test.ts | 115 ++++++++++++++++-- 2 files changed, 116 insertions(+), 12 deletions(-) diff --git a/packages/cli/test/fixtures/published-entry-stderr-error-probe.mjs b/packages/cli/test/fixtures/published-entry-stderr-error-probe.mjs index ca6d272b4e..c90be7e28e 100644 --- a/packages/cli/test/fixtures/published-entry-stderr-error-probe.mjs +++ b/packages/cli/test/fixtures/published-entry-stderr-error-probe.mjs @@ -136,8 +136,17 @@ const poll = setInterval(() => { mark(`LISTENERS count=${process.stderr.listenerCount('error')}`); if (UNGUARDED) { // The live positive control — in this process only, never on disk. - process.stderr.removeAllListeners('error'); - mark(`ARM unguarded listeners=${process.stderr.listenerCount('error')}`); + // + // ⛔ BY NAME, not `removeAllListeners('error')`. The two are equivalent on + // today's tree, but the control has to measure "the ENTRY's listener is + // absent"; clearing the stream measures "no listener at all", and the day + // anything else attaches one here — a library, a future prologue, node + // itself — that would silently become a different experiment from the one + // the driving case claims to run. + for (const fn of process.stderr.listeners('error')) { + if (fn?.name === LISTENER_NAME) process.stderr.removeListener('error', fn); + } + mark(`ARM unguarded listeners=${process.stderr.listenerCount('error')} guard=${guardAttached()}`); } else { mark(`ARM guarded listeners=${process.stderr.listenerCount('error')}`); } diff --git a/packages/cli/test/published-entry-stderr-error-listener.e2e.test.ts b/packages/cli/test/published-entry-stderr-error-listener.e2e.test.ts index 9f6bb72615..fcb9886588 100644 --- a/packages/cli/test/published-entry-stderr-error-listener.e2e.test.ts +++ b/packages/cli/test/published-entry-stderr-error-listener.e2e.test.ts @@ -61,7 +61,8 @@ * case asserting it would smuggle that reading back in. * * The reachability half is not left unheld either — the last case below keeps - * the premise the measurement rests on: `serve` still writes to stderr RAW. + * the premise the measurement rests on: `serve`'s `printDiagnostic`, the one + * writer the reproduction ran through, still writes to stderr RAW. */ import { spawn } from 'node:child_process'; @@ -166,7 +167,7 @@ afterAll(() => { }); describe('the published entry point survives a failed stderr write', () => { - it('attaches its OWN listener before anything of its own can write', () => { + it('has its OWN listener on process.stderr by the time the probe looks', () => { // The probe reports what it SAW rather than being assumed to have found it: // `LISTENER ABSENT` is the reading when `bin/run.js` stops attaching one, // and it is a different sentence from "the probe never ran". @@ -177,6 +178,13 @@ describe('the published entry point survives a failed stderr write', () => { // `LISTENER ATTACHED after 20 ms` against a tree with the whole block // deleted — measured, under the ablation below. The count is carried in // the markers as evidence and decides nothing. + // + // ⛔ PRESENCE ONLY, and the name says so deliberately. `LISTENER ATTACHED + // after N ms` is true for every N inside the probe's 15 s wait, so this + // case cannot tell an attach at the top of `bin/run.js` apart from one + // moved below `await run(…)`. The ORDER is the next case, read from the + // entry's source, because no reading this process can take will ever + // distinguish them. expect( guarded.marks, `the probe never reached the listener check, so it measured NOTHING — a zero reading, not a pass. Markers:\n${guarded.marks}`, @@ -188,6 +196,46 @@ describe('the published entry point survives a failed stderr write', () => { ).toContain('LISTENER ATTACHED'); }); + it('attaches it ABOVE `await run(…)`, which is the order the entry claims', () => { + // ⭐ Why this case exists at all. The case above pins PRESENCE; a refactor + // that moved the attach BELOW `await run(…)` would keep it — and every + // other runtime case here — green, while the entry's own claim ("⚠️ BEFORE + // `run()`, and that order is the whole point") had quietly stopped being + // true. The probe cannot see the difference: it is one process, `--version` + // settles oclif in a few hundred ms, and by the time the poll can look, + // both have already happened. So the order is read STRUCTURALLY, from the + // entry's source, which is the only place it is visible. + // + // What the order buys, in the entry's words: everything the CLI writes to + // stderr is written from inside `run()`, so a listener installed after it + // has already missed the writes it exists to survive. + // + // ⚠️ Both sites are located BY TEXT, never by line number — the docblocks + // around them move whenever anyone edits them. Comments are masked so the + // paragraphs that DISCUSS this order, several of which name `run()`, can + // never answer for the code. + const entry = maskComments(readFileSync(RUN_JS, 'utf8')); + const attaches = [...entry.matchAll(/process\.stderr\.on\s*\(\s*['"]error['"]/g)].map((m) => m.index ?? -1); + expect( + attaches.length, + `${RUN_JS} no longer attaches any \`error\` listener to process.stderr — a failed stderr write is an uncaught ` + + `exception again on the entry point a customer's install runs (#14858, #15564).`, + ).toBeGreaterThan(0); + const runCall = entry.search(/\bawait\s+run\s*\(/); + expect( + runCall, + `${RUN_JS} no longer calls \`await run(\`. That is not automatically a defect, but this case can no longer ` + + `read the order it pins — re-locate both sites by text before trusting it.`, + ).toBeGreaterThan(-1); + expect( + Math.max(...attaches), + `${RUN_JS} attaches its \`error\` listener at or after \`await run(\` (last attach at offset ` + + `${Math.max(...attaches)}, \`await run(\` at ${runCall}). Every byte this CLI puts on stderr is written from ` + + `inside \`run()\`, so a listener installed there has already missed what it exists to survive — and no runtime ` + + `case in this file can see that, because both have happened by the time the probe looks.`, + ).toBeLessThan(runCall); + }); + it("keeps the probe's mirror of the listener name equal to the entry's own", () => { // The probe cannot import the name — `bin/run.js` runs the CLI at module // top — so it mirrors it, and a mirror with nothing holding it is how a @@ -224,7 +272,11 @@ describe('the published entry point survives a failed stderr write', () => { // `console.error` that swallows its own errors, a node that stopped // reporting EPIPE here — would be green on the guarded arm forever. This // arm removes the listener IN THE CHILD's process (nothing on disk), so the - // hazard is re-armed against the same tree in the same run. + // hazard is re-armed against the same tree in the same run. It removes the + // entry's listener BY NAME rather than clearing the stream: the control has + // to be "the entry's guard is gone", and a `removeAllListeners('error')` + // would silently become "nothing is listening at all" the day anything else + // attaches one — a different experiment from the one this case claims. const evidence = `this child ran ${unguarded.elapsedMs} ms. Markers:\n${unguarded.marks}`; expect(unguarded.marks, `the unguarded arm never made its write. ${evidence}`).toContain('WROTE'); expect( @@ -238,22 +290,65 @@ describe('the published entry point survives a failed stderr write', () => { }); describe('the premise the measurement rests on', () => { - it('keeps a RAW stderr write on a long-lived published command', () => { + it('keeps a RAW stderr write inside `printDiagnostic`, the writer it reproduced on', () => { // ⚠️ The reachability half, held rather than assumed. `serve` is what // #15564 reproduced on, and only because two things are true of it at once: // it writes to stderr WITHOUT `console.error`'s `ignoreErrors` guard, and - // it stays alive across the write. If every raw write here were ever routed + // it stays alive across the write. If that raw write were ever routed // through `console.error`, the measurement in this file's header would no // longer describe the tree — re-measure before reading the pins above as // covering a live hazard. // - // Comments are masked so the docblocks that DISCUSS `process.stderr.write` - // — including the one at the call site — cannot answer for the call itself. + // ⛔ ANCHORED ON `printDiagnostic`'S OWN BODY, not on the file. `serve.ts` + // holds about a dozen `process.stderr.write(` sites, so a whole-file + // `toContain` stays green while the ONE writer this reproduction rests on + // — the boot diagnostic, #7915, the line the crash was measured at — moves + // to `console.error` and stops being able to crash anything. + // + // ⚠️ Located BY SYMBOL, never by line number. Comments are masked so the + // docblocks that DISCUSS `process.stderr.write` — including the one + // directly above this declaration — cannot answer for the call itself. const code = maskComments(readFileSync(SERVE_COMMAND, 'utf8')); + const decl = code.indexOf('const printDiagnostic ='); + expect( + decl, + `${SERVE_COMMAND} no longer declares \`const printDiagnostic =\`. That is not automatically a defect, but it ` + + `is the writer #15564 measured the crash on, so re-locate it by symbol and re-point this case rather than ` + + `widening it back to the whole file.`, + ).toBeGreaterThan(-1); + // Brace-matched over comment-masked source: the body is one statement and + // carries no braces of its own today, and the length bound below is what + // keeps a desynchronised match from reporting green against some other + // writer further down the file. + const open = code.indexOf('{', decl); + let depth = 0; + let end = -1; + for (let i = open; i >= 0 && i < code.length; i += 1) { + if (code[i] === '{') depth += 1; + else if (code[i] === '}') { + depth -= 1; + if (depth === 0) { + end = i; + break; + } + } + } + const body = end > open ? code.slice(open, end + 1) : ''; + expect( + body.length, + `could not read \`printDiagnostic\`'s body out of ${SERVE_COMMAND} — the brace match ran away (${body.length} ` + + `chars), so this case measured NOTHING. Re-locate the declaration before trusting any verdict from it.`, + ).toBeGreaterThan(0); + expect( + body.length, + `\`printDiagnostic\`'s body read back as ${body.length} chars, far past the one statement it is — the brace ` + + `match lost sync, so a hit below would be about some other writer in ${SERVE_COMMAND}.`, + ).toBeLessThan(1000); expect( - code, - `${SERVE_COMMAND} no longer writes to stderr directly. That is not automatically a defect, but it ` + - `removes the reproduction #15564 measured, so the header above needs re-measuring rather than trusting.`, + body, + `\`printDiagnostic\` in ${SERVE_COMMAND} no longer writes to stderr RAW. That is not automatically a defect, ` + + `but \`console.error\` carries \`ignoreErrors\` and cannot crash this process at any size, so it removes the ` + + `reproduction #15564 measured — the header above needs re-measuring rather than trusting.`, ).toContain('process.stderr.write('); }); }); From c4b3a348548afe34a9c9272b326b728b3cfedcd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 21:51:27 +0000 Subject: [PATCH 4/4] test(cli): run the stderr-error pin per-PR, without a borrowed dist gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin drives `bin/run.js --version` under a `--import` probe and reads the entry's source; nothing it measures lives in `dist`. Measured with `packages/cli/dist` moved aside: `--version` printed `@objectstack/cli/17.3.0 linux-x64 node-v22.22.2`, and all six cases passed — guarded arm `LISTENER ATTACHED … WROTE … SURVIVED … EXIT code=7`, unguarded arm `UNCAUGHT code=EPIPE … EXIT code=1`. So `PUBLISHED_ENTRY_NEEDS_DIST` was a sentence borrowed from `published-entry-stderr-nonblocking.e2e.test.ts` (whose guard genuinely IS the compiled `../dist/utils/stderr-nonblocking.js` import) without the reason coming with it — the exact failure the helper's docblock above `RUN_JS_RESOLVES_FROM_DIST` names. Removed, and replaced by a note that states the measurement and the difference from that neighbour. With no dist dependency the file has no reason to sit in the nightly `.e2e` tier: renamed out of it, so it runs per-PR in `--project integration` (34 → 35 files). Both neighbouring `.e2e` suites are untouched (#16455). Also corrects a wrong count this branch introduced: `serve.ts` holds 2 code-position `process.stderr.write(` sites (`printDiagnostic` and the artifact `warn:` adapter), not "about a dozen". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --- packages/cli/bin/run.js | 2 +- .../published-entry-stderr-error-probe.mjs | 2 +- ...ished-entry-stderr-error-listener.test.ts} | 46 ++++++++++++------- 3 files changed, 31 insertions(+), 19 deletions(-) rename packages/cli/test/{published-entry-stderr-error-listener.e2e.test.ts => published-entry-stderr-error-listener.test.ts} (89%) diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js index 7d9324d7b0..84b3f2b6ba 100755 --- a/packages/cli/bin/run.js +++ b/packages/cli/bin/run.js @@ -142,7 +142,7 @@ try { // "something is listening" is briefly true in any process and cannot tell this // listener apart from that one — a pin that polled the COUNT passed against a // tree with this whole block deleted, measured. The name is what -// `published-entry-stderr-error-listener.e2e.test.ts` waits for and asserts on; +// `published-entry-stderr-error-listener.test.ts` waits for and asserts on; // it also puts a legible frame in any listener dump. process.stderr.on('error', function objectstackStderrErrorIsNotFatal() { // Nothing to report, and nowhere left to report it. diff --git a/packages/cli/test/fixtures/published-entry-stderr-error-probe.mjs b/packages/cli/test/fixtures/published-entry-stderr-error-probe.mjs index c90be7e28e..4ab3042d4e 100644 --- a/packages/cli/test/fixtures/published-entry-stderr-error-probe.mjs +++ b/packages/cli/test/fixtures/published-entry-stderr-error-probe.mjs @@ -2,7 +2,7 @@ /** * The #14858 crash class, manufactured INSIDE the published entry point's own - * process — driven by `published-entry-stderr-error-listener.e2e.test.ts`. + * process — driven by `published-entry-stderr-error-listener.test.ts`. * * Loaded with `node --import bin/run.js …` against a read end the parent * has destroyed, so everything below runs in the same process as the shipped diff --git a/packages/cli/test/published-entry-stderr-error-listener.e2e.test.ts b/packages/cli/test/published-entry-stderr-error-listener.test.ts similarity index 89% rename from packages/cli/test/published-entry-stderr-error-listener.e2e.test.ts rename to packages/cli/test/published-entry-stderr-error-listener.test.ts index fcb9886588..fcb8957c2f 100644 --- a/packages/cli/test/published-entry-stderr-error-listener.e2e.test.ts +++ b/packages/cli/test/published-entry-stderr-error-listener.test.ts @@ -74,7 +74,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { maskComments } from '../../../scripts/js-comment-mask.mjs'; -import { childEnv, requireBuiltCli } from './helpers/serve-process.js'; +import { childEnv } from './helpers/serve-process.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); /** `packages/cli` — this package's own root, never another package's. */ @@ -83,17 +83,6 @@ const RUN_JS = join(PACKAGE_ROOT, 'bin', 'run.js'); const SERVE_COMMAND = join(PACKAGE_ROOT, 'src', 'commands', 'serve.ts'); const PROBE = join(HERE, 'fixtures', 'published-entry-stderr-error-probe.mjs'); -/** - * Why a `bin/run.js` child needs `packages/cli/dist`, in this file's own terms. - * - * ⚠️ The mechanism is the caller's to supply and must be TRUE OF THIS CALLER — - * a borrowed sentence is a false explanation attached to a true refusal. - */ -const PUBLISHED_ENTRY_NEEDS_DIST = - 'This file drives bin/run.js with NODE_ENV unset, so oclif resolves the command from dist/ and the ' + - 'entry point reaches its own prologue; on an unbuilt tree the child answers "command not found" before ' + - 'the probe can read anything, and every assertion below would be about a run that never happened.'; - /** * The one ceiling, a CONSTANT for the reason both neighbouring files record: a * ceiling derived from a calibration is a prediction about contention that a @@ -155,8 +144,28 @@ function runPublishedEntry(arm: 'guarded' | 'unguarded'): Promise { }); } +// ⛔ NO `requireBuiltCli()` here, and the absence is MEASURED rather than an +// oversight. Everything this file reads lives in source: the listener is +// attached in `bin/run.js` itself — the file npm packs as the `bin` target +// however `files` is written (#14874) — and both `../dist/` imports in that +// entry are wrapped in a `try/catch` that degrades to silence, so an absent +// `dist` costs the child nothing it is measured on here. `--version` is +// answered by oclif's own config and never reaches `dist/commands`. +// +// Measured on this tree with `packages/cli/dist` moved aside: the child printed +// its version, the guarded arm read `LISTENER ATTACHED … WROTE … SURVIVED … +// EXIT code=7` and the unguarded arm `UNCAUGHT code=EPIPE … EXIT code=1` — every +// case below green, and still RED under the listener ablation, with no build in +// the tree at all. +// +// ⚠️ That is exactly where this file differs from its neighbour +// `published-entry-stderr-nonblocking.e2e.test.ts`, whose subject +// (`keepStderrNonBlocking`) IS the compiled `../dist/utils/stderr-nonblocking.js` +// import: that file's gate is true OF THAT FILE, and it keeps both its gate and +// its `.e2e` name. ⛔ Do not borrow it back here. A gate copied without its +// reason is a false explanation attached to a true refusal — the failure class +// the helper's own docblock above `RUN_JS_RESOLVES_FROM_DIST` names. beforeAll(async () => { - requireBuiltCli(PUBLISHED_ENTRY_NEEDS_DIST); dir = mkdtempSync(join(tmpdir(), 'os-published-entry-stderr-error-')); guarded = await runPublishedEntry('guarded'); unguarded = await runPublishedEntry('unguarded'); @@ -300,10 +309,13 @@ describe('the premise the measurement rests on', () => { // covering a live hazard. // // ⛔ ANCHORED ON `printDiagnostic`'S OWN BODY, not on the file. `serve.ts` - // holds about a dozen `process.stderr.write(` sites, so a whole-file - // `toContain` stays green while the ONE writer this reproduction rests on - // — the boot diagnostic, #7915, the line the crash was measured at — moves - // to `console.error` and stops being able to crash anything. + // holds one OTHER `process.stderr.write(` site — the `warn:` adapter it + // hands `resolveArtifactReference`, for the cache-fallback note an operator + // must not miss — so a whole-file `toContain` stays green while the ONE + // writer this reproduction rests on — the boot diagnostic, #7915, the line + // the crash was measured at — moves to `console.error` and stops being able + // to crash anything. (Counted on this head: 2 code-position sites, comments + // masked; the docblocks nearby DISCUSS several more.) // // ⚠️ Located BY SYMBOL, never by line number. Comments are masked so the // docblocks that DISCUSS `process.stderr.write` — including the one