fix(dev): backstop uncaughtException for socket errors pipe() misses#913
Conversation
…sses PR #911 attached an error listener to every connection socket, which fixes the direct res.socket case. But Node's pipe() machinery has a separate re-emission path: when a Readable source errors and the destination has no error listener, pipe() emits the error on the destination — and that destination is not always the connection socket. Two real call sites trigger this: - fromWeb(fetch().body).pipe(res) in proxyExternalRewriteNode, where the source is the upstream fetch body, not the inbound socket - streaming surfaces inside @vitejs/plugin-rsc that own their own pipe topology Outbound sockets created by fetch() also never fire 'connection' on server.httpServer, so they sit completely outside the existing guard. Add a process-level uncaughtException handler scoped to the dev server that drops only peer-disconnect codes (ECONNRESET, EPIPE, ECONNABORTED) and re-throws everything else on nextTick to preserve Node's default crash behavior for real errors. Removed on httpServer 'close' so it doesn't leak across server restarts. Refs #905. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
/bigbonk review |
commit: |
|
There was a problem hiding this comment.
Reviewer notes
I confirmed the diagnosis of the original crash (pipe re-emission from non-socket destinations and outbound fetch() sockets) — the connection-level guard from #911 genuinely doesn't cover those paths, and a process-level uncaughtException handler is a reasonable backstop. Next.js installs one in dev for similar reasons (packages/next/src/server/lib/router-server.ts ~line 809).
However, the re-throw path for non-disconnect errors does not behave the way the comment claims, and that's a meaningful behavioral regression for genuine errors.
Blocker: re-throw on nextTick creates an infinite loop, doesn't crash
packages/vinext/src/index.ts:2020-2028:
const onUncaught = (err: Error) => {
if (isPeerDisconnect(err)) return;
process.nextTick(() => {
throw err;
});
};
process.on("uncaughtException", onUncaught);The comment says this restores Node's default crash behavior — "same shape as not having installed a handler at all." That's not what happens. Node's default crash only fires when there are no uncaughtException listeners. Here the listener is still installed when the nextTick callback runs, so the re-thrown error is caught by the same handler again, which schedules another nextTick throw, which is caught again, forever.
Reproduced locally with the exact pattern:
$ node -e '
process.on(\"uncaughtException\", (err) => {
console.log(\"caught:\", err.message);
if (err.code === \"ECONNRESET\") return;
process.nextTick(() => { throw err; });
});
process.nextTick(() => { throw new Error(\"genuine bug\"); });
setTimeout(() => { console.log(\"still alive\"); process.exit(0); }, 100);
'
caught: genuine bug
caught: genuine bug
caught: genuine bug
... (repeats indefinitely)
still alive
Net effect for vinext dev users: a real programming error that previously would crash the dev server with a stack trace now gets swallowed silently and the event loop spins on it. That is the opposite of the stated intent ("keep real bugs surfacing").
This also means the unchecked test-plan item "Genuine errors thrown from request handlers still crash dev server with full stack" will not pass as written.
Fix options
Pick whichever fits the style of the file; any of these restores the intended semantics:
1. Synchronous re-throw inside the handler (simplest):
const onUncaught = (err: Error) => {
if (isPeerDisconnect(err)) return;
throw err; // sync throw inside the handler aborts the process with code 1
};I verified Node treats a synchronous throw inside an uncaughtException listener as fatal (exit code 7 / 1 depending on version), printing the stack — same observable as no handler.
2. Remove the listener, then re-throw asynchronously:
const onUncaught = (err: Error) => {
if (isPeerDisconnect(err)) return;
process.removeListener(\"uncaughtException\", onUncaught);
process.nextTick(() => { throw err; });
};3. Log + process.exit(1) — matches what Next.js's setup-exception-listeners.ts does. Loses the default stack-trace formatting unless you console.error(err) first.
I'd lean toward (1) for minimum diff and matching Node's default-crash output most closely.
Minor: leak in middleware mode
server.httpServer?.once(\"close\", ...) is a no-op when server.httpServer is null (Vite middleware mode — vinext embedded in a host server). The uncaughtException listener is never cleaned up in that case. Not a hot path today (no test uses middlewareMode), but worth tying the cleanup to something that always exists, e.g. also hook server.watcher.close or a buildEnd hook, or skip installation entirely when httpServer is null.
Nit: also catch unhandledRejection?
The two real crash sources cited (pipe re-emission, outbound fetch() socket errors from middleware) can both reach the process as unhandledRejection rather than uncaughtException depending on which async surface throws. If the goal is a true backstop for peer-disconnect errors in dev, mirroring the same filter on unhandledRejection would close that gap. Optional — fine to leave for a follow-up if you want to keep this PR tight.
Once the re-throw is fixed
Approach LGTM otherwise. Filter is correctly scoped to dev (configureServer only), correctly scoped to peer-disconnect codes, and the cleanup-on-close pattern is reasonable for the httpServer != null case.
|
Review posted as Changes Requested on PR #913. Summary of findings:
Approach itself is sound — process-level backstop is the right shape, filter codes are correct, scope to dev-only via |
Three issues from PR #913 review: 1. (Blocker) The process.nextTick(() => throw err) re-throw created an infinite loop, not a crash: the listener is still installed when the nextTick callback fires, so it catches the re-thrown error and schedules another, forever — silently swallowing genuine bugs. Replace with a synchronous throw inside the handler, which Node treats as fatal and produces the same observable as no listener (stack to stderr, non-zero exit). 2. Skip installation entirely in middleware mode (httpServer is null). The embedding host owns process-level handlers and there's no reliable teardown hook to remove ours, so installation would leak. 3. Mirror the same filter onto unhandledRejection — peer-disconnect errors from outbound fetch() and pipe re-emissions can surface through either channel depending on the async path. Verified locally: sync throw inside the handler crashes Node with the original stack and a non-zero exit code; the previous nextTick pattern loops indefinitely on the same input. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks for the catch — all three addressed in 95cd980. Blocker (1): Confirmed the loop locally — your repro reproduces verbatim. Switched to a synchronous throw inside the handler. Verified Node treats it as fatal: Stack to stderr, non-zero exit — same observable as no listener. Minor (2): Skipped installation entirely when Nit (3): Folded in — same |
|
/bigbonk re-review for issues |
There was a problem hiding this comment.
Re-review of 95cd980
All three points from the previous review are addressed correctly. Verified locally.
Blocker (1) — sync throw: ✅ fixed
packages/vinext/src/index.ts:2030-2033 and :2034-2037 use a synchronous throw inside the listener instead of process.nextTick(() => { throw err }). Reproduced both shapes:
$ node -e 'process.on("uncaughtException", (err) => { if (err.code === "ECONNRESET") return; throw err; });
process.nextTick(() => { throw new Error("genuine bug"); });
setTimeout(() => { console.log("STILL ALIVE"); }, 200);'
[eval]:4
throw err;
^
Error: genuine bug
at [eval]:6:32
Node.js v24.15.0
# exit=7
No loop, stack to stderr, non-zero exit. ECONNRESET still passes through silently as intended (verified separately).
Minor (2) — middleware-mode leak: ✅ fixed
Wrapping the entire block in if (server.httpServer) (line 2020) is the right call. Embedding host keeps ownership of process handlers; nothing leaks. Consistent with the existing server.httpServer?.on("connection", ...) pattern just above.
Nit (3) — unhandledRejection: ✅ folded in
onUnhandledRejection mirrors the same filter and sync-throw shape (:2034-2037) and is removed alongside onUncaught on close. Verified throw reason inside an unhandledRejection listener also crashes Node fatally (exit 1, stack printed), and the ECONNRESET filter still drops peer disconnects from rejected promises.
One subtle property worth noting: rejected non-Error values (Promise.reject("string"), Promise.reject(null)) flow through fine — (reason as {code?: string} | null)?.code is undefined for primitives and null, so isPeerDisconnect returns false and the throw still surfaces them. Good.
Other observations (non-blocking)
- Listener accumulation on Vite restart: if Vite reloads the config and re-runs
configureServerwhile the previoushttpServerhasn't yet fired'close', listeners briefly stack. Node warns at 10. Realistically not hit in normal dev, and the close hook does eventually clean up. Fine to leave. - No automated test: process-level error handlers are genuinely awkward to test — would require a child-process harness. The manual repro in the PR description is the right level of validation for a dev-only safety net.
- Wrapped errors:
new Error("wrapped", { cause: econnreset })won't be filtered (top-levelcodeis undefined). Matches the existing socket-level guard's behavior and is the conservative choice — only drop errors that directly look like peer disconnect.
LGTM.
|
Review posted as APPROVED on PR #913. Summary of findings: All three points from my previous review were addressed correctly in commit 95cd980:
Flagged three non-blocking observations (listener accumulation on Vite config reload, lack of automated test, wrapped-error chains not filtered) but none are worth holding the PR for. |
PR #911 (connection socket guard) and PR #913 (process-level uncaughtException backstop) both left a hole. The dev server still crashes on ECONNRESET in this exact shape: node:events:487 throw er; // Unhandled 'error' event Error: read ECONNRESET at TCP.onStreamRead Emitted 'error' event on Socket instance at: at Socket.onerror (node:internal/streams/readable:1035:14) // pipe() at Socket.emit (node:domain:489:12) // domain wrap at emitErrorNT (node:internal/streams/destroy:170:8) The Socket.onerror frame is Node's pipe() machinery re-emitting the source's error onto a destination Stream that has no error listener. The Socket.emit frame in node:domain wraps the emit and routes the throw through the deprecated-but-still-loaded domain module, which bypasses uncaughtException entirely. Neither the per-connection listener nor a process-level handler can intercept this path. Three real call sites in vinext dev hit it: - fromWeb(fetch().body).pipe(res) in proxyExternalRewriteNode - streaming surfaces inside @vitejs/plugin-rsc with their own pipe topology (destination is not the inbound connection socket) - outbound sockets created by middleware fetch() The only place to catch every variant is at the source: EventEmitter.prototype.emit. Patch it to short-circuit when *all three* conditions hold: type === 'error', the error has a peer-disconnect code (ECONNRESET / EPIPE / ECONNABORTED), and the emitter has zero 'error' listeners (the exact condition under which Node would throw). All other errors — and any error on an emitter that already has a listener — pass through untouched. Genuine bugs still surface. Installed once per process at plugin construction, guarded by a Symbol.for so dep re-optimization, full reloads, or repeated plugin invocations can't double-install or tear it down. Belt-and-braces uncaughtException / unhandledRejection handlers stay in place for surfaces that bypass EventEmitter.emit (raw promise rejections, native callbacks) — also synchronous-throw on non-peer-disconnect errors so Node's default crash semantics are preserved. Refs #905. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three issues from PR #916 review: 1. Module-top-level install was wrong — it ran during 'vinext build', 'vp pack', and Vitest worker imports too, so the JSDoc 'dev-only' claim was inaccurate and a real ECONNRESET from a pre-render fetch in CI would have been silently swallowed. Move the install back into configureServer (which only fires in dev) but **without** binding teardown to httpServer 'close' — that was the original lifecycle bug PR #913 had. Symbol.for guard makes re-invocation a no-op so the listener still survives server restarts within a session. 2. Restore the middleware-mode skip dropped in the previous commit. Embedders running vinext in middleware mode (Express/Connect) keep ownership of their own process-level handlers. Gated on server.httpServer, matching the prior PR #913 behavior. 3. Document the listener-ordering implication of installing during configureServer (registers late in the queue, so Sentry / structured logging / test-runner hooks still observe non-peer-disconnect errors before vinext's sync re-throw aborts iteration), and the Symbol.for first-loaded-wins caveat for the multi-version case. Refactor: collapse the duplicated `(err as Error & { code }).code` casts into a single `peerDisconnectCode` helper that returns the matched code or undefined, so the debug log path reuses it without recasting. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e events can't remove it (#916) * debug(dev): hoist socket-error backstop to module top-level + opt-in trace Two changes intended to pin down why the process-level handler from PR #913 still doesn't catch the ECONNRESET trace some users report: 1. Move installation from inside configureServer to module top-level, guarded by Symbol.for to prevent double-install. Earlier versions tied teardown to httpServer 'close', which Vite emits on dep re-optimization, full reloads, and other lifecycle events — leaving a window where the listener is absent when a stale stream errors. Module-level install + Symbol guard removes that window entirely. 2. Add an opt-in console.warn marker behind VINEXT_DEBUG_SOCKET_ERRORS=1 that fires whenever the listener absorbs a peer-disconnect error. The visible `node:events:487 throw er; // Unhandled 'error' event` output otherwise leaves it ambiguous whether the listener never ran (install / lifecycle issue) or ran but was somehow bypassed (different async surface / dispatcher). The marker disambiguates. No behavior change for users not setting the env flag beyond the hoisting. Filter codes and re-throw shape are unchanged from #913. Refs #905. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address bonk review on dev socket-error backstop Three issues from PR #916 review: 1. Module-top-level install was wrong — it ran during 'vinext build', 'vp pack', and Vitest worker imports too, so the JSDoc 'dev-only' claim was inaccurate and a real ECONNRESET from a pre-render fetch in CI would have been silently swallowed. Move the install back into configureServer (which only fires in dev) but **without** binding teardown to httpServer 'close' — that was the original lifecycle bug PR #913 had. Symbol.for guard makes re-invocation a no-op so the listener still survives server restarts within a session. 2. Restore the middleware-mode skip dropped in the previous commit. Embedders running vinext in middleware mode (Express/Connect) keep ownership of their own process-level handlers. Gated on server.httpServer, matching the prior PR #913 behavior. 3. Document the listener-ordering implication of installing during configureServer (registers late in the queue, so Sentry / structured logging / test-runner hooks still observe non-peer-disconnect errors before vinext's sync re-throw aborts iteration), and the Symbol.for first-loaded-wins caveat for the multi-version case. Refactor: collapse the duplicated `(err as Error & { code }).code` casts into a single `peerDisconnectCode` helper that returns the matched code or undefined, so the debug log path reuses it without recasting. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(dev): re-hoist socket-error backstop to module load (configureServer too late) The previous attempt to address bonk's review by moving install back into configureServer was wrong — it didn't restore working behavior: $ VINEXT_DEBUG_SOCKET_ERRORS=1 vp dlx vinext@455a61b dev ... GET /stage/oliver 200 in 1.1s node:events:487 throw er; // Unhandled 'error' event Error: read ECONNRESET ... No `[vinext] dev socket-error backstop installed` startup line. The function never ran. Cause: in vite-plus's plugin lifecycle, `server.httpServer` is null at the moment configureServer fires, so the `if (server.httpServer)` middleware-mode guard skipped install. Requests still get served (httpServer is created later) but the listener was never attached. Hoist back to module load. To address bonk's "loaded in build/test contexts too" concern, gate install on: - `process.env.VITEST === "true"` → Vitest worker imports - `process.argv` contains "build" → `vinext build` / `vp build` `process.argv` is read because the CLI entry imports index.ts before it has a chance to set an env var, and `configResolved` runs too late for module-load gating. Updated JSDoc to be honest about the install context, the listener- ordering implication, the middleware-mode embedder situation, and the Symbol.for first-loaded-wins caveat. Refs #905. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(dev): positive-gate socket backstop on argv[2]==='dev' Replace the negative VITEST/build skip list with a single positive check: install only when argv[2] === "dev". Matches `vinext dev`, `vp dev`, `vite dev`, and any CLI following the "<bin> dev" convention. Everything else — build, Vitest workers, library embedders with a custom runner — skips install, so genuine peer-disconnect errors surface normally in those contexts. Cleaner default: no listener unless we're confident this is a dev server invocation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(dev): gate socket backstop on Vite's command==='serve' Replace argv-sniffing with the canonical signal: Vite's `config()` hook receives `{ command: "serve" | "build" }` directly. Install the backstop only when `command === "serve"`. Works identically for `vinext dev`, `vp dev`, `vite dev`, and library embedders that call `createServer` themselves — anywhere Vite considers itself a dev server. `config()` runs before `configureServer` (so before httpServer matters) and before the dep-optimization or full-reload events that broke the earlier `httpServer.close`-tied teardown. Symbol.for guard keeps the install idempotent across server restarts within a session. Removes: - Module-load-time install + argv[2] === "dev" check - VITEST / build env-var negative skip list Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(dev): narrow gate to !isPreview so vite preview skips install `command === "serve"` covers both `vite dev` and `vite preview` (the post-build static server). Preview doesn't stream RSC and doesn't need this guard, so narrow to `command === "serve" && !env.isPreview`. `vinext start` runs prod-server.ts directly without Vite, so it was already correctly excluded — `config()` never fires there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: extend socket-error backstop to vinext start (Next.js parity) Next.js installs uncaughtException + unhandledRejection handlers in their router-server unconditionally — no dev/prod gate. Both the Node.js dev server and the Node.js prod server share the same process-level error guards. See vercel/next.js packages/next/src/server/lib/router-server.ts:809-810. vinext was only guarding the dev path. `vinext start` runs prod-server.ts directly as a Node HTTP server (used for self-hosted deploys; Cloudflare Workers prod doesn't load this module — the runtime owns socket lifecycle there) and had the same theoretical exposure to peer-disconnect crashes through the pipeline() / fetch() paths it streams responses through. Refactor: - Extract the install function from index.ts into a new src/server/socket-error-backstop.ts (drop the "Dev" prefix — no longer dev-only). - Keep the call from the vinext:config plugin's config() hook, gated on command === "serve" && !isPreview (covers vinext dev / vp dev / vite dev / library embedders). - Add a parallel call at the top of startProdServer() in prod-server.ts (covers vinext start). vinext is more conservative than Next.js's log-only handler — we filter strictly on peer-disconnect codes and sync re-throw the rest, so genuine bugs still surface. The parity is in *where* we install, not what we swallow. Vitest workers and `vinext build` never reach either entry point, so peer-disconnect errors in those contexts surface normally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(dev): auto-install socket-error backstop at module load The Vite config() hook gate (`command === "serve" && !isPreview`) proved unreliable in vite-plus's lifecycle — the hook didn't fire (or didn't pass command correctly), and install was silently skipped. Field reproducer: published 501dd95 had the call site in the bundle at the right spot, but `VINEXT_DEBUG_SOCKET_ERRORS=1` produced no startup marker, immediate ECONNRESET crash on first request. Move the install back to module load — invoked unconditionally as a side effect when socket-error-backstop.ts is imported. This was the last shape verified to work via the diagnostic flag. Drop the import-and-call pattern in index.ts in favor of a side-effect import (`import "./server/socket-error-backstop.js"`), which is enough to trigger the auto-install. The import order keeps the install ahead of every other vinext server code path. Skip in Vitest workers via `process.env.VITEST === "true"` so genuine peer-disconnect errors during test runs surface normally. Build runs are unaffected (short-lived, no peer-disconnect-prone streams) — matches Next.js's pattern of installing in any HTTP-serving entry without further gating. prod-server.ts still calls installSocketErrorBackstop() explicitly for `vinext start`. Idempotent via Symbol.for guard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: call installSocketErrorBackstop directly in index.ts Remove the side-effect import + auto-install indirection. Just call the function explicitly at module top-level in index.ts, where it's obvious. Same observable behavior, clearer code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: gate socket backstop on NODE_ENV to keep prerender ECONNRESET fatal Bonk's PR #916 review caught a real corruption bug: the build path calls startProdServer() during prerender (build/run-prerender.ts:181, build/prerender.ts:398, :720), so the install fires twice during a build — once at index.ts module load, once inside startProdServer. User fetch() calls inside prerendered pages can hit ECONNRESET from flaky upstream APIs. With the backstop installed, those errors are silently absorbed instead of crashing the build, producing corrupt prerendered HTML/RSC output. Add NODE_ENV gating inside installSocketErrorBackstop: - Skip if NODE_ENV === "production" → covers vinext build + prerender - Skip if NODE_ENV === "test" → covers test runners that follow convention - Skip if VITEST === "true" → kept for Vitest specifically Vite sets NODE_ENV=production for the build command before plugins load, so the gate fires correctly. Trade-off: vinext start with NODE_ENV=production set in shell will also skip install — losing strict Next.js parity for that path. Acceptable: prod-server's pipeline() callbacks already handle the streaming case, and the real-world bug reports are all dev-server. Also fix the now-stale comment at prod-server.ts:817 that still referenced the reverted "Vite plugin config() hook" install path, and document the listener-ordering trade-off more honestly in the JSDoc — `index.ts` imports synchronously at the top of every user's vite.config.ts, registering vinext's listener earlier than user / tooling crash reporters. Sync re-throw on non-peer-disconnect errors still surfaces the crash but later-registered observers don't see the event; users who need crash-reporter visibility for those errors must register their handler before importing vinext. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: bypass socket backstop during prerender via VINEXT_PRERENDER Replace the install-time NODE_ENV gate (which broke vinext start parity by skipping installation entirely when NODE_ENV=production) with a fire-time VINEXT_PRERENDER check inside the listener. The listener is always installed, but during prerender it re-throws all errors unconditionally — acting as if no listener were present — so user fetch() ECONNRESETs during prerender crash the build with the original stack instead of being silently absorbed into corrupt prerendered output. The fire-time check (vs. install-time) is necessary because index.ts loads at Vite plugin import — well before prerender begins — and the Symbol.for guard then makes any later install call a no-op. A static install-time gate can't catch the prerender phase that follows. Set VINEXT_PRERENDER=1 in run-prerender.ts at the top of runPrerender() so the flag covers the entire prerender orchestration including startProdServer setup. prerender.ts already sets the same var around its actual render passes; this widens the scope. Restores Next.js-parity install for vinext start (NODE_ENV gate is gone). Test-runner skip stays install-time on VITEST / NODE_ENV=test since those contexts genuinely shouldn't have the listener. Refs PR #916 review feedback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address bonk PR #916 follow-up review Three issues bonk flagged after the prerender fix landed: 1. Stale comment in prod-server.ts:816-820 — described the NODE_ENV self-gate that was reverted in b3e8759. Replace with one that describes the current behavior: idempotent install via Symbol.for guard, kept here for entry points that load prod-server without index.ts (Next.js parity), prerender bypass is fire-time via VINEXT_PRERENDER not install-time. 2. prerender.ts's finally blocks `delete VINEXT_PRERENDER` clobbers any value set by the caller. After prerenderApp returns inside runPrerender (which sets the flag for the whole orchestration), the var is deleted instead of restored to runPrerender's "1", leaving narrow gap windows around shared-server close where ECONNRESET would be absorbed instead of re-thrown. Save the prior value and restore it. Doesn't cause prerender output corruption (rendering already complete by these gaps) but it's the correct hygiene. 3. Document the orchestrator-induced ECONNRESET trade-off in the backstop's JSDoc — unconditional re-throw during prerender means a hung-route + orchestrator timeout surfaces the build crash as ECONNRESET rather than the route's own error. Acceptable but worth calling out so debuggers know to set VINEXT_DEBUG_SOCKET_ERRORS=1 to disambiguate. Plus minor JSDoc wording nit: the "Symbol.for guard prevents re-install" line was load-bearing in the wrong way — the relevant point is timing, not de-dup. Reworded. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address bonk PR #916 follow-up review (round 2) - run-prerender.ts: save/restore VINEXT_PRERENDER to avoid leaking the env var into Vitest workers (mirrors prerender.ts pattern). - index.ts: correct stale install-site comment — install gate is Vitest-only; build/prerender bypass is fire-time via VINEXT_PRERENDER. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: focused unit test for socket-error-backstop predicate + install gate Addresses bonk PR #916 review item #3 (carried over). Extracts 'peerDisconnectCode' as an exported pure predicate so the matching logic can be tested in isolation without process-state mutation, and adds a thin 'isSocketErrorBackstopInstalled()' query so the test can verify the Vitest install-gate short-circuit fires in worker processes. Tests cover: - ECONNRESET / EPIPE / ECONNABORTED accepted, other codes rejected - non-Error / null / primitive reasons handled (unhandledRejection fires with arbitrary reason values) - Install gate skips Vitest workers (process.env.VITEST === 'true') Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
Summary
Follow-up to #911. The connection-level guard installed there fixes the direct
res.socketcase but doesn't cover Node'spipe()re-emission path. When aReadablesource errors and the destination has no'error'listener,pipe()emits the source's error onto the destination — and the destination isn't always the inbound connection socket.Real call sites in vinext that hit this:
fromWeb(fetch().body).pipe(res)inproxyExternalRewriteNode— source is the upstream fetch body stream, not an inbound socket.@vitejs/plugin-rscthat own their own pipe topology.fetch()from middleware — these never fire'connection'onserver.httpServer, so they sit entirely outside the existing guard.Stack trace from the field (
vp dlx vinext@54c91f1 dev, after a successfulGET /stage/oliver):Socket.onerror at internal/streams/readable:1035ispipe()'s internal error-propagation function — same crash shape that #911 set out to fix, different installation site.Approach
Add a process-level
uncaughtExceptionhandler scoped to the dev server. It drops only peer-disconnect codes (ECONNRESET,EPIPE,ECONNABORTED) and re-throws everything else onnextTick, which preserves Node's default crash semantics for real bugs. The handler is removed onhttpServer'close'so it doesn't leak across restarts.Why a process-level backstop:
fetch()and pipe re-emissions to non-socket destinations.configureServer, never installed in build/prod.Refs
Test plan
vp checkpasses (note: 12 pre-existing typecheck errors about missing peer deps are unchanged)proxyExternalRewriteNodein play: dev server no longer crashes after browser disconnect mid-streamhttpServer.close()followed by anotherlisten()doesn't double-register the handler🤖 Generated with Claude Code