fix(cli): make a failed stderr write non-fatal on the published entry point - #16686
Conversation
`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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
…n-js-stderr-error-listener
📓 Docs Drift Check
What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
验收备注 — one more, and it is the one thing I could not reconcile
PR #15558's docblock in
⛔ This does not put
⇒ A reader reasoning from that sentence alone could conclude Generated by Claude Code |
CI 读数 + 一处本席的更正:
|
Hand docs sweep — the drift bot's null reading, answeredDispatched by the
|
| control term | lines hit | e.g. |
|---|---|---|
os serve |
101 (37 files) | content/docs/deployment/cli.mdx:195 (#### os serve), packages/cli/README.md:193 |
os start |
77 | content/docs/deployment/cli.mdx:313 |
os dev |
152 | content/docs/deployment/cli.mdx:132 |
exit code |
80 | content/docs/deployment/cli.mdx:1376 |
⇒ The instrument reaches operator-facing CLI prose, the os serve/os dev/os start sections, and exit-status prose. The zeros below are therefore readings.
The falsifier class — every term returned ZERO across all 1006 files:
EPIPE · SIGPIPE · broken pipe · closed pipe · pipe closed · output pipe · reader has gone · reader goes · closes stdout · closes stderr · close stdout · close stderr · write to stderr · writes to stderr · writing to stderr · raw write · stops reading* · no longer running · terminal closes · nohup · parent process · runs forever · never exits · uncaughtException · uncaught exception · ignoreErrors · run-dev · 14858 · 15564 · pm2 · process manager · restart policy · journald · docker logs · kubectl logs · log collector · os dev | · os serve | · os start | · os dev > · os serve > · os dev & · os serve & · os start &
* stops reading returns 7 lines, all the idiom "X stops reading as Y" (content/docs/releases/v17.mdx:1577 etc.) — none is about a stream.
Non-zero families, read line by line rather than counted: pipe excluding pipeline (18) · stderr (24) · stdout · read end (1) · SIGHUP (2) · SIGTERM/SIGINT (3) · systemd (7) · supervisor (5) · bin/run (7) · exit code / exit status / exits with / exit non-zero / exit 1 · crash / dies / died / aborts / torn down / killed by / kubernetes / liveness / health check / docker run / restart / hangs / detach / background.
Backstop, run as a backstop and not as the sweep: node scripts/docs-audit/affected-docs.mjs --json c8e5ac645f65dbaed0e44d8e40644f28a156a022 → exit 0, packageMentionDocs = 22 pages. All 22 re-grepped for pipe · EPIPE · exit · crash · stderr · stdout · terminat · kill · restart · dies/die. Zero EPIPE; every exit/crash hit resolves to a row in the table below.
hits and why each is NOT falsified:
| # | hit | why it survives |
|---|---|---|
| 1 | content/docs/deployment/cli.mdx:1561-1564 — "os login still prompts on a pipe … if stdin reaches end of input before a prompt is answered, the command reports it and exits 1, rather than being torn down by Node with an exit code the CLI does not define" |
The closest sentence in the corpus, and it is about the opposite end: stdin EOF, not a stderr write failure — and about os login, a one-shot interactive command, not the long-running trio. Unchanged by this diff. |
| 2 | content/docs/deployment/cli.mdx:367 · content/docs/deployment/environment-variables.mdx:36 · skills/objectstack-platform/SKILL.md:1090,1211 — os start exits 1 on a busy port; "production never auto-shifts" |
The CLI's own status, decided at boot before any diagnostic write can fail. This diff preserves exactly that reading — it removes a crash that replaced it. |
| 3 | content/docs/deployment/self-hosting.mdx:343-380 — bare-Node systemd unit, ExecStart=/usr/bin/os start, Restart=on-failure |
States a supervisor policy, never an output-pipe rule. Under systemd the read end is held by the journal for the process's lifetime, so the falsifying topology (read end destroyed) is not the one documented. |
| 4 | content/docs/deployment/self-hosting.mdx:244-282 · content/docs/deployment/cli.mdx:227-235 — liveness/readiness split, "a configuration fault never restarts the pod", CrashLoopBackOff warning |
Pod restart is driven by GET /api/v1/health over HTTP, never by a stderr write. If anything this diff moves the runtime toward what these pages promise: a process that is still serving stays up. |
| 5 | docs/qa/platform-checklist/areas/cli.json items cli.build-contract / cli.flag-command-error-ux (lines 104-182) — exit-code honesty: legitimate exits are 0/1 via CliExitCode plus oclif's fixed ExitError 2 on a this.error() path |
Scored on one-shot os build/os compile/os migrate, not the long-running trio — and re-measured by this PR's own probe table: with the listener present, definitely-not-a-command and OBJECTSTACK_DEBUG=1 both still answer exit 2, no uncaughtException, 3/3. The clause reads the same before and after. |
| 6 | docs/qa/platform-checklist/RUNNER.md:293-311 — "backgrounded … a rejected flag looks exactly like a server that booted and died"; INVOCATION ERROR; node packages/cli/dist/index.js refuses and exits 1; "the entry point is packages/cli/bin/run.js" |
Names the very file this diff touches, so it was read in full. It states an argv-parse rule, not an output-pipe rule, and its topology is a backgrounded boot redirected to a log file — a read end that is never destroyed. Both the INVOCATION ERROR line and the dist/index.js exit 1 are unchanged. |
| 7 | packages/cli/README.md:298,327 — bin/run.js as the oclif entry point |
Structural description of the package layout; carries no lifecycle claim. |
| 8 | docs/adr/0062-external-datasource-runtime.md:114 — the degraded-boot banner goes to "stderr as well as the logger, because os serve's boot-quiet capture swallows stdout" |
The nearest page in the corpus to this diff's subject. It states which channel a banner uses; it says nothing about what happens when a write to that channel fails. Survives, and the diff makes the banner more likely to be delivered, not less. |
| 9 | docs/adr/0005-metadata-customization-overlay.md:486-487 — "Process restart / SIGHUP" as artifact-reload triggers |
Reload mechanism, unrelated to output pipes. |
| 10 | skills/objectstack-automation/SKILL.md:441 — "read end-to-end" |
English idiom; the only read end hit in the corpus and not a stream. |
| 11 | content/docs/deployment/cli.mdx:1083-family QA note "the boot's own log lines must have gone to stderr (#6217)" · content/docs/references/system/logging.mdx:42,142,337 (stream: 'stdout' | 'stderr') |
Channel selection, not write-failure behaviour. |
The load-bearing absence, stated positively: no page in the corpus states an exit-status contract for os serve / os dev / os start at all beyond row 2's busy-port refusal, and no page anywhere states that the CLI dies, exits, aborts or crashes when its output pipe is closed or its reader goes away. ⇒ The measured delta — dies with an uncaught EPIPE at its first raw diagnostic write → keeps running and returns its own exit status — has no documented sentence to contradict.
content/docs/releases/ — read, ⛔ not touched, and clean
Release notes are release-owned, so they were swept read-only. EPIPE|SIGPIPE|broken pipe = 0 across content/docs/releases/**, and the os serve / os dev / os start prose there (v16.mdx:106,404, v17.mdx:1994,2190,2824,4257,4689) states boot, banner and plugin-resolution behaviour, never pipe or write-failure behaviour. ⇒ Nothing to report and nothing to file — no docs-only PR is owed.
Consequences for this PR
- Case A. Nothing falsified ⇒ no page edited, no commit, no push, no docs gate run (none applies to a zero-file change; a gate's green would only have covered a tree identical to
a48dd2ab93anyway). ⚠️ Every reading above is against heada48dd2ab93a3f096fd7f0953a9889c3c62e69646and holds only for that tree. A later push to this branch that touches doc-adjacent prose re-opens the question.- The docs-drift bot's warning on this PR stays warranted as a null, and is now answered by hand rather than left standing as silence.
Generated by Claude Code
契约复核 PASS —
|
|
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
Review rulings F1–F3 delivered; the tier rename (item 1) is STOPPED on a false premiseHead: ⛔ Item 1 (rename out of
|
| # | premise | verdict |
|---|---|---|
| 1 | #16455 governs moving EXISTING files by name | ✅ packages/cli/vitest.config.ts:571 — "selection is by the EXISTING filename tiers only (*.e2e.test.*, *.live.test.*) and no file is renamed, deleted or edited to move it". This file is A (added) against origin/main, so it is new here. |
| 2 | the predicate is behavioural, and isIntegration fires on this file |
✅ vitest.config.ts:510 — "⛔ THE PREDICATE IS WHAT A FILE DOES, NOT WHAT IT IS CALLED"; vitest-tiers.ts:177 — s.runServe || (s.childProcess && (s.entryBasename || …)). The file imports spawn from node:child_process and names run.js in code position. |
| 3 | spawning per-PR is established, not novel — plugin-commands.test.ts spawns bin/run.js per-PR today |
❌ FALSE. packages/cli/test/plugin-commands.test.ts is 44 lines, imports no child_process, and spawns nothing — it asserts pkg.bin.os === './bin/run.js' (:41) off package.json. It is a unit-tier file. And the broader form is false too: of the 7 queue-population files that do spawn, every one drives bin/run-dev.js or a tsx child — none needs dist. This pin would be the first per-PR test whose verdict depends on packages/cli/dist being built, which is precisely the novelty the premise denied. |
| 4 | cost ~2.5 s | ✅ in substance, measured lower — see below. |
| 5 | turbo.json on origin/main declares @objectstack/cli#test dependsOn build |
✅ turbo.json:102-103. |
Premise 5 does make the move sound (CI would have dist/), but premise 3 was the one carrying "not novel", and the novelty is real. Handing that judgement back rather than making it here.
Measured cost if it is later ruled in (3 runs, --project integration, OS_TEST_TIERS=nightly, warm): vitest Duration 1.12 / 1.15 / 1.13 s (test bodies 657–701 ms); wall including vitest startup 3.06–3.65 s. The two --version spawns are ~700 ms of it.
The two .e2e sibling suites were not touched.
F1 — the order case pinned presence, not order (fixed)
attaches its OWN listener before anything of its own can write asserted LISTENER ATTACHED after N ms, true for any N inside the probe's 15 s wait. On --version oclif settles in a few hundred ms, so an attach moved below await run(…) kept every case green while bin/run.js's own claim ("run(), and that order is the whole point") had stopped being true. The probe cannot see it — one process, both have happened by the time the poll looks.
- the presence case is renamed to claim only presence:
has its OWN listener on process.stderr by the time the probe looks; - a new case reads the order structurally from the comment-masked entry: every
process.stderr.on('error'attach site must sit textually beforeawait run(. Both located by text, never by line number.
F2 — the premise case could be satisfied by the wrong writer (fixed)
serve.ts has 2 code-position process.stderr.write( sites (:1788 in printDiagnostic, :2090 in a warn: adapter), not one — so a whole-file toContain stayed green with printDiagnostic moved to console.error. It now anchors on printDiagnostic's own body, located by symbol and brace-matched over masked source, with a length bound so a desynchronised match reds instead of reporting green about the other writer.
F3 — the control removed too much (fixed)
The unguarded arm called removeAllListeners('error') — "no listener at all" rather than "the entry's listener absent". It now removes the guard by name (objectstackStderrErrorIsNotFatal) and records the result. Measured directly:
LISTENER ATTACHED after 0 ms
LISTENERS count=1
ARM unguarded listeners=0 guard=false
WROTE
UNCAUGHT code=EPIPE msg=write EPIPE
EXIT code=1
Fresh ablation — three arms, at edb7c2bcb4
Committed first, so the restore point is a real commit. Each arm: exact-anchor replace refusing on occurrence count ≠ 1, mutation proved on disk by occurrence counts and git hash-object, restore proved by blob hash and an empty git diff HEAD (never by exit code), trap … EXIT INT TERM, absolute paths.
| arm | mutation to bin/run.js / serve.ts |
blob before → mutated | pin |
|---|---|---|---|
| baseline | none | 7d9324d7b0… |
6 passed, 1.12–1.15 s |
| A — delete | listener block removed (anchor 1→0) | 7d9324d7b0… → ddc5f84f1e… |
4 failed / 2 passed |
| B — move | same block moved BELOW await run( (anchor still 1; attach line 158, await run( line 148) |
7d9324d7b0… → 74d6158407… |
1 failed / 5 passed |
| C — F2 | printDiagnostic → console.error (1 raw site left in file) |
c53cbcb558… → f491fead1a… |
1 failed / 5 passed |
Per case:
| case | A delete | B move | C console.error |
|---|---|---|---|
| has its OWN listener … by the time the probe looks | 🔴 | 🟢 | 🟢 |
attaches it ABOVE await run(…) (NEW) |
🔴 | 🔴 | 🟢 |
| keeps the probe's mirror … equal to the entry's own | 🔴 | 🟢 | 🟢 |
| outlives a failed write … not a crash | 🔴 | 🟢 | 🟢 |
| still crashes with the listener taken away (control) | 🟢 (correct — the on-disk guard is gone, so the control still crashes) | 🟢 | 🟢 |
keeps a RAW stderr write inside printDiagnostic (F2) |
🟢 | 🟢 | 🔴 |
Arm B is the one that matters: it is the exact refactor F1 named, and only the new structural case sees it — every other case in the file stays green. Its message: expected 8534 to be less than 8314. Arm C: the old whole-file assertion would have stayed green (:2090 survives the mutation); the anchored one does not.
Restore after every arm: RESTORE-DIFF-EMPTY, blob back to 7d9324d7b0… / c53cbcb558….
Gates, at edb7c2bcb4
| gate | verdict |
|---|---|
vitest run --project integration <pin> (OS_TEST_TIERS=nightly) |
✅ 6 passed, 1.12–1.15 s |
vitest run --project unit test/vitest-tiers-partition.test.ts |
✅ 22 passed |
vitest run --project unit (whole tier) |
✅ 183 files, 2511 passed | 6 expected fail, 298.80 s |
pnpm --filter @objectstack/cli typecheck |
✅ tsc --noEmit + check:test-typecheck OK |
pnpm exec eslint . --no-inline-config (whole repo) |
✅ exit 0, no output |
No exit 3, no NOT-MEASURED, no UNRUN, no OS-killed run.
Changeset, Clause-②, PR body and draft state untouched; content/docs/releases/ untouched.
Generated by Claude Code
Q2 达档重裁:改名,并删掉钉子自己那道
|
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
The pin moves to the per-PR tier, and the dist gate it copied is goneApplied on 1. Renamed out of the nightly tier
New tier population, measured: The tier is DERIVED, not declared: 2.
|
| case | baseline | A · listener block deleted | B · attach moved below await run( |
|---|---|---|---|
| has its OWN listener by the time the probe looks | ✓ | ✗ LISTENER ABSENT |
✓ (blind to order, as it says) |
attaches it ABOVE await run(…) |
✓ | ✗ 0 attaches | ✗ expected 8530 < 8310 |
| probe's mirror of the listener name | ✓ | ✗ no function objectstackStderrErrorIsNotFatal( |
✓ |
| outlives a failed write, own status | ✓ | ✗ UNCAUGHT on the guarded arm |
✓ |
| still crashes with the listener removed (control) | ✓ | ✓ | ✓ |
RAW stderr write in printDiagnostic |
✓ | ✓ | ✓ |
| 6 passed | 4 failed / 2 passed | 1 failed / 5 passed |
The structural order case is red in both arms — B is the one it was written for: every runtime case stays green there, and only the source-read sees it.
Gates
| gate | verdict |
|---|---|
vitest list --filesOnly --project integration |
EXIT=0 · 35 files |
the pin, --project integration |
EXIT=0 · 6 passed (6) |
the pin with packages/cli/dist absent |
EXIT=0 · 6 passed (6) |
vitest-tiers-partition.test.ts, OS_TEST_TIERS=queue |
EXIT=0 · 22 passed |
@objectstack/cli unit tier |
EXIT=0 · 183 files · 2511 passed, 6 expected fail |
pnpm --filter @objectstack/cli typecheck |
EXIT=0 |
pnpm exec eslint . --no-inline-config (whole repo) |
EXIT=0 |
pnpm check:cli-test-child-env |
EXIT=0 · 70 spawners, 87 spawns, 0 baselined |
⛔ Untouched: the changeset, the Clause-② declaration, the PR body, draft state, auto-merge, content/docs/releases/, and the listener in bin/run.js.
Generated by Claude Code
PM 受理:四项全部落地,Q2 裁定链就此闭合 —— 新头
|
| case | base | A 删块 | B attach 移到 await run( 之下 |
|---|---|---|---|
| 探针看时已有自己的 listener | ✓ | ✗ | ✓ |
attach 在 await run( 之上(新) |
✓ | ✗ | ✗ 8530 < 8310 断言失败 |
| 探针镜像 listener 名 | ✓ | ✗ | ✓ |
| 写失败后仍存活并报自己的状态 | ✓ | ✗ | ✓ |
| 移除 listener 后仍崩(正控制项) | ✓ | ✓ | ✓ |
printDiagnostic 的裸写(F2) |
✓ | ✓ | ✓ |
| 6 passed | 4 failed / 2 passed | 1 failed / 5 passed |
⭐ arm B 那一列是全部意义所在:每一条运行时 case 都活了下来,只有结构 case 变红。⇒ 这就是 F1 点名的那次重构,而在加固之前,它会全绿通过。
四项
git mv去.e2e(git 记为R,历史跟随)。逐 PRintegration档文件数 34 → 35,新条目即本钉子。- 假闸删除,换成实测事实,并写明与
published-entry-stderr-nonblocking.e2e.test.ts的区别 —— 后者的主体就是编译出来的../dist/utils/stderr-nonblocking.js导入,那道闸对它为真,故它保留.e2e。⇒ 下一个人不会再把闸借回来。 - 两处文件名引用按文本重指(
bin/run.js的⚠️ NAMED注释、探针 fixture 头)。run.js上的 diff 只有一行注释,listener 本身未动。 :303「about a dozen」→ 实测 2 处,并点名:serve.ts:1788(printDiagnostic)与:2090(交给resolveArtifactReference的warn:适配器)。⛔ 那个错数字源自首轮复核、经本席转达,不该出货。
门禁(退出码均在管道前捕获):partition test 于 OS_TEST_TIERS=queue EXIT=0(22 passed)· 钉子新档 6/6 · unit 层 183 文件 2511 passed · typecheck EXIT=0 · 全仓 eslint EXIT=0 · check:cli-test-child-env EXIT=0。
回退点更新
edb7c2bcb4 的全绿(33 项)只覆盖那棵树。c4b3a34854 的 CI 要重新读。若新头出问题,edb7c2bcb4 是一个已知全绿、已过达档 PASS、文档 sweep NEGATIVE 的回退点,代价是失去改名与删假闸——那时两者转独立卡。
⇒ 待 c4b3a34854 全绿(latest-per-name 收敛、in_progress = 0)后:先撤 draft、后武装。
Generated by Claude Code
Fixes #15564
The card asked for a measurement. It got one, and the answer flipped.
#15564 was filed NOT REPRODUCED, and its fence was the whole point of it:
So no code was written until the probe was run.
1. The two existing probes were re-run first, as this seat's own baseline
Triage stated plainly that the numbers on the card were the previous dev's and had not been re-run, and that the whole card rested on them. Both reproduce, on
bin/run.js, read end destroyed (stdio: ['ignore','ignore','pipe'], thenchild.stderr.destroy()), traced with a--importobserver that installs no listener onprocess.stderrand wraps no write (uncaughtExceptionMonitor):uncaughtExceptiondefinitely-not-a-commandOBJECTSTACK_DEBUG=1+ unbuilt-spec hook⭐ The card's reading of its own null result was right: NOT REACHED, not GUARDED.
2. Why they read clean — measured, not argued
A timeline instrument on the drained legs (used only there — it wraps the write, so it never touches a crash-measurement leg) puts every byte those two legs produce at the very end of the run:
Two conditions have to hold together, and each was isolated against a plain-node control with the same destroyed read end:
process.exit. A failing write reports through libuv's completion callback, so a synchronous exit on top of it is never told.write+ stay alive → EPIPE crash 3/3;write+process.exit()in the same synchronous run → 0/3, exits with its own status. Both probe legs are the second shape: everything they write lands afterrun()has settled, andhandle()exits on top of its own report.process.stderr.write. Node'sconsole.errorcarriesignoreErrors, which parks a temporaryerrorlistener across the write — so it cannot crash a process at any payload size: 1 MiB throughconsole.error0/3, 60 blocks throughconsole.error0/3, one line throughprocess.stderr.write3/3. The 59 oclif warning blocks in probe 2 areconsole.error. With a listener installed deliberately for that one question, that leg does raise exactly 1write EPIPE— and node's own Console swallows it.⇒ The two probes bounded reachability, exactly as the card said. They did not close it.
3. Widening the window reaches it
os serveis both conditions at once:printDiagnostic(src/commands/serve.ts, #7915) writes straight to stderr, and the boot around it is asynchronous, so the process is alive across the whole sequence — 21 writes spanning 1084 ms on the drained leg, and 16.6 s of writes on a boot that succeeds.node bin/run.js serve objectstack.config.tsonexamples/app-todo, read end destroyed: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, serves, and exits 0 at a 20 s SIGTERM having written 7926 B over 16.6 s.
⇒ 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 (
filesnames onlydist, but npm packs abintarget regardless — #14874).The change
bin/run.jsattaches a no-operrorlistener toprocess.stderrbeforerun(), with the measurement above written into the file. Deliberately not narrowed toerror.code === 'EPIPE', for the reasonbin/run-dev.jsalready records: every event there means one thing, the only channel it could be reported on is the stream that just failed, and there is no other action to take.The listener is named (
objectstackStderrErrorIsNotFatal), and that is a correction the ablation forced rather than a flourish — see below.The pin, and the phantom check the ablation caught
test/published-entry-stderr-error-listener.e2e.test.tsmanufactures the hazard inside the published binary's own process (the pattern its neighbourpublished-entry-stderr-nonblocking.e2e.test.tsalready uses): one raw write to a destroyed pipe, then one loop turn. It carries a live positive control — a second arm that removes the listener in the child's process, nothing on disk — so the guarded arm's silence is a reading and not a zero.process.stderr.listenerCount('error') > 0, and against a tree with the whole listener block deleted it still reportedLISTENER ATTACHED after 20 ms— node'sconsole.errorparks an anonymousonce('error', noop), so a count cannot tell this listener apart from that one. The count also doubled as the probe's only synchronisation point, so a slow box would have written before the attach and red a working tree. The listener is now named, the probe waits for that name, and a parity case holds the probe's mirror equal to the entry's spelling.Ablation, on the corrected pin (fix committed first; mutation proved on disk by anchor counts and blob hash; restore proved by blob hash + empty
git diff HEAD, never by an exit code):SURVIVED, exit 7UNCAUGHT code=EPIPE, exit 1The 2 that stay green under ablation are the ones that must: the live positive control (it crashes either way, by design) and the
serve-still-writes-raw premise case.Risk, and the rollback
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. That is the point of #14858 — the server is still serving and the 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. It is stated in the changeset because it ships to consumers.Rollback is the three-line listener block in
bin/run.js; nothing else in the diff changes behaviour.验收备注
.e2ename. Under today's ci: e2e and live tiers move to a nightly run on main; PR and queue runs keep unit, integration and conformance (maintainer-directed, part B tiering) #16455 ruling*.e2e.test.*is excluded from the per-PR and merge-queue runs. The name follows the convention (it spawns a child) and matches both sibling suites on exactly this subject, so it is what the ruling prescribes — but the consequence is that a future edit tobin/run.jsis not guarded per-PR by this file. Naming a file into a tier is a live choice for a NEW file; flagged for the reviewer rather than decided here. Run it withOS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run test/published-entry-stderr-error-listener.e2e.test.ts(~2.5 s).bin/run.jsandbin/run-dev.jsnow hold the same measurement in two docblocks. Neither imports from the other (run-dev.jsis tsx/src,run.jsis node/dist, and both run the CLI at module top), so there is nothing to single-source.noted, not filed.exit 3PREREQUISITE NOT MET rather than a verdict —check:dual-build-cjs-loadsandcheck:i18n-coverage, both naming a build this worktree did not yet have. They were recorded as NOT MEASURED, never as a pass, and then re-run after the closure build on the merged head, where both report their own OK line. The final reconciliation below has no NOT-MEASURED entries left.Verification
All readings below are against merge head
a48dd2ab93(origin/mainmerged in) unless stated.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived unchanged after the merge, reconciled with--ran— 57 derived, 57 run, 0 NOT-MEASURED, 0 UNRUN, every one exit 0.pnpm --filter @objectstack/cli typecheck— green;--listFilesconfirms the new test file is in thetsconfig.test.jsonprogram, so that green covers it rather than skipping it.pnpm --filter @objectstack/cli exec vitest run --project unit— 183 files, 2511 passed, 6 expected fail. Re-run after the merge becausemainmovedpackages/cli(§10).eslint . --no-inline-config— the whole repo, not a narrowing, so no narrowing needs proving: 6318 files linted, 0 findings, exit 0 ata48dd2ab93(count read from--format json, exit code captured before any pipe).Every exit code above was captured before any pipe (
cmd > log 2>&1; EXIT=$?), and gate verdicts are read from each gate's own verdict line.🤖 Generated with Claude Code
https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
Generated by Claude Code