Repository navigation
fix(cli): drain run-dev.js stderr before oclif exits on top of the unbuilt-workspace diagnostic - #14051
Conversation
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f1730d43cc3c7b9a85dc9b38434e487cc275b2d9 && git checkout f1730d43cc3c7b9a85dc9b38434e487cc275b2d9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c75962ac3e0af69ae20f658618c4d3ab58192cc8 525b2dbd0a8f01bb1666bfa41fdbd76473df5e50 && git checkout -B drift-repro c75962ac3e0af69ae20f658618c4d3ab58192cc8 && git merge --no-ff 525b2dbd0a8f01bb1666bfa41fdbd76473df5e50
node scripts/docs-audit/affected-docs.mjs --json c75962ac3e0af69ae20f658618c4d3ab58192cc8 |
…built-workspace-diagnostic
Fixes #13683
Both hypotheses the card carries are falsified. The real mechanism is confirmed and reproduced.
(1) "a formatter that cannot load because of the very condition it exists to explain" — FALSIFIED
format.ts.announceUnbuiltWorkspaceimports exactlyscripts/cli-unbuilt-workspace-lead.mjs(whose only import isscripts/cli-build-prerequisite.mjs) andsrc/utils/invocation.ts(whose only imports arenode:builtins).invocation.ts's docstring says so deliberately: "Pulling./format.jsfor CLI_NAME would drag chalk, zod and@objectstack/specinto a shim whose whole job is to print one line."Error: command i18n:extract:nope.ts not foundin the same run.handle()prints that, not our formatter, and no import failure can delete it.The card read
imported from packages/cli/src/utils/format.tsas the diagnostic's own dependency. It names the importer —format.tsis what pulls@objectstack/specinside a failing command module, i.e. the diagnostic's input.(2) "the fixture mutates shared
node_modules" — FALSIFIEDtest/fixtures/unbuilt-spec-dist.hook.mjsis aregisterHooksresolve hook importingnode:module,node:path,node:urland touching no disk. The__unbuilt-simulation__path is a specifier handed to node, deliberately non-existent — nothing is renamed. Its docstring already gives the reason: "a test that renamedpackages/spec/distfor a few seconds would break every other run in the box." Fixture and test entered together ine6fd1caf7b(2026-08-29) and were unmodified at the failure, so this is the file that failed.The confirmed mechanism
settings.debugputs ~138 KB of oclifModuleLoadErrorblocks on stderr ahead of the lead lines. A pipe holds 64 KiB.handle()ends inprocess.exit(), which drops undrained writes. A parent that is not draining gets one buffer and loses the lead lines and oclif's own error — measured 64721 bytes, both gone. Byte offsets say why only these: the lead line sits at 99.41% of the stream, the fix line at 99.65%, oclif's error at 99.70%; the surviving[MODULE_NOT_FOUND] Warningis at byte 11.This is the #6531 defect on stderr instead of stdout — the one
src/utils/format.tsdocuments foremitJson: "the fix belongs at the write, not at the exit." It is queue-only because a terminal is a TTY, written synchronously.Awaiting the write is only safe if something bounds the wait, and the first version's bound never armed:
write()returnstruewhen the internal buffer is below the highWaterMark — not when the bytes have reached the pipe. Traced:write() returned true, writableLength=7621, so the early return fired and the cap was never set. The wait was therefore unbounded, not 10 s.Measured against a reader that holds the pipe open and never drains — base exits promptly, so this was introduced by this PR, not pre-existing:
EPIPEdoes reach the write callback, which is why every closed-end path stays fast.The bound: no-progress, not a deadline
A deadline cannot tell a reader that is merely slow from one that is absent, and those want opposite answers. Progress separates them exactly.
STDERR_DRAIN_STALL_MS = 15_000, derived — the derivation is in the code comment so it can be re-checked instead of bumped:Config.load(), the diagnostic ~3 s later, and bytes are lost only if the child exits while the reader is away. Measured child runtime: 1.0 s idle, 6.9 s contended. 15 s is ~2.2x the worst.Exceeding the bound degrades to the prompt-but-lossy behaviour this file had before the drain existed — never to a hang.
The bound firing, traced under a reader that blocks its loop for the whole run:
Verification
Two ablations, direction stated before running, on
525b2dbd0a. Mutation proven on disk each time (blob hash equality plus anchor counts); restore proven by hash equality with the HEAD blob, emptygit diff HEAD, cleangit status --porcelain; trap in the same shell as the measurement.3 failed | 7 passed,expected 64721 to be greater than 65536plus the queue's own two assertion strings, differing from the job log only in pid.1 failed | 9 passed,AssertionError: expected 'SIGKILL' to be null.child.stderr.pause()lets node fill its own 64 KiB buffer; ~128 KiB of headroom swallowed the run and the case passed against unfixed code.There is deliberately no assertion that the child waited for the bound: whether bytes are still pending at the tail moves with load, so that assertion would red on one run and pass on another. The suite pins the property that holds either way — the process ends — and the bound's firing is shown by the trace above.
Gates, all on
525b2dbd0aafter mergingorigin/main(the pre-merge derivation warned STALE TREE and was discarded): named 34, ran 34, unreconciled 0, bothcommdirections empty.check:dual-build-cjs-loadsexit 0 andcheck:type-check-debtexit 0 ("28 ledger entries re-measured, 1468 raw tsc errors total, none above its recorded number") after building the closure; one gate,check-test-completeness.mjs, needs a test-run log CI tees and is NOT MEASURED locally by its own statement. Repo-widepnpm lintexit 0.check-nul-bytesexit 0.packages/cli'stsconfig.jsonhasinclude: ["src"]andtsc --listFilesshows 0test/files in that program, so package typecheck says nothing about the edited test file and cannot move the debt ledger. Checked separately against the package's own compilerOptions: exit 0, 0 errors, noas any.Not established, and left that way
Whether this is new remains unestablished — not established on a quiet commit, and main's runs in that window are unusable as a control.
Generated by Claude Code
Generated by Claude Code