Skip to content

node:quic: make the session the server connection's context from its first packet - #46226

Open
robobun wants to merge 11 commits into
mainfrom
robobun/101086df/quic-early-keylog-owner
Open

robobun wants to merge 11 commits into
mainfrom
robobun/101086df/quic-early-keylog-owner

Conversation

@robobun

@robobun robobun commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to the items that #32602 tracked: key log, pairing.

Problem

  • A server handshake had no link to its session. node:quic found the session again by peer address, or by SSL* address for key log lines (early_keylog).
  • Connections from one client address cross. A failed handshake closes another's session. A session's onkeylog gets another's lines: take ssl=0x7c0270280130 buffered=20 matching=20.
  • A 64-byte Initial-shaped datagram gets a session. busy and maxConnectionsTotal refuse only after a whole TLS handshake.

Fix

  • patches/lsquic/mini-conn-ctx.patch: on_new_mini_conn runs before lsquic reads a connection's first packet. It refuses, or returns the session: the connection's context until promotion or failure.
  • The listen callback runs when TLS has parsed the ClientHello. early_keylog, provisional and dead_provisional_peers are deleted.
  • Verified: quic-server-handshake.test.ts runs under Bun and Node.js: 21 pass here and on Node v26.3.0, main fails 12. Also the vendored test-quic-*.mjs and the HTTP/3 suites.
  • Self-reviewed: 37 concerns raised, 34 addressed. Notes name the 3 others.

Background

  • lsquic runs a server handshake in a mini conn and promotes it to a full conn at the end. Only that had a context.
  • Weighed: a purge at every exit (keeps the address key), and lines owned by the SSL (fixes the key log only).

Downsides

  • Three differences from Node.js stay (Notes): a closing endpoint refuses, a destroyed session's connection is not offered again, a client-rejected handshake logs 5 lines.
  • onkeylog assigned in a later macrotask gets no lines (main 5, Node 1).
  • Each lsquic packet pays 2 to 4 more instructions in lsquic_engine_packet_in, Bun.serve HTTP/3 included.
Notes

Origin. In #32602 a maintainer called both defects real and named the fix: "an lsquic signature change plus shim and Rust updates", "tracked here rather than silently dropped" (links above). This PR is that change. It removes the stores and does not re-key them by (ssl, peer), because lsquic frees the SSL after the handshake and the next handshake can get its address. The first report came from a probe: a server that destroyed 8 sessions in the listen() callback and kept the next 4 saw 10 key log lines on the first kept session.

What is wrong on main. Release build of 9b4284f.

  • A 64-byte datagram with an Initial header gets a session. server.close() then takes 10055 ms (this PR: 1 ms), and the session ends with QUIC transport error 296: handshake failed.
  • Two connections from one client endpoint have one peer address. A handshake that fails (alpn not offered) closes the other connection's session with error 376, and its own session opens. If the callback destroys the first session, the first connection opens and the second is refused.
  • A connection whose source port changes after its first packet gets two sessions.
  • busy, maxConnectionsTotal and a closing endpoint refuse after a whole TLS handshake: 4 packets and about 2.3 KB. Clearing busy during the handshake admits the connection. A closing endpoint runs a handshake for each new client, and close() waits for each.
  • Key log lines wait under the SSL* address until promotion. A connection that is never promoted leaves them for the next handshake at that address. A handshake that the client rejects logs 0 lines.
  • session.path has no addresses in the listen callback (path.remote.port is undefined), and a read there keeps it so.
  • handshakeTimeout does not apply to the session. A fixed 10 s sweep ends it.

What the PR does.

  • on_new_mini_conn creates the session and lsquic keeps it as the mini conn's context. Promotion moves it to the full conn. on_mini_conn_failed(ctx, code) hands it back. Nothing is parked or matched by a key.
  • The hook refuses with lsquic_conn_abort_error(CONNECTION_REFUSED) when the endpoint is busy, at maxConnectionsTotal or closing. A refused mini conn leaves no purgatory entry, so the client's next Initial is offered again.
  • A session is announced when BoringSSL's select-certificate callback runs, that is when TLS has parsed the ClientHello. A handshake that ends before that takes its session with it, with no callback.
  • es_handshake_to_strict (set by the node:quic shim only) makes handshakeTimeout bound the whole handshake. lsquic alone moves the deadline to the idle timeout when it reads the client's first Handshake packet.
  • MiniConn is the Rust type of a handshake's conn. It has the three calls that lsquic accepts on a mini conn.
  • VM teardown (Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021): QuicSession::finalize takes the session off its mini conn, and detach_for_finalize swaps on_mini_conn_failed for a callback that only clears mini_conn.

