Skip to content

fix(api): keep Next's upgrade listener off the run-next server - #15332

Merged
diegosouzapw merged 1 commit into
cheaper-inference:release/v3.8.52from
QuangBlue:fix/next-upgrade-relay
Oct 6, 2026
Merged

diegosouzapw merged 1 commit into
cheaper-inference:release/v3.8.52from
QuangBlue:fix/next-upgrade-relay

Conversation

@QuangBlue

Copy link
Copy Markdown
Contributor

⚠️ base-red inherited: #15306

Problem

Under scripts/dev/run-next.mjs (npm run dev / npm start), every WebSocket on /v1/responses, /api/v1/responses, /v1/ws and /api/v1/ws is cut once the server has served any HTTP request. The client sees socket hang up and close 1006 about 50–110 ms after connecting.

Repro: run node scripts/dev/run-next.mjs start against a real build, send one HTTP request, then open a WebSocket on each path.

Path Base With this PR
/v1/ws socket hang up, 1006 at 57 ms 101, session.ready, stays open until the client closes
/api/v1/ws 1006 at 46 ms stays open
/v1/responses 1006 at 83 ms 101, client close 1000 after 1.5 s
/api/v1/responses 1006 at 111 ms client close 1000

Root cause

next() lazily attaches its router upgrade listener on the first request it serves (NextCustomServer.setupWebSocketHandler), to options.httpServer || req.socket.server. That is the run-next server.

This listener runs alongside run-next's own upgrade dispatcher. For every path that resolves to an app route, it calls socket.end() (router-server.js: if (matchedOutput) return socket.end()). /v1/responses and /v1/ws are rewritten to /api/v1/... app routes, so Next ends the sockets that the Responses WebSocket proxy and the /v1/ws bridge had already claimed.

Fix

  • New scripts/dev/next-upgrade-relay.mjs: an EventEmitter that run-next.mjs passes to next() as httpServer. Next attaches its listener there instead of on the real server.
  • The dispatcher forwards to the relay only the upgrades that no handler claimed (dev HMR).
  • nextApp.getUpgradeHandler() stays as the last fallback. It is a no-op in Next 16 (NextNodeServer.handleUpgrade is empty), so the relay is how HMR upgrades keep reaching Next's router.
  • The standalone wrapper is not affected: it wraps Next's single upgrade listener.
  • run-next.mjs is not packaged (npm files, Dockerfile, assembleStandalone), so no allowlist changes are needed.

Tests

  • New: tests/unit/run-next-upgrade-relay.test.ts. It drives a real NextCustomServer, with a stand-in init because unit tests have no build, plus the real Responses WS proxy and /v1/ws bridge.
    • Control case: Next's auto-attached listener ends the claimed /v1/responses and /v1/ws sockets (1006, closed by the server).
    • Relay case: both sessions live until the client closes them, and Next never sees those upgrades.
    • Unclaimed upgrades (/_next/hmr) still reach Next.
    • forward() returns false before Next attaches.
    • A source check confirms run-next wires the relay after its dispatchers.
    • A guard pins the Next internals the fix relies on.
  • On the base the test file fails: the module is missing, and the run-next wiring check fails.
  • Neighbours: run-next-*, standalone*, pack-artifact*, assemble*, graceful-shutdown*, and the other tests that read run-next.mjs: all pass.
  • npm run typecheck:core, npm run check:cycles, eslint (with the suppressions file) and prettier are clean.

Notes

With the fix, /v1/ws still reports 1006 when the client closes the socket, after the full hold. I did not look into this further; it is in the bridge's close handshake and does not depend on this change. The unit test therefore checks who closed the /v1/ws socket, not the close code.

Under scripts/dev/run-next.mjs (`npm run dev` / `npm start`) every
WebSocket on /v1/responses, /api/v1/responses, /v1/ws and /api/v1/ws was
cut (socket hang up, close 1006, ~50-110 ms after connect) once the server
had served any HTTP request.

Root cause: next() lazily attaches its router upgrade listener on the first
request it serves (NextCustomServer.setupWebSocketHandler), to
`options.httpServer || req.socket.server`, i.e. the run-next server. That
listener runs alongside run-next's own upgrade dispatcher and, for any path
that resolves to an app route, does `socket.end()` (router-server.js:
`if (matchedOutput) return socket.end()`). /v1/responses and /v1/ws are
rewritten to /api/v1/... app routes, so sockets the Responses WebSocket
proxy and the /v1/ws bridge had already claimed were ended under them.

Fix: pass next() an EventEmitter relay as `httpServer`
(scripts/dev/next-upgrade-relay.mjs) so Next attaches its listener there,
and have the dispatcher forward to it only the upgrades no handler claimed
(dev HMR). nextApp.getUpgradeHandler() stays as the final fallback but is a
no-op in Next 16 (NextNodeServer.handleUpgrade is empty), so the relay is
how HMR upgrades still reach Next. The standalone wrapper is not affected
(it wraps Next's single upgrade listener).

Evidence: `node scripts/dev/run-next.mjs start` against a real build, one
HTTP request, then a WebSocket per path: on the base all four paths end
with socket hang up / 1006 before or right after the 101; with the relay
all four open and stay up until the client closes them.

Tests: tests/unit/run-next-upgrade-relay.test.ts drives a real
NextCustomServer (stand-in init, no build needed) with the real Responses
proxy and /v1/ws bridge: a control case reproduces the cut, the relay case
keeps both sessions alive, unclaimed upgrades still reach Next, and guards
pin the Next internals the fix relies on.
@QuangBlue
QuangBlue force-pushed the fix/next-upgrade-relay branch from ec7f2c2 to 9991842 Compare October 2, 2026 05:18
@diegosouzapw
diegosouzapw merged commit bdaa86c into cheaper-inference:release/v3.8.52 Oct 6, 2026
44 of 51 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants