Repository navigation
Conversation
The NewToken dispatch called onSessionNewToken with the token only, so onnewtoken(token, address) and the quic.session.new.token message got an empty object for address. Node's Session::EmitNewToken passes remote_address(). Pass the session's remote address the same way.
QuicSession::create read the addresses from an lsquic conn, so a session with no conn had none. A server session announced at Initial receipt got them only after the handshake, and a version negotiation probe never got a local address. session.path read before then cached two empty objects for the life of the session, and quic.session.created.server published the same empty address. create now takes the peer address and reads the local address from its endpoint, as node's Session::Config does. All four callers pass it. cache_sockaddrs, the later read from lsquic, is deleted with its three calls, and conn_peer_addr returns the address without an Option.
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 1 minute. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Updated 12:51 PM PT - Oct 21st, 2026
✅ @robobun, your commit 6874567fe0cfead00a2d540fdea003bf2af01e7c passed in 🧪 To try this PR locally: bunx bun-pr 46343That installs a local version of the PR into your bun-46343 --bun |
|
Status Reproduction on bun 1.4.3 and on a release build of main (0c864a0), with the script in the PR notes:
PR: #46343 |
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 finding on post-migration addresses, I also checked that QuicEndpoint::local_addr() cannot hand QuicSession::create the default/unset value: connect and the verneg probe run after ensure_bound, and the two server call sites are reached only from datagrams on an already-bound socket, so the debug_assert! in create is not masking a release-build gap there.
Extended reasoning...
The change makes QuicSession's local/remote addresses immutable constructor inputs across all four create call sites in endpoint.rs, deletes cache_sockaddrs, drops the Option from conn_peer_addr, and adds the peer address as a second argument to onnewtoken, with seven new tests. It touches no auth or crypto surface, but the one confirmed finding (addresses never refreshed after path migration, which the author also lists as a downside) means a human should weigh whether that trade-off is acceptable before merging.
Problem
node:quicserver,session.pathread beforesession.openedcaches two empty objects, notnet.SocketAddress. Aquic.session.created.serversubscriber gets the same emptyaddressand primes that cache.QuicSession::createread the addresses only from an lsquic connection (src/runtime/node/quic/session.rs:536). A session announced at the first packet (endpoint.rs:1726) and a version negotiation probe (endpoint.rs:2456) have none.onnewtoken(token, address)andquic.session.new.tokengot the same emptyaddress: the dispatch passed only the token (session.rs:1093).Fix
createrequires the peer address, likeapplicationsince Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021, and reads the local address from its endpoint. The NewToken dispatch passes the same address (first commit).cache_sockaddrs, the later read from lsquic, is deleted. 721 of 721 such reads in the quic tests gave the same values.test/js/node/quic/quic-endpoint.test.ts(7 new tests, 5 fail on main),test/js/node/quic/, the 235 vendoredtest-quic-*.mjs.Background
Session::Configtakes both addresses (session.cc:813-819).EmitNewTokenpassesremote_address()(session.cc:3694-3712). Source only: this Node has no QUIC.Downsides
opened,session.path.remoteis the unverified source of the first datagram. Bun sends no Retry, so a sender can forge it.onnewtokenlistener costs one moreSocketAddressper client session (63 against 43 over 20). Release text: +512 B.session.pathstill reports the creation-time addresses.Notes
Repro
Run in the repo root (the fixture keys resolve from there):
onsession:opened:undefined undefined undefinedundefined true127.0.0.1 <client port> 127.0.0.1127.0.0.1 trueFound with this repro while reading the code. No user reported it.
The same empty objects show up in three more places on main:
diagnostics_channelsubscriber onquic.session.created.servergets an emptyaddress. The publish readssession.path, so the application then sees empty addresses afteropenedtoo.session.pathread inonerrorof a server session whose handshake fails.session.path.localof a client session thatconnect()opens with aversionthe server does not support.The objects are empty and not
undefinedbecause bun'sInternalSocketAddressshim (src/js/internal/quic/quic.ts:166) returns its argument, andnewon a function that returnsundefinedgives a new empty object. That is also why the vendoredtest-quic-diagnostics-channel.mjs(ok(msg.address)) passes on main.The four callers of
QuicSession::createmaybe_announce_provisionalon_new_conn, no earlier announceconnectlsquic_engine_connectconnect()target, the endpoint's bound addressconnect_verneg_probeconnect()target, the endpoint's bound addressThe local address is always the endpoint's bound address. That is the address
on_datagives lsquic with every packet andconnectgiveslsquic_engine_connect, so it is what lsquic reported back.Deleted
QuicSession::cache_sockaddrsand its three calls (create,bind_conn,connect).remote_addr.set(remote)inconnect_verneg_probe.Optiononconn_peer_addr.lsquic_conn_get_sockaddrreturns pointers into the conn's current path and has no failure case (vendor/lsquic/src/liblsquic/lsquic_conn.c).Cellandpub(super)on the two address fields. Nothing writes them after construction. A fix for the path change below will write them again.Before the delete, a temporary build on this branch compared each
cache_sockaddrsread with the value the constructor stored. 721 of 721 were equal: 591 over the 235 vendoredtest-quic-*.mjsand 130 over the threetest/js/node/quicfiles. The runs include IPv6 and wildcard binds.Tests
Seven tests in a new
describe("session addresses")block. Each assertsinstanceof SocketAddressand the exact address, port and family. An awaited event that does not come resolves to"closed"when the session ends, so the failure is a diff and not a timeout.onsession, then the same cached object afteropenedquic.session.created.servermessage, andsession.pathafteropenedonerrorafter an ALPN mismatchonnewtokenandquic.session.new.tokenconnect()The last two guard the two callers that were already correct and now get their addresses from the constructor. A temporary build that passed a wrong address at one caller at a time gave:
on_new_conn(session created at handshake completion)connectonnewtokenonsession, channel message, failed handshakecreateonnewtokenThe resumed 0-RTT test reaches the
on_new_conncaller because lsquic puts the 0-RTT packet ahead of the Initial in the first datagram, and the announce at the first packet looks only at the first header. In 28 of 28 runs the debug log had oneannounce provisionalline for the two sessions. No assertion in the test tells that caller from the other.The failed-handshake test covers Node 26.3.0 behaviour. Node 26.6.0 and later announce a server session only after the ClientHello, so a failed handshake never reaches
onsessionthere (nodejs/node#64132). If bun ports that, the test fails with"no server session reported an error".Results:
quic-endpoint.test.ts16 pass. On a debug build of main, 11 pass and these 5 fail.quic-sni.test.ts11 pass,quic-stream.test.ts11 pass.test-quic-*.mjs, release builds: 235 of 235 in 5 of 5 rounds on this PR and on main.test-quic-session-initial-rtt.mjsfails every debug run on both (its 10 ms RTT bound). Thetest-quic-callback-error-*files fail now and then on both.cargo clippy -p bun_runtimeis clean.Measurements
Release builds of main at 0c864a0, without and with this diff, same toolchain. The vendored runs and the debug comparisons use the same pair. The branch was then rebased onto a later main with no conflict.
llvm-size).node::quicsymbols: +499 B (93,928 to 94,427,llvm-nmsum).lsquic_conn_get_sockaddrcalls per client and server connection: 3 to 1 (gdb hit count: 15 to 5 over 5 connections, 60 to 20 over 20).QuicSession::createper call: 213 to 222 instructions, 15 to 14 conditional branches, 9 to 9 direct calls, 0 to 0 indirect calls (gdb single-step, server and client call).on_dataper dispatch on an established connection: 119 + 113 x packets on both builds (232 for 1 packet, 1023 for 8). The dispatch that creates a session: 326 to 328. The disassembly differs only by the 2 instructions that pass the new argument.getsocknamesyscalls for 5 and 20 connections: 4 and 4 on both builds.SocketAddressobjects after 20 connections: 43 to 43 when nothing readssession.path, 43 to 83 whenonsessionreads it (heapStats).onnewtoken: 1SocketAddressper client session with a listener (63 against 43 over 20 sessions), 0 without.sizeof(QuicSession): 1120 B to 1120 B.QuicSessionPrototype__symbols.quic.classes.ts,src/jsandbun-typesare not touched.Not changed here
validateAddress. A 7-byte datagram from any UDP socket reachesonsession. Its source was an empty object before this PR and is aSocketAddressnow. Node sends a Retry first by default, so the address it shows inonsessionhas passed address validation. Filter by address inendpoint.blockList, or read the address afteropened.on_path_switchdoes not update the two addresses, so the firstsession.pathread after a migration returns the creation-time pair. Node updates its native addresses there (session.cc:2609-2618). This was the same before this PR.connect()does not sendoptions.tokenyet (see the comment inendpoint.rsat thelsquic_engine_connectcall).onnewtokennow reports the right address, but a saved token still has no effect.InternalSocketAddressshim still mapsundefinedto an empty object. After this PR no live session returnsundefinedfor an address.Self-review
A review of this diff before it was pushed raised 11 concerns. Eight are addressed in full:
onnewtokenfix is independent. It is now the first commit.Three are addressed in part. The body now says that no user reported the bug, that the early address is unverified, and what the
onnewtokenaddress does not enable. The review also asked for a tracking issue for each gap under "Not changed here". None is filed with this PR. It also named three gaps outside session addresses. They are not listed here because they were not reproduced for this PR.Order with other open PRs
cache_sockaddrs. This PR is meant to land first: it is small and needs no lsquic patch. After it, node:quic: make the session the server connection's context from its first packet #46226 passes the conn's peer address tocreateand drops itscache_sockaddrscalls, and the resumed 0-RTT test becomes a plain address check. If node:quic: make the session the server connection's context from its first packet #46226 lands first, this PR keeps thecreateargument, the probe and the NewToken commit.remote_addron the announced session for its per-host count. That line becomes redundant here, and the count needs a getter because the field is now private.Node references (v26.3.0)
Session::Impltakes both addresses from its configSession::EmitNewTokenpassesremote_address()doc/api/quic.md:session.pathis{ local: net.SocketAddress, remote: net.SocketAddress }.OnNewTokenCallbackgetsaddress{SocketAddress}.onsession(quic: defer server session emit until TLS ClientHello is processed nodejs/node#64132, in 26.6.0).