What Node.js does. Node v26.3.0 built from source with ./configure --experimental-quic (ngtcp2 1.22.1, OpenSSL 3.5.6). Official builds have no QUIC.

$ node --experimental-quic --no-warnings test/js/node/quic/quic-server-handshake.test.ts
ℹ tests 21
ℹ pass 21
ℹ fail 0
main this PR Node v26.3.0
quic-server-handshake.test.ts 9 pass, 12 fail 21 pass 21 pass
busy, maxConnectionsTotal refused after the handshake refused at the first packet, error 2 refused at the first packet, error 2
64-byte Initial 1 session 0 0
1200-byte Initial that no key decrypts 1 session 0 0 (answers with a Retry)
1200-byte 0-RTT or Handshake packet 0 0 0
failed handshake from a shared client address closes the other session closes its own closes its own
lost refusal, then busy cleared connects connects connects
handshakeTimeout on a stalled handshake ignored (10 s) ends it ends it
closing endpoint, new client error 2 after the handshake error 2 at the first packet no answer
callback destroys a session wrong connection refused its handshake is dropped offered again at the next Initial
client rejects the certificate: server lines 0 5 4
source port changes after the first packet 2 sessions 1 session, opens 0 sessions (Retry token of the old port)
onkeylog assigned in setImmediate: lines 5 0 1

The last five rows are differences from Node.js that stay. The test file has one branch for each of the three that it covers, with a comment. BoringSSL makes CLIENT_TRAFFIC_SECRET_0 when it writes the server's Finished. OpenSSL makes it when it reads the client's.

