fix(http): WebSocket upgrades were silently dropped on the per-worker UDS mirror listeners - #2015
Conversation
With tls.unixDomainSockets enabled, the per-worker UDS mirror is a separate http.Server that never received the 'upgrade' listener onWebSocket() attaches to the port-keyed server, so Node destroyed every WS handshake on it with a zero-byte close (no response, no log). The uWS mirror (HARPER_UWS_UDS) had the same gap for its wsHandler. enableProxyProtocol()'s data interception also outlived its purpose: it kept forwarding post-upgrade frames to the captured (freed) HTTP parser, which the parser pool can re-issue to another connection — verified cross-connection corruption. The wrapper now removes itself and restores the original listeners once the PROXY header decision is made. Also fixes registerWsBehavior passing maxPayload to uWS app.ws(), whose real option key is maxPayloadLength — the configured WS payload cap was silently ignored. Fixes #2013 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Reviewed; no blockers found. |
Patch cherry-pick: conflictCherry-pick onto The conflict markers are committed on branch |
There was a problem hiding this comment.
Code Review
This pull request addresses critical issues with WebSocket upgrades and proxy protocol handling on Unix Domain Socket (UDS) mirrors. It ensures that WebSocket upgrade listeners and uWS configurations are correctly propagated to per-worker UDS mirror servers, fixes a bug where maxPayload was used instead of maxPayloadLength in uWS configuration, and updates enableProxyProtocol to clean up its wrapper listener after the PROXY header is resolved to prevent data leakage into pooled HTTP parsers. Comprehensive unit and integration tests have been added to verify these fixes. I have no feedback to provide as there are no review comments.
…istener uWS accepts WebSocket handshakes natively in app.ws(), so pre-handshake upgrade middleware cannot run on HARPER_UWS_HTTP ports or HARPER_UWS_UDS mirrors (auth is unaffected: it runs in the WS connection chain on both paths). Surface the gap with a warning instead of silently skipping the middleware; the default handler onWebSocket registers is exempt since uWS performs its job natively. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…5.1 backport of #2015) Hand-adapted cherry-pick of 02b4b8a43/16a422840 onto v5.1: the uWS pieces (HARPER_UWS_UDS mirror wsHandler, maxPayloadLength key, upgrade-middleware warning) don't exist on this branch and are omitted; the PROXY handoff fix is grafted onto v5.1's PROXY-v1-only enableProxyProtocol. The per-worker UDS mirror is a separate http.Server that never received the 'upgrade' listener onWebSocket() attaches to the port-keyed server, so Node destroyed every WS handshake on it with a zero-byte close. enableProxyProtocol's data interception also outlived the header decision, forwarding post-upgrade frames into the freed HTTP parser (re-poolable across connections); the wrapper now removes itself and restores the original listeners once the PROXY header resolves. Fixes #2013 on v5.1 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… finally Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes #2013.
What was broken
With
tls.unixDomainSockets: true(a fronting proxy terminating TLS and routing to per-worker UDS mirrors), every WebSocket handshake against a mirror was destroyed with a zero-byte close — no HTTP response, no error, nothing in the log. Plain HTTP and SSE worked; onlyUpgradedied. This took down all WS traffic behind the proxy, including MQTT-over-WSS from browsers. Reproduced on 5.1.22, 5.1.25, andmain.Two independent defects in
server/http.ts:The UDS mirror has no
'upgrade'listener.onWebSocket()attaches the upgrade middleware dispatch viaserver.on('upgrade', …)on the port-keyed server only. The mirror is a separatehttp.Server(SERVERS[udsPath]), and a Node HTTP server with zero'upgrade'listeners destroys upgrade sockets (socket.destroy()in_http_server.js) — exactly the observed silent close. The experimentalHARPER_UWS_UDSpath had the same gap:onWebSocket()setwsHandleronuwsServeConfigs[port]but never on the mirror'suwsServeConfigs[udsPath].enableProxyProtocol()'s interception outlives its purpose and corrupts post-upgrade traffic. The wrapper captured the HTTP parser's'data'listeners at connection time and forwarded to that captured array forever. On upgrade, Node removes its parser listener by reference (a no-op — the wrapper holds it) andwsattaches new listeners, so every inbound WS frame was delivered both towsand to the freed parser. Verified empirically: once the parser pool re-issues that parser to another connection, frames from the upgraded socket are injected into the other connection's parser (clientError: Parse Error: Data after 'Connection: close') — cross-connection corruption, so fixing (1) alone was not enough.The fix
getHTTPServer()exposes the mirror on the port server (server.udsMirrorfor Node,server.udsMirrorUwsConfigforHARPER_UWS_UDS);onWebSocket()attaches the same upgrade dispatch to the mirror (chains stay keyed by the mirrored port) and installs the uWSwsHandleron the mirror config. The uWS handler creation is extracted into one helper used by both the port config and the mirror config.enableProxyProtocol()now hands the socket back to its original listeners as soon as the PROXY header decision is made: it removes its wrapper and re-attaches the captured listeners, so all post-header traffic (including protocol handoffs like HTTP upgrade → ws) runs on real listeners with native semantics.registerWsBehavior()passed{ maxPayload }to uWS'sapp.ws(), but uWS's option key ismaxPayloadLength— the configured WS payload cap (including thewsMaxPayloadthis PR propagates) was silently ignored and uWS's 16 KiB default applied. Now passed under the correct key, with a regression test (oversized frame must close the connection; verified fails pre-fix).Known limitation, now surfaced instead of silent (raised in review): uWS-served transports (
HARPER_UWS_HTTPports,HARPER_UWS_UDSmirrors) accept WS handshakes natively inapp.ws(), so customserver.upgrade()middleware cannot run pre-handshake there — this predates this PR on the uWS port path, no core component registers such middleware, and auth is unaffected (it runs in the WS connection chain on both paths, matching Node's upgrade-then-authorize order).onUpgrade()/installUwsWsHandler()now warn when custom upgrade middleware is registered for a uWS-served port. A real middleware bridge for uWS (if ever needed) is follow-up work.Not affected / out of scope, per analysis requested in the thread: the raw MQTT (non-WS) UDS mirrors (
server.socket()path — no HTTP upgrade involved) and theHARPER_H2C_UDSh2c mirror (HTTP/1.1Upgradedoesn't exist in h2; the fronting proxy routes WS to the h1 mirror by ALPN).websocketChainsitself was never stranded — it's invoked through the upgrade dispatch, which was the missing link.Verification
tls.unixDomainSockets: true; the original repro (curl --unix-socket … -H Upgrade:websocket) now returns 101 on the mirror (was000), and an MQTT-over-WebSocket CONNECT through the mirror reaches the broker and gets a CONNACK back.unitTests/server/udsMirror.test.js: real UDS socket + PROXY v1 header + WS handshake + masked-frame echo, with an interleaved HTTP request to force parser-pool reuse (fails pre-fix withParse Error: Data after 'Connection: close'); plus a listener-restoration unit test on the wrapper.unitTests/apiTests/mqtt-test.mjs:server.ws({securePort})withtls_unixDomainSocketson — asserts the mirror gets the upgrade dispatch and completes a real handshake + echo through the mirror socket (fails pre-fix with a zero-byte close); plus aHARPER_UWS_UDStest asserting the uWS mirror config receives thewsHandler/wsMaxPayload.unitTests/server/serverHelpers/uwsServer.test.js: oversized-frame close test for themaxPayloadLengthfix (fails pre-fix).test:unit:resourcesclean (1334 passing);test:unit:main2904 passing with 2 failures andtest:unit:apitests182 passing with 10 failures — all 12 failures reproduce identically on a cleanorigin/mainbaseline in this environment (verified with a stash + rebuild), so they are pre-existing and unrelated;test:integration:allpasses.Notes
agy) + Codex legs + Harper-domain adjudication. No blockers, no significant concerns. Gemini's one raised blocker (uWS-backed port leaving the Node mirror unwired) was refuted with code evidence — theHARPER_UWS_HTTPbranch requires!secureand returns early, while mirrors are created only for secure ports, so the combination is unreachable. Codex's two real findings (themaxPayloadLengthkey bug;node:prefixes) are fixed in this PR; its uWS-mirror test-gap suggestion is covered by the addedHARPER_UWS_UDSconfig test. Gemini'ssocket.unshiftalternative was considered and declined: the manual forward preserves the exact pre-existing delivery mechanism the split-header tests pin, and the module's own Node-v24 comment documents that the parser's intake doesn't reliably see stream-level re-emission.