Skip to content

dev: unhandled ECONNRESET when a proxied websocket dies with its worker #4493

Description

@kedrzu

Environment

  • Nitro: 2.13.4
  • Nuxt: 4.2.2, @nuxt/cli 3.35.1 (also reproduced against 3.36.0)
  • Node: v24 (macOS); the same code path is present on all platforms
  • Preset: node dev worker (default nuxt dev)

Reproduction

Any nuxt dev session where a proxied websocket dies:

  1. npx nuxi init repro && cd repro && npm i && npm run dev
  2. Open http://localhost:3000 and leave the tab open.
  3. Edit a file that ends up in the server bundle (e.g. a server/api/* handler, or a module imported by one), so the dev worker is replaced.
  4. Repeat, or keep any client that reconnects pointed at the port.

The dev server then prints and restarts itself:

 ERROR  [unhandledRejection] read ECONNRESET
    at TCP.onStreamRead (node:internal/stream_base_commons:216:20)
    at TCP.callbackTrampoline (node:internal/async_hooks:130:17)

●  Restarting Nuxt due to error: Error: read ECONNRESET

I could not attach a StackBlitz because the failure needs a websocket to be alive across a worker swap, which the in-browser container does not really exercise — but the cause is a plain unhandled promise, visible in the source, and I verified it by instrumentation rather than by guessing (below).

Describe the bug

DevServer.handleUpgrade is async, and both of its callers drop the promise it returns:

// src/core/dev-server/server.ts
upgrade: (req, socket, head) => devServer.handleUpgrade(req, socket, head),
// ...
listener.server.on("upgrade", (req, sock, head) =>
  this.handleUpgrade(req, sock, head)
);

It can reject in two ways: createError({ statusCode: 503 }) when no worker is available, and — far more often — from NodeDevWorker.handleUpgrade, which returns proxy.ws(...) without awaiting or catching it:

// src/core/dev-server/worker.ts
handleUpgrade(req: IncomingMessage, socket: Socket, head: any) {
  if (!this.ready) {
    return;
  }
  return this.#proxy!.proxy.ws(req, socket, { target: this.#address, xfwd: true }, head);
}

Every time the dev worker is replaced, the websockets proxied to it die and that promise rejects with ECONNRESET. Nothing catches it, so it becomes an unhandled rejection.

For Nitro alone that is a stray warning. Under Nuxt it ends the session: @nuxt/cli treats any unhandled rejection as fatal and restarts the dev server. With a client that keeps reconnecting it loops, and the dev server never stays up long enough to be useful. It has been reported against the CLI several times — nuxt/cli#247, nuxt/cli#968, nuxt/cli#994 — where it reads as a Windows/HMR flake, but the leak is here and platform-independent.

Additional context

I traced it rather than inferred it: I wrapped both candidate call sites in a real project and logged which one rejected. Over 30 seconds — NodeDevWorker.handleUpgrade 8, the CLI request handler 0. Adding a .catch() at that call site removed the rejections and the restarts entirely.

I am opening a PR against v2 that makes handleUpgrade never reject: it closes the socket on failure and only logs errors that are not a peer disconnect (there is nothing to reply to the client with once a handshake is in flight). main has the same shape in src/dev/server.ts (upgrade() is async and server.on("upgrade", ...) drops the promise), so it likely wants the same treatment — happy to follow up there if you would like.

Logs

 ERROR  [unhandledRejection] read ECONNRESET

    at TCP.onStreamRead (node:internal/stream_base_commons:216:20)
    at TCP.callbackTrampoline (node:internal/async_hooks:130:17)

│
●  Restarting Nuxt due to error: Error: read ECONNRESET

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions