From 789481502d35f0f080fc711f08bbe43be1f04561 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 26 Apr 2026 21:27:07 +0100 Subject: [PATCH] fix(dev): patch EventEmitter.emit to swallow peer-disconnect errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/vinext/src/index.ts | 122 ++++++++++++++++++++++++----------- 1 file changed, 84 insertions(+), 38 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index c0c354c388..f4dbb3a064 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -112,6 +112,7 @@ import { pathToFileURL } from "node:url"; import { createRequire } from "node:module"; import fs from "node:fs"; import { randomBytes } from "node:crypto"; +import { EventEmitter } from "node:events"; import commonjs from "vite-plugin-commonjs"; type ASTNode = ReturnType["body"][number]["parent"]; @@ -492,7 +493,90 @@ type NitroSetupContext = { }; }; +/** + * Dev-only backstop for peer-disconnect errors emitted on EventEmitters + * with no `'error'` listener. + * + * Earlier attempts (PR #911 socket guard, PR #913 process-level handler) + * left a hole: errors that escape through Node's `pipe()` re-emission or + * are routed via the `node:domain` wrapper bypass `uncaughtException` + * entirely. The remaining trace looks like this even with both prior + * fixes installed: + * + * 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) + * + * Real call sites in vinext dev that hit this: + * - `fromWeb(fetch().body).pipe(res)` in proxyExternalRewriteNode. + * - Streaming surfaces inside @vitejs/plugin-rsc with their own pipe + * topology (destinations aren't inbound connection sockets). + * - Outbound sockets created by middleware `fetch()`. + * + * The only place to catch every variant is at the synchronous source: + * `EventEmitter.prototype.emit('error', ...)`. We wrap it once, filter + * strictly by peer-disconnect codes (ECONNRESET / EPIPE / ECONNABORTED), + * and only short-circuit when the emitter has no `'error'` listeners — + * the exact condition under which Node would throw. All other errors, + * and any error on an emitter that has a listener, pass through + * untouched. Genuine bugs still surface. + * + * Installed once per process at plugin construction; subsequent calls + * are no-ops via a Symbol.for guard, so dep re-optimization or server + * restarts can't tear it down. + * + * Dev-only by virtue of running inside the Vite plugin factory, which + * is not invoked in production (Cloudflare Worker) where socket + * lifecycle is owned by the runtime. + */ +const DEV_SOCKET_BACKSTOP_FLAG = Symbol.for("vinext.devSocketErrorBackstop"); +function installDevSocketErrorBackstop(): void { + const proc = process as typeof process & { [DEV_SOCKET_BACKSTOP_FLAG]?: true }; + if (proc[DEV_SOCKET_BACKSTOP_FLAG]) return; + proc[DEV_SOCKET_BACKSTOP_FLAG] = true; + + const isPeerDisconnect = (err: unknown): boolean => { + const code = (err as { code?: string } | null)?.code; + return code === "ECONNRESET" || code === "EPIPE" || code === "ECONNABORTED"; + }; + + // oxlint-disable-next-line typescript-eslint/unbound-method -- invoked with explicit .apply(this, ...) + const originalEmit = EventEmitter.prototype.emit; + EventEmitter.prototype.emit = function patchedEmit( + this: EventEmitter, + type: string | symbol, + // oxlint-disable-next-line typescript/no-explicit-any + ...args: any[] + // oxlint-disable-next-line typescript/no-explicit-any + ): any { + if (type === "error" && isPeerDisconnect(args[0]) && this.listenerCount("error") === 0) { + return false; + } + return originalEmit.apply(this, [type, ...args]); + }; + + // Belt-and-braces: also catch via uncaughtException / unhandledRejection + // for surfaces that bypass EventEmitter.emit entirely (raw promise + // rejections, native callbacks). Synchronous throw inside the listener + // for non-peer-disconnect errors restores Node's default crash. + process.on("uncaughtException", (err: Error) => { + if (isPeerDisconnect(err)) return; + throw err; + }); + process.on("unhandledRejection", (reason: unknown) => { + if (isPeerDisconnect(reason)) return; + throw reason; + }); +} + export default function vinext(options: VinextOptions = {}): PluginOption[] { + installDevSocketErrorBackstop(); + const viteMajorVersion = getViteMajorVersion(); let root: string; let pagesDir: string; @@ -2005,44 +2089,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { socket.on("error", () => {}); }); - // Backstop for stream-pipe paths the connection-level guard misses. - // When a Readable is piped to a Writable that has no 'error' listener, - // Node's pipe() machinery re-emits the source's error onto the - // destination, which then throws (e.g. fromWeb(fetch().body).pipe(res) - // in proxyExternalRewriteNode, or any streaming surface inside - // @vitejs/plugin-rsc). Outbound sockets created by fetch() also never - // fire 'connection' on server.httpServer. Filtering by error code - // keeps real bugs surfacing — only peer-disconnect codes are dropped. - // - // Skipped in middleware mode (httpServer is null): the embedding host - // owns process-level handlers, and we have no reliable teardown hook - // to remove ours, so installation would leak. - if (server.httpServer) { - const isPeerDisconnect = (err: unknown): boolean => { - const code = (err as { code?: string } | null)?.code; - return code === "ECONNRESET" || code === "EPIPE" || code === "ECONNABORTED"; - }; - // Synchronous throw inside an uncaughtException listener aborts - // the process the same way no listener would (stack to stderr, - // non-zero exit). Re-throwing on nextTick instead would re-enter - // this same listener and loop indefinitely, silently swallowing - // genuine errors. - const onUncaught = (err: Error) => { - if (isPeerDisconnect(err)) return; - throw err; - }; - const onUnhandledRejection = (reason: unknown) => { - if (isPeerDisconnect(reason)) return; - throw reason; - }; - process.on("uncaughtException", onUncaught); - process.on("unhandledRejection", onUnhandledRejection); - server.httpServer.once("close", () => { - process.removeListener("uncaughtException", onUncaught); - process.removeListener("unhandledRejection", onUnhandledRejection); - }); - } - server.watcher.on("add", (filePath: string) => { if (hasPagesDir && filePath.startsWith(pagesDir) && pageExtensions.test(filePath)) { invalidateRouteCache(pagesDir);