From d53479b708ea7457a13eb1c72c32f7146e59b4fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20K=C4=99drzy=C5=84ski?= Date: Thu, 30 Jul 2026 13:29:20 +0200 Subject: [PATCH] fix(dev): do not leak a rejection when a proxied upgrade fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DevServer.handleUpgrade` is async and both callers drop the promise it returns: the `upgrade` method handed out by `createDevServer`, and the `server.on("upgrade")` listener in `listen`. It rejects when no worker is available, and — far more often — from `NodeDevWorker.handleUpgrade`, which returns `proxy.ws(...)` unawaited. Replacing the dev worker kills every websocket proxied to it, so that promise rejects with `ECONNRESET` and nothing is there to catch it. For Nitro alone that is a stray unhandled rejection. Under Nuxt it ends the session, because the CLI treats any unhandled rejection as fatal and restarts the dev server; with a client that reconnects it loops. Make `handleUpgrade` never reject. There is nothing left to reply with once a handshake is in flight, so a failed upgrade closes the socket, and only errors that are not a peer disconnect are reported. Resolves #4493 Co-Authored-By: Claude Opus 5 --- src/core/dev-server/server.ts | 48 ++++++++++++++++++++++++++++++----- src/core/dev-server/worker.ts | 6 ++++- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/core/dev-server/server.ts b/src/core/dev-server/server.ts index 8553160e32..fb43c86750 100644 --- a/src/core/dev-server/server.ts +++ b/src/core/dev-server/server.ts @@ -290,15 +290,35 @@ class DevServer { return app; } + /** + * Proxy a websocket upgrade to the current worker. + * + * Never rejects. Both callers -- `server.on("upgrade")` below and the + * `upgrade` method exposed by `createDevServer` -- hand this to a callback + * that drops the returned promise, so a rejection here surfaces as an + * unhandled rejection instead of an error anyone can act on. There is also + * nothing left to reply with once a handshake is in flight, so a failed + * upgrade just closes the socket. + */ async handleUpgrade(req: IncomingMessage, socket: Socket, head: any) { - const worker = await this.getWorker(); - if (!worker) { - throw createError({ - statusCode: 503, - message: "No worker available.", - }); + try { + const worker = await this.getWorker(); + if (!worker) { + throw createError({ + statusCode: 503, + message: "No worker available.", + }); + } + await worker.handleUpgrade(req, socket, head); + } catch (error) { + // A websocket dying together with its connection is ordinary traffic: the + // peer went away, or the worker it was proxied to has been replaced by a + // reload. Only report what is not that. + if (!isDisconnect(error)) { + consola.warn("[nitro] [dev] Failed to proxy websocket upgrade.", error); + } + socket.destroy(); } - return worker.handleUpgrade(req, socket, head); } #generateError() { @@ -346,3 +366,17 @@ class DevServer { ); } } + +/** + * Whether an error is only the other end of a connection going away, rather + * than a fault worth reporting. + */ +function isDisconnect(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return ( + code === "ECONNRESET" || + code === "ECONNABORTED" || + code === "EPIPE" || + code === "ERR_STREAM_PREMATURE_CLOSE" + ); +} diff --git a/src/core/dev-server/worker.ts b/src/core/dev-server/worker.ts index b763f7e0c6..4787da8db9 100644 --- a/src/core/dev-server/worker.ts +++ b/src/core/dev-server/worker.ts @@ -22,7 +22,11 @@ export interface DevWorker { readonly closed: boolean; close(): Promise; handleEvent: (event: H3Event) => Promise; - handleUpgrade: (req: IncomingMessage, socket: Socket, head: any) => void; + handleUpgrade: ( + req: IncomingMessage, + socket: Socket, + head: any + ) => Promise | void; } export class NodeDevWorker implements DevWorker {