Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/tidy-cars-repeat.md
Original file line number Diff line number Diff line change
@@ -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.
78 changes: 78 additions & 0 deletions packages/cli/bin/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,84 @@ 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.
*/
// ⚠️ 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.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.
});

await run(process.argv.slice(2), import.meta.url)
.then(async (result) => {
flush();
Expand Down
154 changes: 154 additions & 0 deletions packages/cli/test/fixtures/published-entry-stderr-error-probe.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// 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.test.ts`.
*
* Loaded with `node --import <this> 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.
*
* ## 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`.
*/

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;

/**
* 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,
* 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 = 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.
//
// ⛔ 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')}`);
}
writeAndOutliveIt();
}, POLL_MS);
Loading
Loading