node:http: keep parsing other connections after an upgrade from a request body handler
Closed
RO
1mo ago+115+115+012345678901234567890123456789-20-20-01234567890123456789
Problem
- A
node:httpserver upgrades a request to a WebSocket from the request's body handler (wss.handleUpgrade()insidereq.on("end")of an Upgrade request with a body). After that, the next request on any other connection of the server stops the parser after its first request. Requests pipelined behind it get no response and the connection stays open. Node answers all of them. HttpResponse::upgrade()setshttpContextData->upgradedWebSocketwhile the parser runs. Only the request handler inHttpContext::onDatachecks the field (HttpContext.h:560). The data handler does not. After an upgrade frominStream()it writes into the destroyedHttpResponseDataand returns the socket.onDatatakes its normal path and never resets the field. The next request on another connection sees the stale field afterroute()and returnsnullptr.
Fix
- The data handler returns
nullptrwhenupgradedWebSocketis set afterinStream()returns, like it does for a closed socket.onDatathen takes its upgrade path, which uncorks the WebSocket and resets the field. upgrade()sets the field only for the socketonDataparses (httpContextData->parsingSocket). A body handler can upgrade another connection's parked request. To the parser that is an async upgrade, and the parsed connection keeps going.- The parser's Content-Length branches (
HttpParser.h, three places) updateremainingStreamingBytesbefore they call the data handler, like the chunked branch does. A handler that upgrades destroys theHttpResponseDatathe parser lives in. A handler that only shuts the socket down leaves it alive, andonEndreads the count from it. - Verified:
test/js/first_party/ws/ws.test.ts(three new cases: body in the same read as the head, body in a read of its own, upgrade of another connection from the body handler; all fail on bun 1.4.3). Also the rest of that file,test/js/node/http/node-http*.test.ts,test/js/bun/http/serve.test.tsandtest/js/bun/websocket/.
Background
HttpContext::onDataruns the HTTP parser on each read. The parser calls a request handler for each request head and a data handler for each body chunk. A handler returns the socket to continue, ornullptrto stop.HttpResponse::upgrade()adopts the socket into the WebSocket context in place. It destroys theHttpResponseDatain the socket ext, which also holds the parser state. It then setsupgradedWebSocketso thatonDataknows the socket changed.onDatatells a stopped parse apart by that field:nullptrwith the field set means upgraded, without it means closed.
Fixes #43163
Notes
- Repro: the script in #43163. On main it prints
["/one"] (no answer to /two after 3 s). With this change it prints["/one","/two"] (connection closed). The same for a body in the same read as the head and for a chunked body. - The node:http tunnel
dataevent (JSNodeHTTPServerSocket::onData) is posted as a task, so JS never runs insideonSocketData. No check is needed there. - Bun.serve does not reach the data-handler upgrade path:
server.upgrade(req)afterawait req.text()returns true on main, and a second connection with two pipelined requests gets both responses. - The
parsingSocketguard is the same idea as #37463, which also removesisParsingHttp. This PR keeps that flag. #43153 dispatches the bytes after the request head to the WebSocket. Neither covers an upgrade from the data handler. - The first version of this PR returned from the Content-Length branches before the state update. On a TLS socket that a handler shut down,
onEndwould then have reported a spuriousHPE_INVALID_EOF. Review caught it. The update now runs before the handler. test/js/bun/websocket/websocket-server.test.tshas 9 to 10 timeouts in this container under the debug build, on main and with this change alike (the tests that run concurrently with the 10-client benchmark). They pass in isolation.serve.test.tshas two failures that come from running as root in the container.