Questions for a maintainer.

  1. A closing endpoint answers a new client with CONNECTION_REFUSED, as on main (endpoint.close() while a session is live expects it). Node.js answers nothing (endpoint.cc#L1409). Keep?
  2. A connection whose session the callback destroyed completes its handshake with no session and is dropped, as on main. Node.js destroys before it sends its first flight, so the client's next Initial gets a new session. Keep?
  3. onkeylog runs when the secret is made. Is that accepted, given rows 11 and 13?
  4. Node.js announces at the Initial, after a Retry has validated the address by default (endpoint.cc#L1617). Bun has no Retry (validateAddress is read by no native code, node:quic: wire validateAddress through to a Retry flow #35746 is closed), so this PR announces at the ClientHello. Is that the right substitute?

Behaviour changes from main.

  • busy, maxConnectionsTotal and closing refuse at the first packet. The server sends 1 packet of 47 bytes and makes no SSL (main: 4 packets, 2278 to 2279 bytes). The client logs 0 secrets (main: 5). serverBusyCount counts each refused Initial.
  • Clearing busy during a handshake no longer admits that connection. Its next Initial is admitted.
  • The listen callback runs at the ClientHello. A datagram that starts no handshake gets no session. A ClientHello in two datagrams gets its session at the second.
  • onkeylog runs at derivation. The order per session is unchanged: listen callback, 5 lines, opened.
  • session.path has the addresses of the first packet in the listen callback. They are not validated before opened. certificate, peerCertificate, ephemeralKeyInfo and remoteTransportParams stay undefined until opened, as before.
  • handshakeTimeout bounds the handshake. The 10 s sweep of unfinished handshakes is deleted. lsquic's mini conn deadline replaces it.
  • stats.serverSessions counts announced sessions.

lsquic patches.

  • node-quic-accessors.patch owns on_mini_conn_failed, so its new signature, the es_handshake_to_strict setting and two comments are edited there. connection-close-pns.patch changes one context line.
  • mini-conn-ctx.patch is the hook alone: 101 lines in lsquic.h, lsquic_engine.c and lsquic_full_conn_ietf.c. It deletes no line of another patch.
  • Draft of an upstream issue, for a maintainer to file: "A server cannot give a connection a context before its handshake completes. on_new_conn runs at promotion, so a TLS callback during the handshake (key log, certificate selection) reaches an lsquic_conn_t with no context, and a failed handshake is not reported. Proposal: an optional on_new_mini_conn(stream_if_ctx, conn) in lsquic_stream_if, called from process_packet_in when a server mini conn is created and before its first packet is processed. Its return value is cn_conn_ctx. lsquic_ietf_full_conn_server_new moves it to the full conn before on_new_conn. The callback can refuse with lsquic_conn_abort_error."

Cost. Release builds of 9b4284f (main) and f20ccc5 (this PR without its last commit, which changes the type of one field).

  • Binary (size): text 107345030 to 107338390 (-6640). Data and bss do not change. CI's size report for an earlier revision (build 129851) showed -8.0 KB for bun-linux-x64.
  • Symbols (nm -S): keylog_cb 542 to 278, session::on_new_conn 2636 to 366, on_mini_conn_failed 1905 to 404, on_data 5405 to 4422, QuicSession::teardown 1599 to 1631. New: on_new_mini_conn 670, client_hello_cb 176, on_mini_conn_failed_detached 19. retain_mut::<take_early_keylog> (461) is gone.
  • Per accepted server conn, key log off (gdb nexti, calls stepped over): on_new_conn runs 56 instructions of its own and makes 2 calls (main 210 and 9). on_new_mini_conn runs 109 to 113 of its own. client_hello_cb runs 49 to 52. on_data runs 130 of its own for a connection's first datagram (main 326).
  • Per datagram with a short header on an open connection: on_data runs 211 instructions of its own (main 232).
  • Allocations on the JS thread while on_data handles a connection's first datagram (gdb hit counts on the mimalloc entry points): 598 to 599 (main 600). CID purgatory lookups in that call: 1 (main 2).
  • Key log on, 12 handshakes: 60 keylog_cb calls and 60 lines on both.
  • Other lsquic users: lsquic_engine_packet_in runs 2 to 4 more instructions of its own per packet of an open connection (256 to 260 on one path). serve-http3.test.ts creates 60 mini conns and calls the hook 0 times. The numbers come from gdb single-stepping. perf and valgrind are not on the machine, and no HTTP/3 benchmark was run.
  • Sizes (gdb on the debug build): QuicSession 1128 bytes (+8), QuicEndpoint 1024 (-72), NqVtable 208 (+8).

Tests.

  • quic-server-handshake.test.ts uses node:test and node:assert. Its last test runs the file under Node.js when node has QUIC, and skips otherwise. quic-relay.ts is a UDP relay that drops, reorders and rebinds datagrams.
  • Main fails 12 of the 21: sibling close during a handshake, client-rejected handshake, busy, maxConnectionsTotal, closing, destroyed session, failure attribution, path, 64-byte Initial, undecryptable Initial, source-port change, handshakeTimeout.
  • Each new clause has a test that fails without it. One build with five clauses removed failed exactly these: the three 1200-byte stray packets (announce at the ClientHello), the lost refusal (no purgatory entry), handshakeTimeout (strict deadline), "does not refuse its own client connection when it is busy as a server" (client_engine_owner), "a client is refused when the listen callback throws" (the closing branch of on_new_conn).
  • quic-worker-terminate-handshake-fixture.ts terminates a Worker while 12 server sessions are in their handshake. Without the finalize change an ASAN build reports heap-use-after-free in nq_on_mini_conn_failed, 3 of 3 runs.
  • The refused-then-accepted key log test needs a later handshake's SSL to get a refused one's address. It fails on an unfixed ASAN build without the quarantine and passes on release main when the allocator picks another block.
  • Release build of this PR, 5 runs: quic-server-handshake.test.ts and quic-endpoint.test.ts 33 pass, 2 skip. Vendored test-quic-*.mjs: 235 of 235, as on main.
  • Debug (ASAN) build of this PR: test/js/node/quic/ 54 pass, serve-http3.test.ts and fetch-http3-client.test.ts 146 pass. Vendored: 230 of 235. The five are test-quic-session-initial-rtt.mjs and four test-quic-callback-error-* files. They fail now and then on a debug build of main too.

Other open PRs, and the order. git merge-tree of this branch with each:

Left for later.

  • validateAddress (Retry) is not implemented.
  • maxConnectionsPerHost is not enforced (node:quic: enforce QuicEndpoint maxConnectionsPerHost #35726).
  • endpoint.setSNIContexts() builds its contexts with keylog false.
  • With disableStatelessReset: true, a late long-header datagram for a dropped client session of a dual endpoint still makes a mini conn on the server engine. It gets no session now, because it has no ClientHello.

If a maintainer wants less. Two parts can come out without the rest (sketches, not built): key log delivery at promotion (a held Vec drained in bind_conn, about 15 lines), and admission when busy clears during a handshake (a second gate at promotion, about 40 lines). Lines owned by the SSL alone fix the key log and give up the pairing fixes.

Self-review items not done.


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/quic/quic-endpoint.test.ts

…first packet

A server handshake runs in an lsquic mini conn, which had no context.
node:quic found the session of a handshake again by peer address, and kept
the handshake's TLS key log in an endpoint list under the SSL address until
the session claimed it. A connection that the listen() callback destroyed
never claimed its lines, and the next handshake whose SSL got that address
took them. Two connections from one client endpoint could also lose their
lines or get each other's session.

lsquic now calls on_new_mini_conn before it reads a new connection's first
packet (patches/lsquic/mini-conn-ctx.patch). The session it returns is the
connection's context: the key log callback pushes to it, promotion carries
it to the full conn, and on_mini_conn_failed receives it. A busy endpoint,
an endpoint at maxConnectionsTotal and a closing endpoint refuse the
connection at that point, before the TLS handshake.
@robobun

robobun commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:23 AM PT - Oct 21st, 2026

✅ @robobun, your commit c74d2d266c49c58d16f8dfe4eb5d98d275b718dc passed in Build #130074! 🎉


🧪   To try this PR locally:

bunx bun-pr 46226

That installs a local version of the PR into your bun-46226 executable, so you can run:

bun-46226 --bun

@robobun

robobun commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduction on a release build of main (9b4284f):

  • Default configuration: one 64-byte datagram with an Initial header makes the server announce a second session. server.close() then takes 10055 ms, and that session ends with QUIC transport error 296: handshake failed. With this PR: one session, close in 1 ms.
  • With keylog: true: a server destroys the first 8 sessions in the listen() callback and keeps the next 4. The first kept session's onkeylog gets 10 lines, and 5 of them carry the client random of a destroyed connection. On a debug build without the ASAN quarantine, three destroyed handshakes park their lines under one SSL* address, and the fourth session takes all 20 (take ssl=0x7c0270280130 buffered=20 matching=20).
  • test/js/node/quic/quic-server-handshake.test.ts: main fails 12 of 21. This PR and Node v26.3.0 (built with --experimental-quic) pass 21.

PR: #46226

… promotion

A session that is destroyed before its handshake completes takes itself
off the mini conn and nothing more. The handshake runs on without a
session, and on_new_conn refuses the conn when lsquic promotes it:
CONNECTION_REFUSED from a closing endpoint, no CONNECTION_CLOSE otherwise.
This is the packet sequence main has for these two cases.

Stopping the mini conn at teardown made the server answer the client's
next packet with a stateless reset. On Windows the client read it before
test-quic-endpoint-onsession-throws.mjs destroyed the client session.

The lsquic patch no longer adds an abort to the mini conn.
Comment thread src/runtime/node/quic/endpoint.rs Outdated
Comment thread src/runtime/node/quic/endpoint.rs Outdated
Comment thread src/runtime/node/quic/endpoint.rs Outdated
Comment thread src/runtime/node/quic/endpoint.rs Outdated
Comment thread src/runtime/node/quic/endpoint.rs Outdated
Comment thread src/runtime/node/quic/endpoint.rs Outdated
Comment thread src/runtime/node/quic/endpoint.rs Outdated
Comment thread src/runtime/node/quic/endpoint.rs Outdated
Comment thread src/runtime/node/quic/endpoint.rs Outdated
Comment thread src/runtime/node/quic/ffi.rs Outdated
Comment thread src/runtime/node/quic/ffi.rs Outdated
Comment thread src/runtime/node/quic/session.rs Outdated
Comment thread src/runtime/node/quic/session.rs Outdated
Comment thread src/runtime/node/quic/session.rs Outdated
Comment thread src/runtime/node/quic/session.rs Outdated
Comment thread src/runtime/node/quic/session.rs Outdated
Comment thread src/runtime/node/quic/session.rs Outdated
Comment thread src/runtime/node/quic/session.rs Outdated
- The listen callback gets a session when BoringSSL has parsed the
  ClientHello of its handshake. A handshake that ends before that takes
  its session with it, so a datagram that starts no handshake gets no
  session.
- A mini conn that the accept hook refuses leaves no purgatory entry.
  The client's next Initial is offered again.
- es_handshake_to_strict makes handshakeTimeout bound the whole handshake
  of a node:quic server connection.
- node-quic-accessors.patch owns the signature of on_mini_conn_failed.
  mini-conn-ctx.patch only adds the hook.
- MiniConn is the type of a handshake's conn, which accepts few calls.
Comment thread src/lsquic_sys/lib.rs Outdated
Comment thread src/lsquic_sys/lib.rs Outdated
Comment thread src/lsquic_sys/lib.rs Outdated
Comment thread src/runtime/node/quic/tls.rs Outdated
@robobun
robobun marked this pull request as ready for review October 21, 2026 10:42
@coderabbitai

coderabbitai Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You'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 3 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 13b61c66-d48a-436e-801c-72d8d78d1bfe
📥 Commits

Reviewing files that changed from the base of the PR and between 9b4284f and c74d2d2.

📒 Files selected for processing: 16
  • packages/bun-usockets/src/node_quic_shim.c
  • patches/lsquic/connection-close-pns.patch
  • patches/lsquic/mini-conn-ctx.patch
  • patches/lsquic/node-quic-accessors.patch
  • scripts/build/deps/lsquic.ts
  • src/boringssl_sys/boringssl.rs
  • src/lsquic_sys/lib.rs
  • src/runtime/node/quic/endpoint.rs
  • src/runtime/node/quic/ffi.rs
  • src/runtime/node/quic/session.rs
  • src/runtime/node/quic/tls.rs
  • test/js/node/quic/quic-endpoint.test.ts
  • test/js/node/quic/quic-keylog-refused-fixture.ts
  • test/js/node/quic/quic-relay.ts
  • test/js/node/quic/quic-server-handshake.test.ts
  • test/js/node/quic/quic-worker-terminate-handshake-fixture.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 two ownership paths in the new mini-conn ctx scheme and found them sound: the *(us_nq_vtable **) ctx read in nq_on_mini_conn_failed relies on QuicSession being #[repr(C)] with vtable as its first field (it is, and the patched engine only fires the callback when cn_conn_ctx is non-null, nulling it first), and the ctx move at promotion in mini-conn-ctx.patch sits after handshake_ok, the last failure exit in lsquic_ietf_full_conn_server_new, so a promotion that fails still hands the session back via on_mini_conn_failed rather than orphaning it. I also looked at whether a stalled handshake with no handshakeTimeout now settles closed as a clean code-0 close instead of "handshake failed" — that is the existing TEC_NO_ERROR mapping for an abandoned mini conn, unchanged by this PR.

Extended reasoning...

The change reworks node:quic server session binding across an lsquic patch, the C shim, the lsquic_sys and boringssl_sys bindings, and endpoint/session/tls Rust, replacing address-keyed provisional-session tables with a mini-conn context callback; it touches the TLS handshake path and connection admission (busy/maxConnectionsTotal/closing refusals). Four confirmed findings are posted inline, so human review is already signalled; this note only records two FFI ownership paths and one ruled-out candidate that were examined and found not to be defects.

3 parts of this review's analysis that stopped early were each run once more. All of those finished.

}
// Node.js announces at a Retry-validated Initial: https://github.com/nodejs/node/blob/v26.3.0/src/quic/endpoint.cc#L1617
if let Some(endpoint) = self.endpoint_ref() {
endpoint.announce(core::ptr::from_ref(self).cast_mut());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Users who called endpoint.close() can still get a new listen-callback session afterwards, and close() then waits on it. A ClientHello that spans two Initials (common with post-quantum key shares) creates the session at the first packet (endpoint.rs:1629-1643, closing not yet set); when close() lands before the second packet, on_client_hello at session.rs:551-557 announces it anyway, and on_new_conn at endpoint.rs:1671 binds and opens it because its ctx is non-null. Fix: in on_client_hello and on_new_conn, refuse (CONNECTION_REFUSED) and discard a not-yet-announced session once closing is set, covering both the announce and the promotion path.

Why this was flagged

Trigger: a client whose ClientHello does not fit one 1200-byte Initial, so the handshake's first packet and the ClientHello completion arrive in two datagrams, and the server calls endpoint.close() in between. The session was created before closing was set at endpoint.rs:1608-1612, so refuse_mini_conn did not run. When the second Initial arrives, BoringSSL parses the ClientHello, client_hello_cb (tls.rs) calls on_client_hello, and session.rs:557 pushes the session to pending_new_sessions with no closing check; process() at endpoint.rs:1387-1412 then runs the listen callback on a closing endpoint. At promotion on_new_conn (endpoint.rs:1660-1681) finds a non-null ctx and binds and opens the session; the closing branch at endpoint.rs:1672 is only taken when the ctx is gone. close() then waits for that session's full life (finish gate at endpoint.rs:1424-1426). Remedy: gate announce and promotion on closing and refuse those sessions, as on_new_mini_conn already does for the first packet.

Verification: nit — triggered when a server handshake's first Initial arrives before endpoint.close() and the packet that completes the ClientHello arrives after it. on_client_hello (session.rs:551-559) announces with no closing check, and on_new_conn refuses only when ctx is null (endpoint.rs:1671), so the session binds and opens and close() waits on it.

* default of 2 lets the body reach JS ahead of the `onheaders` before it. */
s->es_max_header_sets = 1;
/* node's handshakeTimeout bounds the whole handshake. */
s->es_handshake_to_strict = 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Every node:quic server handshake that takes longer than lsquic's default 10 s es_handshake_to now dies, where the base let it run to the idle timeout once a Handshake packet arrived. node_quic_shim.c:455 sets es_handshake_to_strict = 1 for every server engine, and the patched ignore_init no longer extends imc_expire. Clients on lossy or high-latency links, or with client-certificate verification, lose connections the base completed. Fix: apply the strict bound only when the user set handshakeTimeout, or derive es_handshake_to from the configured idle timeout when none is given, and cover both states in a test.

Why this was flagged

The strict flag is unconditional: us_nq_settings_init at node_quic_shim.c:455 sets es_handshake_to_strict for every node:quic server engine, and endpoint.rs:852-853 only overrides es_handshake_to when the user passes handshakeTimeout, so the lsquic default of 10 s applies otherwise. The patched ignore_init (node-quic-accessors.patch, lsquic_mini_conn_ietf.c) now skips conn->imc_expire += ext_to, so a mini conn that has read the client's Handshake packet still dies at created + 10 s. On base that conn lived until the idle timeout (30 s default, or the user's maxIdleTimeout). Population: any server handshake that needs more than 10 s end to end. The consequence, via L31, is a clean code-0 close rather than an error, so the operator does not even see a handshake failure. Remedy: set es_handshake_to_strict only when handshakeTimeout was supplied, otherwise keep the base's idle-timeout fallback.

Verification: Trigger: a server with maxIdleTimeout above 10 s and no handshakeTimeout. packages/bun-usockets/src/node_quic_shim.c:454-455 sets s->es_handshake_to_strict = 1; unconditionally; endpoint.rs:852-853 only calls s.handshake_to(...) when the user passes handshakeTimeout, so lsquic's default 10 s stays. On base such a handshake could run up to 30 s; after this PR it is killed at 10 s.

import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { connect, listen, QuicEndpoint } from "node:quic";
import { describe, test } from "node:test";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): maintainers get a .test.ts file that breaks several test/CLAUDE.md rules so the same file can run under Node.js. It imports node:test instead of bun:test (line 17), loads harness through await import("harness") (line 459), probes Node with Bun.spawnSync (line 464) and sets an explicit { timeout: 180_000 } (line 470). Fix: keep the Bun-run tests on bun:test with module-scope harness imports and no explicit timeouts, and move the shared cases into a plain .ts/.mjs driver that both a bun:test wrapper and the Node-only spawn load. The new 30000 timeouts at quic-endpoint.test.ts:370 and :491 follow the same rule. [also at: test/js/node/quic/quic-endpoint.test.ts:490 - nit: new tests set per-test timeouts, which test/CLAUDE.md forbids ("Do not set a timeout on tests"). sweep:\}, 30000\);|timeout: 180_000 Example: quic-endpoint.test.ts:490 passes 30000 as the third argument of test(...), and quic-server-handshake.test.ts:470 passes { timeout: 180_000 }.]

