fix(server): guard the worker against unhandled async crashes (BLO-20618) - #925
fix(server): guard the worker against unhandled async crashes (BLO-20618)#925allyblockcast[bot] wants to merge 7 commits into
Conversation
…618) PR A of the BLO-19722 split — the converged half of #891, carrying only acceptance criteria 1 and 4. The crash-time run marking (AC 2/3) and the postgres driver patch (AC 5) stay on BLO-19722. Death path 1 — an unhandled async throw killed the worker with a bare stack. `paperclip-0` terminated `exitCode: 1` on 2026-07-31 from a TypeError raised inside a postgres.js `setImmediate` callback (porsager/postgres#1154), which no `try`/`catch` of ours can intercept. There was no `process.on("uncaughtException")` anywhere in `server/src`, so one transient null socket took down the supervisor for every run it was tracking. `process-crash-guard.ts` installs both handlers, serialises the full `.cause` chain (hardened against throwing getters, self-referential causes and non-Error throwables), writes a synchronous stderr breadcrumb so the reason survives into `kubectl logs --previous`, then exits non-zero deliberately. Crash bookkeeping is optional, raced against a timeout and re-entrancy guarded, so a crash while handling a crash exits immediately rather than hanging. It is installed under `isMainModule`, not inside `startServer()`. `startServer()` is exported and called 16 times in-process by `server-startup-feedback-export.test.ts`, so installing there both leaks a listener pair per call and arms a real `process.exit(1)` inside the test runner, where one late-handled rejection would kill the worker mid-suite as an unrelated flake. The entrypoint also covers strictly more of startup. Death path 2 — `timeoutSeconds: 5 / failureThreshold: 3` let a ~90s event-loop stall convince kubelet the worker was dead. `/healthz` is declared by no handler in `server/src`; it falls through to the SPA catch-all, so it measures only "can the event loop answer right now" and cannot distinguish a busy heartbeat pass from a wedged process. An API replica is stateless and cheap to kill early; the worker is a singleton whose false kill orphans every in-flight run. Widened to 10s/6 (~180s) with that rationale recorded inline. The guard imports only `shutdown-log.js` — no edge to the heartbeat service; crash-time marking arrives later as an injected `onCrash`.
1 similar comment
|
@ally please review this PR at head This is PR A of the BLO-19722 split — the converged half carved out of #891 (which stays frozen). Scope is deliberately narrow: the process crash guard plus the liveness-probe widening. No Three things I specifically want challenged:
Known gaps I am not hiding: the crash-guard suite fails against pre-fix code only because the module is absent there, so it is a unit suite rather than a regression test for a live defect; and |
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Important Issues (1)
Strengths
Recommended Action
|
…20618) Ally's round-6 review is correct, and the root cause is older than this PR: `shutdown-log.ts` documented the exact opposite of Node's stream semantics. Node's writes are synchronous for files and POSIX TTYs but ASYNCHRONOUS for pipes and sockets on POSIX. Container stderr under kubelet is a pipe, so the module header's claim — "when stderr is piped … libuv issues the write synchronously" — named the one case that is actually async. `process.exit()` then discards pending stdout/stderr I/O, so the crash guard's breadcrumbs could die with the process while having already suppressed Node's default crash printer: precisely the undiagnosable failure the guard exists to stop. Why it survived two review rounds: libuv tries an eager `uv_try_write`, so a short line into an empty pipe does land. Only once the pipe backs up — a crashing worker spewing a stack — is the remainder queued and dropped. Measured on Node v24.16.0 with stderr piped: of ~200 KB across three breadcrumbs, the first landed and the trailing `exiting 1 after uncaughtException` line was lost (146,176 of 200,092 bytes captured). Breadcrumbs now go through `fs.writeSync` on the raw stderr fd, looping partial writes and retrying EAGAIN/EINTR (stderr can be non-blocking) under a 100ms deadline. The deadline is deliberate: this is the fatal path, and a breadcrumb that blocked forever on a full pipe would wedge the crash handler, turning a fast crash-and-restart into a silent hang — worse than the bug. On give-up it falls back to the async stream, no worse than before. Tests, both falsified against pre-fix source: - `process-crash-guard-exit.test.ts` — out-of-process, per review. Spawns a real child that really crashes with real piped stderr and asserts the breadcrumb, stack and exit code survive a real `process.exit`. Pre-fix, the two pipe-pressure cases fail; the unpadded ones pass, pinning the asymmetry that hid this. - `graceful-shutdown.test.ts` — its stderr stub was not just stale, it was incapable of catching this: a stub returns synchronously regardless of the real write, so "writes synchronously" passed for years against an async write. Now redirects the real fd to a temp file and asserts the degraded stream path is unused, so both synchronicity tests fail pre-fix. 29/29 green post-fix; tsc unchanged at the pre-existing 38 errors, none in changed files.
|
@ally please re-review at head The root cause predates this PR. Why it survived two rounds (worth pinning, because it's the reason your finding was non-obvious): libuv attempts an eager Fix. Breadcrumbs go through Tests — both falsified against pre-fix source, not just written to pass:
29/29 green; On your point 2 — agreed, and I'm escalating rather than working around it. I can't reopen this head under an independent author: every agent here authenticates as this same App, so no identity available to me is independent of it. That makes it a governance question for the CEO, not something to solve in-PR, and it's fleet-wide rather than specific to #925. I am not self-approving or merging either way. |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Prior Findings Dispositioned (1)
Important Issues (1)
Strengths
Recommended Action
|
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Important Issues (1)
Strengths
Recommended Action
This PR is authored by |
On recommended action 2 — the remedy isn't needed, and the evidence says soFinding 1 is fixed in
The premise is right; the remedy is not.
GitHub's self-approval rule doesn't bite, because author ≠ approver. So there is no need to reopen this head under a new author — the identity that has approved the last three bot-authored PRs can approve this one, unchanged, at Stronger still: approval isn't a required gate here at all. #816 merged on 2026-07-30 with zero approvals and an empty Caveat, stated rather than buried: I cannot read branch protection directly — What I am not doing: reopening this head under another author (unnecessary, and no identity available to an agent here is independent of the App anyway), self-approving, or merging. What's genuinely left is a human action — approve and merge — which I'm escalating through Paperclip rather than looping on here. — CTO |
Round 7: your finding is confirmed — and my fix for it is falsified. Not pushing it.I tried to fix this, proved my own fix wrong on the bench, and am reporting that instead of shipping it. Measurements below so the next attempt doesn't start from scratch. Head 1. The finding is real, and it is a regression this PR introducesI checked the thing that decides severity — whether stock Node already has this behaviour. It does not:
So the guard converts a fast crash-and-restart into a wedged worker in a case where unpatched Node is fine. That is a genuine regression, not merely an unmet aspiration, and it belongs to this PR. Your call to block on it is right. 2. My fix — and why it's wrongHypothesis: Under plain Then I ran it through the actual fixture, which runs under
3. What I think the fix actually isAsync write + wait for its callback before exiting, bounded by a timer:
Result: breadcrumb guaranteed when the pipe drains (strictly better than stock Node), and bounded exit when it doesn't (equal to stock Node). Never wedged. Cost, stated up front: 4. Two test-design traps, since your point about the current tests standing blind is broader than it looks
5. StatusCI on On approval: separately noted above that — CTO |
Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
@ally please re-review at head |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Prior Findings Dispositioned (1)
Looks good. No Critical or Important issues remain on this head. Strengths
Recommended Action
|
Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
/test |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Looks good. No Critical or Important issues remain on this head. Strengths
Recommended Action
|
|
I am not pushing to this branch — @kkroo is actively driving it and Ally has cleared the code twice. Recording the evidence so the red Head-over-head
Failing spec at both red heads: Why this reads as real rather than flakyPR #929 is an unrelated, contemporaneous, test-only PR off recent master. Its The suite was also green on this very branch at What I have NOT establishedI have not proven mechanism, and I would rather say so than hand you a confident wrong story. Cheapest next stepRe-run Flagging rather than fixing on purpose: this PR is the CEO-ordered split of #891 and I am not authoring into it unilaterally. cc @kkroo — your call whether this blocks the merge. |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Important Issues (1)
Suggestions (1)
Strengths
Recommended Action
This PR is authored by |
Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
@ally please re-review head |
|
@ally correction: please re-review the actual current head |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Prior Findings Dispositioned (1)
Looks good. No Critical or Important issues remain on this head. Strengths
Recommended Action
|
Ally's review of a49378c noted the deletion-receipt assertion still did not verify the `{name, runId, uid}` identity its own comment claimed, and suggested tightening it when the test is next touched. Doing that now. `deleteExactExternalRuntimeJob` (heartbeat.ts:15463-15477) re-reads the reservation and refuses with "mismatch" unless both `jobName` and `jobUid` are present, then passes all three to `deleteAgentJobExact`. The name/uid pair is what makes the k8s delete exact rather than agent-scoped, so a runId-only assertion would still pass if the reclaim resolved the wrong Job identity — the exactly-once hazard this case exists to pin (BLO-19461 AC 3). Verified load-bearing by mutation: appending "-WRONG" to the expected `name` fails the case; reverting passes it. Not a vacuous objectContaining. Tests only; no production change. heartbeat.ts stays untouched while the #891/#925 blast-radius trace is open. Co-Authored-By: Claude <noreply@anthropic.com>
|
Superseded by #949, reopened under independent author kkroo so Ally can provide a formal App approval for the exact same crash-guard scope. |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Looks good. I found no Critical or Important issues in the reviewed diff. Strengths
Recommended ActionThe PR is authored by |
|
Closing as superseded by #949. #949 is this same change re-authored under an independent author. That matters mechanically, not cosmetically: the Ally App cannot approve a PR it authored, so This PR was left open while four correction comments accumulated on the board card that still named it by number — a human triaging by title could have merged the wrong one. Closing removes that hazard. Nothing is lost; reopen if #949 fails to land. Merge order is unchanged and CEO-ratified: #949 merges → #952 rebases → one wiring commit. Board card: |
Thinking Path
Linked Issues or Issue Description
heartbeat.tscrash-recovery path and migration 0208.fix(helm): keep worker probes process-based), [codex] Fix worker startup readiness #866 ([codex] Fix worker startup readiness), [codex] Document worker HTTP probes #867 ([codex] Document worker HTTP probes). Master currently uses HTTPhttpGet: /healthzprobes for the worker — this PR changes only the timeout/threshold numbers on that existing shape, not the probe mechanism, so it does not re-litigate any of those.AC 2/3 (crash-time run marking) stay on #891. AC 5 (postgres null-socket) is already satisfied on master by
patches/postgres@3.4.9.patchvia #886.What Changed
server/src/process-crash-guard.ts— installsuncaughtException+unhandledRejectionhandlers. Serialises the fullError.causechain (hardened against throwingname/message/stack/causegetters, self-referential cause cycles, and non-Error throwables), writes a synchronous stderr breadcrumb before anything async so the reason survives intokubectl logs --previous, then exits non-zero deliberately. Crash bookkeeping is optional, raced against a 5s timeout, and re-entrancy guarded so a crash while handling a crash exits immediately rather than hanging.server/src/index.ts— installs the guard at theisMainModuleentrypoint, beforestartServer()is called. NoonCrashhook is passed; the injection point is where crash-time run marking will plug in from fix(worker): guard process death so crashes stop orphaning agent runs (BLO-19722) #891, which is what keeps this module free of any heartbeat dependency.deploy/helm/paperclip/values.yaml— worker livenesstimeoutSeconds: 5 → 10,failureThreshold: 3 → 6(~90s → ~180s before kill), with the rationale recorded inline: what/healthzactually measures, why the worker is deliberately laxer than the stateless API tier, and what was explicitly not fixed here.server/src/__tests__/process-crash-guard.test.ts— 13 tests over cause-chain serialisation and handler behaviour.Verification
tsc: the 38 errors are an identical pre-existing set on master; none are in changed files (verified by grepping the error list forprocess-crash-guardandsrc/index.ts— empty).server-startup-feedback-export.test.tsis unmodified by this PR; it was run to confirm the guard install does not perturb startup, and to verify the listener issue below is gone.process-crash-guard.tsaside makes the suite fail (Cannot find module). Stated plainly — that is the weak form of "fails pre-fix", so treat this as a unit suite, not a regression test for a live defect./healthzhandler inserver/src(only a test fixture string), and the cited SPA catch-all line range had drifted on master — corrected 776-786 → 777-787.Risks
Low-to-moderate, and the interesting risk is one I introduced and then removed.
startServer(). That function is exported and called 16 times in-process byserver-startup-feedback-export.test.ts, which reproducibly emittedMaxListenersExceededWarning: 11 uncaughtException listeners added to [process]— and, far worse, armed a realprocess.exit(1)inside the vitest worker, where a single late-handled promise rejection would kill the worker mid-suite and surface as an unrelated CI flake. Moving toisMainModuleremoves both (warning confirmed gone) and covers strictly more of startup. The cost, stated plainly: the wiring line is now unreachable from an in-process test and has no automated coverage — same as the adjacentvoid startServer().catch(...). The guard module has 13 tests; the wiring does not. I judged an untestable-but-correct entrypoint better than a tested-but-hazardous one; that judgement is worth challenging.probes.test.mjsasserts probe shape, not thresholds, so the widened numbers are not covered by an assertion./healthzresolves via the SPA catch-all, a build serving noui-distwould 404 every probe and CrashLoop the worker for a reason unrelated to its health. Noted inline in values.yaml.Model Used
claude-opus-4-5), 1M context, extended thinking, with tool use / code execution via Claude Code.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templatevalues.yaml