Skip to content

node:quic: send a client's coded close before the server's first packet, ignore close options without code - #44846

Open
robobun wants to merge 5 commits into
robobun/ede10bad/quic-h3-close-before-handshakefrom
robobun/e1e97914/quic-close-code-before-handshake
Open

robobun wants to merge 5 commits into
robobun/ede10bad/quic-h3-close-before-handshakefrom
robobun/e1e97914/quic-close-code-before-handshake

Conversation

@robobun

@robobun robobun commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Merge #44751 first. This PR targets its branch.

Problem

  • After the handshake, session.close({ reason }) rejects the peer's closed with INTERNAL_ERROR "connection error".
  • Before the server's first packet, a client close({ code }) sends no CONNECTION_CLOSE. Node sends it. A bun server keeps the session for 10 s.
  • Cause: lsquic's immediate_close() (lsquic_full_conn_ietf.c:4190) sends code 0 as INTERNAL_ERROR, and sends nothing before the first server packet. No user reported this.

Fix

  • patches/lsquic/user-abort-close.patch marks a close that comes from lsquic_conn_abort_error, which only node:quic calls. A marked close keeps code 0 and can use an Initial packet.
  • parse_close_options ignores type and reason without code, as Node does. lsquic_conn_ack_now queues an ACK only when one is owed, so destroy() stays silent.
  • Verified: test/js/node/quic/quic-endpoint.test.ts (47 new tests, 31 fail without the fix), the vendored test-quic-*.mjs, the HTTP/3 tests.
  • Self-reviewed: 14 concerns raised, 12 addressed. Not done (see Notes): two tests of node:quic: capture the handshake while the conn is alive, set the application at session creation #44533, one ACK test.

Background

  • Before the ServerHello a QUIC client can encrypt only Initial packets (RFC 9000 section 10.2.3).
  • Considered a Rust-only fallback to lsquic_conn_close, which sends only NO_ERROR: the code never reaches the peer.

Downsides

  • destroy(err, {}) sent NO_ERROR and now sends nothing, as Node does. The peer sees a stateless reset or its idle timer.
  • A server with no handler on session.closed gets an unhandled rejection from an early coded close.
  • Release .text grows 320 bytes. Each abort runs 1 to 8 more instructions.
Notes

Repro. The peer is a UDP socket that never answers. Run bun probe.mjs plain|reason|code|app.

import { createSocket } from "node:dgram";
import { connect } from "node:quic";
const how = process.argv[2] ?? "plain";
const silent = createSocket("udp4");
const sizes = [];
silent.on("message", m => sizes.push(m.length));
await new Promise(r => silent.bind(0, "127.0.0.1", r));
const session = await connect({ address: "127.0.0.1", port: silent.address().port }, { servername: "localhost", verifyPeer: "manual", onerror() {} });
session.opened.catch(() => {});
const arg = { reason: { reason: "bye" }, code: { code: 5n }, app: { code: 5n, type: "application" } }[how];
await session.close(arg);
await new Promise(r => setTimeout(r, 300));
console.log(`${how}: datagrams at the peer: ${sizes.length}`);
process.exit(0);
plain reason code app
main (release) 2 1 1 1
this PR (release) 2 2 2 2
Node v26.3.0 --experimental-quic 2 2 2 2

The frames, before the server's first packet. The same peer decrypts every client Initial (RFC 9001 section 5.2). The table shows the CONNECTION_CLOSE as code and reason. Every frame is type 0x1c. Node is v26.3.0 built with --experimental-quic (ngtcp2 1.22.1). "main" is the base of this PR, the head of #44751.