Why this was flagged

test/CLAUDE.md says tests use bun:test, use module-scope imports and never dynamic import, prefer async spawns, and never set timeouts. The new file test/js/node/quic/quic-server-handshake.test.ts:17 imports describe, test from node:test, test/js/node/quic/quic-server-handshake.test.ts:459 does await import("harness") inside the module body, test/js/node/quic/quic-server-handshake.test.ts:464 uses Bun.spawnSync, and test/js/node/quic/quic-server-handshake.test.ts:470 passes { timeout: 180_000 }. test/js/node/quic/quic-endpoint.test.ts:370 and test/js/node/quic/quic-endpoint.test.ts:491 add new tests with a 30000 timeout argument. The base branch has no such file; the deviations are deliberate so Node v26 can run the file, but the convention allows that only through a separate non-.test.ts driver. Nothing fails at runtime; this is a convention finding.

Verification: nit. Triggering condition: the file lands as-is in test/js/node/quic/. /home/claude/bun/test/js/node/quic/quic-server-handshake.test.ts:17 import { describe, test } from "node:test";, line 459 await import("harness"), and line 470 { timeout: 180_000 } break test/CLAUDE.md line 19, line 220 and line 120 "CRITICAL: Do not set a timeout on tests." Nothing observably breaks.

// SAFETY: session was just created.
unsafe { (*session).note_handshake_ok(handshaken) };
unsafe { (*session).bind_mini_conn(conn) };
self.sessions.with_mut(|v| v.push(session));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: Operators can no longer see or destroy the sessions that fill maxConnectionsTotal: an attacker's spoofed 1200-byte Initials create native sessions JS never receives. endpoint.rs:1629-1643 creates a QuicSession for every new mini conn and pushes it into sessions before any ClientHello is parsed; the listen callback runs only from on_client_hello (session.rs:551-557). Until lsquic expires the mini conn (10 s), the invisible session counts at endpoint.rs:1624 and real clients get CONNECTION_REFUSED. Fix: count only announced sessions toward maxConnectionsTotal, or expose and cap unannounced handshakes separately.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger: a datagram of 1200 bytes with a valid Initial header whose payload is encrypted under the public Initial keys and carries only part of a ClientHello, sent from spoofed source addresses at line rate. lsquic creates a mini conn for each, process_packet_in calls on_new_mini_conn (mini-conn-ctx.patch), and endpoint.rs:1629-1643 pushes the session into sessions. Because BoringSSL's select-certificate callback never fires for a partial ClientHello, announced stays false and the listen callback never runs, yet self.sessions.get().len() >= max_conns at endpoint.rs:1624 counts the phantom, so genuine clients are refused with CONNECTION_REFUSED. On base the provisional session was handed to the listen callback at Initial receipt, so an operator could destroy it or at least observe the flood; now there is no JS handle and endpoint.close() waits on them through the finish gate at endpoint.rs:1424-1426. Remedy: count toward maxConnectionsTotal only sessions that have been announced, or refuse at on_new_mini_conn based on mini-conn count separately from sessions.

Verification: on_new_mini_conn creates a QuicSession before any TLS runs and pushes it into sessions at endpoint.rs:1643. The admission gate at line 1625 is self.sessions.get().len() >= max_conns, so every such un-announced session occupies a maxConnectionsTotal slot. The listen callback only fires from QuicSession::on_client_hello (session.rs:551-559). The base (9b4284f) already fails the same way.

@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

#46343 changes the same QuicSession::create call sites. It makes the peer address a required argument of create, reads the local address from the endpoint, and deletes cache_sockaddrs, so that session.path is right from the listen() callback on.

Proposed order: #46343 first. It is about 105 source lines and needs no lsquic patch. After it, this PR passes the mini conn's peer address to create in on_new_mini_conn and drops its cache_sockaddrs calls (bind_mini_conn, bind_conn, connect). The resumed 0-RTT test in #46343 then stays as a plain address check, because the caller it reaches today does not exist here.

If this PR lands first, #46343 shrinks to the create argument, the version negotiation probe and the onnewtoken address.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant