docs(cli): name the writer that actually made the first failing stderr write - #16971
Conversation
…r write `bin/run-dev.js` explained #14858's crash with "oclif's `displayWarnings()` makes the first write". Re-traced with a `--import` observer that wraps `process.stderr.write` and logs the call site of the first EPIPE-ing call: node's OWN default `warning` handler (`internal/process/warning.js`: `onWarning` -> `writeOut` -> `console.error`) makes write #1, and `displayWarnings()` makes writes #2 and #3 of the same warning. Every write on that path is a `console.error`, and the reason that is fatal here while `bin/run.js` measured it harmless is not payload size. Console's `ignoreErrors` keep-alive is installed by the write CALLBACK and only `if (stream.listenerCount('error') === 0)`. `tsx` registers an off-thread module-customization hook, so node pipes the hooks worker's stderr into `process.stderr` and `Stream.prototype.pipe` prepends an `onerror` there; the count is 1, the keep-alive never installs, `onerror` takes the first EPIPE and re-emits it with nothing listening. Controls, node 22.22.2, read end destroyed, one variable between the legs: `console.error` alone 0/3, `module.register()` of a no-op hook plus the same `console.error` 3/3, raw `process.stderr.write` 3/3. The shim as shipped is 0/3 (exit 2); with the #14858 listener ablated it is 3/3 (exit 1). Comment text only. No behaviour changes, the listener stays exactly as it is, and the three `displayWarnings()` sites that state listener TIMING rather than authorship are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
📓 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): |
PM review — accepted, armingCI. 33 check-run rows, all terminal, 0 red. Every Clause-② — exit 0, both carriers agree, no widening tell. ⭐ This card asked which of two readings was right, and the answer is neither. That is the most valuable outcome available on a card like this, and it was reached by instrument rather than argument. Reading 2 (another raw write) is falsified: the only raw The actual mechanism: ⭐ And it is proven by a one-variable ablation, not inferred from a trace. Plain node, one short line, nothing else changed: ⭐ Equally important is the reading that neither sentence was lying: ⭐ My four dispatch fences held, and I verified that myself rather than reading the table. Changeset — measured, and the measurement is the interesting part. Docs drift. The bot named its own no-anchor mode outright — 2 changed file(s) yielded no anchor … this is not a clean bill of health — which is exactly why the hand sweep exists; it fired ( ⭐ The out-of-scope item you declined to touch is the right call, and worth naming. What I accept on your measurement rather than re-running: the observer legs themselves. The controls are the right ones and they move in opposite directions (3/3 raw, 0/3 through Marking ready for review, then arming auto-merge, in that order. Generated by Claude Code |
Fixes #16691
Clause-②: no
Explanatory text only, re-declared from the diff: three comment-only edits plus one changeset. No behaviour, no accept set, no exported member, no runtime contract.
bin/run-dev.js'serrorlistener, its 15 s bound and thewarningcollector are byte-identical; so isbin/run.js's named listener; so is every assertion in the touched test file.Step one: who actually made the first failing write
The card left two readings it could not choose between. Neither is what the instrument shows, and the third answer is more useful than either.
The instrument
An
--importobserver that wrapsprocess.stderr.writeand nothing else. It installs noerrorlistener on the stream and never attaches one from inside a write callback, so a raw write stays fatal and aconsole.errorwrite stays protected by Console's own handler, which the observer forwards to. It logs the call site of every write, the code delivered to every completion,listenerCount('error')around each write, and everyerroremission with the names of the listeners present.Both control legs, quoted
Plain node, read end destroyed (
stdio: ['ignore','ignore','pipe'], thenchild.stderr.destroy()), child kept alive one turn. node v22.22.2.process.stderr.writeuncaughtException EPIPE— 3/3console.error, 1 MiBconsole.error, one short lineconsole.errorin one tickThe observer moves on both legs, in opposite directions, and says why:
That is the required 3/3 raw and 0/3 through
console.error, and the 0/3 is a real reading rather than a blind one: the EPIPE was delivered and observed on that leg too.The shim legs
Same observer,
tsx+ the suite's ownunbuilt-spec-dist.hook.mjs+ destroyed read end,i18n extract nope.tsfrom a fresh temp cwd.bin/run-dev.jsas shippedbin/run-dev.jswith the #14858 listener ablatedconsole.errorWrite order and bytes, taken from a draining reader so the totals are real (179 writes, 147 729 bytes):
console.errorfrom node's own defaultwarninghandler —writeOut (node:internal/process/warning:56)fromprocess.onWarning (…:132)console.errorfrom oclifdisplayWarnings()—config.js:69,warning.stackconsole.errorfrom oclifdisplayWarnings()—config.js:71,warning.detailprocess.stderr.write—run-dev.js:113, i.e.writeStderr()console.errorfrom oclifhandle()The reading this establishes
Reading 2 is falsified. There is no other raw write on that path. The only raw write is #178 of 179, and the crashing leg is dead at 87 writes —
writeStderr()is never reached, exactly as #15558 already said.Reading 1's conclusion is half right and its reason is wrong. The failing writes are
console.error, but nothing is landing afterkWriteToConsole'sfinally, and payload size is not the variable. The observer showslisteners=1at every completion witherrorEmitted=false: Console'screateWriteErrorHandlerdeclines to attach its keep-alivenoopbecause the count is not zero.The listener that occupies that slot is node's own. Traced with a second observer that logs every
error-listener attachment onprocess.stderr:tsxcallsmodule.register(), so node pipes the module-customization hooks worker's stderr intoprocess.stderr, andStream.prototype.pipeprependsonerroron the destination for the whole run. Console's keep-alive therefore never installs;onerrortakes the first EPIPE, tears the pipe's listeners down including itself, finds nothing else listening and re-emits — and that second emit is the uncaught one:One-variable ablation of exactly that mechanism, plain node, one short line, nothing else changed:
console.error('one line')(= leg C)module.register()of a no-op hook, then the SAMEconsole.error('one line')uncaughtException EPIPE— 3/3And the mirror census on the published entry, measured rather than inferred: under plain
node bin/run.jsthe onlyerrorlistener onprocess.stderrfor a whole run is that file's ownobjectstackStderrErrorIsNotFatal. So #16686's measurement is correct there, and it is the generalisation that was unsafe.So both sentences needed work, for different reasons:
run-dev.js:378names the wrong writer, andrun.js:106states a conditional protection unconditionally. The two files were never really contradicting each other — the two entry points genuinely differ, and the discriminator istsx'smodule.register(), not the payload size.Per-site table — every
displayWarnings()occurrenceLine numbers as measured on
origin/main@ec5db7b44f(unchanged throughfcddd151c3).run-dev.js:66settings.debugon, sodisplayWarnings()queued ~138 KB ahead of these linesdisplayWarnings()wrote 111 751 of the 147 729 bytes; node's own defaultwarninghandler wrote the other 35 133. Now names both and carries the measured split.run-dev.js:378displayWarnings()makes the first writewarninghandler;displayWarnings()makes #2 and #3. The rest of the sentence (EPIPE, uncaught exception, 12/12, timings) stands unchanged.run-dev.js:433warninglistener only whenlistenerCount('warning') <= 1run-dev.js:442run()reachesConfig.load()anddisplayWarnings()in its synchronous prefixrun-dev.js:444config.js:displayWarnings()precedesload()'s firstawaitrun.js:106displayWarnings()occurrence, but the contradicting half. Its measurement is kept verbatim and its condition is now stated: the keep-alive attaches only whilelistenerCount('error') === 0, which is a fact about this entry point.run-dev-unbuilt-workspace.e2e.test.ts:465-466:378near-verbatimrun-dev-unbuilt-workspace.e2e.test.ts:507process.stderrany more (see #14858 and the ablation table)"A new paragraph was added above
run-dev.js'sNOT narrowed to EPIPEnote, carrying the mechanism and the one-variable ablation — that is the sentence a reader needs when asking "doconsole.errorsites need guarding?".Nothing else changed.
git diff -U0hunks:run-dev.jsat 66, 381 and 405;run.jsat 105; the test file at 464.What this does NOT overturn
run-dev.js's 12/12 crash is real, the listener fix is correct, and this PR strengthens rather than weakens it: the ablated leg reproduces exit 1 at 3/3 here, and the new paragraph states that the listener is what covers theconsole.errorsites too, not onlywriteStderr(). No behaviour is touched, the listener is neither removed nor narrowed, and nothing here reads as "the original fix was wrong".Changeset — measured, not assumed
packages/cli'sfiles[]is["dist","README.md","CHANGELOG.md"]and names nobin/. It still shipsbin/run.js, because npm packs abintarget regardless (#14874). Packed for real withnpm pack --ignore-scriptsand searched inside the tarball, exit codes captured before any pipe:The control fires on the same file the subject is found in, and
bin/run-dev.jsis not in the tarball at all. So one published file's bytes move and two do not: a changeset is owed, and.changeset/cli-epipe-writer-attribution.mddeclares@objectstack/cli: patchfor thebin/run.jsprose only.dist/is absent from the listing only because the package was unbuilt at pack time; that is orthogonal, since nosrc/file is touched.Docs drift — re-derived, and its zero corroborated by hand
First run was against a dirty worktree with an empty diff range and is discarded as NOT MEASURED (
dirty: true,diffBase == head,changedPackages: []). Re-derived after committing:So the zero is the tool's own no-anchor mode, not a clean bill. Hand-swept
content/for this change's tokens, with a live positive control:The sweep fires. No hand-written doc restates the corrected claim, so nothing under
content/moves.content/docs/releases/was not touched.Verification
Final HEAD
1727827692.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack— 57 families derived; re-derived after the changeset existed and again against the newestorigin/main(identical list, and "none of the commit(s) this tree can SEE touched what this answer derives from").pnpm check:type-check-debtexits 3 =PREREQUISITE NOT MET— itstsc --re-measurechild OOMed at the 4096 MB heap on this shared container ("refusing to record 0"), so it is NOT MEASURED, neither a pass nor a finding;pnpm check:type-check-coverage(the same checker's self-test plus the coverage clause) is exit 0. Declared to CI.node scripts/pm/dispatch-gates.mjs --ran …— "57 derived famil(ies) accounted for — 57 run, 0 NOT-MEASURED, 0 UNRUN".pnpm --filter @objectstack/cli typecheck— exit 0 (tsc --noEmitpluscheck:test-typecheck).pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2— 189 files, 2618 tests, exit 0.OS_TEST_TIERS=nightly … --project integration run-dev-unbuilt-workspace— 1 file, 11 tests, exit 0. It is a nightly-tier file (*.e2e.test.*), so the queue population does not collect it; it is run here because this PR edits it and because itsSHIM_DRAIN_STALL_MSmirror case readsbin/run-dev.jsas text.published-entry-stderr-error-listener(6 tests, the pin onbin/run.js's named listener),published-entry-stderr-nonblockingandrun-dev-stderr-nonblocking(10 tests) — all exit 0.eslint . --no-inline-config --format json— 6384 files, 0 errors, 0 warnings, exit 0. The build closureturbo run build --filter='@objectstack/cli...'is 57/57 successful.check:nul-bytes:grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'over the three edited files — exit 1, clean.Every exit code above was captured before any pipe.
Generated by Claude Code