call Node main this PR
close(), close({}), close({ code: 0n }) 0 0 0
close({ reason }) 0 none 0
close({ type: "application" }) 0 none 0
close({ code: 5n }) 5 none 5
close({ code: 5n, reason }) 5 "bye" none 5 "bye"
close({ code: 5n, type: "application" }), also with reason 12 none 12
close({ code: 0n, reason }) 0 "bye" none 0 "bye"
close({ code: 0n, type: "application", reason }) 12 none 12
destroy() none none none
destroy(undefined, {}) none 0 none
destroy(undefined, { reason }), destroy(undefined, { type: "application" }) none none none
destroy(undefined, { code: 0n }) 0 0 0
destroy(undefined, { code: 5n }) 5 none 5
destroy(undefined, { code: 5n, reason }) 5 "bye" none 5 "bye"
destroy(undefined, { code: 5n, type: "application" }), also with reason 12 none 12
destroy(undefined, { code: 0n, reason }) 0 "bye" none 0 "bye"
destroy(undefined, { code: 0n, type: "application", reason }) 12 none 12

This PR equals Node in 22 of 22 cells. Main equals Node in 7 of 22. The reason equals Node's only up to 255 bytes and with no NUL byte (see "Left for later").

Node's rule for options without code. MaybeSetCloseError returns at code_val->IsUndefined() before it reads type or reason (session.cc#L926). Destroy sends a CONNECTION_CLOSE only when a close error was set (session.cc#L992-L1000). validateCloseOptions in quic.ts is a port of Node's and keeps code undefined when the caller omits it.

Why this is one change.

  • The marker alone makes every option set a close error. close({ type: "application" }) before the handshake would then send APPLICATION_ERROR, and destroy(err, { reason }) would send a frame. Node sends NO_ERROR and nothing.
  • With the parser rule, destroy(err, {}) takes the silent arm of destroy(). That arm calls lsquic_conn_ack_now first. With no application packet received, lsquic failed to build the ACK, raised an internal error, and sent CONNECTION_CLOSE INTERNAL_ERROR. So the silent arm was not silent between the server's first packet and its first 1-RTT packet. The last new test shows this (5 of 5 runs of its probe on main).
  • destroy() drives the engine only to send that ACK. A drive with no ACK queued is a normal tick: it sent a ClientHello for a session that had not sent one, or retransmitted one that was due.
  • destroy(err, { code }) now always uses lsquic_conn_abort_error. lsquic_conn_close first resets the streams and sends the frame only when they are gone. For { code: 0n } on a server with an open client stream, the client got a stream reset and then the close after about 1,195 ms (3 of 3 runs on main). This PR sends the close in 3 to 5 ms and no reset, as Node does.

Why this PR is on top of #44751. The two PRs change different stages of the same call. With the parser rule, close({ type: "application" }) is a plain close. On an HTTP/3 client with a pending stream, a plain close calls lsquic_conn_going_away before HTTP/3 starts, which is the crash that #44751 fixes. With a pending stream that has a body, main defers a coded close for 10 s, which #44751 also fixes. Two of the new tests cover these cells.

What the lsquic patch changes, by caller.

  • QuicSession::apply_close (src/runtime/node/quic/session.rs) sends a close with a non-zero code, a reason or the application type through lsquic_conn_abort_error. Every other close uses lsquic_conn_close, which can only send NO_ERROR.
  • lsquic_conn_abort_error has 5 callers, all in node:quic (session.rs 3, endpoint.rs 2). Bun.serve HTTP/3 and the fetch HTTP/3 client use lsquic_conn_close and lsquic_conn_abort, which do not set the marker.
  • lsquic's own code calls ci_abort_error directly and not through that wrapper. Those aborts stay unmarked.
  • Unmarked aborts keep upstream's behaviour: nothing before the server's first packet (version negotiation failure, handshake timeout), and a Handshake packet after it.
  • An application close in an Initial or Handshake packet becomes the transport code APPLICATION_ERROR (0x0c) with no reason (RFC 9000 section 10.2.3). Upstream did that for Handshake packets only.
  • Upstream PR litespeedtech/lsquic#693 proposes the Initial fallback for every abort. It does not change the early return.

