Repository navigation
Conversation
…ponse is given to emit('connection')
The listeners that internal/http1_server_fallback puts on a socket call
socketDrained(), socketEnded() and flushed() on the handle of the response
that has the socket. That response can be one of the listening server: a
CONNECT that was parsed behind it is handed to 'connect' at once, and the
listener can give the socket to a second server with emit('connection').
A native handle has none of these methods, so 'drain', 'finish' and 'close'
on the socket threw an uncaught TypeError. The four calls are now optional.
|
Status: open, waiting for CI and review. How it was reproduced, on a debug build of main at 491aaad (Linux x64):
Main then throws The new test fails on main's source and passes with the fix. The rest of |
|
Updated 11:14 PM PT - Oct 21st, 2026
✅ @robobun, your commit 15e647827ab39d933cfdd81bcaeb6df7620e19af passed in 🧪 To try this PR locally: bunx bun-pr 46421That installs a local version of the PR into your bun-46421 --bun |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
WalkthroughHTTP/1 fallback socket lifecycle callbacks now tolerate response handles without fallback-specific methods. A concurrent subprocess regression test covers CONNECT socket handoff to a second HTTP server while an earlier response remains open. ChangesHTTP/1 socket handoff
Possibly related issues
Suggested reviewers: Priority: ⬇️ Low Severity of issue fixed: Low Merge Risk: ⚪ Minimal · up to
|
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟣
src/js/internal/http1_server_fallback.ts— A proxy whose second server has httpAllowHalfOpen set leaks the tunnel connection after the client sends FIN: the first server's response ends later, but the socket is never closed. The 'end' listener at src/js/internal/http1_server_fallback.ts:656-658 sets_last = trueonsocket._httpMessage, which after a handoff is the first server's native response. Nothing in src/js/node/_http_server.ts reads_last, so the flag is dropped and the fallback's own close-on-finish at :519 never runs for that response. Fix: whensocket._httpMessageis a native response (no fallback handle), end the socket after that response finishes (listen for its 'finish' and callsocket.end()), or mark it with the native close flag the native finish path honours.Why this was flagged
_lastis read only by fallback responses (src/js/internal/http1_server_fallback.ts:519 readsthis._last). The native ServerResponse in src/js/node/_http_server.ts never reads_last. Trigger: the same scenario the PR fixes (GET /first pending, CONNECT behind it, 'connect' hands the socket tosecondvia emit('connection')) withsecond.httpAllowHalfOpen = true. The client sends FIN; the second server's onHttp1SocketEnd (:645) runsparser.finish()then, because httpAllowHalfOpen is true, sets_laston the native /first response at :658 instead of ending the socket. When /first later callsres.end(), the native finish path (onResponseFinishHandleSocket :2713) checks only kMustCloseConnection, so the socket stays open with the peer gone; Node's resOnFinish wouldsocket.destroySoon()because_lastis set. Before this PR the process died on the next drain/finish/close anyway, so the leak was masked; with the fix the process lives and accumulates half-open sockets.Verification:
onHttp1SocketEndwithhttpAllowHalfOpentrue setshttpMessage._last = true(src/js/internal/http1_server_fallback.ts:656-658) on the first server's native ServerResponse. The only reader of_lastis the fallback's own finish handler (fallback :519); the native finish path reads onlykMustCloseConnection(src/js/node/_http_server.ts:2714). The socket is never ended or destroyed. -
🟣
src/js/internal/http1_server_fallback.ts— Proxies that hand a tunnel socket to an in-process server now stay up but every request the client sends on that tunnel is never answered while the first server's response is pending. The second server's parser queues its response behind socket._httpMessage (src/js/internal/http1_server_fallback.ts:504-505), and that queue is advanced only by the first server's native finish path. The PR fixes only the crash that hid this hang, so the proxy-chain customConnectServer flow goes from exit code 1 to a silent timeout. Fix: either assign the socket to the second server's response when the in-flight response belongs to another server, or land the deferred pipelined-tunnel change together with this one so the unblocked path has a defined outcome.Why this was flagged
If a 'connect' listener on a native-listener server hands the socket to a second http.Server with emit("connection") while the first response is still pending (e.g. proxy-chain's customConnectServer), then any HTTP request the client writes into the tunnel is parsed by the fallback and reaches src/js/internal/http1_server_fallback.ts:504-505, where it is queued behind socket._httpMessage, the first server's native response. The queue is only advanced by advanceResponsePipeline (src/js/node/_http_server.ts:2923) from the first server's finish, which checks socket[kHandle]?.response and then dispatches via the native pipeline, not the fallback's; the fallback's own advance never runs. Before this PR the process crashed on the first drain/close so the hang was unreachable; after it the request hangs with no error, no timeout and no 'close' until the client gives up. Remedy: land the deferred queue-advance for foreign in-flight responses with this fix, or at least add a test pinning the hang so the follow-up has a failing test.
Verification: The fallback parser hits src/js/internal/http1_server_fallback.ts:504-505, so the second server's response is parked behind the first server's native response. The only advance is advanceResponsePipeline at src/js/node/_http_server.ts:2706, whose native branch at :2976-2990 passes the fallback's JS handle to startPipelinedResponse, which returns false, so :2985-2988 destroys the response.
|
Both optional findings are about behaviour outside this diff. I ran them on a debug build of main at 491aaad, on this branch, and on Node v26.3.0. Requests on the tunnel that stay unanswered. This is the first bullet of Downsides. While the first server's response is pending, Node does not answer them either. A second pull request for the requests on such a socket is in work. This one stays limited to the crash. The socket that stays open with One detail differs from the analysis. I added the case to the Notes of the description as left alone. The same runs showed a simpler trigger for the crash, which is now in the Notes too: with default options, a client that only sends its FIN makes main throw the |
Problem
TypeError: inflight?.[kHttp1ResponseHandle]?.socketEnded is not a functionends the process. One client connection does it: a request with a pending response, a CONNECT behind it, and a'connect'listener that gives the socket to a second server withemit('connection'). Node v26.3.0 stays up.'drain','finish'and'close'listeners callsocketDrained(),socketEnded()andflushed()on the handle of the response that has the socket (src/js/internal/http1_server_fallback.ts:668,:677,:681,:687). Here that is the first server's pending response, whose native handle has none of them.Fix
?.()).should leave the response ahead alone when a second server gets the socketintest/js/node/http/node-http-connect.test.ts. Main printsdrain, then theTypeError, and exits with code 1. Each call, reverted alone, brings theTypeErrorback.Background
emit('connection')in JS (internal/http1_server_fallback), where responses write through a JS handle.'connect'at once. The pending response keeps the socket until it ends.Downsides
onHttp1SocketDrainhas 22 instructions, was 21. Sockets fromlisten()have none of these listeners.Notes
Repro (runs under Node and Bun). The response to
/firstnever ends. The client closes when the tunnel is established.Node v26.3.0 prints
closeand exits with code 0. Main throws theTypeErrorand exits with code 1.What was run (debug build, Linux x64, base 491aaad. Main has moved 9 commits since, none changes these two files.)
drain, theTypeErrorin stderr, exit code 1) and passes with the fix.TypeErrorof that call (socketDrained,socketEndedin'finish',flushed,socketEndedin'close').drain,close, exit code 0.TypeErrorat'finish'(3 of 3 runs). Node and the fix do not (3 of 3 each).customConnectServer, which gives the tunnel socket to an in-process server this way. One connection that sends a proxied GET to an upstream that never answers, then a CONNECT: the process exits with code 1 on main's JS (2 of 2 runs) and stays up with the fix and on Node (2 of 2 each). The client was written to trigger it. How often an ordinary client does was not measured.node-http-connect.test.tsdoes not pass on this debug build, with or without the fix:should handle backpressureandtests should run on buntime out at 5 s on main's source too. The new test passed in every run (3 of 3).bundle-modules.ts --debug=OFF). No release binary was linked. The other three call sites are the same edit in anonymous listeners, and their instructions were not counted.Self-review. The crash was first part of a larger change for pipelined requests on such a socket. The review asked for it as a separate pull request, and for a
'drain'trigger in the test that does not depend on how much one write the kernel takes. The test now writes untilwrite()returns false.Left alone, the same on main. With
httpAllowHalfOpenset on the second server, the tunnel socket stays open after the client's FIN and the end of the first server's response. Node v26.3.0 closes it. Main does the same as this branch (3 of 3 runs each) and does not exit in that flow. The_lastmark of the second server's'end'listener goes to the queue entry that the pipelined CONNECT leaves behind, and nothing reads it there.Not checked. Windows and macOS were not run. No issue reports this crash (tracker search for the three
TypeErrortexts and forcustomConnectServer: 0 results). The path exists since #43557.Open pull requests that edit the same listeners: #46014, #46141.