node:http: run a response's 'finish' connection step on the socket the dispatcher detached it from - #44457
node:http: run a response's 'finish' connection step on the socket the dispatcher detached it from#44457robobun wants to merge 2 commits into
Conversation
|
Warning Review limit reached
This review includes 4 billable files and costs up to $1.00.
Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing. Or wait 21 minutes for your next included review. View limit detailsLimit details: You’ve used all 10 included reviews currently available. Review configuration: ⚙️ Run configuration
📒 Files selected for processing (4)
Comment |
|
Status Reproduced on Bun 1.4.3-canary.1+367d939d9 (Linux x64) with the script in the Notes of the description. The first request ends the process with exit code 1: Node.js v26.3.0 answers 401 three times and stays alive. The new tests fail on that build ( |
…rom, and 'finish' runs its connection step on that record A 'request' listener that runs the stream destroyer on req (for example Readable.toWeb(req).cancel()) and ends the response in the same tick made the 'finish' listener read a placeholder socket: the destroyer had set req.socket to null and the dispatcher had already detached the response. The listener then threw a TypeError from node:_http_server, or skipped the close of a response that said Connection: close. The dispatcher now stores the socket in the slot where it stored true, and emitResponseFinish uses that socket for a response the dispatcher detached. A response that still owns its socket uses its own slot, and falls back to the request's links only when user code emptied or replaced that slot. ServerResponse#writableHighWaterMark reads the same slot, so it no longer builds a placeholder socket for a detached response.
5ad9082 to
04a98b1
Compare
|
Updated 5:04 AM PT - Oct 3rd, 2026
✅ @robobun, your commit 8a3ab1cc79b516dfbc2931dd7ccacad4df11506c passed in 🧪 To try this PR locally: bunx bun-pr 44457That installs a local version of the PR into your bun-44457 --bun |
Problem
'request'listener that runs the stream destroyer onreq(Readable.toWeb(req).cancel()) and callsres.end()in the same tick ends the process:TypeError: undefined is not an object (evaluating 'res')atonResponseFinishHandleSocket.emitResponseFinish(src/js/node/_http_server.ts:2617) readsthis.req?.socket ?? this.socket. The destroyer nulledreq.socketand the dispatcher already detached the response, so the getter builds aFakeSocketwith no.server.Connection: closeresponse leaves the connection open.Fix
socketwhere it storedtrue(kDispatcherDetached, nowkDetachedFrom).emitResponseFinishruns the connection step on it.req.socketandreq.clientstand in only when user code emptied or replaced it.writableHighWaterMarkreads that slot, so a detached response builds noFakeSocket.node-http-server-timeouts.test.ts(6 new),node-http.test.ts(31 new: 29 scenarios, 26 equal to node v26.3.0). Also34485.test.tsand thenode-http-*files.Background
resOnFinish: close the connection or arm the keep-alive timeout.serveris missing. It skips the step: 0 of 100 idle connections close (Node 100).Downsides
util.inspect(res)of a same-tick response prints 11027 characters, 6493 before: the slot holds the socket.emitResponseFinishhas 79 bytecode instructions, 60 before. The builtin grows 357 bytes.Notes
Reach. Bun 1.4.0 and later. 1.3.14 and Node.js are not affected: 1.3.14 bound the socket into the listener, and #32488 replaced that with the shared listener. #34488 added the flag that this PR turns into a value. Node main does the same since nodejs/node#65802: one shared
'finish'listener that reads the connection from a private slot of the response.Repro. The server below answers 401 three times on Node.js. On Bun the first request ends the process with exit code 1.
Every way in. The same
'finish'failed forreader.cancel(),stream.destroy(req), an abortedpipeline(),compose().destroy(),Duplex.from(req).destroy(),req.socket = null,req.socket = undefined,req.connection = nulland a foreignreq.socket, from'request','checkContinue','checkExpectation'and'dropRequest', over http, https and a unix socket.node-http-finish-connection-scenarios.mjshas one scenario for each, and runs under Node.js too. Unfixed Bun gives 7 of the 26 Node rows and 22 uncaught exceptions.Why the listener has two arms. With one shared tail, the arm that never ran in a process is still compiled (a
get_by_valthat never ran has no profile and is not pruned). Thesocketvariable is then untyped at the merge and the DFG code grows from 304 nodes and 9 Branch nodes to 366 and 15. With two arms each keeps its own types. The request fallback is guarded by&& req, so that block starts with a property read that never ran and is compiled as an exit. The fallback also takes a replacedres.socketwhoseserverisnull, the shape of the socket of Node.js'snetmodule.Measurements. main 4b02e10 against the same diff. Debug builds for bytecode and heap, release builds for DFG, FTL and size. Handlers:
res.end()in the listener (same-tick),setImmediate(() => res.end())(later-tick),res.write(); res.end()(write+end).Reflect.ownKeys)emitResponseFinish79 (main 60),onNodeHTTPRequest749 (main 749),writableHighWaterMarkgetter 14 (main 12)BUN_JSC_traceBaselineJITExecution=1, requests 2 to 5 of a keep-alive connection.emitResponseFinishin the optimising tiers, 45000 keep-alive requests,BUN_JSC_useConcurrentJIT=0: DFG same-tick 273 nodes and 7 Branch nodes (main 304 and 9), later-tick 315 and 9 (main 314 and 8). FTL 195 / 255 Air instructions withonResponseFinishHandleSocketinlined (main 128 / 174, plus 107 / 109 in the callee that main compiles apart). The release build has no disassembler, so the unit is DFG nodes and Air instructions.PutByOffset,PutStructureand 1FencedStoreBarrier, 43 bytes of DFG code and 5 Air instructions (main 49 bytes, 5 Air instructions, 1 barrier)perf_event_openis denied in the build container and valgrind is not installed.res.end20.0 (main 19.9, the same types), write+end 19.99 (main 31.88).FakeSocketplaceholders per 200 responses: 0 / 0 / 0 (main 0 / 200 / 200) for end, write+end, and awritableHighWaterMarkread in 'finish'._http_server.js: +357 bytes; release binary text: +357 bytesutil.inspect(res)at 'close': 11027 chars (main 6493)keepAliveTimeout300: 100 of 100 (node 100, main 0).socket.timeoutin 'finish': 300 (node 300).Other suites.
node-http-displaced-response,node-http-with-ws,node-http-server-close-drain, and node'stest-http-*.jsandtest-stream-destroy.js(392 files: 389 pass;test-http-agent-keepalive.jsandtest-http-client-timeout-option.jsfail on the debug build with and without this diff and pass on both release builds).Left for later.
keepAliveTimeoutafter 'finish'. This is what main already does when the response ends in a later tick, and node:http: refresh the socket inactivity timer on reads that reach no JS callback #43744 changes it. Node.js reads the body to its end first.res.socketread by user code after the listener returns is aFakeSocketin Bun andnullin Node.js.Expect: 100-continueanswered from'checkContinue'withoutwriteContinue(): Node.js closes the connection, Bun keeps it.socket.serverthat user code set toundefinedstill makes 'finish' throw, as on main.Open PRs near these lines. #43461, #37974, #35664, #43744, #43441, #37724.
Not run. macOS, Windows.