Repository navigation
Conversation
|
Status: reproduced on main ad4089b (release build, Linux x64) with a TCP client that connects to an Fix: #46167 (this PR). |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
WalkthroughThe change separates TCP acceptance from TLS handshake completion in Node HTTP servers. It adds named connection-filter events and passes secured status through the native callback bridge. HTTP parser setup and timeout behavior are deferred until TLS negotiation succeeds. ChangesTLS connection lifecycle
Possibly related issues
Suggested reviewers: Priority: ➖ Normal 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.
Beyond the inline findings, I also checked the FilterEvent refactor for behavior drift: the plain-TCP path of the node:http thunk still forwards only Opened (unknown values still return early), and every arm of on_connection_filter in src/runtime/server/mod.rs maps one-to-one onto the old integer values. Bun__NodeHTTP__securedServerSocket null-checks a detached socket and gates on isAuthorized(), and the Rust side checks the empty JSValue before calling into JS, so a failed or torn-down handshake is not reported.
Extended reasoning...
The change moves the node:https 'connection' event from handshake end to TCP accept across the Rust uWS filter thunk (src/runtime/server/server_body.rs), a new C++ export (src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp), a new FilterEvent enum (src/uws_sys/App.rs) and a two-phase onServerConnection in src/js/node/_http_server.ts, with 656 lines of new tests. It touches the TLS server connection lifecycle and the point where user JS runs re-entrantly inside the uWS accept callback, so it is not a candidate for automated approval. Two confirmed findings on the JS timer and hidden-class handling are posted inline; the native refactor was checked and found behavior-preserving for the non-SSL path.
|
One row of this PR (a What the branch did: it kept the one dispatch, and the error arm of the filter thunk called the connection callback a second time to emit only The test of that branch can move here. It is a fixture that runs unchanged under Node.js, and a table of rows. I ran the fixture on a release build of 6a5fec1:
Two edge rows of the table show a difference from Node.js on this PR's build:
If |
6a5fec1 to
c3079e7
Compare
There was a problem hiding this comment.
I re-reviewed the two pushed commits and found no bugs; the hidden-class nit from the earlier pass is addressed by the class-body [kHandshaking] = false in c3079e7. Because this reorders the TLS accept/handshake lifecycle across uWS, the Rust filter thunk, the C++ socket binding and node:http's socket state, a human look is still worthwhile.
What was reviewed:
- The
Opened→securedpath: usockets sets HANDSHAKE_COMPLETED andisAuthorizedbeforeonHandshakeruns the filter, soBun__NodeHTTP__securedServerSocketreads the real outcome and a failed handshake yields no second JS call. - The
securedarm's reliance onsocketHandle.duplex: the only producer of a pre-handshake JSNodeHTTPServerSocket is the accept dispatch, whose constructor setsduplexbefore anything can throw; dropped/blocked sockets are closed soonHandshakeskips them. FilterEventconsumers: both switches (mod.rscounting,server_body.rsforwarding) are exhaustive and the old_ => returnfallthrough is preserved asNone.listenerGeneration.tlsis the same value that selects the native SSL app, soencrypted/'secureConnection' now tracklisten({ tls })too.
Extended reasoning...
The change spans the uWS filter event model (new strum FromRepr enum in src/uws_sys/App.rs), the Rust filter thunk and on_connection dispatch in src/runtime/server/server_body.rs and mod.rs, a new C++ export in JSNodeHTTPServerSocket.cpp, and the node:http socket lifecycle in src/js/node/_http_server.ts, plus ~650 lines of new tests. It touches TLS connection lifecycle (handshake gating, maxConnections/blockList now applied at accept) but no auth/crypto decisions themselves; rejectUnauthorized handling in onHandshake is unchanged. The code is consistent with usockets ordering and the new tests cover each entry point and the failure/destroy/timeout cases, which is why no finding was raised, but the complexity and the cross-layer lifecycle semantics are why a human should still weigh in rather than auto-approve.
Still open from earlier reviews (1):
- Unresolved: 1 minor or pre-existing.
c3079e7 to
0a2bb56
Compare
There was a problem hiding this comment.
I re-reviewed the latest three commits and didn't find any bugs; the [kHandshaking] = false class-body default from the earlier nit is now in place. Because this changes connection-lifecycle ordering across the uWS filter, Rust FFI, C++ and node:https in one go, a human look would still be worthwhile.
What was reviewed:
- The accept/handshake split:
HttpContext::onHandshakecloses a rejected handshake before firing filter1, andBun__NodeHTTP__securedServerSocketreturns empty whenisAuthorized()is false, so a failed handshake never reaches thesecuredarm. - The
$assert(!socket[kHandshaking])on the request path:ssl_on_datain usockets fireson_handshakebefore dispatching any decrypted bytes, so the assumption holds. - The socket created at accept is rooted by
strongThisuntil close, and asocket.destroy()in a 'connection' listener closes the uWS socket synchronously, so no 'secureConnection' can follow it. - The
FilterEventmatch inon_connection_filterand the thunk preserve the previous integer handling (unknown codes andOpened/Closedstill return early).
Extended reasoning...
The PR touches src/runtime/server/server_body.rs and mod.rs (connection filter decoding and the JS onConnection dispatch), src/uws_sys/App.rs (new FilterEvent enum), src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp (new securedServerSocket export), and src/js/node/_http_server.ts (kHandshaking gating of timeouts, sweeps and closeAllConnections), plus ~650 lines of new tests. It touches the TLS server accept path, which is security-adjacent (rejectUnauthorized handling stays in uWS and is unchanged). The bug hunt ran dry with no findings and the earlier nit was addressed, but the change is large, alters event ordering and per-connection state for every https server, and spans four languages, so it is not a candidate for bot-only approval.
The connection filter thunk of node:http forwards the accept of a TLS connection to JS. It forwards the end of a handshake only when the handshake succeeded. Before, it forwarded only the end of a handshake, also a failed one. onServerConnection() emits 'connection' for the accept and 'secureConnection' for the handshake. getConnections(), maxConnections and blockList act at accept. The parser shim, server.timeout and the 'timeout' listener attach at 'secureConnection'. closeAllConnections() and the headers timeout sweep leave a socket whose handshake is pending. 'connection' carries the same socket object as 'secureConnection' and req.socket. FilterEvent names the integers that a uWS connection filter receives. Co-authored-by: Ciro Spaciari <ciro.spaciari@gmail.com>
0a2bb56 to
3560d13
Compare
Problem
https.createServer()does not see a connection until its TLS handshake ends: no'connection',getConnections()is 0,maxConnectionsandblockListact after a full handshake. Node emits'connection'at TCP accept (tls.md).src/runtime/server/server_body.rsdrops the accept event (if opened != 1 { return; }).Fix
'connection'at accept and'secureConnection'after the handshake. As in Node, the parser andserver.timeoutstart at'secureConnection'.'connection'carries the socket object ofreq.socket, astls.createServer()in Bun does. Node passes the rawnet.Socket.test/js/node/http/node-https-server.test.ts(26 new tests: 23 fail on main, 24 pass on Node v26.3.0),node-http.test.ts, 450 vendored tests. Self-reviewed: 15 concerns raised, 12 addressed (rest in Notes).Background
2accept,1open. For TLS, open is the handshake end, failed or not.FilterEventnames them.Bun.serveshares and costs 3 operations per https connection, not 1.Downsides
node:httpserver socket grows by 32 bytes. A pending handshake holds 13 JS cells (1,620 bytes). A burst of 500 TLS connects runs 500 accept dispatches in one loop iteration (main: 5).handshakeTimeoutor'tlsClientError'.socket.encryptedistruein'connection'.test-https-timeout.jshangs in 10 of 750 runs (main: 4).Notes
No issue reports this. It was found by comparison with Node.
What changes for a program. One TCP peer sends the first byte of a TLS record and stalls. Linux x64, release builds.
'connection',getConnections()'connection'nowdestroy()to'close'server.close()in a'connection'listener, then the requestcloseIdleConnections()in a'connection'listener, then the requestsocket.destroy()in'connection'socket.setTimeout(n, destroy)in'connection'maxConnectionsreached, next TLS client'drop', ECONNRESET, 0 bytes'drop', ECONNRESET, 0 bytesblockListhas the peer'connection'listener throws and the process handles it'secureConnection'follows'secureConnection''secureConnection'followsclose()+closeAllConnections()server.timeout,headersTimeoutbefore the handshake'connection'.new https.Server(),http.createServer({ key, cert }),http.createServer().listen({ tls })and a cluster worker.closeAllConnections()leaves a socket that has not finished its handshake, as Node does. The uWS idle timer closes a stalled handshake after 8 to 12 s, as on main. Node waits forhandshakeTimeout(120 s).Which socket
'connection'carries. OneNodeHTTPServerSocketper connection, for'connection','secureConnection'andreq.socket. Node has a rawnet.Socketfor'connection'and aTLSSocketwith_parentfor the rest.destroy(),setTimeout(),remoteAddressand'close'work on the one object. Before the handshake it differs from Node in this:socket.encryptedistrue,socket.authorizedisfalse,socket.servernameisfalse(Node:undefined).socket.write()is held and sent as TLS data after the handshake (Node writes cleartext).socket.end(data)waits for that write.socket.end()sends a FIN and the socket stays until the peer closes.socket.resetAndDestroy()throwsERR_INVALID_HANDLE_TYPE, as it does on this socket after the handshake.TLSSocket.server.timeoutis armed at'secureConnection'. It does not replace a shorter timeout that a'connection'listener armed: that one starts again there.'timeout'of a timer that a'connection'listener armed also runs the http timeout handler. With no'timeout'listener on the request, the response or the server, that handler destroys the socket. Node runs only the callback of the listener, because its http handler listens on theTLSSocket. Probe:socket.setTimeout(300, cb)in'connection', and a request 900 ms after the handshake. Node v26.3.0 callscband answers. Bun 1.4.3 and this PR callcband destroy the socket. A callback that destroys the socket gives the same result on all three.destroy(err),emit('error')and a peer reset reach'clientError'through the http error listener. A peer FIN, non-TLS bytes and a stalled handshake emit no error event, and'tlsClientError'is never emitted. Node emits both events for all of them.Transport and phase. The listener generation carries the TLS options of its
listen()call (thetlsentry that #45659 also adds) and JS takes the transport from there. Native passes only the phase. Solisten({ tls })sockets getencrypted: trueand'secureConnection'. TheirauthorizedandauthorizationErrorstill read the constructor's options: #45659 fixes those getters. The handshake phase of a socket is a private symbol, so a plain-http program that setsreq.socket.encrypted = trueis unaffected.Other open PRs on these lines.
encryptedand_secureEstablishedinto subclass field initialisers, which run after the base constructor. After either rebase,handshakingin the constructor readslistenerGeneration.tls && !handle.secureEstablished. The tests here fail if http state is attached at accept.'connection'and'secureConnection'in one call. With this PR that point is thesecuredarm, before'secureConnection'is emitted, which is where Node's own listener runs. The PR that lands second takes that move.server.maxHeadersCountbehind the filters inonHandshake: still after both events. A'connection'listener of an https server now runs before the handshake, and the connection takes a limit it assigns, as in Node.on_connection_eventin place ofon_connection_callback.handshakeTimeout,'tlsClientError'listener andtest-https-timeout-server.jsare not here.emit('connection', rawSocket)on an https server) and node:https: make req.socket a tls.TLSSocket with getPeerCertificate() #37255, node: tls/https v26 compat wave 2 — allowHalfOpen close_notify, fetch setDefaultCACertificates, https.Server setSecureContext/keylog/TLSSocket (+6 tests) #35535, node: net/tls/http/http2 compat fixes #41400 (the server socket becomes atls.TLSSocket) are independent. With the latter,'connection'carries aTLSSocketbefore its handshake.Not in this PR.
options.handshakeTimeout,'tlsClientError'and its'clientError'bridge.headersTimeoutclock of a TLS connection still starts at accept, as on main. WithheadersTimeout1,500 ms and a handshake 3 s after connect, Node answers 408 1,530 ms after the handshake and main 30 ms after. The stamp is inHttpContext::onOpen, which node:http: honor server.maxHeadersCount above 198 fields #45532 edits.TCP_DEFER_ACCEPTdelay for a client that sends nothing (node:http: skip TCP_DEFER_ACCEPT so server.setTimeout fires on time for silent clients #36074).clientError, for a connection with no socket yet) do not run the gates.Existing tests changed. Three
blockListtests checked the refusal withhttps.get(). The refusal is now at accept, and reaches a client as a FIN or as a reset. Bun's https client reports a FIN with a request queued asERR_SOCKET_CLOSED(Node:ECONNRESET), so they now use a TLS client that sends nothing. They fail on main, where the blocked peer completes its handshake. The whole file passes withTCP_DEFER_ACCEPTrefused by anLD_PRELOADshim, which is the listener mode of macOS and Windows.Self-review. Done:
'secureConnection'.close()in a listener, the shutdown helpers, a handled throw,end()at accept,destroy(err), bothserver.timeoutorders, and plain sockets that setencrypted.TCP_DEFER_ACCEPT.Not done:
headersTimeoutclock (above).listen({ tls })getters (node:http, node:net: a plain socket has none of TLSSocket's property names #45659).test-https-timeout.js. It needs the client fix in net: fire socket.setTimeout() when the write queue stalls #42404. Its 10 ms client timeout is lost while the request is queued behind the handshake (socket.setTimeout() never fires when the peer stops reading and the write queue stalls #42403), and the first'connection'dispatch now runs before the handshake.Measurements. Release builds (
bun-profile) of main ad4089b and of this change on that commit, Linux x64. Counts are gdb breakpoints with ignore counts, 200 sequential connections, one run. Instructions are user-modeperf_eventcounts of the server's main thread, read at the entry and theretof the filter thunk. Each cell is the median of the last 200 of 1,200 connections.objdumpof the plain thunk: 1 more instruction on the dispatch path (the store of the new argument), none for the other filter events.Bun.servedoes not register this thunk, and no uWS code changed.blockList, 1 run, last 200 of 400 TLS attempts): 2,576 -> 0 bytes received, 200 -> 0 handshakes completed, 7,751,487 -> 43,532 instructions per peer.bun:jscheapStats()after a full GC, 1 run): +13.02 cells and +1,588 bytes each (main: 0). The cells are back to the baseline after the clients leave.[kHandshaking] = false, added in review after the numbers above: the cell of everynode:httpserver socket goes from 224 to 256 bytes (inline capacity 26 -> 30 indescribe()ofbun:jsc, out-of-line capacity 64 -> 64).heapStats()on the debug build, 3 paired runs of 1,000 held handshakes: +27 to +35 bytes each. So a pending handshake holds 1,620 bytes. Plain and TLS sockets now have the same 70 properties (before: 69 and 70). The instruction counts above are from before this field and were not measured again.'secureConnection'calls stay at 5 per iteration, behind the handshake throttle..text+0 bytes, total +761 bytes (size), which is the growth of the builtin_http_servermodule. No new symbol: the one newextern "C"function is inlined at link time.bloatyandbpftraceare not installed here, sosize,nmand gdb were used.test-http-*.jsandtest-https-*.js: 450 of 450 pass on both release builds, one run each.test-https-timeout.jsover 750 runs each: 4 hangs on main, 10 on this branch.no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/http/node-https-server.test.ts