Other states checked by hand on this PR (a peer that forges the server's packets, with the frames read from the decrypted Initials):

  • After a Retry: close({ code: 5n, reason }) sends an Initial with the Retry token and code 5 "bye". Main sends nothing.
  • After a Version Negotiation packet that offers QUIC v2: the coded close is sent. Main sends nothing.
  • After a server Initial that carries only an ACK (no Handshake keys yet): close({ code: 5n }) sends an Initial with code 5, an application close sends code 12. Main sends nothing. One new test covers this state with a server packet that the client cannot read.
  • destroy() after the JS thread was blocked for 400 ms (the ClientHello retransmission is due): 1 datagram, as on main.
  • destroy() after the handshake with no ACK owed and one open stream: the client sends 1 datagram, the reset of the stream. Main sends 2, the reset and an ACK that nothing needs. This state has no test (see "Not done from the self-review").
  • With BUN_DEBUG_lsquic=1 on the debug build, generated CONNECTION_CLOSE frame appears 0 times for destroy(), destroy(err), a handshake timeout at a silent peer, and a version negotiation failure.

Measurements (release builds of the base 6546653 and of this PR, same toolchain and build directory):

  • Release text: immediate_close 771 B to 838 B, parse_close_options 1,156 B to 822 B plus a 157 B closure, QuicSessionPrototype__destroy 624 B to 676 B, QuicSessionPrototype__gracefulClose 455 B to 600 B. Total .text +320 B (llvm-nm -S, size). The stripped binary has the same file size, 89,142,856 B.
  • struct ietf_full_conn (release): 10,232 B to 10,232 B (the calloc argument in both constructors). The marker is spare bit 5 of cn_flags.
  • ietf_full_conn_ci_tick: 2,396 B to 2,396 B, 555 instructions, identical when addresses are stripped. immediate_close: 187 to 206 instructions. The unmarked path runs 1 to 8 more instructions, once per aborting connection.
  • Plain close(), whole gracefulClose call: property gets 3 to 1, string conversions 1 to 0, mi_malloc 5 to 2, mi_free 4 to 1 (gdb hit counters). A coded close is unchanged at 3, 1, 5, 4.
  • destroy() with nothing to acknowledge: engine passes 2 to 1 per call.
  • bun server as the peer, 5 interleaved runs per cell. Before the handshake, close({ code: 5n }): fulfilled after 10,015 to 10,021 ms on main, rejected with errorCode 5 after 11 to 19 ms on this PR (a plain close takes 12 to 89 ms on both). After the handshake, close({ reason }): rejected with errorCode 1 "connection error" on main, fulfilled on this PR. After the handshake, close({ code: 5n }): errorCode 5 on both.
  • Vendored test-quic-*.mjs: 233 of 235 pass on both release builds. The 2 that fail import node:stream/iter, which does not exist. On debug builds the same 8 fail with and without this PR.
  • serve-http3, fetch-http3-client, fetch-http3-adversarial, fetch-http3-cold-post, fetch-http3-syscall-fault: 167 pass and 0 fail on both release builds.
  • quic-endpoint.test.ts with the LeakSanitizer settings of the ASAN lane: 17 of 18 runs clean. The file without the new tests: 11 of 12. The abort is an lsquic leak that Update lsquic to 4.9.4 and free unsent stateless resets on destroy #41337 fixes. The new tests that use a server are not concurrent because of that leak: 5 of 5 runs abort when they are.
  • The lsquic patch is 122 lines.

Cells that change on purpose.

  • destroy(err, {}) sent NO_ERROR and now sends nothing, like destroy(err). Example on a server with an open client stream: the client's closed fulfilled after 4 ms on main. On this PR it rejects with transport error 1 after about 1.4 s, from a stateless reset. destroy() does the same on main.
  • close({ reason }) and close({ type: "application" }) reached the peer as INTERNAL_ERROR after the handshake and sent nothing before it. They are now a plain close. On a server with an open client stream a plain close reaches the client after about 1.4 s, so these two calls now take that long there.
  • destroy() sends no ACK when none is owed.
  • A bun server whose session.closed has no handler: a client close with a code before the handshake now rejects that promise, which is an unhandled rejection. On main the promise fulfils after 10 s. A close with a code after the handshake rejects it on main too.

Where this PR does not follow Node v26.3.0.

  • A session that has not sent its ClientHello yet. connect() inside the callback of another session on the same endpoint returns before the ClientHello leaves. destroy(undefined, { code: 5n }) there sends nothing, as on main. Node sends the ClientHello inside connect() and then the close. A close with no ClientHello before it is a close for a connection that the server never saw, so the patch stays silent there. close({ code: 5n }) in the same state sends the ClientHello and then the close, as Node does.
  • A server destroy(undefined, { code: 5n, reason }) before the handshake ends still reaches the client as a stateless reset (transport error 1, no reason). Node delivers code 5 "bye". The server session has no connection yet, so destroy() does not read the options. A server close({ code }) in the session callback works.
  • A server close() that the session callback makes synchronously: bun keeps it until the connection exists, Node drops it.

Since the first push.

  • The new tests have no type error under bun check after a merge with main (Fix every type error in test/ #44614). The branch has no conflict with main in its own files.
  • parse_close_options only parses. close() and destroy() record the close that report_close reports. Each comment that the branch adds in src/ is one line.
  • The test for a session that has not sent its ClientHello asserts that destroy() with a code sends nothing at all.

Not done from the self-review.

  • node:quic: capture the handshake while the conn is alive, set the application at session creation #44533 has two tests in which a client calls this.destroy() as soon as it has its 1-RTT keys and expects a CONNECTION_CLOSE. That frame is the INTERNAL_ERROR from the failed ACK. With this PR the call sends nothing, so those tests wait for a frame that does not come. this.destroy(undefined, { code: 1n }) sends a close with and without this PR. The tests are not in this branch. The one of the two PRs that merges second must make that change.
  • No test shows that destroy() after the handshake sends no ACK when none is owed. To owe none, the client needs a peer that stops answering. The retransmission timer of the client then sends datagrams, and a count cannot tell them from the ACK without the 1-RTT keys. A probe that counts the datagrams of one destroy() call: main sends the ACK in 40 of 40 runs and this PR in 0 of 40 (release builds). On the debug build under load, 2 of 24 runs of this PR count a retransmission.

Left for later.

  • lsquic's own aborts in the window without Handshake keys still build a Handshake packet that cannot be encrypted (litespeedtech/lsquic#693).
  • A bun server answers every Initial without address validation (validateAddress is not read by the native code). Any Initial that no handshake follows holds a server session for 10 s. This PR removes one cause of that. The first server test depends on it.
  • A plain close() on a server with an open client stream reaches the client after about 1.4 s.
  • The reason is cut at 255 bytes, also inside a UTF-8 sequence, and at the first NUL byte. Error codes above 2^32-1 are clamped. ci_abort_error takes unsigned and a 256-byte buffer.
  • A bun server reports a close that arrives during the handshake with the code only.
  • After a Version Negotiation packet that moves the client to QUIC v2, lsquic sends Initial packets with the v2 version field that decrypt only with the v1 Initial keys. Main does the same. It is not related to close.
  • endpoint.destroy(err) gives the close options only to sessions whose handshake is complete, as Node does. A session that is still in its handshake stays silent there, and a bun server holds its side for 10 s.

[policy-decision:dep] gate passed · iteration 0 · 5 files touched

fails on main (without fix)
ASAN without fix: 10 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/quic/quic-endpoint.test.ts
bun test v1.4.3 (367d939d9)

test/js/node/quic/quic-endpoint.test.ts:
(node:22761) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun-debug --trace-warnings ...` to show where the warning was created)
(pass) QuicEndpoint client-engine mode > an explicit endpoint rejects a connect() in the other mode [552.22ms]
(pass) endpoint blockList > applies to packets forwarded from a sibling endpoint on the same loop [588.92ms]
(pass) server ALPN list > rejects a list mixing HTTP/3 and non-HTTP/3 protocols [129.33ms]
(pass) node:quic under --isolate > a second test file in the same process gets its own callbacks [7383.02ms]
(pass) dual-mode endpoint > does not stateless-reset its own client connection [428.70ms]
(pass) transportParams.maxIdleTimeout > 0 disables the idle timeout instead of falling back to the default [210.51ms]
(pass) endpoint.close() while a session is live > stops accepting new sessions so closed can resolve [145.79ms]
518 |       const session = aw
... (truncated)

release without fix: 31 FAILED
bun test v1.4.3-canary.1 (367d939d9)

test/js/node/quic/quic-endpoint.test.ts:
(node:22811) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun --trace-warnings ...` to show where the warning was created)
(pass) QuicEndpoint client-engine mode > an explicit endpoint rejects a connect() in the other mode [22.72ms]
(pass) endpoint blockList > applies to packets forwarded from a sibling endpoint on the same loop [78.72ms]
(pass) server ALPN list > rejects a list mixing HTTP/3 and non-HTTP/3 protocols [7.58ms]
(pass) node:quic under --isolate > a second test file in the same process gets its own callbacks [165.47ms]
(pass) dual-mode endpoint > does not stateless-reset its own client connection [15.87ms]
(pass) transportParams.maxIdleTimeout > 0 disables the idle timeout instead of falling back to the default [21.15ms]
(pass) endpoint.close() while a session is live > stops accepting new sessions so closed can resolve [21.36ms]
518 |       const session = await peer.dial();
519 |       session.opened.catch(() => {});
520 |       if (stream) (await session.createBidirectionalStream(pending[stream])).closed.catch(() => {});
521 |    
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/quic/quic-endpoint.test.ts
bun test v1.4.3 (367d939d9)

test/js/node/quic/quic-endpoint.test.ts:
(node:23506) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun-debug --trace-warnings ...` to show where the warning was created)
(pass) QuicEndpoint client-engine mode > an explicit endpoint rejects a connect() in the other mode [551.16ms]
(pass) endpoint blockList > applies to packets forwarded from a sibling endpoint on the same loop [618.30ms]
(pass) server ALPN list > rejects a list mixing HTTP/3 and non-HTTP/3 protocols [125.20ms]
(pass) node:quic under --isolate > a second test file in the same process gets its own callbacks [8042.13ms]
(pass) dual-mode endpoint > does not stateless-reset its own client connection [382.55ms]
(pass) transportParams.maxIdleTimeout > 0 disables the idle timeout instead of falling back to the default [184.29ms]
(pass) endpoint.close() while a session is live > stops accepting new sessions so closed can resolve [186.86ms]
(pass) session close options b
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 2568ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/9] gen generated_host_exports.rs
generated_host_exports.rs: 120 exports (host=5, lazy=10, generic=105, rust=0); 248 extern-C blocks audited
[2/8] rustc bun_lsquic_sys 
[3/8] rustc bun_runtime 
[4/8] link bun-profile
ld.lld: warning: Linking two modules of different target triples: 'obj/src/simdutf_sys/bun-simdutf.cpp.o' is 'x86_64-pc-linux-gnu' whereas 'rust-target/x86_64-unknown-linux-gnu/deps/libbun_sema_driver-66da6243a5e329ad.rlib(bun_sema_driver-66da6243a5e329ad.bun_sema_driver.e96fa8ad2ea53803-cgu.0.rcgu.o at 276626)' is 'x86_64-unknown-linux-gnu'


ld.lld: warning: Linking two modules of different target triples: 'obj/unified/UnifiedSource-src_jsc_bindings-5.cpp.o' is 'x86_64-pc-linux-gnu' whereas 'rust-target/x86_64-unknown-linux-gnu/deps/libbun_sema_driver-66da6243a5e329ad.rlib(bun_sema_driver-66da6243a5e329ad.bun_sema_driver.e96fa8ad2ea53803-cgu.0.rcgu.o at 276626)' is 'x86_64-unknown-linux-gnu'


ld.lld: warning: Linking two modules of different target triples: 'obj/vendor/highway/hwy/target
... (truncated)
diff hotspot
patches/lsquic/user-abort-close.patch   | 122 ++++++++
 scripts/build/deps/lsquic.ts            |   8 +
 src/lsquic_sys/lib.rs                   |   8 +-
 src/runtime/node/quic/session.rs        | 124 ++++----
 test/js/node/quic/quic-endpoint.test.ts | 512 +++++++++++++++++++++++++++++++-
 5 files changed, 716 insertions(+), 58 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                     reads  edits  tests
patches/lsquic/user-abort-close.patch        0      0     47
scripts/build/deps/lsquic.ts                 1      1     48
src/lsquic_sys/lib.rs                        2      2     47
src/runtime/node/quic/session.rs             4      2     47
test/js/node/quic/quic-endpoint.test.ts      4      3     47

…acket

lsquic's immediate_close() sent nothing for lsquic_conn_abort_error() on a
client that had not heard from the server, never used the Initial packet
number space, and turned code 0 into INTERNAL_ERROR. A close that the
application asked for now goes out with its code in the highest space that
has keys. lsquic's own aborts are not changed.

Options without `code` follow Node's parser: close() is a plain NO_ERROR
close and destroy() sends nothing. destroy() with a code always uses
lsquic_conn_abort_error(), which does not wait for the streams.

lsquic_conn_ack_now() no longer queues an ACK when no ack-eliciting
application packet waits for one. With nothing received that ACK failed
and reached the peer as an INTERNAL_ERROR close from a destroy() that must
be silent.
@github-actions github-actions Bot added the claude label Oct 9, 2026
@robobun

robobun commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on a release build of main. The peer is a UDP socket that never answers. After session.close({ code: 5n }) the socket has 1 datagram, the ClientHello. Node v26.3.0 with --experimental-quic leaves 2, and the second one carries the CONNECTION_CLOSE. The probe is in the Notes of the description.

PR: #44846

@robobun

robobun commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:32 AM PT - Oct 15th, 2026

@robobun, your commit e37e601 is building: #125929

@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.

I reviewed this PR and didn't find any bugs. Because it patches lsquic's immediate_close() packet-space selection and changes what goes on the wire for every close()/destroy() option shape, a human look at the lsquic patch and the Node-parity claims would still be worthwhile.

What was reviewed:

  • The parse_close_options refactor against the base: close() always reaches native with an object (quic.ts validateCloseOptions), so the explicit self_close = (false, 0, []) record in the None arm keeps the base's report_close outcome; destroy(err, {}) losing its NO_ERROR frame is the intentional Node-parity change stated in the description.
  • Helper wiring: close_with_options has no remaining callers; apply_close is still reached from apply_graceful_close and maybe_finish_deferred_close; the two lsquic_conn_abort_error sites in endpoint.rs are server-side, where the patch's new branches (version-negotiation early return, Handshake-keys fallback) are client-only and the non-zero CONNECTION_REFUSED code is unaffected by the code-0 change.
  • ack_now gating on ifc_n_slack_akbl[PNS_APP]: an ACK-eliciting app packet recorded in rechist is exactly what generate_ack_frame_for_pns needs, so the previously failing ACK build cannot be reached from the new path.
  • Candidates ruled out: lone Initial close for a session that never sent its ClientHello (gated by IFC_FIRST_TICK, with a test covering the connect-inside-callback case); Initial-space close after Initial keys are discarded (the PNS_INIT arm requires Handshake keys not ready, which precedes key discard).
Extended reasoning...

The change adds a new vendored lsquic patch (user-abort marker on cn_flags bit 5, Initial-space fallback and code-0 handling in immediate_close, lsquic_conn_ack_now returning whether an ACK was queued), updates the Rust FFI wrapper, and reworks node:quic session close()/destroy() option parsing so that options without code are a plain close or a silent destroy, plus roughly 500 lines of new tests that decrypt client Initial packets to assert the CONNECTION_CLOSE frames. It touches no auth, injection, or data-exposure surface, but it does alter protocol behavior on the wire for every close path and modifies vendored crypto-transport internals. The Node source lines cited for the parity claims could not be fetched from this environment, and the lsquic tick ordering around IFC_FIRST_TICK could only be checked from the patch context rather than the full vendored source, which is why a human look is recommended rather than an approval.

One part of this review's analysis that stopped early was run once more. It finished.

@robobun

robobun commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

The review names two things that it could not check. Here is where to check them.

Node parity. Both rules are in src/quic/session.cc at the tag v26.3.0.

The frame table in the Notes comes from runs of a Node v26.3.0 build with QUIC, not from a read of the source.

IFC_FIRST_TICK. The line numbers are for vendor/lsquic/src/liblsquic/lsquic_full_conn_ietf.c with the patches of this branch applied.

  • ietf_full_conn_init sets the flag (line 1313). Nothing else sets it.
  • ietf_full_conn_ci_tick clears it (line 8672). Four CLOSE_IF_NECESSARY() checks run before that line (8641, 8654, 8657, 8667).
  • So immediate_close() sees the flag only when the abort came before the first tick of the connection.

The test destroy(undefined, { code: 5n }) sends no close without a ClientHello covers that state. In a build without the clause, the same call sent a CONNECTION_CLOSE with no ClientHello before it.

On main `cd test && bun check` had no type error after #44614. Merged with
main, the tests of this branch had 8, all for the arguments of connect().

The edits are casts and `!`. The import line of node:quic is now the line
that main has, so the two merge with no conflict. The transpiled output
differs only in the line breaks of one object literal.
Comment thread src/lsquic_sys/lib.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

@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.

Comment thread test/js/node/quic/quic-endpoint.test.ts Outdated
fn apply_abort(&self, app: bool, code: u64, reason: &[u8]) {
let Some(c) = self.conn() else { return };
let creason = core::ffi::CStr::from_bytes_until_nul(reason).unwrap_or(c"close");
c.abort_error(app, code.min(u32::MAX as u64) as core::ffi::c_uint, creason);

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: pre-existing: a peer receiving a node:quic close with a code above 0xFFFFFFFF gets code 0xFFFFFFFF on the wire, not the code the caller passed. apply_abort at session.rs:1966 clamps code.min(u32::MAX as u64) before lsquic_conn_abort_error, which takes unsigned. This PR now routes every destroy(undefined, { code }) and every coded close() through this helper, while self_close at session.rs:1906 keeps the full 64-bit code for the local report, so the two sides disagree. Fix: carry the full 62-bit QUIC error code to the CONNECTION_CLOSE frame (widen the lsquic_conn_abort_error/ci_abort_error code parameter in the vendored patch, or reject codes above the supported range at parse time as an error instead of silently clamping).
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A caller passes a close code that does not fit in 32 bits, for example a WebTransport-style application code, through session.close({ code: 0x52e4a40fa8dbn, type: "application" }) or session.destroy(undefined, { code: ... }). parse_close_options at src/runtime/node/quic/session.rs:1890 reads it as u64 and records the full value in self_close at session.rs:1906. apply_abort at session.rs:1966 then calls c.abort_error(app, code.min(u32::MAX as u64) as core::ffi::c_uint, creason), so the lsquic CONNECTION_CLOSE frame carries 0xFFFFFFFF. The peer's closed rejects with errorCode: 4294967295n while the local closed reports the original code. The base branch has the same clamp (the line is moved, not changed), so this is pre-existing; the PR does make destroy(undefined, { code }) for every code and the new Initial/Handshake-space closes go through it as well. No safeguard rejects the out-of-range code: validateCloseOptions in src/js/internal/quic/quic.ts:5195 only checks the type is bigint or number.

Verification: pre-existing. Trigger: a caller passes a close code above 0xFFFFFFFF. validateCloseOptions (src/js/internal/quic/quic.ts:5199-5203) only checks typeof code, no range. apply_abort (session.rs:1966) calls c.abort_error(app, code.min(u32::MAX as u64) as core::ffi::c_uint, creason), so the wire code is saturated to 0xFFFFFFFF. The identical clamp exists in the base at session.rs:1954.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is from before this PR, as the comment says. The description has it under "Left for later". It is not in this push. The full code needs a wider ci_abort_error in lsquic, which takes unsigned, or a second field for the code, and tests of its own. A throw for a code above 2^32-1 would reject a code that Node accepts.

close() records the close that report_close reports for every call, and
destroy() records it when the options have a code. A plain close is no
longer a case that the caller of the parser has to patch up.

Each comment that this branch adds in src/ is now one line. The comment in
destroy() about a parse failure is the one from before this branch.

Test: on a session that has not sent its ClientHello, destroy() with a code
must send nothing. The assertion ignored a connection that had sent a
ClientHello, so it also passed when that state was not reached.
…ons cast

The cast needed `type SessionOptions` on the import line of node:quic. Main
changed that line in #44614, and the formatter removes a name that this
branch alone does not use. The line is now the one of the base again, so it
merges with main with no conflict.

@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.

I re-reviewed the two new pushes and found no new bugs; because the fix lives in a hand-written patch to lsquic's immediate_close() whose base source is not in this checkout to diff against, a human look at patches/lsquic/user-abort-close.patch is still worthwhile.

What was reviewed:

  • The parse_close_options split: close() always arrives with an object from validateCloseOptions, so recording self_close on every call keeps the old report_close output; destroy() without code no longer records one and takes the silent arm, which is the intended Node behavior.
  • The destroy() silent arm now drives the engines only when ack_now() queued an ACK, and still calls abort_silent() on every path; apply_abort without a conn stays a no-op as apply_close did.
  • The "has not sent its ClientHello" test now asserts the unfiltered [], so it fails if the ClientHello went out before the destroy.
Extended reasoning...

The change adds a vendored lsquic patch marking application-requested aborts so a client can send a coded CONNECTION_CLOSE in an Initial packet before the server's first packet, changes lsquic_conn_ack_now to report whether it queued an ACK, reworks parse_close_options/record_self_close/apply_abort in src/runtime/node/quic/session.rs, and adds ~520 lines of tests including a hand-rolled QUIC Initial decoder. It touches no auth or credential surface, but it alters wire-level close behavior of the QUIC stack shared with HTTP/3. The two pushes since the prior review addressed the earlier test nit and moved close recording out of the parser; the Rust side checked out as behavior-preserving for close(). The lsquic C patch cannot be verified against the upstream source from this checkout, and the protocol-level change is non-trivial, which is why a human look is still worthwhile rather than approval.

One part of this review's analysis that stopped early was run once more. It finished.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

@robobun

robobun commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

Three commits since the first review.

  • 40e25a0 and e37e601: the new tests have no type error under bun check after a merge with main, where Fix every type error in test/ #44614 made test/ clean. The edits are !, one cast and a spread. The branch has no conflict with main in its own files.
  • 65db235: parse_close_options only parses, and close() and destroy() record the close that report_close reports. The six comments that the comment check flagged are one line each or gone. The comment of destroy() about a parse failure is the one from the base branch again.
  • 65db235 also makes the test for a session that has not sent its ClientHello strict: destroy() with a code must send nothing.

Not changed: a close code above 2^32-1 is clamped, as before this PR. That thread is open, and the description lists it under "Left for later".

Verified on a debug build of e37e601:

  • quic-endpoint.test.ts 54 pass, quic-stream.test.ts 10 pass, quic-sni.test.ts 6 pass. A debug build of the base branch fails 31 of the 54.
  • The vendored test-quic-*.mjs, 6 at a time: 231 of 235 pass. Two import node:stream/iter, which does not exist. test-quic-session-initial-rtt wants a smoothed RTT under 10 ms, and the debug build has 36 ms. test-quic-callback-error-onstream-async fails under that load on the base branch too (39 of 60 runs on the base branch, 41 of 60 on this PR). It passes alone (10 of 10).

#44751, the base of this PR, now has a conflict with main in quic-stream.test.ts.

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