fix(cli): stop run-dev.js freezing in write(2) when its stderr reader stops reading - #14875
fix(cli): stop run-dev.js freezing in write(2) when its stderr reader stops reading#14875os-trump wants to merge 4 commits into
run-dev.js freezing in write(2) when its stderr reader stops reading#14875Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…pipe Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…n-dev-unread-reader-hang
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
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): 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 0be249bd5a760739729d429bbec935001e7950a2 && git checkout 0be249bd5a760739729d429bbec935001e7950a2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2263ca4d679026335f559184c0bed4e76d35a242 47e92775eea2441b17c365b1eabec43d6f2c3a31 && git checkout -B drift-repro 2263ca4d679026335f559184c0bed4e76d35a242 && git merge --no-ff 47e92775eea2441b17c365b1eabec43d6f2c3a31
node scripts/docs-audit/affected-docs.mjs --json 2263ca4d679026335f559184c0bed4e76d35a242
|
Fixes #14832
The named blocking handle — there isn't one, and that is the finding
The card asked for the pending handle rather than a theory. Sampled from outside the process, at 20 s, 35 s and 50 s of a hung run, so the instrument could not perturb it:
There is no pending JS handle. The main thread is parked inside
write(2)on fd 2 — 580 bytes, in the kernel's pipe-send path — so the event loop is not running at all.writeStderr's 50 mssetIntervalnever ticks andSTDERR_DRAIN_STALL_MScan never trip: the bound is not late, it is unreachable. That is why no ceiling ever helped. A ceiling separates slow from stuck, and this is stuck at a point where nothing in the file has run yet — the frozen child had written 138459 bytes (oclif's warning blocks alone) against 138868 for a clean one, so it never reached the shim's own diagnostic.A handle dump from inside a hung child agrees: it logged nothing for 60 s, then flushed everything and exited 2 within 136 ms of node's
flushStdio()resuming the parent's paused stream on child exit — released by the reader draining, which is exactly what a blockedwrite(2)waits for.Why the flag is clear, and why it is intermittent
Node sets
O_NONBLOCKon fd 2 when it opens the pipe. libuv clears it again in the pre-exec of every child spawned with inherited stdio (deliberately — a child expects blocking stdio), and inheriting isdup2, so the flag lives on an open file description the spawner shares: clearing it for the child clears it for the spawner. Undertsxthat child is the esbuild service, started when a module has to be transformed.Timeline of one run,
/proc/PID/fdinfo/2sampled every 50 ms:⭐ That is also why it is intermittent, and the variable is not load: it is whether
tsxhad to transform anything.Reproduction rate (out of band — the fenced test file was never run for this)
Driven by
spawnof the same child (tsx bin/run-dev.js i18n extract nope.tsunder the unbuilt-spec resolve hook), stderr piped and never read:tsxcache, 6 concurrenttsxcache (privateTMPDIRper run), 6 concurrentcode=2 signal=nullSame driver, same box, same concurrency, same cold-cache condition — only the tree differs. A merge-queue runner is a fresh checkout with a cold transform cache, which is why CI hits this and a warm developer box almost never does.
The fix
packages/cli/bin/stderr-nonblocking.mjs(new) re-asserts non-blocking mode on fd 2 immediately before each stderr write;bin/run-dev.jsinstalls it aboverun().⭐ On the write path rather than once at startup, and that is measured rather than stylistic: the clearing happens at 1132 ms, caused by a spawn this process does not control and cannot see. A one-shot at module top is undone by the next
spawn(…, { stdio: 'inherit' })anywhere in the process — including from a module-hooks worker thread, which shares the same descriptions — and it fails silently, back into the hang. Re-asserting per write costs onefcntland cannot be outrun by a later spawn, whoever makes it.⛔ This is not the call both
run-dev.jsandsrc/utils/format.tsrefuse; it is its inverse. Their refusal ofsetBlocking(TRUE)stands untouched. What this adds is the thing that keeps their shared premise — a write to a pipe is buffered, not blocking — actually true when something else has quietly flipped the flag.The pin, and why it holds on a run where the hang does not reproduce
test/run-dev-stderr-nonblocking.e2e.test.ts+test/fixtures/stderr-nonblocking-probe.mjs.A 27-in-30 reproduction is still not a pin — it reports "fixed" on the runs where the defect simply did not fire. So the fixture manufactures the condition deterministically in about a second: materialise stderr, spawn a trivial child with inherited stdio (the same clearing, without needing esbuild), then write 2 MiB at a reader that is gone. The two arms differ in exactly one thing — whether the guard is installed — and both are deterministic.
Five cases: a positive control (unguarded ⇒ freezes and must be killed), the pin (guarded ⇒ issues every write and exits 7 on its own), a substitution guard (the bytes were accepted and the stream is not destroyed — so "fixing" it by discarding output cannot pass), and two wiring cases holding that the shim still installs the guard and installs it before
run(), read throughmaskCommentsso the prose naming the function cannot stand in for the call.Two sizes in that fixture are measurements, not round numbers, and both are recorded where the next person will hit them: 192 KiB let the unguarded arm finish unblocked on one run in two (the kernel pipe plus the parent's own readable buffer absorb ~128 KiB), and a progress marker every 32 chunks landed its first mark after the freeze, so the control read "froze before any write landed" on a perfectly good reproduction.
Ablation, red-first, on the final tree — mutation proven on disk before each run (anchor 1 -> 0, marker 0 -> 1, blob moved), restored under
trap … EXIT INT TERMon absolute paths and proven back by blob identity plus an emptygit diff HEAD:run-dev.js⇒ only the wiring case reds.No build leg is owed and that is shown, not assumed: the fixture and the guard are plain
.mjsrun from source by a barenode, with nodistanywhere in the path.File face
bin/run-dev.js, a new sibling module beside it, a new test file and its fixture, and a changeset. ⛔packages/cli/test/run-dev-unbuilt-workspace.e2e.test.tsis not touched — it is #14716's face (PR #14863). It was run against this tree as a regression check: 11 passed (11), 59.87 s.⛔ Not done, per the card: no timeout raised, no cap re-derived, nothing skipped, quarantined or retried.
#14858 is untouched, and that is measured
#14858 is the same file with the reader closed rather than paused — an uncaught
write EPIPE, exit 1 at ~1.4 s. This PR adds noerrorlistener, so it neither fixes nor hides it. Measured on this branch after the fix: the closed-reader arm endscode=1 signal=nullat 1362, 1412, 1439, 1446, 1471, 1482 ms, 6 of 6 — inside the 1387-1711 ms range #14716 measured before it. Its own card and its own PR.Verification, on
47e92775ee(origin/mainmerged in)Heavy runs through
scripts/pm/os-verify-lock.sh; verdicts quoted from itsVERDICTline; every exit captured by redirecting to a file first, never after a pipe.Tests 5 passed (5),VERDICT command-exit 0each time.run-dev-unbuilt-workspace.e2e.test.ts:11 passed (11),VERDICT command-exit 0.pnpm --filter '@objectstack/cli^...' build --concurrency=2):VERDICT command-exit 0, 443 s.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands— identical list before and after the merge): 35 derived, 35 run, 32 exit 0.check-test-completeness.mjsexit 3 ("PREREQUISITE NOT MET — this gate grades a savedturbo run testlog, and no log was named"; CI tees one);check-half-states.mjsexit 3 ("the transport authenticates but repo-scoped reads are refused" — this container gets HTTP 403 onGET /repos/…, which my own REST probe hit independently);check:dual-build-cjs-loadsexit 3 ("Runpnpm buildfirst. ⛔ This is NOT a pass: nothing was measured" — I built only the CLI's closure, not the repo).pnpm lint(eslint . --no-inline-config):VERDICT command-exit 0, 110 s. Not narrowed.pnpm check:nul-bytes: exit 0, 8076 files, 0 raw control bytes; plus a directgrep -naPcontrol-byte scan of all five changed files (no hits).packages/cli/tsconfig.jsonisinclude: ['src'], so the package's own green says nothing about atest/file and nothing at all aboutbin/. The new test file was checked explicitly —tsc --ignoreConfig --noEmit --strict --module nodenext --moduleResolution nodenext --types node --listFiles— exit 0, with--listFilesconfirming the file is one of the 243 in that program rather than a green over nothing.Changeset
patchon@objectstack/cli. The measurement behind the fork, since the dispatch asked:filesis['dist', 'README.md', 'CHANGELOG.md']and does not namebin/, so npm packs only./bin/run.js(thebintarget) —bin/run-dev.jsand the new module beside it are not in the tarball, and this diff changes no published bytes.patchis the conservative fork rather than an arguedskip-changesetexemption; the changeset text says so, and downgrading it is a one-file edit if a reviewer prefers that.Residue filed, not ridden
#14874 — the same mechanism on the published path:
os dev(src/commands/dev.ts:221, 470, 582),os startandos environments bindall spawn with inherited stdio, so the long-lived parent puts its OWN stdout and stderr on the blocking path for the rest of the run.format.tsalready names that hazard as a reason to refusesetBlocking(true)— the premise it protects is the one the CLI breaks on itself, with nothing saying so. Filed unassigned with the two measurements that would settle it, and deliberately not fixed here: it changes shipped behaviour and needs its own review, pin and changeset, which should not ride inside a p1 hang fix.🤖 Generated with Claude Code
https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Generated by Claude Code