Skip to content

node:quic: report the error lsquic closes a connection with - #46487

Open
robobun wants to merge 3 commits into
mainfrom
robobun/f1721e61/quic-local-abort-error-code
Open

robobun wants to merge 3 commits into
mainfrom
robobun/f1721e61/quic-local-abort-error-code

Conversation

@robobun

@robobun robobun commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #45499

Problem

  • With verifyPeer: "strict" and an untrusted certificate, closed resolves and onerror never runs. Node v26.3.0 rejects with transport 304.
  • map_conn_status (src/runtime/node/quic/session.rs:2487) read only lsquic_conn_status(), which has no error code. A local error became transport 1, or a clean close when the handshake also failed.

Fix

  • close-cause.patch adds lsquic_conn_close_info(): the close cause, and the error from conn_close_error(), which immediate_close() builds CONNECTION_CLOSE from. on_conn_closed reads it once.
  • self_close (the session's own copy of a close) is deleted: it let close() with no code hide a later error. This needs the recorded flag of node:quic: send a close with code 0 as code 0, not INTERNAL_ERROR #45702, so its patch and 8 tests are included.
  • Verified: test/js/node/quic/quic-endpoint.test.ts (32 new tests, 25 fail on main). Also test/js/node/quic/, the 235 ported test-quic-*.mjs, and the HTTP/3 suites.
  • Self-reviewed: 29 concerns raised, 27 addressed (the 2 rejected are in Notes).

Background

  • The build fetches lsquic and applies patches/lsquic/*.patch. lsquic keeps a local error (type, code) in conn->ifc_error, read only by immediate_close().
  • Weighed: an accessor for ifc_error beside lsquic_conn_status(). It keeps two reads, and an abort with no code stays a clean close.

Downsides

  • Some closes that resolved now reject (list in Notes). node:quic has no flag in Bun, so a caller with no handler gets an unhandled rejection.
  • Needs a maintainer decision: an HTTP/3 violation now reports the application code of this side's own frame (259). Main and Node v26.3.0 report transport 1.
  • Release .text is 336 bytes smaller. The teardown callback runs 106 instructions (main 122).
Notes

Repro (public API only). A server with a certificate that has no trusted issuer, and a client with verifyPeer: "strict":

import { createPrivateKey } from "node:crypto";
import { readFileSync } from "node:fs";
import { connect, listen } from "node:quic";
const keys = "test/js/node/test/fixtures/keys/";
const key = createPrivateKey(readFileSync(keys + "agent1-key.pem"));
const cert = readFileSync(keys + "agent1-cert.pem");
const fmt = e => `${e.code} ${e.errorCode}`;
const server = await listen(s => { s.onerror = () => {}; s.opened.catch(() => {}); s.closed.catch(() => {}); },
  { sni: { "*": { keys: [key], certs: [cert] } }, alpn: ["quic-test"] });
let onerror = 0;
const client = await connect(server.address, { alpn: "quic-test", servername: "localhost", verifyPeer: "strict", onerror() { onerror++; } });
console.log("opened:", await client.opened.then(() => "resolved", fmt));
console.log("closed:", await client.closed.then(() => "resolved", fmt), "onerror calls:", onerror);
process.exit(0);
opened closed onerror calls
main ERR_INVALID_STATE resolved 0
this PR ERR_QUIC_TRANSPORT_ERROR 304 ERR_QUIC_TRANSPORT_ERROR 304 1
Node v26.3.0 --experimental-quic ERR_QUIC_TRANSPORT_ERROR 304 ERR_QUIC_TRANSPORT_ERROR 304 1

The server gets transport 304 from the client's CONNECTION_CLOSE on all three. With the default verifyPeer: "auto" all three print the same: opened rejects, closed resolves. The strict block of Node's test/parallel/test-quic-reject-unauthorized.mjs fails on main and passes here. The whole file still fails at its line 38 on both builds (the server's opened), so it is not vendored.

Other inputs, side that finds the error. Release builds, main then this PR.

input main this PR Node v26.3.0
TLS alert in the client handshake (forged server Initial) closed resolves transport 266 transport 266
server overruns a stream's flow-control window transport 1 transport 3 transport 3
packet with no frames, frame that does not parse (forged Initial) transport 1 transport 10, 7 ngtcp2 drops the packet
HTTP/3: server-initiated bidirectional stream, second control stream transport 1 application 259 transport 1
HTTP/3 client, server allows 2 unidirectional streams closed resolves application 257 aborts on an nghttp3 assertion
0-RTT resume, server now has a lower initialMaxData closed resolves transport 1 keeps the connection
close(), close({ reason }) or close({ type: "application" }), then an HTTP/3 violation by the peer closed resolves application 259 rejects with transport 1 (run for close())
a server's close() that waits 60 s on an open request (lsquic's no-progress timer) closed resolves transport 12 has no such timer

Closes that resolved and now reject.

  • A failed verifyPeer: "strict" check, and any TLS alert that fails the client handshake.
  • An HTTP/3 client against a server with initialMaxStreamsUni below 3. The client reports application 257. Its frame goes out in a Handshake packet, so the peer gets transport APPLICATION_ERROR (RFC 9000 section 10.2.3).
  • A 0-RTT resume that the server's new limits refuse. lsquic aborts the connection there. That is an lsquic defect (RFC 9001 section 4.6.2 keeps the connection, and Node does). This PR only makes the abort visible.
  • An error that ends the connection after close() with no code, on a client or a server. On a server this includes lsquic's 60 s no-progress timer (es_noprogress_timeout). A graceful close() that waits 60 s on an open request now rejects with transport 12, the code its peer already got on main. Node has no such timer. Turning the timer off for node:quic is a separate change.

Codes that change on a close that already rejected. Every error lsquic records: 1 becomes the recorded code (3, 7, 10, 12 and so on, or an HTTP/3 application code). The server's no-progress timer goes from 1 to 12, the code in its frame.

What does not change. A stateless reset stays transport 1 (Node: 10). A handshake timeout stays 0x128 (Node: 1). A failed version negotiation stays a clean close (Node rejects with ERR_QUIC_VERSION_NEGOTIATION_ERROR after onversionnegotiation). lsquic has no code for the first two, and the third needs the peer's version list. They are test.todo entries. Streams that lsquic had opened still resolve closed when the session rejects: that is #45741. Server handshakes that fail in lsquic's mini connection keep their own path (on_mini_conn_failed). The fetch HTTP/3 client still reads lsquic_conn_status() for a debug log.

The error is the one lsquic selects, not always one it sends. Before a client has Handshake keys, lsquic at this pin sends no CONNECTION_CLOSE. The first four new tests run there: the peer sees nothing and the client reports 10, 7, 266 or 1.

Rejected self-review concerns.

Other open PRs.

Tests. 24 new tests and 3 test.todo in one describe, plus the 8 of #45702. A node:dgram socket plays the server for four of them: it answers the client's first packet with one Initial packet built with node:crypto (the keys of an Initial packet come from the client's first connection ID, RFC 9001 section 5.2). 7 of the 32 pass on main and pin what must not change. 1,280 runs of the 32 tests passed on the debug build with four runners in parallel.

Measurements. Release builds of b1d4686 and of this diff on it.

  • Clean teardown callback: 106 instructions (main 122), gdb stepi, 20 of 20 teardowns identical.
  • Per teardown: 1 lsquic_conn_close_info call, 0 separate ietf_full_conn_ci_status calls (main 1), 0 mallocs in on_conn_closed on a clean close (main 0), 1 when lsquic recorded a message (main 1).
  • Release binary: .text 81,997,829 to 81,997,493 B, .rodata 22,627,276 B on both (llvm-size -A). immediate_close 771 to 776 B, on_conn_closed 402 to 373 B, report_close 1723 to 1398 B, lsquic_conn_close_info 223 B.
  • Per packet and per tick: ietf_full_conn_ci_packet_in, lsquic_engine_packet_in, lsquic_engine_process_conns and ietf_full_conn_ci_tick have identical instruction lists (llvm-objdump, data addresses normalized). process_regular_packet gains 4 instructions, one store in each of its abort branches (the recorded flag).
  • sizeof(struct ietf_full_conn): 10,232 B on both (the mi_calloc size). sizeof(QuicSession): 1120 to 1088 B, same 1280 B bin.
  • Close frames at the peer, compared arm by arm with main: recorded application error, recorded transport error, user code with a reason, no code, user abort: 5 of 5 identical.
  • Ported quic tests: 0 of 235 change outcome. The close reports with an error code are the same set on both builds. test-quic-session-initial-rtt.mjs fails on both debug builds, and three test-quic-callback-error-* files fail on both when four run in parallel.
  • lsquic_close_info: 24 B in C and Rust, offsets equal (gdb ptype /o). bun run rust:check-all: 12 of 12 targets pass.

robobun and others added 3 commits October 22, 2026 07:38
lsquic read "error code != 0" as "an error is recorded". A close with
code 0 that came in through lsquic_conn_abort_error (an application
close, or a transport close with a reason) lost its code and type in
immediate_close() and went out as a transport CONNECTION_CLOSE with
INTERNAL_ERROR. Record the error with a flag of its own in struct
conn_err. The same line in immediate_close() is also changed by the
lsquic patch of #44846, which has to rebase onto this.
When the local lsquic connection closed with an error, the session read
only lsquic_conn_status(). That enum has no error code, and it reports
every error that comes with a failed handshake as a version negotiation
failure. So the side that found the error reported transport error 1
for each one, and reported an error that also failed the handshake as a
clean close: `closed` resolved and `onerror` did not run.

A new lsquic patch adds lsquic_conn_close_info(). It returns the close
cause and the error from conn_close_error(), the selection that
immediate_close() builds CONNECTION_CLOSE from. on_conn_closed reads it
in place of lsquic_conn_status().

lsquic's record is now the only local record of a close. The session's
own copy, self_close, is gone: with the `recorded` flag of the previous
commit lsquic keeps a close with code 0 too. So close() with no code
can no longer hide an error that ends the connection afterwards.
@robobun

robobun commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on main (9c2dd6b) with the script in the PR notes. A client with verifyPeer: "strict" gets an untrusted server certificate: closed resolves, onerror does not run, and the server gets transport error 304 from that client.
  • The first report was a flow-control violation that the client finds. On main the client reports errorCode=1 and the server reports errorCode=3.
  • With this PR the side that finds the error reports 304 and 3.
  • PR: node:quic: report the error lsquic closes a connection with #46487

@coderabbitai

coderabbitai Bot commented Oct 22, 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 1 minute.

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
📥 Commits

Reviewing files that changed from the base of the PR and between 38afee6 and 112f3bc.

📒 Files selected for processing: 8
  • packages/bun-usockets/src/node_quic_shim.c
  • patches/lsquic/abort-error-app-code-zero.patch
  • patches/lsquic/close-cause.patch
  • scripts/build/deps/lsquic.ts
  • src/lsquic_sys/lib.rs
  • src/runtime/node/quic/session.rs
  • test/js/node/quic/quic-endpoint.test.ts
  • test/js/node/quic/quic-sni.test.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.

@robobun

robobun commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:05 AM PT - Oct 22nd, 2026

❌ @robobun, your commit 112f3bc has 1 failures in Build #130823 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46487

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

bun-46487 --bun

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

Beyond the inline findings, I also checked the FFI boundary for the new close-info path: Conn::close_info() copies lsquic's reason into an owned Vec inside on_conn_closed before the conn is freed, RawCloseInfo matches struct lsquic_close_info field-for-field (c_int, c_int, u64, pointer) with a size check in debug_assert_layout, and map_close_info is an exhaustive match over CloseCause with no wildcard arm, so a future variant cannot silently fall through.

Extended reasoning...

The change adds two lsquic vendor patches (a recorded flag on struct conn_err and a new lsquic_conn_close_info() API), a Rust FFI wrapper in src/lsquic_sys/lib.rs, and rewires report_close in src/runtime/node/quic/session.rs to use the captured close info instead of lsquic_conn_status and the deleted self_close field. It touches the TLS certificate-rejection paths only insofar as those now rely on lsquic recording the abort code rather than a session-side copy. Inline findings are posted; this note only records the lifetime and ABI checks ruled out on the new FFI surface.

Findings marked 🟡 are optional suggestions and need no follow-up push.

// The error lsquic selects for its CONNECTION_CLOSE frame. For an
// HTTP/3 violation that is an RFC 9114 application code, where node
// sends and reports transport INTERNAL_ERROR.
CloseCause::Error => (i32::from(info.is_app), info.code, Some(info.reason)),

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.

🔴 Server operators whose graceful close() waits on a stalled request now get closed rejected with transport error 12 after 60 s, where main resolved and Node resolves. The PR notes this follows from its purpose, but it is lsquic's own no-progress abort (es_noprogress_timeout, default 60 s on servers, never set in us_nq_settings_init), not a peer error, and an uncaught await session.close() now ends the process with an unhandled rejection. Fix: keep reporting peer- and lsquic-detected protocol errors through map_close_info at session.rs:2456, but do not surface the self-inflicted no-progress abort as an error after a user close(): set es_noprogress_timeout to 0 for node:quic engines in node_quic_shim.c (Node has no such timer), or map that abort to a clean close. [also at: src/runtime/node/quic/session.rs:1761 - Server operators who call session.close() on an HTTP/3 session with an idle open request now get closed rejected with transport error 12 after 60 s, where the base branch resolved it. report_close at session.rs:1754-1761 no longer records the session's own code-0 close, so lsquic's server-only no-progress abort (es_noprogress_timeout, default 60 s, not set by Bun) becomes the reported error via map_close_info.; src/runtime/node/quic/session.rs:2454 - Server apps whose graceful close() waits on a request the client leaves open now get closed rejected with transport error 12 after 60 s, where the base resolved it.]

Why this was flagged

A server session accepts a request stream, the client keeps the connection alive (keepalive pings) but makes no stream progress, and the server calls session.close() with no code; src/runtime/node/quic/session.rs:1876 defers the close on the open stream.

Verification: normal — acknowledged in diff: the PR description's table lists "a server's close() that waits 60 s on an open request (lsquic's no-progress timer) | closed resolves | transport 12 | [Node] has no such timer" and defers it ("Turning the timer off for node:quic is a separate change"); the note's claims are accurate, but it is a declared cost put up for sign-off, not a resolved one. Trigger: a server session calls session.close() with no code while a request stream stays open without progress until lsquic's server-default es_noprogress_timeout (60 s) fires.

Buffer.from([0x0a, 0x0a, 0x0a, 0x0a]),
]);
});
expect(closed).not.toEqual(expect.objectContaining({ code: "ERR_QUIC_TRANSPORT_ERROR" }));

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): the version-negotiation test cannot catch a wrong close report other than a transport error. At test/js/node/quic/quic-endpoint.test.ts:369 it only asserts closed is not ERR_QUIC_TRANSPORT_ERROR, so an application error, an idle-type close or any other wrong shape still passes. Fix: assert the exact expected value, expect(closed).toBe("resolved"), since map_close_info returns (2, 0, None) for VersionNegotiationFailed and kFinishClose resolves on code === 0n. [also at: test/js/node/quic/quic-endpoint.test.ts:365 - nit: the version-negotiation test cannot catch a wrong report, so a regression to an application error or a different transport code still passes.]

Why this was flagged

The test at test/js/node/quic/quic-endpoint.test.ts:355-370 drives a client into a failed version negotiation and reads closed through settled, which yields either the string "resolved" or a { code, type, errorCode } object. The assertion at line 369 is not.toEqual(expect.objectContaining({ code: "ERR_QUIC_TRANSPORT_ERROR" })). The code under test maps CloseCause::VersionNegotiationFailed to (2, 0, None) at src/runtime/node/quic/session.rs:2466, and src/js/internal/quic/quic.ts:3640 resolves when code === 0n, so the real expected value is "resolved". If a future change made that path report an application error or any non-transport shape, this assertion would still pass; the base branch has no test for this path at all, so this is a weak new assertion rather than a regression.

Verification: nit. Triggering condition: any future regression that makes a failed version negotiation report an application error, idle close, or other non-transport shape. test/js/node/quic/quic-endpoint.test.ts:369 asserts only expect(closed).not.toEqual(expect.objectContaining({ code: "ERR_QUIC_TRANSPORT_ERROR" })). The base branch already returned (2, 0, None), so nothing in production breaks.

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.

node:quic: an application close with error code 0 is sent as transport error INTERNAL_ERROR

1 participant