From d5c2d526e4f5d4d347cdaa37bb84068f7152bc01 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Fri, 11 Sep 2026 17:01:53 +0200 Subject: [PATCH 1/9] quic: add Http3Session, so you can explicitly pick the app protocol Previously, ALPN decided automatically which application protocol implementation was used. QuicSession was used everywhere, but its actual behaviour and API changes implicitly based on the wire traffic involved. Now, QuicSession is used for pure QUIC only, and Http3Session is used for HTTP/3 sessions only. To do HTTP/3 on a connection, you enable it explicitly by wrapping a QUIC session in Http3Session. Doing so attaches the internal protocol application handling so everything is HTTP/3 on that session from that point onwards. For now, this only changes the application selection process and the top-level APIs involved, but none of the details. In a future PR, we can introduce Http3Stream and migrate other HTTP/3 specific functionality (e.g. SETTINGS & GOAWAY handling) out of the QUIC API. Signed-off-by: Tim Perry --- benchmark/quic/h3-request.js | 18 +- benchmark/quic/handshake.js | 15 +- doc/api/quic.md | 251 ++++++++++++---- lib/internal/quic/http3.js | 269 ++++++++++++++++++ lib/internal/quic/quic.js | 53 +++- lib/internal/quic/state.js | 43 ++- lib/internal/quic/symbols.js | 2 + lib/quic.js | 5 + src/node_builtins.cc | 4 +- src/quic/README.md | 45 ++- src/quic/application.cc | 3 - src/quic/application.h | 21 +- src/quic/bindingdata.cc | 12 + src/quic/bindingdata.h | 3 + src/quic/endpoint.cc | 6 +- src/quic/http3.cc | 50 +++- src/quic/http3.h | 18 +- src/quic/quic.cc | 3 + src/quic/session.cc | 177 ++++++------ src/quic/session.h | 37 +-- src/quic/tlscontext.cc | 4 - src/quic/tlscontext.h | 2 +- test/parallel/test-quic-alpn-h3.mjs | 26 +- test/parallel/test-quic-alpn.mjs | 10 + .../test-quic-certificate-compression.mjs | 2 + .../test-quic-early-selection-order.mjs | 12 +- test/parallel/test-quic-h3-attach.mjs | 267 +++++++++++++++++ .../parallel/test-quic-h3-callback-errors.mjs | 44 +-- test/parallel/test-quic-h3-close-behavior.mjs | 11 +- .../test-quic-h3-concurrent-requests.mjs | 11 +- test/parallel/test-quic-h3-datagram.mjs | 33 ++- test/parallel/test-quic-h3-error-codes.mjs | 20 +- .../test-quic-h3-flow-control-volume.mjs | 11 +- test/parallel/test-quic-h3-goaway.mjs | 10 +- .../test-quic-h3-handshake-failure.mjs | 17 +- .../parallel/test-quic-h3-header-interest.mjs | 10 +- .../test-quic-h3-header-validation.mjs | 20 +- .../test-quic-h3-informational-headers.mjs | 11 +- ...-maxstreamdata-external-buffer-failure.mjs | 11 +- test/parallel/test-quic-h3-origin.mjs | 18 +- test/parallel/test-quic-h3-pending-stream.mjs | 11 +- .../parallel/test-quic-h3-post-filehandle.mjs | 11 +- test/parallel/test-quic-h3-post-request.mjs | 11 +- test/parallel/test-quic-h3-priority.mjs | 20 +- test/parallel/test-quic-h3-qpack-settings.mjs | 34 ++- .../test-quic-h3-request-rejected.mjs | 11 +- .../test-quic-h3-request-response.mjs | 18 +- test/parallel/test-quic-h3-settings.mjs | 58 ++-- .../test-quic-h3-status-code-type.mjs | 11 +- test/parallel/test-quic-h3-stream-credit.mjs | 11 +- ...st-quic-h3-stream-destroy-no-resurrect.mjs | 14 +- ...st-quic-h3-stream-destroy-with-headers.mjs | 11 +- .../test-quic-h3-stream-idle-timeout.mjs | 29 +- .../test-quic-h3-stream-without-onstream.mjs | 43 ++- .../test-quic-h3-trailing-headers.mjs | 11 +- ...quic-h3-uni-stream-limit-start-failure.mjs | 20 +- .../test-quic-h3-uni-stream-teardown.mjs | 26 +- .../test-quic-h3-zero-rtt-bogus-ticket.mjs | 2 + ...est-quic-h3-zero-rtt-rejected-settings.mjs | 36 ++- test/parallel/test-quic-h3-zero-rtt.mjs | 16 +- ...quic-internal-endpoint-listen-defaults.mjs | 9 +- ...est-quic-internal-endpoint-stats-state.mjs | 8 +- .../test-quic-session-application-options.mjs | 121 ++++---- .../test-quic-session-emit-ordering.mjs | 6 +- .../test-quic-session-stream-lifecycle.mjs | 5 +- .../test-quic-zero-rtt-disabled-server.mjs | 3 +- 66 files changed, 1581 insertions(+), 560 deletions(-) create mode 100644 lib/internal/quic/http3.js create mode 100644 test/parallel/test-quic-h3-attach.mjs diff --git a/benchmark/quic/h3-request.js b/benchmark/quic/h3-request.js index f96a18407ae1..88fca0225f0e 100644 --- a/benchmark/quic/h3-request.js +++ b/benchmark/quic/h3-request.js @@ -22,7 +22,7 @@ const bench = common.createBenchmark(main, { '--no-warnings'] }); async function main({ mode, n }) { - const { listen, connect } = require('node:quic'); + const { listen, connect, Http3Session } = require('node:quic'); const { bytes } = require('stream/iter'); const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); @@ -37,11 +37,13 @@ async function main({ mode, n }) { ':authority': 'localhost', }; - const endpoint = await listen((session) => { + const endpoint = await listen((quicSession) => { + const session = new Http3Session(quicSession); session.opened.catch(() => {}); session.closed.catch(() => {}); session.onstream = (stream) => { stream.closed.catch(() => {}); }; }, { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders() { this.sendHeaders({ ':status': '200' }); @@ -63,12 +65,12 @@ async function main({ mode, n }) { // A full handshake, one request, one response. When resume is supplied the // request goes out in the first flight, before the handshake completes. async function exchange(resume) { - const session = await connect(address, { + const session = new Http3Session(await connect(address, { servername: 'localhost', verifyPeer: 'manual', alpn: 'h3', ...resume, - }); + })); const stream = await session.createBidirectionalStream({ headers: request, onheaders, @@ -89,7 +91,7 @@ async function main({ mode, n }) { const { promise, resolve } = Promise.withResolvers(); let ticket; let token; - const session = await connect(address, { + const session = new Http3Session(await connect(address, { servername: 'localhost', verifyPeer: 'manual', alpn: 'h3', @@ -101,7 +103,7 @@ async function main({ mode, n }) { token ??= value; if (ticket !== undefined) resolve(); }, - }); + })); await session.opened; await promise; session.close(); @@ -115,12 +117,12 @@ async function main({ mode, n }) { // otherwise a ticket the server stopped accepting would quietly turn this // into a measurement of the 1-RTT path. async function checkEarlyDataAccepted() { - const session = await connect(address, { + const session = new Http3Session(await connect(address, { servername: 'localhost', verifyPeer: 'manual', alpn: 'h3', ...resume, - }); + })); const stream = await session.createBidirectionalStream({ headers: request, onheaders, diff --git a/benchmark/quic/handshake.js b/benchmark/quic/handshake.js index 9f0404008e03..430e6a6e198f 100644 --- a/benchmark/quic/handshake.js +++ b/benchmark/quic/handshake.js @@ -11,21 +11,23 @@ const { createPrivateKey } = require('crypto'); const bench = common.createBenchmark(main, { // 'raw' negotiates a non-HTTP ALPN and does no application work. - // 'h3' negotiates HTTP/3, so the server also builds an nghttp3 connection - // and its control/QPACK streams for every session. + // 'h3' installs HTTP/3 on every session, so each peer also builds an + // nghttp3 connection and its control/QPACK streams. protocol: ['raw', 'h3'], concurrency: [1, 10], n: [1000], }, { flags: ['--experimental-quic', '--no-warnings'] }); async function main({ protocol, concurrency, n }) { - const { listen, connect } = require('node:quic'); + const { listen, connect, Http3Session } = require('node:quic'); const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); const cert = fixtures.readKey('agent1-cert.pem'); - const alpn = protocol === 'h3' ? 'h3' : 'quic-bench'; + const http3 = protocol === 'h3'; + const alpn = http3 ? 'h3' : 'quic-bench'; - const endpoint = await listen((session) => { + const endpoint = await listen((quicSession) => { + const session = http3 ? new Http3Session(quicSession) : quicSession; // A benchmark peer never reads these; swallow so a torn-down session // cannot produce an unhandled rejection. session.opened.catch(() => {}); @@ -46,11 +48,12 @@ async function main({ protocol, concurrency, n }) { const address = endpoint.address; async function handshake() { - const session = await connect(address, { + const quicSession = await connect(address, { servername: 'localhost', verifyPeer: 'manual', alpn, }); + const session = http3 ? new Http3Session(quicSession) : quicSession; await session.opened; session.close(); await session.closed.catch(() => {}); diff --git a/doc/api/quic.md b/doc/api/quic.md index bd737ae230f0..832bfafd0f30 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -239,20 +239,20 @@ counter tracks how many packets have been dropped by the filter. ### Applications -Every `QuicSession` is associated with a single application protocol, negotiated -via ALPN during the TLS handshake. The `quic` module is designed to be -application-agnostic in general but includes built-in support for HTTP/3 as a -specific application protocol. When using HTTP/3, the `quic` module provides +Every active `QuicSession` is associated with a single application protocol +implementation. The `quic` module is designed to be application-agnostic +in general, but includes optional built-in support for HTTP/3 as a specific +application protocol. When using HTTP/3, the `quic` module provides additional APIs for handling HTTP/3-specific features such as headers, trailers, and prioritization. For other application protocols, users can implement their own message framing and multiplexing on top of the core QUIC transport features. When initiating a TLS handshake, the client will include a list of supported ALPN protocols in the `ClientHello`. The server selects one of these protocols -(if any) and includes it in the `ServerHello`. The negotiated protocol determines -how the `QuicSession` and `QuicStream` APIs behave. For example, when the `h3` -protocol is negotiated for HTTP/3, the `QuicSession` and `QuicStream` will support -HTTP/3-specific features. +(if any) and includes it in the `ServerHello`. The negotiated protocol does not +automatically change how the session behaves: HTTP/3 is attached explicitly +using the [`Http3Session`][] API, and a session it is never attached to uses +the raw QUIC protocol directly. Currently, the `quic` module only supports HTTP/3 as a built-in application protocol. All other protocols must be implemented by the user on top of the provided JavaScript @@ -505,7 +505,10 @@ const endpoint = new QuicEndpoint({ address: '127.0.0.1:1234', }); -const client = await connect('123.123.123.123:8888', { endpoint }); +const client = await connect('123.123.123.123:8888', { + alpn: 'h3', + endpoint, +}); ``` ## `quic.listen(onsession[, options])` @@ -527,7 +530,7 @@ import { listen } from 'node:quic'; const endpoint = await listen((session) => { // ... handle the session -}); +}, { alpn: ['h3'] }); // Closing the endpoint allows any sessions open when close is called // to complete naturally while preventing new sessions from being @@ -961,8 +964,9 @@ added: * Type: {quic.ApplicationOptions} The current application-level options for this session. These include settings -that are specific to the negotiated application protocol (e.g. HTTP/3) and may -be negotiated separately from the transport parameters. Read only. +that are specific to the installed application protocol (e.g. HTTP/3) and may +be negotiated separately from the transport parameters. `undefined` until an +application is installed. Read only. You can use the callback [`session.onapplication`][] to be informed, when settings from the remote arrive. @@ -2702,7 +2706,8 @@ added: * Type: {Object} -The application specific options. +The application specific options, configured for HTTP/3 with +[`new Http3Session()`][]. #### `applicationOptions.maxHeaderPairs` @@ -3102,36 +3107,7 @@ preference order that the server supports (e.g. `['h3', 'h3-29']`). During the TLS handshake, the server selects the first protocol from its list that the client also supports. -The negotiated ALPN determines which Application implementation is used -for the session. `'h3'` and `'h3-*'` variants select the HTTP/3 -application; all other values select the default application. - -Default: `'h3'` - -#### `sessionOptions.application` - - - -* Type: {quic.ApplicationOptions} - -Application-specific options. - -```mjs -const { listen } = await import('node:quic'); - -await listen((session) => { /* ... */ }, { - application: { - maxHeaderPairs: 64, - qpackMaxDTableCapacity: 8192, - enableDatagrams: true, - }, - // ... other session options -}); -``` +This option is required; omitting it throws `ERR_MISSING_OPTION`. #### `sessionOptions.ca` @@ -3553,6 +3529,7 @@ contain: ```mjs const endpoint = await listen(callback, { + alpn: ['h3'], sni: { '*': { keys: [defaultKey], certs: [defaultCert] }, 'api.example.com': { keys: [apiKey], certs: [apiCert], port: 8443 }, @@ -4134,13 +4111,12 @@ added: - v24.20.0 --> -When the negotiated ALPN identifier is `'h3'` (or one of the `'h3-*'` -draft variants), the QUIC session runs the HTTP/3 application backed -by `nghttp3`. `'h3'` is the default ALPN for `quic.connect()` and -`quic.listen()`, so HTTP/3 is what you get unless you select a -different ALPN explicitly. +HTTP/3, backed by `nghttp3`, can run on top of a QUIC session by attaching +an [`Http3Session`][]. Negotiating the `'h3'` ALPN tells the peer which +protocol to speak, but does not change how the connection works locally, +so both are needed. See [`new Http3Session()`][] for more details. -Selecting the HTTP/3 application enables a number of stream- and +Attaching the HTTP/3 application enables a number of stream- and session-level capabilities that are not available to non-HTTP/3 applications: @@ -4176,13 +4152,13 @@ applications: ### Minimal HTTP/3 client ```mjs -import { connect } from 'node:quic'; +import { connect, Http3Session } from 'node:quic'; import process from 'node:process'; -const session = await connect('example.com:443', { - // ALPN defaults to 'h3'. +const session = new Http3Session(await connect('example.com:443', { + alpn: 'h3', servername: 'example.com', -}); +})); await session.opened; const stream = await session.createBidirectionalStream({ @@ -4227,17 +4203,21 @@ A few things to note: ### Minimal HTTP/3 server ```mjs -import { listen } from 'node:quic'; +import { listen, Http3Session } from 'node:quic'; const encoder = new TextEncoder(); -const endpoint = await listen((session) => { +const endpoint = await listen((quicSession) => { + // Attaching HTTP/3 has to happen here, synchronously, before the + // callback returns. + const session = new Http3Session(quicSession); + // The session.onstream callback fires for each new client-initiated // stream. It is optional here: with `onheaders` configured below, // request streams are consumed through that callback. }, { + alpn: ['h3'], sni: { '*': { keys: [defaultKey], certs: [defaultCert] } }, - // ALPN defaults to 'h3'. onheaders(headers) { // `this` is the QuicStream. Pseudo-headers are available on the // request header block (`:method`, `:path`, `:scheme`, @@ -4286,6 +4266,151 @@ Server-side notes: cookie handling. These are deliberately left to higher-level libraries built on top of `node:quic`. +## Class: `Http3Session` + + + +This class wraps a [`QuicSession`][], attaching an HTTP/3 application protocol +implementation which interprets the raw QUIC data and exposes APIs to allow +you to use HTTP/3 over QUIC. Once the HTTP/3 application is attached, this +session should be used instead of the raw QUIC session for all HTTP/3 +interactions. The streams that this session exposes are still `QuicStream` +instances, but they gain HTTP/3 APIs and functionality from the application. + +The HTTP/3 session API exposes all key HTTP/3 session details: the settings, +statistics, TLS identity, and HTTP/3-level events. The QUIC transport details +underneath (e.g. paths, transport parameters, and key updates) remain on the +QUIC session, accessible as [`http3session.quicSession`][]. + +HTTP/3 frames every stream on the connection, so once this is attached, +streams cannot be opened on the QUIC session directly: +[`session.createBidirectionalStream()`][] and +[`session.createUnidirectionalStream()`][] will throw `ERR_INVALID_STATE`, +and request streams should be opened with +[`http3session.createBidirectionalStream()`][] instead. + +### `new Http3Session(session[, options])` + + + +* `session` {quic.QuicSession} The QUIC session to attach HTTP/3 to. +* `options` {Object} + * `settings` {quic.ApplicationOptions} The HTTP/3 settings to use. + Defaults apply to anything left out. + * `ongoaway` {Function} See [`http3session.ongoaway`][]. + * `onorigin` {Function} See [`session.onorigin`][]. + * `onsettings` {Function} See [`http3session.onsettings`][]. + +HTTP/3 can only be attached before the session becomes **active**. A session +becomes active when: a stream is created on it; a datagram is sent with +[`session.sendDatagram()`][]; immediately after a server session's +[`quic.listen()`][] callback returns; or immediately after a client's +`session.opened` promise resolves. + +Only what this side does is listed, because nothing the peer sends can arrive +any earlier: its streams and datagrams need keys that are only unlocked once +the session is already active. + +In practice this means a server session should be attached synchronously +inside the [`quic.listen()`][] callback, and a client session should be +attached synchronously when the [`session.opened`][] promise resolves (or +before), and in both cases before anything is sent on the session. + +Attaching to a session that is already active throws `ERR_INVALID_STATE`, and +leaves the session untouched. + +### Members forwarded to the QUIC session + + + +Each of the following behaves exactly as the member of the same name on the +underlying [`QuicSession`][]: `alpnProtocol`, `certificate`, `close()`, +`closed`, `destroy()`, `destroyed`, `ephemeralKeyInfo`, `onerror`, `opened`, +`peerCertificate`, `servername`, and `stats`. + +### `http3session.createBidirectionalStream([options])` + + + +* Returns: {Promise} fulfilled with a {quic.QuicStream} + +Opens an HTTP/3 request stream. Equivalent to +[`session.createBidirectionalStream()`][] on the underlying session. + +HTTP/3 has no server-initiated request streams, so calling this on a server +session throws `ERR_INVALID_STATE`. + +### `http3session.ongoaway` + + + +* Type: {Function} + +See [`session.ongoaway`][]. + +### `http3session.onorigin` + + + +* Type: {quic.OnOriginCallback} + +See [`session.onorigin`][]. + +### `http3session.onsettings` + + + +* Type: {quic.OnApplicationCallback} + +See [`session.onapplication`][]. + +### `http3session.onstream` + + + +* Type: {Function} + +Called with each request stream the peer opens, as a {quic.QuicStream}. See +[`session.onstream`][]. + +### `http3session.quicSession` + + + +* Type: {quic.QuicSession} + +The QUIC session on which this HTTP/3 session is running. + +### `http3session.settings` + + + +* Type: {quic.ApplicationOptions|null} + +The HTTP/3 settings in effect, including any update received from the peer's +SETTINGS frame, which may arrive after the session opens. `null` once the +session is destroyed. + ## Performance measurement - -* Type: {quic.OnApplicationCallback} - -The callback to invoke when new application options, e.g. HTTP/3 settings arrived. - ### `session.onerror` - -* Type: {quic.OnOriginCallback} - -The callback to invoke when an ORIGIN frame (RFC 9412) is received from -the server, indicating which origins the server is authoritative for. -Read/write. - -### `session.ongoaway` - - - -* Type: {Function} - -The callback to invoke when the peer sends an HTTP/3 GOAWAY frame, -indicating it is initiating a graceful shutdown. The callback receives -`(lastStreamId)` where `lastStreamId` is a `{bigint}`: - -* When `lastStreamId` is `-1n`, the peer sent a shutdown notice (intent - to close) without specifying a stream boundary. All existing streams - may still be processed. -* When `lastStreamId` is `>= 0n`, it is the highest stream ID the peer - may have processed. Streams with IDs above this value were NOT - processed and can be safely retried on a new connection. - -After GOAWAY is received, `session.createBidirectionalStream()` will -throw `ERR_INVALID_STATE`. Existing streams continue until they -complete or the session closes. - -This callback is only relevant for HTTP/3 sessions. Read/write. - ### `session.onkeylog` -* `this` {quic.QuicSession} +* `this` {quic.Http3Session} * `applicationoption` {quic.QuicSession} The callback function that is invoked when application options change. @@ -4005,7 +3957,7 @@ added: - v24.20.0 --> -* `this` {quic.QuicSession} +* `this` {quic.Http3Session} * `origins` {string\[]} The list of origins the server is authoritative for. ### Callback: `OnKeylogCallback` @@ -4137,10 +4089,10 @@ applications: * **ORIGIN frame (RFC 9412)** — servers automatically advertise the hostnames in their [`sessionOptions.sni`][] map (entries with `authoritative: true`); clients receive the list via - [`session.onorigin`][]. + [`http3session.onorigin`][]. * **GOAWAY** — graceful shutdown. The server emits `GOAWAY` as part of [`session.close()`][]; the client observes it via - [`session.ongoaway`][] and stops opening new bidirectional streams. + [`http3session.ongoaway`][] and stops opening new bidirectional streams. * **Extended CONNECT settings (RFC 9220)** — the `SETTINGS_ENABLE_CONNECT_PROTOCOL` setting can be enabled via [`application.enableConnectProtocol`][]. The setting is exchanged @@ -4155,7 +4107,7 @@ applications: import { connect, Http3Session } from 'node:quic'; import process from 'node:process'; -const session = new Http3Session(await connect('example.com:443', { +const session = Http3Session.from(await connect('example.com:443', { alpn: 'h3', servername: 'example.com', })); @@ -4210,7 +4162,7 @@ const encoder = new TextEncoder(); const endpoint = await listen((quicSession) => { // Attaching HTTP/3 has to happen here, synchronously, before the // callback returns. - const session = new Http3Session(quicSession); + const session = Http3Session.from(quicSession); // The session.onstream callback fires for each new client-initiated // stream. It is optional here: with `onheaders` configured below, @@ -4289,9 +4241,13 @@ streams cannot be opened on the QUIC session directly: [`session.createBidirectionalStream()`][] and [`session.createUnidirectionalStream()`][] will throw `ERR_INVALID_STATE`, and request streams should be opened with -[`http3session.createBidirectionalStream()`][] instead. +[`http3session.createBidirectionalStream()`][] instead. Similarly, incoming +streams are then only reported through [`http3session.onstream`][]: setting +`onstream` on the QUIC session throws `ERR_INVALID_STATE`. Errors are the +exception: they are transport-level, so they reach [`session.onerror`][] and +then [`http3session.onerror`][], each of which may be set independently. -### `new Http3Session(session[, options])` +### `Http3Session.from(session[, options])` + +* Type: {Function|undefined} + +The HTTP/3 session's error handler, invoked with the error the session is +destroyed with. Setting this alone is enough: like [`session.onerror`][], it +marks the session's promises as handled, and a throw or rejection here +surfaces as an uncaught exception. + +The underlying `QuicSession`'s [`session.onerror`][] is separate, for code +that wants to observe transport errors regardless of the application. When +both are set, it is invoked first, with the same error, and one throwing does +not prevent the other from running. Read/write. ### `http3session.createBidirectionalStream([options])` @@ -4361,7 +4335,20 @@ added: REPLACEME * Type: {Function} -See [`session.ongoaway`][]. +The callback to invoke when the peer sends an HTTP/3 GOAWAY frame, +indicating it is initiating a graceful shutdown. The callback receives +`(lastStreamId)` where `lastStreamId` is a `{bigint}`: + +* When `lastStreamId` is `-1n`, the peer sent a shutdown notice (intent + to close) without specifying a stream boundary. All existing streams + may still be processed. +* When `lastStreamId` is `>= 0n`, it is the highest stream ID the peer + may have processed. Streams with IDs above this value were NOT + processed and can be safely retried on a new connection. + +After GOAWAY is received, `http3session.createBidirectionalStream()` will +reject with `ERR_INVALID_STATE`. Existing streams continue until they +complete or the session closes. Read/write. ### `http3session.onorigin` @@ -4371,28 +4358,32 @@ added: REPLACEME * Type: {quic.OnOriginCallback} -See [`session.onorigin`][]. +The callback to invoke when an ORIGIN frame (RFC 9412) is received from +the server, indicating which origins the server is authoritative for. +Read/write. -### `http3session.onsettings` +### `http3session.onstream` -* Type: {quic.OnApplicationCallback} +* Type: {Function} -See [`session.onapplication`][]. +Called as `onstream(stream)` with each request stream the client opens. +HTTP/3 has no server-initiated requests, so this is never called on a client +session. See [`session.onstream`][]. Read/write. -### `http3session.onstream` +### `http3session.onsettings` -* Type: {Function} +* Type: {quic.OnApplicationCallback} -Called with each request stream the peer opens, as a {quic.QuicStream}. See -[`session.onstream`][]. +The callback to invoke when the peer's HTTP/3 SETTINGS arrive, which may be +after the session opens. See [`http3session.settings`][]. Read/write. ### `http3session.quicSession` @@ -4951,6 +4942,7 @@ throughput issues caused by flow control. [RFC 9369]: https://www.rfc-editor.org/rfc/rfc9369 [RFC 9412]: https://www.rfc-editor.org/rfc/rfc9412 [RFC 9443]: https://www.rfc-editor.org/rfc/rfc9443 +[`Http3Session.from()`]: #http3sessionfromsession-options [`Http3Session`]: #class-http3session [`PerformanceEntry`]: perf_hooks.md#class-performanceentry [`PerformanceObserver`]: perf_hooks.md#class-performanceobserver @@ -4980,12 +4972,15 @@ throughput issues caused by flow control. [`error.errorCode`]: #errorerrorcode [`fs.promises.open(path, 'r')`]: fs.md#fspromisesopenpath-flags-mode [`http3session.createBidirectionalStream()`]: #http3sessioncreatebidirectionalstreamoptions +[`http3session.onerror`]: #http3sessiononerror [`http3session.ongoaway`]: #http3sessionongoaway +[`http3session.onorigin`]: #http3sessiononorigin [`http3session.onsettings`]: #http3sessiononsettings +[`http3session.onstream`]: #http3sessiononstream [`http3session.quicSession`]: #http3sessionquicsession +[`http3session.settings`]: #http3sessionsettings [`maxDatagramFrameSize`]: #transportparamsmaxdatagramframesize [`net.BlockList`]: net.md#class-netblocklist -[`new Http3Session()`]: #new-http3sessionsession-options [`quic.connect()`]: #quicconnectaddress-options [`quic.listen()`]: #quiclistenonsession-options [`session.close()`]: #sessioncloseoptions @@ -4993,15 +4988,12 @@ throughput issues caused by flow control. [`session.createUnidirectionalStream()`]: #sessioncreateunidirectionalstreamoptions [`session.destroy()`]: #sessiondestroyerror-options [`session.maxPendingDatagrams`]: #sessionmaxpendingdatagrams -[`session.onapplication`]: #sessiononapplication [`session.ondatagram`]: #sessionondatagram [`session.ondatagramstatus`]: #sessionondatagramstatus [`session.onearlyrejected`]: #sessiononearlyrejected [`session.onerror`]: #sessiononerror -[`session.ongoaway`]: #sessionongoaway [`session.onkeylog`]: #sessiononkeylog [`session.onnewtoken`]: #sessiononnewtoken -[`session.onorigin`]: #sessiononorigin [`session.onqlog`]: #sessiononqlog [`session.onsessionticket`]: #sessiononsessionticket [`session.onstream`]: #sessiononstream diff --git a/lib/internal/quic/http3.js b/lib/internal/quic/http3.js index 608a27e1236f..d54a832dd58a 100644 --- a/lib/internal/quic/http3.js +++ b/lib/internal/quic/http3.js @@ -26,9 +26,14 @@ const { getQuicSessionHandle, getQuicSessionState, isQuicSession, + setApplicationCallback, } = require('internal/quic/quic'); -const { kInspect } = require('internal/quic/symbols'); +const { + kInspect, + kPrivateConstructor, +} = require('internal/quic/symbols'); +const { kEmptyObject } = require('internal/util'); const { inspect } = require('internal/util/inspect'); const { @@ -45,13 +50,13 @@ const { const { codes: { + ERR_ILLEGAL_CONSTRUCTOR, ERR_INVALID_ARG_TYPE, ERR_INVALID_STATE, ERR_OUT_OF_RANGE, }, } = require('internal/errors'); -const kEmptyObject = { __proto__: null }; const kMaxUint64 = (1n << 64n) - 1n; function validateUint64Setting(value, name) { @@ -113,9 +118,7 @@ function prepareH3Settings(settings) { } function checkAttachable(session, state) { - if (session.destroyed || - state.applicationType === undefined || - getQuicSessionHandle(session) === undefined) { + if (session.destroyed) { throw new ERR_INVALID_STATE( 'An application cannot be attached to a destroyed QUIC session'); } @@ -141,8 +144,16 @@ class Http3Session { * @param {Function} [options.ongoaway] * @param {Function} [options.onorigin] * @param {Function} [options.onsettings] + * @returns {Http3Session} */ - constructor(session, options = kEmptyObject) { + static from(session, options) { + return new Http3Session(kPrivateConstructor, session, options); + } + + constructor(privateSymbol, session, options = kEmptyObject) { + if (privateSymbol !== kPrivateConstructor) { + throw new ERR_ILLEGAL_CONSTRUCTOR(); + } if (!isQuicSession(session)) { throw new ERR_INVALID_ARG_TYPE('session', 'QuicSession', session); } @@ -170,15 +181,15 @@ class Http3Session { this.#session = session; if (ongoaway !== undefined) { this.#ongoaway = ongoaway; - session.ongoaway = this.#bind(ongoaway); + setApplicationCallback(session, 'ongoaway', this.#bind(ongoaway)); } if (onorigin !== undefined) { this.#onorigin = onorigin; - session.onorigin = this.#bind(onorigin); + setApplicationCallback(session, 'onorigin', this.#bind(onorigin)); } if (onsettings !== undefined) { this.#onsettings = onsettings; - session.onapplication = this.#bind(onsettings); + setApplicationCallback(session, 'onapplication', this.#bind(onsettings)); } } @@ -219,40 +230,50 @@ class Http3Session { } /** - * Called with each request stream the peer opens. + * Called with each request stream the client opens. HTTP/3 has no + * server-initiated requests, so this never fires on a client session. * @type {Function|undefined} */ get onstream() { return this.#onstream; } set onstream(fn) { - this.#session.onstream = this.#bind(fn); + if (fn !== undefined) validateFunction(fn, 'onstream'); + setApplicationCallback(this.#session, 'onstream', this.#bind(fn)); this.#onstream = fn; } /** @type {Function|undefined} */ get ongoaway() { return this.#ongoaway; } set ongoaway(fn) { - this.#session.ongoaway = this.#bind(fn); + if (fn !== undefined) validateFunction(fn, 'ongoaway'); + setApplicationCallback(this.#session, 'ongoaway', this.#bind(fn)); this.#ongoaway = fn; } /** @type {Function|undefined} */ get onorigin() { return this.#onorigin; } set onorigin(fn) { - this.#session.onorigin = this.#bind(fn); + if (fn !== undefined) validateFunction(fn, 'onorigin'); + setApplicationCallback(this.#session, 'onorigin', this.#bind(fn)); this.#onorigin = fn; } /** @type {Function|undefined} */ get onsettings() { return this.#onsettings; } set onsettings(fn) { - this.#session.onapplication = this.#bind(fn); + if (fn !== undefined) validateFunction(fn, 'onsettings'); + setApplicationCallback(this.#session, 'onapplication', this.#bind(fn)); this.#onsettings = fn; } - /** @type {Function|undefined} */ + /** + * Called with a session error after the QUIC session's own onerror, which + * stays the transport-level handler. Either may be set independently. + * @type {Function|undefined} + */ get onerror() { return this.#onerror; } set onerror(fn) { - this.#session.onerror = this.#bind(fn); + if (fn !== undefined) validateFunction(fn, 'onerror'); + setApplicationCallback(this.#session, 'onapperror', this.#bind(fn)); this.#onerror = fn; } diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index ec0747f3a788..be9cc64b5323 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -463,11 +463,8 @@ const endpointRegistry = new SafeSet(); * @property {OnVersionNegotiationCallback} [onversionnegotiation] Version negotiation callback. * @property {OnHandshakeCallback} [onhandshake] Handshake-completed callback. * @property {OnNewTokenCallback} [onnewtoken] NEW_TOKEN frame callback (client only). - * @property {OnOriginCallback} [onorigin] ORIGIN frame callback (client only). - * @property {OnGoawayCallback} [ongoaway] GOAWAY frame callback. * @property {OnKeylogCallback} [onkeylog] TLS key-log callback. * @property {OnQlogCallback} [onqlog] qlog data callback. - * @property {OnApplicationCallback} [onapplication] application options callback. * @property {OnHeadersCallback} [onheaders] Default per-stream initial-headers callback. * @property {OnTrailersCallback} [ontrailers] Default per-stream trailing-headers callback. * @property {OnInfoCallback} [oninfo] Default per-stream informational-headers callback. @@ -1339,11 +1336,8 @@ function applyCallbacks(session, cbs) { if (cbs.onhandshake) session.onhandshake = cbs.onhandshake; if (cbs.onnewtoken) session.onnewtoken = cbs.onnewtoken; if (cbs.onearlyrejected) session.onearlyrejected = cbs.onearlyrejected; - if (cbs.onorigin) session.onorigin = cbs.onorigin; - if (cbs.ongoaway) session.ongoaway = cbs.ongoaway; if (cbs.onkeylog) session.onkeylog = cbs.onkeylog; if (cbs.onqlog) session.onqlog = cbs.onqlog; - if (cbs.onapplication) session.onapplication = cbs.onapplication; if (cbs.onheaders || cbs.ontrailers || cbs.oninfo || cbs.onwanttrailers) { session[kStreamCallbacks] = { __proto__: null, @@ -1566,6 +1560,7 @@ let isQuicStream; let isQuicSession; let getQuicSessionHandle; let createApplicationStream; +let setApplicationCallback; let isQuicEndpoint; function maybeGetCloseError(context, status, pendingError) { @@ -2865,6 +2860,8 @@ class QuicSession { stats: undefined, streams: new SafeSet(), onerror: undefined, + // The attached application's own error handler, told after onerror. + onapperror: undefined, onstream: undefined, ondatagram: undefined, ondatagramstatus: undefined, @@ -2918,6 +2915,21 @@ class QuicSession { return session.#createStream(direction, options); }; + // For the attached application, which owns these callbacks and passes + // them in already validated and bound: + setApplicationCallback = (session, name, fn) => { + assertIsQuicSession(session); + const inner = session.#inner; + inner[name] = fn; + if (name === 'onorigin') { + inner.state.hasOriginListener = fn !== undefined; + } else if (name === 'onapplication') { + inner.state.hasApplicationListener = fn !== undefined; + } else if (name === 'onapperror' && fn !== undefined) { + session.#markErrorsHandled(); + } + }; + getQuicSessionState = function(session) { assertIsQuicSession(session); return session.#inner.state; @@ -2966,7 +2978,6 @@ class QuicSession { return this.#handle.applicationOptions(); } - get localTransportParams() { if (this.#inner.localTransportParams !== undefined) { return this.#inner.localTransportParams; @@ -3029,14 +3040,19 @@ class QuicSession { // as handled so that rejections from destroy(error) don't surface // as unhandled rejections. The onerror callback is the // application's error handler for this session. - markPromiseAsHandled(inner.pendingClose.promise); - markPromiseAsHandled(inner.pendingOpen.promise); - // Also mark existing streams' closed promises. Stream rejections - // during session destruction are expected collateral when the - // session has an error handler. - for (const stream of inner.streams) { - markPromiseAsHandled(stream.closed); - } + this.#markErrorsHandled(); + } + } + + #markErrorsHandled() { + const inner = this.#inner; + markPromiseAsHandled(inner.pendingClose.promise); + markPromiseAsHandled(inner.pendingOpen.promise); + // Mark existing streams' closed promises. Stream rejections + // during session destruction are expected collateral when the + // session has an error handler. + for (const stream of inner.streams) { + markPromiseAsHandled(stream.closed); } } @@ -3048,12 +3064,24 @@ class QuicSession { set onstream(fn) { assertIsQuicSession(this); + // Once an application is attached, incoming streams are reported + // through it: + if (this.#inner.state.applicationType > kApplicationTypeDefault) { + throw new ERR_INVALID_STATE( + 'onstream cannot be set on a session with a non-default ' + + 'application attached. Set it through the application interface ' + + '(e.g. Http3Session) instead'); + } const inner = this.#inner; if (fn === undefined) { inner.onstream = undefined; } else { validateFunction(fn, 'onstream'); inner.onstream = FunctionPrototypeBind(fn, this); + // Listening for raw streams means committing to raw QUIC: + if (inner.state.applicationType === 0) { + inner.state.applicationType = kApplicationTypeDefault; + } } } @@ -3209,25 +3237,6 @@ class QuicSession { } } - /** @type {Function|undefined} */ - get onapplication() { - assertIsQuicSession(this); - return this.#inner.onapplication; - } - - set onapplication(fn) { - assertIsQuicSession(this); - const inner = this.#inner; - if (fn === undefined) { - inner.onapplication = undefined; - inner.state.hasApplicationListener = false; - } else { - validateFunction(fn, 'onapplication'); - inner.onapplication = FunctionPrototypeBind(fn, this); - inner.state.hasApplicationListener = true; - } - } - /** @type {Function|undefined} */ get onversionnegotiation() { assertIsQuicSession(this); @@ -3298,42 +3307,6 @@ class QuicSession { } } - /** @type {Function|undefined} */ - get onorigin() { - assertIsQuicSession(this); - return this.#inner.onorigin; - } - - set onorigin(fn) { - assertIsQuicSession(this); - const inner = this.#inner; - if (fn === undefined) { - inner.onorigin = undefined; - inner.state.hasOriginListener = false; - } else { - validateFunction(fn, 'onorigin'); - inner.onorigin = FunctionPrototypeBind(fn, this); - inner.state.hasOriginListener = true; - } - } - - /** @type {Function|undefined} */ - get ongoaway() { - assertIsQuicSession(this); - return this.#inner.ongoaway; - } - - set ongoaway(fn) { - assertIsQuicSession(this); - const inner = this.#inner; - if (fn === undefined) { - inner.ongoaway = undefined; - } else { - validateFunction(fn, 'ongoaway'); - inner.ongoaway = FunctionPrototypeBind(fn, this); - } - } - /** * The maximum datagram size the peer will accept, or 0 if datagrams * are not supported or the handshake has not yet completed. @@ -3769,6 +3742,10 @@ class QuicSession { if (typeof inner.onerror === 'function') { invokeOnerror(inner.onerror, error); } + // The transport is told first, then the application layered on top: + if (typeof inner.onapperror === 'function') { + invokeOnerror(inner.onapperror, error); + } } // First, forcefully and immediately destroy all open streams, if any. @@ -3841,6 +3818,7 @@ class QuicSession { } inner.onerror = undefined; + inner.onapperror = undefined; inner.onstream = undefined; inner.ondatagram = undefined; inner.ondatagramstatus = undefined; @@ -4703,11 +4681,8 @@ class QuicEndpoint { onhandshake, onnewtoken, onearlyrejected, - onorigin, - ongoaway, onkeylog, onqlog, - onapplication, // Stream-level callbacks applied to each incoming stream. onheaders, ontrailers, @@ -4733,11 +4708,8 @@ class QuicEndpoint { onhandshake, onnewtoken, onearlyrejected, - onorigin, - ongoaway, onkeylog, onqlog, - onapplication, onheaders, ontrailers, oninfo, @@ -5513,12 +5485,8 @@ function processSessionOptions(options, config = kEmptyObject) { onhandshake, onnewtoken, onearlyrejected, - onorigin, - ongoaway, onkeylog, onqlog, - onapplication, - // Application level options changed, e.g. HTTP/3 settings related // Stream-level callbacks. onheaders, ontrailers, @@ -5641,11 +5609,8 @@ function processSessionOptions(options, config = kEmptyObject) { onhandshake, onnewtoken, onearlyrejected, - onorigin, - ongoaway, onkeylog, onqlog, - onapplication, onheaders, ontrailers, oninfo, @@ -5771,6 +5736,7 @@ module.exports = { createApplicationStream, getQuicSessionHandle, isQuicSession, + setApplicationCallback, // These are exported only for internal testing purposes. getQuicStreamState, getQuicSessionState, diff --git a/src/quic/README.md b/src/quic/README.md index c8daf0bade5f..9d2d51f8d1b4 100644 --- a/src/quic/README.md +++ b/src/quic/README.md @@ -146,7 +146,7 @@ protocol-specific behavior to. Two implementations exist: prioritization. Manages unidirectional control streams internally. A Session starts without an Application. JavaScript schedules an attach (that -is what `new Http3Session(session)` does) by writing to the shared state, and +is what `Http3Session.from(session)` does) by writing to the shared state, and the Session attaches it - or the `DefaultApplication` - when it becomes active, meaning the first time an Application is needed. diff --git a/src/quic/http3.h b/src/quic/http3.h index 257d24180bf2..3097b8e85c54 100644 --- a/src/quic/http3.h +++ b/src/quic/http3.h @@ -8,7 +8,6 @@ #include "session.h" namespace node { -class ExternalReferenceRegistry; class Realm; namespace quic { diff --git a/test/parallel/test-quic-early-selection-order.mjs b/test/parallel/test-quic-early-selection-order.mjs index 3d7c39a3e1eb..f7cc89fb04c0 100644 --- a/test/parallel/test-quic-early-selection-order.mjs +++ b/test/parallel/test-quic-early-selection-order.mjs @@ -13,7 +13,6 @@ if (!hasQuic) { } const { listen, connect } = await import('../common/quic.mjs'); -const { Http3Session } = await import('node:quic'); const { bytes } = await import('stream/iter'); const encoder = new TextEncoder(); @@ -28,8 +27,7 @@ const decoder = new TextDecoder(); const gotTicket = Promise.withResolvers(); const gotToken = Promise.withResolvers(); - const endpoint = await listen(mustCall((quicSession) => { - const ss = new Http3Session(quicSession); + const endpoint = await listen(mustCall((ss) => { // No streams initially, stream must arrive in the onstream event, for // both the normal and the 0RTT sessions: assert.strictEqual(ss.stats.bidiInStreamCount, 0n); @@ -54,12 +52,12 @@ const decoder = new TextDecoder(); }; // Open a 1st session, send a request, get session ticket & token: - const cs1 = new Http3Session(await connect(endpoint.address, { + const cs1 = await connect(endpoint.address, { servername: 'localhost', alpn: 'h3', onsessionticket: mustCall((t) => { ticket = t; gotTicket.resolve(); }, 2), onnewtoken: mustCall((t) => { token = t; gotToken.resolve(); }), - })); + }); await cs1.opened; await Promise.all([gotTicket.promise, gotToken.promise]); const s1 = await cs1.createBidirectionalStream({ @@ -70,12 +68,12 @@ const decoder = new TextDecoder(); await Promise.all([s1.closed, cs1.closed]); // Open 2nd session, reusing the ticket & token: - const cs2 = new Http3Session(await connect(endpoint.address, { + const cs2 = await connect(endpoint.address, { servername: 'localhost', alpn: 'h3', sessionTicket: ticket, token, - })); + }); // Send a 0RTT request immediately, before the handshake completes: const s2 = await cs2.createBidirectionalStream({ diff --git a/test/parallel/test-quic-h3-attach.mjs b/test/parallel/test-quic-h3-attach.mjs index 798e5b4aefae..f51170829469 100644 --- a/test/parallel/test-quic-h3-attach.mjs +++ b/test/parallel/test-quic-h3-attach.mjs @@ -19,41 +19,68 @@ const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); const cert = fixtures.readKey('agent1-cert.pem'); const serverOpts = { alpn: ['h3'], + autoWrap: false, sni: { '*': { keys: [key], certs: [cert] } }, }; const clientOpts = { alpn: 'h3', + autoWrap: false, servername: 'localhost', verifyPeer: 'manual', }; const enc = new TextEncoder(); const dec = new TextDecoder(); -// Only a QuicSession can carry an HTTP/3 session. -assert.throws(() => new Http3Session({}), { code: 'ERR_INVALID_ARG_TYPE' }); +// Only a QuicSession can carry an HTTP/3 session, attached with from(): +assert.throws(() => Http3Session.from({}), { code: 'ERR_INVALID_ARG_TYPE' }); +assert.throws(() => new Http3Session(), { code: 'ERR_ILLEGAL_CONSTRUCTOR' }); // Both peers attached after the session already exists, and the attach // itself validated. { const endpoint = await listen(mustCall((quicSession) => { - const session = new Http3Session(quicSession); + const session = Http3Session.from(quicSession); assert.strictEqual(session.quicSession, quicSession); // Can only attach once: - assert.throws(() => new Http3Session(quicSession), + assert.throws(() => Http3Session.from(quicSession), { code: 'ERR_INVALID_STATE' }); + + // HTTP/3 frames every stream, so raw streams can no longer be opened: + const rawRefused = { + code: 'ERR_INVALID_STATE', + message: /Raw QUIC streams cannot be created/, + }; + assert.rejects(quicSession.createUnidirectionalStream(), rawRefused) + .then(mustCall()); + assert.rejects(quicSession.createBidirectionalStream(), rawRefused) + .then(mustCall()); + + // Incoming streams are now reported through the Http3Session only: + assert.throws(() => { quicSession.onstream = () => {}; }, { + code: 'ERR_INVALID_STATE', + message: /cannot be set on a session/, + }); + // And the HTTP/3-only callbacks exist only there: + for (const name of ['ongoaway', 'onorigin', 'onapplication']) { + assert.strictEqual(name in quicSession, false); + } + // The onerror callback stays transport-level, so both sides keep their own: + quicSession.onerror = () => {}; + session.onerror = () => {}; + assert.notStrictEqual(quicSession.onerror, session.onerror); }), serverOpts); const quicClient = await connect(endpoint.address, clientOpts); // Options are validated before anything is recorded, so the session is // still attachable after these failures: - assert.throws(() => new Http3Session(quicClient, null), + assert.throws(() => Http3Session.from(quicClient, null), { code: 'ERR_INVALID_ARG_TYPE' }); - assert.throws(() => new Http3Session(quicClient, { ongoaway: 5 }), + assert.throws(() => Http3Session.from(quicClient, { ongoaway: 5 }), { code: 'ERR_INVALID_ARG_TYPE' }); - const client = new Http3Session(quicClient); + const client = Http3Session.from(quicClient); await client.opened; assert.strictEqual(client.alpnProtocol, 'h3'); @@ -69,12 +96,41 @@ const tooLate = { message: /already has an application/, }; +// HTTP/3-only callbacks can't be passed as QuicSession options, so they +// can't be registered before the application exists: +for (const name of ['ongoaway', 'onorigin', 'onapplication']) { + const expected = { + code: 'ERR_INVALID_ARG_VALUE', + message: new RegExp(`options\\.${name}.*Http3Session`), + }; + const callback = { [name]: () => {} }; + await assert.rejects(listen(() => {}, { ...serverOpts, ...callback }), + expected); + await assert.rejects(connect('127.0.0.1:1', { ...clientOpts, ...callback }), + expected); +} + +// Setting onstream claims the session for raw QUIC, so HTTP/3 can't be +// attached afterwards, whether it is set directly or passed as an option. +{ + const endpoint = await listen(mustCall((quicSession) => { + quicSession.onstream = () => {}; + assert.throws(() => Http3Session.from(quicSession), tooLate); + }), serverOpts); + const client = await connect(endpoint.address, + { ...clientOpts, onstream: () => {} }); + assert.throws(() => Http3Session.from(client), tooLate); + await client.opened; + await client.close(); + await endpoint.close(); +} + // Server: an attach deferred past the session callback is rejected. { const done = Promise.withResolvers(); const endpoint = await listen(mustCall((quicSession) => { setImmediate(mustCall(() => { - assert.throws(() => new Http3Session(quicSession), tooLate); + assert.throws(() => Http3Session.from(quicSession), tooLate); done.resolve(); })); }), serverOpts); @@ -89,7 +145,7 @@ const tooLate = { // `opened`, so the negotiated ALPN can be read and acted on before attaching. { const endpoint = await listen(mustCall((quicSession) => { - new Http3Session(quicSession); + Http3Session.from(quicSession); }), serverOpts); const client = await connect(endpoint.address, clientOpts); const info = await client.opened; @@ -97,7 +153,7 @@ const tooLate = { assert.strictEqual(client.alpnProtocol, 'h3'); // Further already-settled awaits are still the same checkpoint. await null; - const http3 = new Http3Session(client); + const http3 = Http3Session.from(client); assert.strictEqual(http3.alpnProtocol, 'h3'); await http3.close(); await endpoint.close(); @@ -108,13 +164,13 @@ const tooLate = { // some other reason. { const endpoint = await listen(mustCall((quicSession) => { - new Http3Session(quicSession); + Http3Session.from(quicSession); }), serverOpts); const client = await connect(endpoint.address, clientOpts); await client.opened; await new Promise(setImmediate); - assert.throws(() => new Http3Session(client), tooLate); - assert.throws(() => new Http3Session(client), tooLate); + assert.throws(() => Http3Session.from(client), tooLate); + assert.throws(() => Http3Session.from(client), tooLate); await client.close(); await endpoint.close(); @@ -132,7 +188,7 @@ const tooLate = { }), serverOpts); const client = await connect(endpoint.address, clientOpts); const raw = await client.createUnidirectionalStream({ body: enc.encode('x') }); - assert.throws(() => new Http3Session(client), tooLate); + assert.throws(() => Http3Session.from(client), tooLate); await client.opened; await serverGot.promise; await raw.closed; @@ -149,7 +205,40 @@ const tooLate = { const client = await connect(endpoint.address, { ...clientOpts, ...dgramOpts }); await client.opened; await client.sendDatagram(enc.encode('x')); - assert.throws(() => new Http3Session(client), tooLate); + assert.throws(() => Http3Session.from(client), tooLate); + await client.close(); + await endpoint.close(); +} + +// Settings are validated before anything is recorded, so a rejected value +// names the property at fault and leaves the session still attachable. +{ + const endpoint = await listen(mustCall((quicSession) => { + Http3Session.from(quicSession); + }), serverOpts); + const client = await connect(endpoint.address, clientOpts); + for (const settings of [42, true, 'nope', null]) { + assert.throws(() => Http3Session.from(client, { settings }), + { code: 'ERR_INVALID_ARG_TYPE', message: /options\.settings/ }); + } + const badType = { code: 'ERR_INVALID_ARG_TYPE' }; + const badRange = { code: 'ERR_OUT_OF_RANGE' }; + for (const [settings, expected] of [ + [{ maxHeaderPairs: 'lots' }, badType], + [{ maxHeaderPairs: 1.5 }, badRange], + [{ qpackBlockedStreams: 1n << 65n }, badRange], + [{ enableDatagrams: 1 }, badType], + ]) { + assert.throws(() => Http3Session.from(client, { settings }), (err) => { + assert.strictEqual(err.code, expected.code); + assert.match(err.message, /options\.settings\./); + return true; + }); + } + // Numbers are accepted for bigint settings: + const http3 = Http3Session.from(client, { settings: { maxHeaderPairs: 12 } }); + assert.strictEqual(http3.settings.maxHeaderPairs, 12n); + await http3.opened; await client.close(); await endpoint.close(); } @@ -164,9 +253,9 @@ const tooLate = { enableConnectProtocol: false, }; const endpoint = await listen(mustCall((quicSession) => { - new Http3Session(quicSession); + Http3Session.from(quicSession); }), serverOpts); - const client = new Http3Session( + const client = Http3Session.from( await connect(endpoint.address, clientOpts), { settings }); await client.opened; @@ -189,7 +278,7 @@ const tooLate = { const settings = { get maxHeaderPairs() { quicSession.destroy(); return 10n; }, }; - assert.throws(() => new Http3Session(quicSession, { settings }), { + assert.throws(() => Http3Session.from(quicSession, { settings }), { code: 'ERR_INVALID_STATE', message: /destroyed/, }); @@ -217,7 +306,7 @@ const tooLate = { return 10n; }, }; - assert.throws(() => new Http3Session(client, { settings }), tooLate); + assert.throws(() => Http3Session.from(client, { settings }), tooLate); await (await raw).closed; await client.close(); await endpoint.close(); @@ -226,20 +315,20 @@ const tooLate = { // Client: the getter attaches another Http3Session. That inner attach is // the one that sticks; the outer one finds the session already claimed. const endpoint = await listen(mustCall((quicSession) => { - new Http3Session(quicSession); + Http3Session.from(quicSession); }), serverOpts); const client = await connect(endpoint.address, clientOpts); let inner; const settings = { - get maxHeaderPairs() { inner = new Http3Session(client); return 10n; }, + get maxHeaderPairs() { inner = Http3Session.from(client); return 10n; }, }; - assert.throws(() => new Http3Session(client, { settings }), { + assert.throws(() => Http3Session.from(client, { settings }), { code: 'ERR_INVALID_STATE', message: /already has an application/, }); assert.ok(inner instanceof Http3Session); - assert.throws(() => new Http3Session(client), { + assert.throws(() => Http3Session.from(client), { code: 'ERR_INVALID_STATE', message: /already has an application/, }); @@ -253,14 +342,15 @@ const tooLate = { { const refused = Promise.withResolvers(); const endpoint = await listen(mustCall((quicSession) => { - const server = new Http3Session(quicSession); + const server = Http3Session.from(quicSession); refused.resolve(assert.rejects(server.createBidirectionalStream(), { code: 'ERR_INVALID_STATE', message: /Server sessions cannot open HTTP\/3 request streams/, })); }), serverOpts); - const client = new Http3Session(await connect(endpoint.address, clientOpts)); + const client = Http3Session.from(await connect(endpoint.address, clientOpts)); await refused.promise; + await client.opened; await client.close(); await endpoint.close(); } diff --git a/test/parallel/test-quic-h3-callback-errors.mjs b/test/parallel/test-quic-h3-callback-errors.mjs index 9fbd64ab0a44..67f8dcc6daa5 100644 --- a/test/parallel/test-quic-h3-callback-errors.mjs +++ b/test/parallel/test-quic-h3-callback-errors.mjs @@ -2,6 +2,7 @@ // Test: HTTP/3 callback error handling. // Sync throw in onorigin callback destroys the session +// Session errors reach the QuicSession's onerror, then the Http3Session's // Sync throw in onheaders callback destroys the stream // Async rejection in onheaders callback destroys the stream // Sync throw in ontrailers callback destroys the stream @@ -24,8 +25,7 @@ const encoder = new TextEncoder(); async function makeServer(onheadersHandler, extraOpts = {}) { const done = Promise.withResolvers(); - const ep = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession); + const ep = await listen(mustCall(async (ss) => { ss.onstream = mustCall((stream) => { // The server completes its response before the client's // callback throws, so the server stream always resolves. @@ -53,12 +53,12 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }), ); - const c = new Http3Session(await connect(ep.address, { + const c = await connect(ep.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', transportParams: { maxIdleTimeout: 1 }, - })); + }); await c.opened; const s = await c.createBidirectionalStream({ @@ -94,12 +94,12 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }), ); - const c = new Http3Session(await connect(ep.address, { + const c = await connect(ep.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', transportParams: { maxIdleTimeout: 1 }, - })); + }); await c.opened; const s = await c.createBidirectionalStream({ @@ -140,12 +140,12 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }, ); - const c = new Http3Session(await connect(ep.address, { + const c = await connect(ep.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', transportParams: { maxIdleTimeout: 1 }, - })); + }); await c.opened; const s = await c.createBidirectionalStream({ @@ -176,8 +176,7 @@ async function makeServer(onheadersHandler, extraOpts = {}) { // Sync throw in onorigin callback destroys the session. { - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall(async (ss) => { await ss.closed; }), { alpn: ['h3'], @@ -194,17 +193,19 @@ async function makeServer(onheadersHandler, extraOpts = {}) { const quicSession = await connect(serverEndpoint.address, { alpn: 'h3', + autoWrap: false, servername: 'example.com', verifyPeer: 'manual', transportParams: { maxIdleTimeout: 1 }, onerror: mustCall(function(error) { assert.strictEqual(error.message, 'onorigin error'); }), + }); + const clientSession = Http3Session.from(quicSession, { onorigin: mustCall(function() { throw new Error('onorigin error'); }), }); - const clientSession = new Http3Session(quicSession); await clientSession.opened; const stream = await clientSession.createBidirectionalStream({ @@ -235,8 +236,7 @@ async function makeServer(onheadersHandler, extraOpts = {}) { const serverStreamRejected = Promise.withResolvers(); const serverDone = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { // The server stream rejects because onwanttrailers threw. await assert.rejects(stream.closed, mustCall((err) => { @@ -261,12 +261,12 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }), }); - const clientSession = new Http3Session(await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', transportParams: { maxIdleTimeout: 1 }, - })); + }); await clientSession.opened; const stream = await clientSession.createBidirectionalStream({ @@ -290,3 +290,52 @@ async function makeServer(onheadersHandler, extraOpts = {}) { await Promise.all([stream.closed, serverDone.promise]); await serverEndpoint.close(); } + +// A session error reaches the QuicSession's onerror first, then the +// Http3Session's, with the same error. A throw in one does not stop the +// other, and surfaces as an uncaught exception like any onerror throw. +{ + const order = []; + const serverEndpoint = await listen(mustCall(async (quicSession) => { + quicSession.onerror = () => {}; + await quicSession.closed.catch(() => {}); + }), { + alpn: ['h3'], + sni: { '*': { keys: [key], certs: [cert] } }, + }); + + const uncaught = Promise.withResolvers(); + process.once('uncaughtException', (err) => uncaught.resolve(err)); + + const quicSession = await connect(serverEndpoint.address, { + alpn: 'h3', + autoWrap: false, + servername: 'localhost', + verifyPeer: 'manual', + onerror: mustCall(function(err) { + order.push(['transport', this, err]); + throw new Error('transport handler failed'); + }), + }); + const clientSession = Http3Session.from(quicSession, {}); + clientSession.onerror = mustCall(function(err) { + order.push(['application', this, err]); + }); + await clientSession.opened; + + const boom = new Error('boom'); + quicSession.destroy(boom); + assert.deepStrictEqual(order.map(([who]) => who), + ['transport', 'application']); + assert.strictEqual(order[0][1], quicSession); + assert.strictEqual(order[1][1], clientSession); + assert.strictEqual(order[0][2], boom); + assert.strictEqual(order[1][2], boom); + + const err = await uncaught.promise; + assert.strictEqual(err.error.message, 'transport handler failed'); + assert.strictEqual(err.suppressed, boom); + + await assert.rejects(clientSession.closed, boom); + await serverEndpoint.close(); +} diff --git a/test/parallel/test-quic-h3-close-behavior.mjs b/test/parallel/test-quic-h3-close-behavior.mjs index 20292ffb8ca1..293520bae4f9 100644 --- a/test/parallel/test-quic-h3-close-behavior.mjs +++ b/test/parallel/test-quic-h3-close-behavior.mjs @@ -12,7 +12,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -27,8 +27,7 @@ const decoder = new TextDecoder(); let requestCount = 0; const serverDone = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall(async (ss) => { serverSession = ss; ss.onstream = mustCall(2); }), { @@ -50,11 +49,11 @@ const decoder = new TextDecoder(); }, 2), }); - const clientSession = new Http3Session(await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', - })); + }); await clientSession.opened; const stream1 = await clientSession.createBidirectionalStream({ diff --git a/test/parallel/test-quic-h3-concurrent-requests.mjs b/test/parallel/test-quic-h3-concurrent-requests.mjs index 5ec48540dfe6..d5a6c3154ffa 100644 --- a/test/parallel/test-quic-h3-concurrent-requests.mjs +++ b/test/parallel/test-quic-h3-concurrent-requests.mjs @@ -16,7 +16,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -29,8 +29,7 @@ const REQUEST_COUNT = 5; let serverStreamsCompleted = 0; const serverDone = Promise.withResolvers(); -const serverEndpoint = await listen(mustCall(async (quicSession) => { - const serverSession = new Http3Session(quicSession); +const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall((stream) => { stream.closed.then(mustCall(() => { if (++serverStreamsCompleted === REQUEST_COUNT) { @@ -54,11 +53,11 @@ const serverEndpoint = await listen(mustCall(async (quicSession) => { }, REQUEST_COUNT), }); -const clientSession = new Http3Session(await connect(serverEndpoint.address, { +const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', -})); +}); await clientSession.opened; // Open all requests concurrently. diff --git a/test/parallel/test-quic-h3-datagram.mjs b/test/parallel/test-quic-h3-datagram.mjs index 55eb13e107b6..b30eb10e728f 100644 --- a/test/parallel/test-quic-h3-datagram.mjs +++ b/test/parallel/test-quic-h3-datagram.mjs @@ -32,7 +32,7 @@ const decoder = new TextDecoder(); const serverDone = Promise.withResolvers(); const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession, { + const ss = Http3Session.from(quicSession, { settings: { enableDatagrams: true }, }); ss.onstream = mustCall(async (stream) => { @@ -44,6 +44,7 @@ const decoder = new TextDecoder(); serverDone.resolve(); }), { alpn: ['h3'], + autoWrap: false, sni: { '*': { keys: [key], certs: [cert] } }, transportParams: { maxDatagramFrameSize: 100 }, // Server echoes received datagram back to client. @@ -66,6 +67,7 @@ const decoder = new TextDecoder(); const quicSession = await connect(serverEndpoint.address, { alpn: 'h3', + autoWrap: false, servername: 'localhost', verifyPeer: 'manual', transportParams: { maxDatagramFrameSize: 100 }, @@ -79,7 +81,7 @@ const decoder = new TextDecoder(); clientGotDatagram.resolve(); }), }); - const clientSession = new Http3Session(quicSession, { settings: { enableDatagrams: true } }); + const clientSession = Http3Session.from(quicSession, { settings: { enableDatagrams: true } }); await clientSession.opened; // Datagrams work alongside H3 request/response. @@ -120,7 +122,7 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (quicSession) => { // Server explicitly disables H3 datagrams. - const ss = new Http3Session(quicSession, { + const ss = Http3Session.from(quicSession, { settings: { enableDatagrams: false }, }); ss.onstream = mustCall(async (stream) => { @@ -130,6 +132,7 @@ const decoder = new TextDecoder(); }); }), { alpn: ['h3'], + autoWrap: false, sni: { '*': { keys: [key], certs: [cert] } }, // But transport-level datagrams ARE supported. transportParams: { maxDatagramFrameSize: 100 }, @@ -144,11 +147,12 @@ const decoder = new TextDecoder(); const quicSession = await connect(serverEndpoint.address, { alpn: 'h3', + autoWrap: false, servername: 'localhost', verifyPeer: 'manual', transportParams: { maxDatagramFrameSize: 100 }, }); - const clientSession = new Http3Session(quicSession, { settings: { enableDatagrams: true } }); + const clientSession = Http3Session.from(quicSession, { settings: { enableDatagrams: true } }); await clientSession.opened; const stream = await clientSession.createBidirectionalStream({ diff --git a/test/parallel/test-quic-h3-error-codes.mjs b/test/parallel/test-quic-h3-error-codes.mjs index 12d3a7043255..82d087555339 100644 --- a/test/parallel/test-quic-h3-error-codes.mjs +++ b/test/parallel/test-quic-h3-error-codes.mjs @@ -12,7 +12,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -25,8 +25,7 @@ const decoder = new TextDecoder(); { const serverDone = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { await stream.closed; // Close with an explicit H3 application error code. @@ -43,11 +42,11 @@ const decoder = new TextDecoder(); }), }); - const clientSession = new Http3Session(await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', - })); + }); await clientSession.opened; const stream = await clientSession.createBidirectionalStream({ @@ -80,8 +79,7 @@ const decoder = new TextDecoder(); { const serverDone = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { await stream.closed; ss.close(); @@ -97,11 +95,11 @@ const decoder = new TextDecoder(); }), }); - const clientSession = new Http3Session(await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', - })); + }); await clientSession.opened; const stream = await clientSession.createBidirectionalStream({ diff --git a/test/parallel/test-quic-h3-flow-control-volume.mjs b/test/parallel/test-quic-h3-flow-control-volume.mjs index ebe8cf58a8e0..5015d1b3b709 100644 --- a/test/parallel/test-quic-h3-flow-control-volume.mjs +++ b/test/parallel/test-quic-h3-flow-control-volume.mjs @@ -24,7 +24,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { makePayload, hashBytes } = await import('../common/quic.mjs'); const { bytes } = await import('stream/iter'); @@ -48,8 +48,7 @@ assert.notStrictEqual(requestHash, responseHash); const serverDone = Promise.withResolvers(); -const serverEndpoint = await listen(mustCall(async (quicSession) => { - const serverSession = new Http3Session(quicSession); +const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { // Read the large request body. This is the path where DATA payload // credit is deferred until consumption. @@ -80,7 +79,7 @@ const serverEndpoint = await listen(mustCall(async (quicSession) => { }), }); -const clientSession = new Http3Session(await connect(serverEndpoint.address, { +const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', @@ -90,7 +89,7 @@ const clientSession = new Http3Session(await connect(serverEndpoint.address, { }, maxStreamWindow: kStreamWindow, maxWindow: kConnWindow, -})); +}); const info = await clientSession.opened; assert.strictEqual(info.protocol, 'h3'); diff --git a/test/parallel/test-quic-h3-goaway-non-h3.mjs b/test/parallel/test-quic-h3-goaway-non-h3.mjs deleted file mode 100644 index f61df78da9e8..000000000000 --- a/test/parallel/test-quic-h3-goaway-non-h3.mjs +++ /dev/null @@ -1,63 +0,0 @@ -// Flags: --experimental-quic --experimental-stream-iter --no-warnings - -// Test: Non-H3 session close does not fire ongoaway. -// GOAWAY is an HTTP/3 concept. When a non-H3 session closes, the -// ongoaway callback must not fire. - -import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs'; -import assert from 'node:assert'; -import { setImmediate } from 'node:timers/promises'; -import * as fixtures from '../common/fixtures.mjs'; - -if (!hasQuic) { - skip('QUIC is not enabled'); -} - -const { listen, connect } = await import('node:quic'); -const { createPrivateKey } = await import('node:crypto'); -const { bytes } = await import('stream/iter'); - -const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); -const cert = fixtures.readKey('agent1-cert.pem'); -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); - -const serverDone = Promise.withResolvers(); - -const serverEndpoint = await listen(mustCall(async (ss) => { - ss.onstream = mustCall(async (stream) => { - // Read client data, send response, close stream. - const data = await bytes(stream); - assert.strictEqual(decoder.decode(data), 'ping'); - stream.writer.writeSync('pong'); - stream.writer.endSync(); - await stream.closed; - ss.close(); - serverDone.resolve(); - }); -}), { - sni: { '*': { keys: [key], certs: [cert] } }, - alpn: 'quic-test', -}); - -const clientSession = await connect(serverEndpoint.address, { - servername: 'localhost', - verifyPeer: 'manual', - alpn: 'quic-test', - // Ongoaway must NOT fire for non-H3 sessions. - ongoaway: mustNotCall(), -}); -await clientSession.opened; - -const stream = await clientSession.createBidirectionalStream({ - body: encoder.encode('ping'), -}); - -const response = await bytes(stream); -assert.strictEqual(decoder.decode(response), 'pong'); -await Promise.all([stream.closed, serverDone.promise]); - -// Wait a tick for any deferred callbacks to fire. -await setImmediate(); -await clientSession.close(); -await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-goaway.mjs b/test/parallel/test-quic-h3-goaway.mjs index cc1a625c882d..41a1976ddaa8 100644 --- a/test/parallel/test-quic-h3-goaway.mjs +++ b/test/parallel/test-quic-h3-goaway.mjs @@ -42,8 +42,7 @@ dc.subscribe('quic.session.goaway', mustCall((msg) => { const bothHeadersReceived = Promise.withResolvers(); let clientHeaderCount = 0; - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall(async (ss) => { serverSession = ss; ss.onstream = mustCall(2); }), { @@ -70,15 +69,17 @@ dc.subscribe('quic.session.goaway', mustCall((msg) => { const quicSession = await connect(serverEndpoint.address, { alpn: 'h3', + autoWrap: false, servername: 'localhost', verifyPeer: 'manual', + }); + const clientSession = Http3Session.from(quicSession, { // Ongoaway fires when the peer sends GOAWAY. ongoaway: mustCall(function(lastStreamId) { assert.strictEqual(lastStreamId, -1n); goawayReceived.resolve(); }), }); - const clientSession = new Http3Session(quicSession); await clientSession.opened; const onClientHeaders = mustCall(function(headers) { diff --git a/test/parallel/test-quic-h3-handshake-failure.mjs b/test/parallel/test-quic-h3-handshake-failure.mjs index 351e738f32b9..05fd7b344be1 100644 --- a/test/parallel/test-quic-h3-handshake-failure.mjs +++ b/test/parallel/test-quic-h3-handshake-failure.mjs @@ -20,14 +20,13 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); const cert = fixtures.readKey('agent1-cert.pem'); -const serverEndpoint = await listen(async (quicSession) => { - const serverSession = new Http3Session(quicSession); +const serverEndpoint = await listen(async (serverSession) => { await serverSession.closed; }, { alpn: ['h3'], @@ -38,13 +37,13 @@ const serverEndpoint = await listen(async (quicSession) => { // Connect then immediately close the session before the handshake completes. // This exercises the H3 shutdown path on the server while the H3 application // exists but hasn't started (control streams not yet bound). -const clientSession = new Http3Session(await connect(serverEndpoint.address, { +const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', // h3 ALPN — must match the server so the H3 application is selected // on the server side before we tear it down. -})); +}); // Close immediately — don't wait for handshake. await clientSession.close(); diff --git a/test/parallel/test-quic-h3-header-interest.mjs b/test/parallel/test-quic-h3-header-interest.mjs index 7aa5162bd4c9..e221c2ed2853 100644 --- a/test/parallel/test-quic-h3-header-interest.mjs +++ b/test/parallel/test-quic-h3-header-interest.mjs @@ -12,7 +12,7 @@ if (!hasQuic) { } const { createPrivateKey } = await import('node:crypto'); -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { bytes } = await import('stream/iter'); const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); @@ -21,8 +21,7 @@ const encoder = new TextEncoder(); const decoder = new TextDecoder(); const serverDone = Promise.withResolvers(); -const serverEndpoint = await listen(mustCall(async (quicSession) => { - const serverSession = new Http3Session(quicSession); +const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { await stream.closed; serverSession.close(); @@ -44,12 +43,11 @@ const serverEndpoint = await listen(mustCall(async (quicSession) => { }), }); -const quicSession = await connect(serverEndpoint.address, { +const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); -const clientSession = new Http3Session(quicSession); await clientSession.opened; const infoReceived = Promise.withResolvers(); diff --git a/test/parallel/test-quic-h3-header-validation.mjs b/test/parallel/test-quic-h3-header-validation.mjs index 3ea5965a942c..a8ca20adbebf 100644 --- a/test/parallel/test-quic-h3-header-validation.mjs +++ b/test/parallel/test-quic-h3-header-validation.mjs @@ -21,7 +21,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -35,8 +35,7 @@ const decoder = new TextDecoder(); { const serverDone = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { await stream.closed; ss.close(); @@ -74,11 +73,11 @@ const decoder = new TextDecoder(); }), }); - const clientSession = new Http3Session(await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', - })); + }); await clientSession.opened; const stream = await clientSession.createBidirectionalStream({ @@ -117,8 +116,7 @@ const decoder = new TextDecoder(); { const serverDone = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { await stream.closed; ss.close(); @@ -139,11 +137,11 @@ const decoder = new TextDecoder(); }), }); - const clientSession = new Http3Session(await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', - })); + }); await clientSession.opened; const stream = await clientSession.createBidirectionalStream({ diff --git a/test/parallel/test-quic-h3-informational-headers.mjs b/test/parallel/test-quic-h3-informational-headers.mjs index a5b1c9ad3330..ac052834755e 100644 --- a/test/parallel/test-quic-h3-informational-headers.mjs +++ b/test/parallel/test-quic-h3-informational-headers.mjs @@ -19,7 +19,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -45,8 +45,7 @@ dc.subscribe('quic.stream.headers', mustCall((msg) => { const serverDone = Promise.withResolvers(); -const serverEndpoint = await listen(mustCall(async (quicSession) => { - const serverSession = new Http3Session(quicSession); +const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { await stream.closed; serverSession.close(); @@ -74,11 +73,11 @@ const serverEndpoint = await listen(mustCall(async (quicSession) => { }), }); -const clientSession = new Http3Session(await connect(serverEndpoint.address, { +const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', -})); +}); await clientSession.opened; const clientInfoReceived = Promise.withResolvers(); diff --git a/test/parallel/test-quic-h3-maxstreamdata-external-buffer-failure.mjs b/test/parallel/test-quic-h3-maxstreamdata-external-buffer-failure.mjs index 4a7b8a125fe6..65f135dafffd 100644 --- a/test/parallel/test-quic-h3-maxstreamdata-external-buffer-failure.mjs +++ b/test/parallel/test-quic-h3-maxstreamdata-external-buffer-failure.mjs @@ -11,7 +11,7 @@ import { setTimeout as sleep } from 'node:timers/promises'; if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { drainableProtocol } = await import('stream/iter'); @@ -30,8 +30,7 @@ const BODY = WINDOW - 11; let letServerRead; const serverMayRead = new Promise((resolve) => { letServerRead = resolve; }); -const endpoint = await listen((quicSession) => { - const session = new Http3Session(quicSession); +const endpoint = await listen((session) => { session.onstream = async (stream) => { await serverMayRead; // eslint-disable-next-line no-unused-vars @@ -47,11 +46,11 @@ const endpoint = await listen((quicSession) => { onheaders() { this.sendHeaders({ ':status': '200' }); }, }); -const session = new Http3Session(await connect(endpoint.address, { +const session = await connect(endpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', -})); +}); await session.opened; // Budget well above the window, so the window is what stops the writer. diff --git a/test/parallel/test-quic-h3-origin.mjs b/test/parallel/test-quic-h3-origin.mjs index 970072378806..fe5ceed5da22 100644 --- a/test/parallel/test-quic-h3-origin.mjs +++ b/test/parallel/test-quic-h3-origin.mjs @@ -29,8 +29,7 @@ const decoder = new TextDecoder(); const originReceived = Promise.withResolvers(); const serverDone = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { await stream.closed; ss.close(); @@ -54,8 +53,11 @@ const decoder = new TextDecoder(); const quicSession = await connect(serverEndpoint.address, { alpn: 'h3', + autoWrap: false, servername: 'example.com', verifyPeer: 'manual', + }); + const clientSession = Http3Session.from(quicSession, { // Client receives ORIGIN frame via onorigin callback. onorigin: mustCall(function(origins) { assert.ok(Array.isArray(origins)); @@ -70,7 +72,6 @@ const decoder = new TextDecoder(); originReceived.resolve(); }), }); - const clientSession = new Http3Session(quicSession); await clientSession.opened; const stream = await clientSession.createBidirectionalStream({ @@ -101,8 +102,7 @@ const decoder = new TextDecoder(); const originReceived = Promise.withResolvers(); const serverDone = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { await stream.closed; ss.close(); @@ -136,8 +136,11 @@ const decoder = new TextDecoder(); const quicSession = await connect(serverEndpoint.address, { alpn: 'h3', + autoWrap: false, servername: 'custom-port.example.com', verifyPeer: 'manual', + }); + const clientSession = Http3Session.from(quicSession, { onorigin: mustCall(function(origins) { assert.ok(Array.isArray(origins)); @@ -170,7 +173,6 @@ const decoder = new TextDecoder(); originReceived.resolve(); }), }); - const clientSession = new Http3Session(quicSession); await clientSession.opened; const stream = await clientSession.createBidirectionalStream({ diff --git a/test/parallel/test-quic-h3-pending-stream.mjs b/test/parallel/test-quic-h3-pending-stream.mjs index 5bec314b9a55..4dea83f980bd 100644 --- a/test/parallel/test-quic-h3-pending-stream.mjs +++ b/test/parallel/test-quic-h3-pending-stream.mjs @@ -12,7 +12,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -26,8 +26,7 @@ const decoder = new TextDecoder(); { const serverDone = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { await stream.closed; ss.close(); @@ -48,11 +47,11 @@ const decoder = new TextDecoder(); }), }); - const clientSession = new Http3Session(await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', - })); + }); // Create the stream BEFORE awaiting opened. The stream is pending // until the handshake completes and the QUIC stream can be opened. diff --git a/test/parallel/test-quic-h3-post-filehandle.mjs b/test/parallel/test-quic-h3-post-filehandle.mjs index 91809031bbf5..20306a6ddc01 100644 --- a/test/parallel/test-quic-h3-post-filehandle.mjs +++ b/test/parallel/test-quic-h3-post-filehandle.mjs @@ -17,7 +17,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -35,8 +35,7 @@ writeFileSync(testFile, testContent); { const serverDone = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const serverSession = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { const body = await bytes(stream); assert.strictEqual(decoder.decode(body), testContent); @@ -58,11 +57,11 @@ writeFileSync(testFile, testContent); }), }); - const clientSession = new Http3Session(await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', - })); + }); const info = await clientSession.opened; assert.strictEqual(info.protocol, 'h3'); diff --git a/test/parallel/test-quic-h3-post-request.mjs b/test/parallel/test-quic-h3-post-request.mjs index 527200904be8..a1c69e09bda4 100644 --- a/test/parallel/test-quic-h3-post-request.mjs +++ b/test/parallel/test-quic-h3-post-request.mjs @@ -17,7 +17,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -30,8 +30,7 @@ const requestBody = 'Hello from the client'; const serverDone = Promise.withResolvers(); -const serverEndpoint = await listen(mustCall(async (quicSession) => { - const serverSession = new Http3Session(quicSession); +const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { // Read the full request body from the client. const body = await bytes(stream); @@ -65,11 +64,11 @@ const serverEndpoint = await listen(mustCall(async (quicSession) => { }), }); -const clientSession = new Http3Session(await connect(serverEndpoint.address, { +const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', -})); +}); const info = await clientSession.opened; assert.strictEqual(info.protocol, 'h3'); diff --git a/test/parallel/test-quic-h3-priority.mjs b/test/parallel/test-quic-h3-priority.mjs index 007a1d475c95..598a18a53739 100644 --- a/test/parallel/test-quic-h3-priority.mjs +++ b/test/parallel/test-quic-h3-priority.mjs @@ -17,7 +17,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -30,8 +30,7 @@ const decoder = new TextDecoder(); let requestCount = 0; const serverDone = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall((stream) => { // Server sees priority on the stream. const pri = stream.priority; @@ -52,11 +51,11 @@ const decoder = new TextDecoder(); }, 4), }); - const clientSession = new Http3Session(await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', - })); + }); await clientSession.opened; // Priority set at creation time via options. @@ -165,8 +164,7 @@ const decoder = new TextDecoder(); const serverSawHighPriority = Promise.withResolvers(); const serverDone = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { // Read the request body — this acts as a signal that the // client's PRIORITY_UPDATE has been sent. The control stream @@ -195,11 +193,11 @@ const decoder = new TextDecoder(); }), }); - const clientSession = new Http3Session(await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', - })); + }); await clientSession.opened; // Create stream with default priority and a body. The body serves diff --git a/test/parallel/test-quic-h3-qpack-settings.mjs b/test/parallel/test-quic-h3-qpack-settings.mjs index e965cfac9618..91f27d6b545a 100644 --- a/test/parallel/test-quic-h3-qpack-settings.mjs +++ b/test/parallel/test-quic-h3-qpack-settings.mjs @@ -52,12 +52,13 @@ async function makeRequest(clientSession, path) { const serverEndpoint = await listen(mustCall(async (quicSession) => { // Server disables QPACK dynamic table. - const ss = new Http3Session(quicSession, { + const ss = Http3Session.from(quicSession, { settings: { qpackMaxDTableCapacity: 0, qpackBlockedStreams: 0 }, }); ss.onstream = mustCall(2); }), { alpn: ['h3'], + autoWrap: false, sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { this.sendHeaders({ ':status': '200' }); @@ -72,10 +73,11 @@ async function makeRequest(clientSession, path) { // Client also disables QPACK dynamic table. const quicSession = await connect(serverEndpoint.address, { alpn: 'h3', + autoWrap: false, servername: 'localhost', verifyPeer: 'manual', }); - const clientSession = new Http3Session(quicSession, { + const clientSession = Http3Session.from(quicSession, { settings: { qpackMaxDTableCapacity: 0, qpackBlockedStreams: 0 }, }); await clientSession.opened; @@ -96,12 +98,13 @@ async function makeRequest(clientSession, path) { let requestCount = 0; const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession, { + const ss = Http3Session.from(quicSession, { settings: { qpackMaxDTableCapacity: 8192, qpackBlockedStreams: 200 }, }); ss.onstream = mustCall(2); }), { alpn: ['h3'], + autoWrap: false, sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { this.sendHeaders({ ':status': '200' }); @@ -115,10 +118,11 @@ async function makeRequest(clientSession, path) { const quicSession = await connect(serverEndpoint.address, { alpn: 'h3', + autoWrap: false, servername: 'localhost', verifyPeer: 'manual', }); - const clientSession = new Http3Session(quicSession, { + const clientSession = Http3Session.from(quicSession, { settings: { qpackMaxDTableCapacity: 8192, qpackBlockedStreams: 200 }, }); await clientSession.opened; diff --git a/test/parallel/test-quic-h3-request-rejected.mjs b/test/parallel/test-quic-h3-request-rejected.mjs index 82d6c22945c4..8fe0acceda12 100644 --- a/test/parallel/test-quic-h3-request-rejected.mjs +++ b/test/parallel/test-quic-h3-request-rejected.mjs @@ -14,7 +14,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); @@ -25,19 +25,18 @@ const H3_REQUEST_REJECTED = 0x10bn; // The server registers no stream consumer, so an incoming request stream // is rejected on arrival. -const serverEndpoint = await listen(mustCall((quicSession) => { - const serverSession = new Http3Session(quicSession); +const serverEndpoint = await listen(mustCall((serverSession) => { serverSession.onerror = () => {}; }), { alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, }); -const clientSession = new Http3Session(await connect(serverEndpoint.address, { +const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', -})); +}); await clientSession.opened; const reset = Promise.withResolvers(); diff --git a/test/parallel/test-quic-h3-request-response.mjs b/test/parallel/test-quic-h3-request-response.mjs index f868629d1456..91404e026df8 100644 --- a/test/parallel/test-quic-h3-request-response.mjs +++ b/test/parallel/test-quic-h3-request-response.mjs @@ -17,7 +17,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -34,8 +34,7 @@ const serverDone = Promise.withResolvers(); // to the stream. A regular function is used so `this` is accessible. // safeCallbackInvoke(fn, owner, ...args) consumes the owner for error // handling and forwards only ...args to fn. -const serverEndpoint = await listen(mustCall(async (quicSession) => { - const serverSession = new Http3Session(quicSession); +const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { await stream.closed; serverSession.close(); @@ -72,11 +71,11 @@ const serverEndpoint = await listen(mustCall(async (quicSession) => { }), }); -const clientSession = new Http3Session(await connect(serverEndpoint.address, { +const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', -})); +}); const info = await clientSession.opened; assert.strictEqual(info.protocol, 'h3'); diff --git a/test/parallel/test-quic-h3-settings.mjs b/test/parallel/test-quic-h3-settings.mjs index f74856cfa83c..e17d9baeb260 100644 --- a/test/parallel/test-quic-h3-settings.mjs +++ b/test/parallel/test-quic-h3-settings.mjs @@ -31,7 +31,7 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (quicSession) => { // Allow 5 header pairs: 4 pseudo-headers + 1 custom. - const ss = new Http3Session(quicSession, { + const ss = Http3Session.from(quicSession, { settings: { maxHeaderPairs: 5 }, }); ss.onstream = mustCall(async (stream) => { @@ -41,6 +41,7 @@ const decoder = new TextDecoder(); }); }), { alpn: ['h3'], + autoWrap: false, sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { assert.strictEqual(headers[':method'], 'GET'); @@ -58,11 +59,11 @@ const decoder = new TextDecoder(); }), }); - const clientSession = new Http3Session(await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', - })); + }); await clientSession.opened; const stream = await clientSession.createBidirectionalStream({ @@ -98,7 +99,7 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (quicSession) => { // Limit total header bytes. The 4 pseudo-headers fit within 100 // bytes, but adding x-long (6 + 200 = 206 bytes) exceeds it. - const ss = new Http3Session(quicSession, { + const ss = Http3Session.from(quicSession, { settings: { maxHeaderLength: 100 }, }); ss.onstream = mustCall(async (stream) => { @@ -108,6 +109,7 @@ const decoder = new TextDecoder(); }); }), { alpn: ['h3'], + autoWrap: false, sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { assert.strictEqual(headers[':method'], 'GET'); @@ -121,11 +123,11 @@ const decoder = new TextDecoder(); }), }); - const clientSession = new Http3Session(await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', - })); + }); await clientSession.opened; const stream = await clientSession.createBidirectionalStream({ @@ -154,7 +156,7 @@ const decoder = new TextDecoder(); const serverDone = Promise.withResolvers(); const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession, { + const ss = Http3Session.from(quicSession, { settings: { enableConnectProtocol: true, enableDatagrams: true }, onsettings: mustCall((appopt) => { assert.strictEqual(appopt.enableDatagrams, true); @@ -169,6 +171,7 @@ const decoder = new TextDecoder(); }); }), { alpn: ['h3'], + autoWrap: false, sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { this.sendHeaders({ ':status': '200' }); @@ -179,10 +182,11 @@ const decoder = new TextDecoder(); const quicSession = await connect(serverEndpoint.address, { alpn: 'h3', + autoWrap: false, servername: 'localhost', verifyPeer: 'manual', }); - const clientSession = new Http3Session(quicSession, { + const clientSession = Http3Session.from(quicSession, { settings: { enableConnectProtocol: true, enableDatagrams: true }, }); clientSession.onsettings = mustCall((appopt) => { diff --git a/test/parallel/test-quic-h3-status-code-type.mjs b/test/parallel/test-quic-h3-status-code-type.mjs index 485f6e7e4b83..886f321e03e3 100644 --- a/test/parallel/test-quic-h3-status-code-type.mjs +++ b/test/parallel/test-quic-h3-status-code-type.mjs @@ -11,7 +11,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); @@ -21,8 +21,7 @@ const codes = [200, 204, 404]; let serverResponses = 0; const serverDone = Promise.withResolvers(); -const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession); +const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(() => { if (++serverResponses === codes.length) { ss.close(); @@ -39,11 +38,11 @@ const serverEndpoint = await listen(mustCall(async (quicSession) => { }, codes.length), }); -const clientSession = new Http3Session(await connect(serverEndpoint.address, { +const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', -})); +}); await clientSession.opened; for (const expected of codes) { diff --git a/test/parallel/test-quic-h3-stream-credit.mjs b/test/parallel/test-quic-h3-stream-credit.mjs index acf24fbd8bd6..16f77e9876ee 100644 --- a/test/parallel/test-quic-h3-stream-credit.mjs +++ b/test/parallel/test-quic-h3-stream-credit.mjs @@ -17,7 +17,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -29,8 +29,7 @@ const kRequests = 6; let liveServerStreams = 0; let peakLiveServerStreams = 0; -const serverEndpoint = await listen(mustCall((quicSession) => { - const serverSession = new Http3Session(quicSession); +const serverEndpoint = await listen(mustCall((serverSession) => { serverSession.onstream = mustCall((stream) => { liveServerStreams++; peakLiveServerStreams = Math.max(peakLiveServerStreams, liveServerStreams); @@ -49,11 +48,11 @@ const serverEndpoint = await listen(mustCall((quicSession) => { }, kRequests), }); -const clientSession = new Http3Session(await connect(serverEndpoint.address, { +const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', -})); +}); const info = await clientSession.opened; assert.strictEqual(info.protocol, 'h3'); diff --git a/test/parallel/test-quic-h3-stream-destroy-no-resurrect.mjs b/test/parallel/test-quic-h3-stream-destroy-no-resurrect.mjs index 1e08a318cd14..4311aafddd5c 100644 --- a/test/parallel/test-quic-h3-stream-destroy-no-resurrect.mjs +++ b/test/parallel/test-quic-h3-stream-destroy-no-resurrect.mjs @@ -23,7 +23,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { makePayload } = await import('../common/quic.mjs'); @@ -41,8 +41,7 @@ const responseBody = makePayload(kResponseSize, 17); assert.ok(kResponseSize * kRequests > kConnWindow * 4, 'aggregate response data must far exceed the connection window'); -const serverEndpoint = await listen(mustCall((quicSession) => { - const serverSession = new Http3Session(quicSession); +const serverEndpoint = await listen(mustCall((serverSession) => { serverSession.onstream = mustCall((stream) => { // The client destroys these early; the truncated write is expected. stream.onerror = () => {}; @@ -56,7 +55,7 @@ const serverEndpoint = await listen(mustCall((quicSession) => { }, kRequests), }); -const clientSession = new Http3Session(await connect(serverEndpoint.address, { +const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', @@ -64,7 +63,7 @@ const clientSession = new Http3Session(await connect(serverEndpoint.address, { initialMaxData: kConnWindow, initialMaxStreamDataBidiLocal: kResponseSize * 2, }, -})); +}); // The client opens every stream itself; the server opens none. Any onstream // here is a destroyed request stream being resurrected and misreported as diff --git a/test/parallel/test-quic-h3-stream-destroy-with-headers.mjs b/test/parallel/test-quic-h3-stream-destroy-with-headers.mjs index d0ac441891e3..402bbe3d06ff 100644 --- a/test/parallel/test-quic-h3-stream-destroy-with-headers.mjs +++ b/test/parallel/test-quic-h3-stream-destroy-with-headers.mjs @@ -12,7 +12,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); @@ -20,8 +20,7 @@ const cert = fixtures.readKey('agent1-cert.pem'); const serverDone = Promise.withResolvers(); -const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession); +const serverEndpoint = await listen(mustCall(async (ss) => { // The server may or may not see the stream depending on timing. // Either way, it should not crash. await ss.closed; @@ -31,11 +30,11 @@ const serverEndpoint = await listen(mustCall(async (quicSession) => { sni: { '*': { keys: [key], certs: [cert] } }, }); -const clientSession = new Http3Session(await connect(serverEndpoint.address, { +const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', -})); +}); await clientSession.opened; // Create a stream with headers, then immediately destroy it. diff --git a/test/parallel/test-quic-h3-stream-idle-timeout.mjs b/test/parallel/test-quic-h3-stream-idle-timeout.mjs index 553f95485aaf..59ef9d6a01be 100644 --- a/test/parallel/test-quic-h3-stream-idle-timeout.mjs +++ b/test/parallel/test-quic-h3-stream-idle-timeout.mjs @@ -17,7 +17,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); @@ -29,8 +29,7 @@ const encoder = new TextEncoder(); { const streamDestroyed = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const serverSession = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { // Don't read — let the stream sit idle after the initial headers. // The stream idle timeout should destroy it, rejecting stream.closed. @@ -48,12 +47,12 @@ const encoder = new TextEncoder(); }, }); - const clientSession = new Http3Session(await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', transportParams: { maxIdleTimeout: 1 }, - })); + }); await clientSession.opened; @@ -89,8 +88,7 @@ const encoder = new TextEncoder(); { const serverGotData = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const serverSession = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { const data = await text(stream); assert.strictEqual(data, 'xy'); @@ -108,11 +106,11 @@ const encoder = new TextEncoder(); }), }); - const clientSession = new Http3Session(await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', - })); + }); await clientSession.opened; const stream = await clientSession.createBidirectionalStream({ @@ -142,8 +140,7 @@ const encoder = new TextEncoder(); { const streamSurvived = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const serverSession = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { const data = await text(stream); assert.strictEqual(data, 'xy'); @@ -161,11 +158,11 @@ const encoder = new TextEncoder(); }), }); - const clientSession = new Http3Session(await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', - })); + }); await clientSession.opened; const stream = await clientSession.createBidirectionalStream({ diff --git a/test/parallel/test-quic-h3-stream-without-onstream.mjs b/test/parallel/test-quic-h3-stream-without-onstream.mjs index 20ec0350e6d1..8e96a4d4ba06 100644 --- a/test/parallel/test-quic-h3-stream-without-onstream.mjs +++ b/test/parallel/test-quic-h3-stream-without-onstream.mjs @@ -19,7 +19,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { text } = await import('stream/iter'); @@ -42,8 +42,7 @@ function failOnConsumerWarning(warning) { const serverDone = Promise.withResolvers(); // Note: no `onstream` callback anywhere on this session. - const serverEndpoint = await listen(mustCall((quicSession) => { - const serverSession = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall((serverSession) => { serverSession.onerror = () => {}; }), { alpn: ['h3'], @@ -61,11 +60,11 @@ function failOnConsumerWarning(warning) { }), }); - const clientSession = new Http3Session(await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', - })); + }); await clientSession.opened; const headersReceived = Promise.withResolvers(); @@ -150,19 +149,18 @@ const kNonConsumerCallbacks = ['oninfo', 'ontrailers', 'onwanttrailers']; // QuicStream is not exported; obtain its prototype from a stream instance, // then offer every `on*` accessor to listen() and see which ones the // session actually attaches to a received stream. - const bootstrap = await listen(mustCall((quicSession) => { - const session = new Http3Session(quicSession); + const bootstrap = await listen(mustCall((session) => { session.onerror = () => {}; + session.onstream = () => {}; }), { alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, - onstream: () => {}, }); - const bootSession = new Http3Session(await connect(bootstrap.address, { + const bootSession = await connect(bootstrap.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', - })); + }); await bootSession.opened; const probeStream = await bootSession.createBidirectionalStream(); probeStream.onerror = () => {}; @@ -175,24 +173,23 @@ const kNonConsumerCallbacks = ['oninfo', 'ontrailers', 'onwanttrailers']; for (const name of candidates) probes[name] = () => {}; const applied = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall((quicSession) => { - const session = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall((session) => { session.onerror = () => {}; + session.onstream = mustCall((stream) => { + applied.resolve(candidates.filter((n) => typeof stream[n] === 'function')); + }); }), { __proto__: null, ...probes, alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, - onstream: mustCall((stream) => { - applied.resolve(candidates.filter((n) => typeof stream[n] === 'function')); - }), }); - const clientSession = new Http3Session(await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', - })); + }); await clientSession.opened; const stream = await clientSession.createBidirectionalStream({ headers: { @@ -227,8 +224,7 @@ for (const callbackName of kNonConsumerCallbacks) { } }); - const serverEndpoint = await listen(mustCall((quicSession) => { - const serverSession = new Http3Session(quicSession); + const serverEndpoint = await listen(mustCall((serverSession) => { serverSession.onerror = () => {}; }), { alpn: ['h3'], @@ -236,11 +232,11 @@ for (const callbackName of kNonConsumerCallbacks) { [callbackName]: mustNotCall(), }); - const clientSession = new Http3Session(await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', - })); + }); await clientSession.opened; const stream = await clientSession.createBidirectionalStream({ diff --git a/test/parallel/test-quic-h3-trailing-headers.mjs b/test/parallel/test-quic-h3-trailing-headers.mjs index c6e08b5748d9..3224d581277d 100644 --- a/test/parallel/test-quic-h3-trailing-headers.mjs +++ b/test/parallel/test-quic-h3-trailing-headers.mjs @@ -18,7 +18,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -47,8 +47,7 @@ dc.subscribe('quic.stream.trailers', mustCall((msg) => { const serverDone = Promise.withResolvers(); -const serverEndpoint = await listen(mustCall(async (quicSession) => { - const serverSession = new Http3Session(quicSession); +const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { await stream.closed; serverSession.close(); @@ -79,11 +78,11 @@ const serverEndpoint = await listen(mustCall(async (quicSession) => { }), }); -const clientSession = new Http3Session(await connect(serverEndpoint.address, { +const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', -})); +}); await clientSession.opened; const clientHeadersReceived = Promise.withResolvers(); diff --git a/test/parallel/test-quic-h3-uni-stream-limit-start-failure.mjs b/test/parallel/test-quic-h3-uni-stream-limit-start-failure.mjs deleted file mode 100644 index bc784d4aa0c9..000000000000 --- a/test/parallel/test-quic-h3-uni-stream-limit-start-failure.mjs +++ /dev/null @@ -1,40 +0,0 @@ -// Flags: --experimental-quic --no-warnings - -// An HTTP/3 session must cleanly fail if the peer advertises fewer than -// the 3 unidirectional streams that HTTP/3 needs for control and QPACK. - -import { hasQuic, skip, mustNotCall } from '../common/index.mjs'; -import assert from 'node:assert'; -import * as fixtures from '../common/fixtures.mjs'; - -if (!hasQuic) { - skip('QUIC is not enabled'); -} - -const { listen, connect, Http3Session } = await import('node:quic'); -const { createPrivateKey } = await import('node:crypto'); - -const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); -const cert = fixtures.readKey('agent1-cert.pem'); - -const serverEndpoint = await listen(async (serverSession) => { - await serverSession.closed; -}, { - alpn: ['h3'], - sni: { '*': { keys: [key], certs: [cert] } }, - // No uni streams allowed: - transportParams: { initialMaxStreamsUni: 0 }, - onheaders: mustNotCall(), -}); - -const clientSession = new Http3Session(await connect(serverEndpoint.address, { - alpn: 'h3', - servername: 'localhost', - verifyPeer: 'manual', -})); - -// Expect the client to cleanly fail & close - not crash the process. -await assert.rejects(clientSession.closed, - { code: 'ERR_QUIC_TRANSPORT_ERROR' }); - -await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs b/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs index e472844656fe..f2c519750b4f 100644 --- a/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs +++ b/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs @@ -34,13 +34,14 @@ async function getTicket({ settings, ...endpointOptions }) { const gotToken = Promise.withResolvers(); const ep = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession, { settings }); + const ss = Http3Session.from(quicSession, { settings }); ss.onstream = mustCall(async (stream) => { await stream.closed; ss.close(); }); }), { alpn: ['h3'], + autoWrap: false, sni, ...endpointOptions, onheaders: mustCall(function(headers) { @@ -50,8 +51,9 @@ async function getTicket({ settings, ...endpointOptions }) { }), }); - const cs = new Http3Session(await connect(ep.address, { + const cs = Http3Session.from(await connect(ep.address, { alpn: 'h3', + autoWrap: false, servername: 'localhost', verifyPeer: 'manual', ...endpointOptions, @@ -96,23 +98,25 @@ async function getTicket({ settings, ...endpointOptions }) { async function attemptRejected0RTT({ settings, ...endpointOptions }, ticket, token) { const ep = await listen(mustCall(async (quicSession) => { - const ss = new Http3Session(quicSession, { settings }); + const ss = Http3Session.from(quicSession, { settings }); await ss.closed; }), { alpn: ['h3'], + autoWrap: false, sni, ...endpointOptions, }); const quicSession = await connect(ep.address, { alpn: 'h3', + autoWrap: false, servername: 'localhost', verifyPeer: 'manual', ...endpointOptions, sessionTicket: ticket, token, }); - const cs = new Http3Session(quicSession, { settings }); + const cs = Http3Session.from(quicSession, { settings }); // Trigger the deferred handshake by opening a stream. // With 0-RTT, the handshake is deferred until the first stream diff --git a/test/parallel/test-quic-h3-zero-rtt.mjs b/test/parallel/test-quic-h3-zero-rtt.mjs index ee2ec0ba7987..65ded2fd4a00 100644 --- a/test/parallel/test-quic-h3-zero-rtt.mjs +++ b/test/parallel/test-quic-h3-zero-rtt.mjs @@ -14,7 +14,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -31,8 +31,7 @@ const gotToken = Promise.withResolvers(); let serverSessionCount = 0; const secondDone = Promise.withResolvers(); -const serverEndpoint = await listen(mustCall((quicSession) => { - const ss = new Http3Session(quicSession); +const serverEndpoint = await listen(mustCall((ss) => { const num = ++serverSessionCount; ss.onstream = mustCall(async (stream) => { if (num === 2) { @@ -55,7 +54,7 @@ const serverEndpoint = await listen(mustCall((quicSession) => { }); // --- First connection: establish H3 session, receive ticket --- -const cs1 = new Http3Session(await connect(serverEndpoint.address, { +const cs1 = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', @@ -70,7 +69,7 @@ const cs1 = new Http3Session(await connect(serverEndpoint.address, { savedToken = token; gotToken.resolve(); }), -})); +}); const info1 = await cs1.opened; assert.strictEqual(info1.earlyDataAttempted, false); @@ -98,13 +97,13 @@ assert.ok(savedTicket); assert.ok(savedToken); // --- Second connection: 0-RTT with H3 --- -const cs2 = new Http3Session(await connect(serverEndpoint.address, { +const cs2 = await connect(serverEndpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', sessionTicket: savedTicket, token: savedToken, -})); +}); // Send H3 request BEFORE handshake completes — true 0-RTT. const s2 = await cs2.createBidirectionalStream({ diff --git a/test/parallel/test-quic-session-application-options.mjs b/test/parallel/test-quic-session-application-options.mjs index 60e28597c6c2..4523423a7593 100644 --- a/test/parallel/test-quic-session-application-options.mjs +++ b/test/parallel/test-quic-session-application-options.mjs @@ -50,8 +50,8 @@ function check(settings, side) { const serverDone = Promise.withResolvers(); const serverEndpoint = await listen(mustCall((quicSession) => { - const server = new Http3Session(quicSession, { settings: customSettings }); - quicSession.onstream = mustCall(async (stream) => { + const server = Http3Session.from(quicSession, { settings: customSettings }); + server.onstream = mustCall(async (stream) => { check(quicSession.applicationOptions, 'server'); await stream.closed; server.close(); @@ -59,14 +59,15 @@ const serverEndpoint = await listen(mustCall((quicSession) => { }); }), { alpn: ['h3'], + autoWrap: false, onheaders: mustCall(function() { this.sendHeaders({ ':status': '200' }); this.writer.endSync(); }), }); -const client = new Http3Session( - await connect(serverEndpoint.address, { alpn: 'h3' }), +const client = Http3Session.from( + await connect(serverEndpoint.address, { alpn: 'h3', autoWrap: false }), { settings: customSettings }); // The settings are in effect from the attach onwards: before the handshake From f036f05273fed5cf5aed5d5fcf849e8f67b2c196 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Mon, 28 Sep 2026 19:41:52 +0200 Subject: [PATCH 7/9] quic: fix subtle bugs in resume & failed H3 start teardown And a little related cleanup en route Signed-off-by: Tim Perry --- src/quic/endpoint.cc | 7 +- src/quic/session.cc | 22 +++++-- src/quic/session.h | 3 +- test/parallel/test-quic-alpn-h3.mjs | 3 +- test/parallel/test-quic-h3-attach.mjs | 18 ------ test/parallel/test-quic-h3-start-failure.mjs | 68 ++++++++++++++++++++ 6 files changed, 93 insertions(+), 28 deletions(-) create mode 100644 test/parallel/test-quic-h3-start-failure.mjs diff --git a/src/quic/endpoint.cc b/src/quic/endpoint.cc index 35dc7f9227f8..ed677b2dbcb5 100644 --- a/src/quic/endpoint.cc +++ b/src/quic/endpoint.cc @@ -1997,7 +1997,12 @@ void Endpoint::SocketAddressInfoTraits::Touch(const SocketAddress& address, // JavaScript call outs void Endpoint::EmitNewSession(const BaseObjectPtr& session) { - if (!env()->can_call_into_js()) return; + if (!env()->can_call_into_js()) { + // Even if we can't call into JS, we need to attach the app to handle + // other callbacks before we do proper teardown: + session->EnsureApplication(); + return; + } CallbackScope scope(this); session->set_wrapped(); Local arg = session->object(); diff --git a/src/quic/session.cc b/src/quic/session.cc index 051b16e89804..00828c58781f 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -2629,7 +2629,7 @@ bool Session::EnsureApplication() { if (is_destroyed()) [[unlikely]] return false; if (impl_->application_) [[likely]] - return true; + return !flags_.application_start_failed; if (application_type() == Application::Type::HTTP3) { SetApplication(CreateHttp3Application(this)); @@ -2639,9 +2639,16 @@ bool Session::EnsureApplication() { } // If the keys are already ready, that means we should start immediately. - // If application start fails then we can't continue. + // If application start fails then we can't continue. Inside an ngtcp2 + // callback the session can't be closed directly, but the failure sticks, + // and HandshakeCompleted() then fails the callback, which closes it. if (keys_ready_ && !application().Start()) { Debug(this, "Application start failed"); + flags_.application_start_failed = 1; + if (!flags_.in_ngtcp2_callback_scope) { + SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); + Close(); + } return false; } return true; @@ -2865,7 +2872,7 @@ bool Session::AfterNgtcp2Read(int err) { if (is_server() && tls_session().early_selection() == TLSSession::EarlySelection::kSelected) { endpoint().EmitNewSession(BaseObjectPtr(this)); - if (!is_destroyed()) ResumeHandshake(); + if (has_application()) ResumeHandshake(); } } return true; @@ -3408,15 +3415,16 @@ void Session::StreamDataBlocked(stream_id id) { void Session::CollectSessionTicketAppData( SessionTicket::AppData* app_data) const { - DCHECK(!is_destroyed()); - CHECK(has_application()); + if (!has_application()) [[unlikely]] + return; application().CollectSessionTicketAppData(app_data); } SessionTicket::AppData::Status Session::ExtractSessionTicketAppData( const SessionTicket::AppData& app_data, Flag flag) { - DCHECK(!is_destroyed()); - CHECK(has_application()); + if (!has_application()) [[unlikely]] { + return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; + } return application().ExtractSessionTicketAppData(app_data, flag); } diff --git a/src/quic/session.h b/src/quic/session.h index 07799df28ab5..2dbe74ab332c 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -11,7 +11,6 @@ #include #include #include -#include #include #include #include "bindingdata.h" @@ -741,6 +740,8 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // Set during FlushPendingData to avoid the one-tick latency of // async-only sends from the uv_check callback. uint8_t prefer_try_send : 1 = 0; + // Set if the application couldn't be started, which is fatal to it. + uint8_t application_start_failed : 1 = 0; }; Flags flags_; diff --git a/test/parallel/test-quic-alpn-h3.mjs b/test/parallel/test-quic-alpn-h3.mjs index aaf0bdf85171..e2d620c84eba 100644 --- a/test/parallel/test-quic-alpn-h3.mjs +++ b/test/parallel/test-quic-alpn-h3.mjs @@ -49,5 +49,6 @@ assert.throws(() => stream.sendHeaders({ ':status': '200' }), { message: /does not support headers/, }); -clientSession.destroy(); +stream.destroy(); +await clientSession.close(); await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-attach.mjs b/test/parallel/test-quic-h3-attach.mjs index f51170829469..1369e38d1808 100644 --- a/test/parallel/test-quic-h3-attach.mjs +++ b/test/parallel/test-quic-h3-attach.mjs @@ -61,10 +61,6 @@ assert.throws(() => new Http3Session(), { code: 'ERR_ILLEGAL_CONSTRUCTOR' }); code: 'ERR_INVALID_STATE', message: /cannot be set on a session/, }); - // And the HTTP/3-only callbacks exist only there: - for (const name of ['ongoaway', 'onorigin', 'onapplication']) { - assert.strictEqual(name in quicSession, false); - } // The onerror callback stays transport-level, so both sides keep their own: quicSession.onerror = () => {}; session.onerror = () => {}; @@ -96,20 +92,6 @@ const tooLate = { message: /already has an application/, }; -// HTTP/3-only callbacks can't be passed as QuicSession options, so they -// can't be registered before the application exists: -for (const name of ['ongoaway', 'onorigin', 'onapplication']) { - const expected = { - code: 'ERR_INVALID_ARG_VALUE', - message: new RegExp(`options\\.${name}.*Http3Session`), - }; - const callback = { [name]: () => {} }; - await assert.rejects(listen(() => {}, { ...serverOpts, ...callback }), - expected); - await assert.rejects(connect('127.0.0.1:1', { ...clientOpts, ...callback }), - expected); -} - // Setting onstream claims the session for raw QUIC, so HTTP/3 can't be // attached afterwards, whether it is set directly or passed as an option. { diff --git a/test/parallel/test-quic-h3-start-failure.mjs b/test/parallel/test-quic-h3-start-failure.mjs new file mode 100644 index 000000000000..113d99805d73 --- /dev/null +++ b/test/parallel/test-quic-h3-start-failure.mjs @@ -0,0 +1,68 @@ +// Flags: --experimental-quic --no-warnings + +// Test: HTTP/3 can't start when the peer allows fewer than the three +// unidirectional streams it needs for its control and QPACK streams. That +// must close the session, rather than leave it running without HTTP/3. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const cert = fixtures.readKey('agent1-cert.pem'); +const headers = { + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', +}; +const internalError = { + code: 'ERR_QUIC_TRANSPORT_ERROR', + message: /INTERNAL_ERROR/, +}; + +async function attachToLowUniServer() { + const endpoint = await listen(mustCall((quicSession) => { + quicSession.onerror = () => {}; + }), { + alpn: ['h3'], + sni: { '*': { keys: [key], certs: [cert] } }, + transportParams: { initialMaxStreamsUni: 2 }, + }); + const client = await connect(endpoint.address, { + alpn: 'h3', + servername: 'localhost', + verifyPeer: 'manual', + }); + return { endpoint, client }; +} + +// Nothing opened: the session closes when the handshake completes. +{ + const { endpoint, client } = await attachToLowUniServer(); + await assert.rejects(client.closed, internalError); + await endpoint.close(); +} + +// A request opened as soon as the session opens fails, and the session +// closes rather than accepting further requests. +{ + const { endpoint, client } = await attachToLowUniServer(); + client.onerror = mustCall((err) => { + assert.strictEqual(err.code, internalError.code); + }); + await client.opened; + await assert.rejects(client.createBidirectionalStream({ headers }), + { code: 'ERR_QUIC_OPEN_STREAM_FAILED' }); + await assert.rejects(client.closed, internalError); + await assert.rejects(client.createBidirectionalStream({ headers }), + { code: 'ERR_INVALID_STATE' }); + await endpoint.close(); +} From 7977f9a56e8b55cb4a156796f8dbc1c88f9d7eba Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Mon, 28 Sep 2026 21:24:16 +0200 Subject: [PATCH 8/9] quic: add autoWrap option to auto-attach HTTP/3 --- doc/api/quic.md | 48 +++++++---- lib/internal/quic/quic.js | 40 +++++++-- test/parallel/test-quic-alpn-h3.mjs | 8 +- test/parallel/test-quic-h3-attach.mjs | 10 --- test/parallel/test-quic-h3-autowrap.mjs | 81 +++++++++++++++++++ .../test-quic-h3-uni-stream-teardown.mjs | 6 +- 6 files changed, 157 insertions(+), 36 deletions(-) create mode 100644 test/parallel/test-quic-h3-autowrap.mjs diff --git a/doc/api/quic.md b/doc/api/quic.md index 7f6b452fa93e..e72e8174c3f2 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -476,7 +476,8 @@ added: v23.8.0 * `address` {string|net.SocketAddress} * `options` {quic.SessionOptions} -* Returns: {Promise} a promise for a {quic.QuicSession} +* Returns: {Promise} a promise for a {quic.QuicSession}, or {quic.Http3Session} + if [`sessionOptions.autoWrap`][] is enabled and an HTTP/3 ALPN is negotiated. Initiate a new client-side session. @@ -3061,6 +3062,23 @@ list that the client also supports. This option is required; omitting it throws `ERR_MISSING_OPTION`. +#### `sessionOptions.autoWrap` + + + +* Type: {boolean} +* **Default:** `true` + +If this option is set for [`quic.connect()`][] or [`quic.listen()`][], then +sessions are automatically exposed as wrapped [`Http3Session`][] instances +instead of raw [`QuicSession`][], if an HTTP/3 ALPN (`h3` or an `h3-*` draft) +is negotiated. + +Set this to `false` to always receive a raw [`QuicSession`][] and configure +HTTP/3 yourself with [`Http3Session.from()`][] instead. + #### `sessionOptions.ca` * `this` {quic.QuicEndpoint} -* `session` {quic.QuicSession} +* `session` {quic.QuicSession|quic.Http3Session} The callback function that is invoked when a new server session is initiated by a remote peer. It is called once the peer's TLS `ClientHello` has been @@ -4063,10 +4081,13 @@ added: - v24.20.0 --> -HTTP/3, backed by `nghttp3`, can run on top of a QUIC session by attaching -an [`Http3Session`][]. Negotiating the `'h3'` ALPN tells the peer which -protocol to speak, but does not change how the connection works locally, -so both are needed. See [`new Http3Session()`][] for more details. +HTTP/3, backed by `nghttp3`, runs on top of a QUIC session as an +[`Http3Session`][]. By default, [`quic.listen()`][] and [`quic.connect()`][] +provide one whenever an HTTP/3 ALPN is negotiated (see +[`sessionOptions.autoWrap`][]). + +HTTP/3 can also be configured manually, by setting `autoWrap: false` and using +the [`Http3Session.from()`][] API to attach HTTP/3 to an existing QUIC session. Attaching the HTTP/3 application enables a number of stream- and session-level capabilities that are not available to non-HTTP/3 @@ -4104,13 +4125,13 @@ applications: ### Minimal HTTP/3 client ```mjs -import { connect, Http3Session } from 'node:quic'; +import { connect } from 'node:quic'; import process from 'node:process'; -const session = Http3Session.from(await connect('example.com:443', { +const session = await connect('example.com:443', { alpn: 'h3', servername: 'example.com', -})); +}); await session.opened; const stream = await session.createBidirectionalStream({ @@ -4155,15 +4176,11 @@ A few things to note: ### Minimal HTTP/3 server ```mjs -import { listen, Http3Session } from 'node:quic'; +import { listen } from 'node:quic'; const encoder = new TextEncoder(); -const endpoint = await listen((quicSession) => { - // Attaching HTTP/3 has to happen here, synchronously, before the - // callback returns. - const session = Http3Session.from(quicSession); - +const endpoint = await listen((session) => { // The session.onstream callback fires for each new client-initiated // stream. It is optional here: with `onheaders` configured below, // request streams are consumed through that callback. @@ -4999,6 +5016,7 @@ throughput issues caused by flow control. [`session.onstream`]: #sessiononstream [`session.opened`]: #sessionopened [`session.sendDatagram()`]: #sessionsenddatagramdatagram-encoding +[`sessionOptions.autoWrap`]: #sessionoptionsautowrap [`sessionOptions.cc`]: #sessionoptionscc [`sessionOptions.ciphers`]: #sessionoptionsciphers [`sessionOptions.datagramDropPolicy`]: #sessionoptionsdatagramdroppolicy diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index be9cc64b5323..34571f4c605a 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -19,6 +19,7 @@ const { PromiseResolve, PromiseWithResolvers, SafeSet, + StringPrototypeStartsWith, Symbol, SymbolAsyncDispose, SymbolAsyncIterator, @@ -407,6 +408,8 @@ const endpointRegistry = new SafeSet(); * @property {string|string[]} [alpn] The ALPN protocol identifier(s). * For client sessions, a single string. For server sessions, an array * of protocol names in preference order. + * @property {boolean} [autoWrap] Whether to provide the session wrapped in the + * application matching its ALPN (e.g. an Http3Session for 'h3'). * @property {string} [ciphers] The TLS ciphers * @property {string} [groups] The TLS key-exchange groups * @property {Array<'zlib'|'brotli'|'zstd'>} [certificateCompression] The @@ -1320,6 +1323,21 @@ function updateHeaderInterest(handle, inner) { ); } +/** + * Wraps a new session in the application matching its ALPN, if any. The + * http3 module is loaded lazily, as it depends on this one. + * @param {QuicSession} session + * @param {string} alpn + * @returns {QuicSession|Http3Session} + */ +function autoWrapSession(session, alpn) { + if (alpn === 'h3' || StringPrototypeStartsWith(alpn, 'h3-')) { + const { Http3Session } = require('internal/quic/http3'); + return Http3Session.from(session); + } + return session; +} + /** * Applies session and stream callbacks from an options object to a session. * @param {QuicSession} session @@ -4385,6 +4403,7 @@ class QuicEndpoint { stats: undefined, truncatedReads: undefined, onsession: undefined, + autoWrap: undefined, sessionCallbacks: undefined, }; @@ -4690,10 +4709,12 @@ class QuicEndpoint { onwanttrailers, // Stored on the endpoint and applied to each incoming session. truncatedReads, + autoWrap, ...rest } = options; inner.truncatedReads = truncatedReads; + inner.autoWrap = autoWrap; // Store session and stream callbacks to apply to each new incoming session. inner.sessionCallbacks = { @@ -4725,15 +4746,17 @@ class QuicEndpoint { * Initiates a session with a remote endpoint. * @param {object} address * @param {SessionOptions} [options] - * @returns {QuicSession} + * @param {string} alpn The client's offered ALPN + * @returns {QuicSession|Http3Session} */ - [kConnect](address, options) { + [kConnect](address, options, alpn) { assertEndpointNotClosedOrClosing(this); assertEndpointIsNotBusy(this); validateObject(options, 'options'); const { sessionTicket, truncatedReads, + autoWrap, ...rest } = options; @@ -4750,7 +4773,7 @@ class QuicEndpoint { if (options.verifyPeer !== undefined) { session[kVerifyPeer] = options.verifyPeer; } - return session; + return autoWrap ? autoWrapSession(session, alpn) : session; } /** @@ -4984,6 +5007,8 @@ class QuicEndpoint { if (inner.sessionCallbacks) { applyCallbacks(session, inner.sessionCallbacks); } + const wrapped = inner.autoWrap ? + autoWrapSession(session, session.alpnProtocol) : session; if (onEndpointServerSessionChannel.hasSubscribers) { onEndpointServerSessionChannel.publish({ __proto__: null, @@ -4997,7 +5022,7 @@ class QuicEndpoint { // endpoint with the error rather than surfacing as an unhandled // exception or unhandled rejection coming out of the C++ -> JS // boundary. - safeCallbackInvoke(inner.onsession, this, session); + safeCallbackInvoke(inner.onsession, this, wrapped); } // Called by the QuicSession when it closes to remove itself from @@ -5472,6 +5497,7 @@ function processSessionOptions(options, config = kEmptyObject) { streamIdleTimeout, verifyPeer = 'auto', truncatedReads = 'error', + autoWrap, // Session callbacks that can be set at construction time to avoid // race conditions with events that fire during or immediately // after the handshake. @@ -5554,6 +5580,8 @@ function processSessionOptions(options, config = kEmptyObject) { const tls = processTlsOptions(options, forServer); + if (autoWrap !== undefined) validateBoolean(autoWrap, 'options.autoWrap'); + const actualEndpoint = processEndpointOption(endpoint, reuseEndpoint, forServer, @@ -5584,6 +5612,7 @@ function processSessionOptions(options, config = kEmptyObject) { }, verifyPeer, truncatedReads, + autoWrap: autoWrap ?? true, qlog, maxPayloadSize, unacknowledgedPacketThreshold, @@ -5675,7 +5704,8 @@ async function connect(address, options = kEmptyObject) { }); } - const session = endpoint[kConnect](address[kSocketAddressHandle], rest); + const session = endpoint[kConnect](address[kSocketAddressHandle], rest, + options.alpn); if (onEndpointClientSessionChannel.hasSubscribers) { onEndpointClientSessionChannel.publish({ diff --git a/test/parallel/test-quic-alpn-h3.mjs b/test/parallel/test-quic-alpn-h3.mjs index e2d620c84eba..e72cf05687df 100644 --- a/test/parallel/test-quic-alpn-h3.mjs +++ b/test/parallel/test-quic-alpn-h3.mjs @@ -14,9 +14,9 @@ const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); const cert = fixtures.readKey('agent1-cert.pem'); -// Negotiating the h3 ALPN does not itself activate HTTP/3. The ALPN is -// reported as usual, but the session keeps the default application unless it -// has an Http3Session attached. +// With autoWrap off, negotiating the h3 ALPN does not activate HTTP/3. The +// ALPN is reported as usual, but the session keeps the default application +// unless an Http3Session is attached. const serverOpened = Promise.withResolvers(); @@ -27,6 +27,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { serverOpened.resolve(); }), { alpn: ['h3'], + autoWrap: false, sni: { '*': { keys: [key], certs: [cert] } }, }); @@ -34,6 +35,7 @@ assert.notStrictEqual(serverEndpoint.address, undefined); const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', + autoWrap: false, servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-attach.mjs b/test/parallel/test-quic-h3-attach.mjs index 1369e38d1808..c09f9e4499e9 100644 --- a/test/parallel/test-quic-h3-attach.mjs +++ b/test/parallel/test-quic-h3-attach.mjs @@ -46,16 +46,6 @@ assert.throws(() => new Http3Session(), { code: 'ERR_ILLEGAL_CONSTRUCTOR' }); assert.throws(() => Http3Session.from(quicSession), { code: 'ERR_INVALID_STATE' }); - // HTTP/3 frames every stream, so raw streams can no longer be opened: - const rawRefused = { - code: 'ERR_INVALID_STATE', - message: /Raw QUIC streams cannot be created/, - }; - assert.rejects(quicSession.createUnidirectionalStream(), rawRefused) - .then(mustCall()); - assert.rejects(quicSession.createBidirectionalStream(), rawRefused) - .then(mustCall()); - // Incoming streams are now reported through the Http3Session only: assert.throws(() => { quicSession.onstream = () => {}; }, { code: 'ERR_INVALID_STATE', diff --git a/test/parallel/test-quic-h3-autowrap.mjs b/test/parallel/test-quic-h3-autowrap.mjs new file mode 100644 index 000000000000..f4f81d86f058 --- /dev/null +++ b/test/parallel/test-quic-h3-autowrap.mjs @@ -0,0 +1,81 @@ +// Flags: --experimental-quic --no-warnings + +// Test: sessions arrive wrapped in the application matching their ALPN, +// unless autoWrap is false. Servers know the negotiated protocol before +// surfacing a session, and clients offer exactly one. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect, Http3Session } = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const cert = fixtures.readKey('agent1-cert.pem'); +const clientOpts = { servername: 'localhost', verifyPeer: 'manual' }; + +const isHttp3 = (session) => session instanceof Http3Session; + +// Both sides wrap by ALPN. +{ + const seen = []; + const endpoint = await listen(mustCall((session) => { + seen.push(isHttp3(session)); + session.onerror = () => {}; + }, 3), { + alpn: ['h3', 'h3-29', 'other'], + sni: { '*': { keys: [key], certs: [cert] } }, + }); + + // One protocol: wrapped up front, before the handshake. + const single = await connect(endpoint.address, { ...clientOpts, alpn: 'h3' }); + assert.ok(isHttp3(single)); + assert.throws(() => Http3Session.from(single.quicSession), + { code: 'ERR_INVALID_STATE' }); + await single.opened; + await single.close(); + + // Draft ALPNs count as HTTP/3 too. + const draft = await connect(endpoint.address, { ...clientOpts, alpn: 'h3-29' }); + assert.ok(isHttp3(draft)); + await draft.opened; + await draft.close(); + + // A non-HTTP/3 protocol stays a plain QuicSession on both sides. + const other = await connect(endpoint.address, { ...clientOpts, alpn: 'other' }); + assert.ok(!isHttp3(other)); + await other.opened; + await other.close(); + + await endpoint.close(); + assert.deepStrictEqual(seen, [true, true, false]); +} + +// Opting out gives the raw session on either side, to attach yourself. +{ + const endpoint = await listen(mustCall((quicSession) => { + assert.ok(!isHttp3(quicSession)); + Http3Session.from(quicSession); + }), { + alpn: ['h3'], + autoWrap: false, + sni: { '*': { keys: [key], certs: [cert] } }, + }); + const quicSession = await connect(endpoint.address, + { ...clientOpts, alpn: 'h3', autoWrap: false }); + assert.ok(!isHttp3(quicSession)); + const session = Http3Session.from(quicSession); + await session.opened; + await session.close(); + await endpoint.close(); +} + +for (const autoWrap of [1, 'yes', null]) { + await assert.rejects(connect('127.0.0.1:1', { ...clientOpts, alpn: 'h3', autoWrap }), + { code: 'ERR_INVALID_ARG_TYPE', message: /options\.autoWrap/ }); +} diff --git a/test/parallel/test-quic-h3-uni-stream-teardown.mjs b/test/parallel/test-quic-h3-uni-stream-teardown.mjs index 62aebd804c02..e6fb49e6ea77 100644 --- a/test/parallel/test-quic-h3-uni-stream-teardown.mjs +++ b/test/parallel/test-quic-h3-uni-stream-teardown.mjs @@ -15,7 +15,7 @@ if (!hasQuic) { } const { createPrivateKey } = await import('node:crypto'); -const { listen, connect, Http3Session } = await import('node:quic'); +const { listen, connect } = await import('node:quic'); const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); const cert = fixtures.readKey('agent1-cert.pem'); @@ -25,11 +25,11 @@ const endpoint = await listen(mustNotCall(), { sni: { '*': { keys: [key], certs: [cert] } }, }); -const session = new Http3Session(await connect(endpoint.address, { +const session = await connect(endpoint.address, { alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', -})); +}); const refused = { code: 'ERR_INVALID_STATE', From 8a3b18394eefb311f2ea470959aaf6718524f00e Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Mon, 28 Sep 2026 23:38:01 +0200 Subject: [PATCH 9/9] quic: move pure-QUIC fields back from Http3Session to QuicSession --- doc/api/quic.md | 14 +++++++------- lib/internal/quic/http3.js | 18 +++--------------- test/parallel/test-quic-h3-attach.mjs | 10 ++++++---- 3 files changed, 16 insertions(+), 26 deletions(-) diff --git a/doc/api/quic.md b/doc/api/quic.md index e72e8174c3f2..5221e53fba16 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -4248,10 +4248,11 @@ session should be used instead of the raw QUIC session for all HTTP/3 interactions. The streams that this session exposes are still `QuicStream` instances, but they gain HTTP/3 APIs and functionality from the application. -The HTTP/3 session API exposes all key HTTP/3 session details: the settings, -statistics, TLS identity, and HTTP/3-level events. The QUIC transport details -underneath (e.g. paths, transport parameters, and key updates) remain on the -QUIC session, accessible as [`http3session.quicSession`][]. +The HTTP/3 session API exposes the HTTP/3 session details: the settings, +statistics, and HTTP/3-level events. The connection details underneath (e.g. +the TLS identity and negotiated ALPN, paths, transport parameters, and key +updates) remain on the QUIC session, accessible as +[`http3session.quicSession`][]. HTTP/3 frames every stream on the connection, so once this is attached, streams cannot be opened on the QUIC session directly: @@ -4305,9 +4306,8 @@ added: REPLACEME --> Each of the following behaves exactly as the member of the same name on the -underlying [`QuicSession`][]: `alpnProtocol`, `certificate`, `close()`, -`closed`, `destroy()`, `destroyed`, `ephemeralKeyInfo`, `opened`, -`peerCertificate`, `servername`, and `stats`. +underlying [`QuicSession`][]: `close()`, `closed`, `closing`, `destroy()`, +`destroyed`, `opened`, and `stats`. Any callback set through the `Http3Session` - `onerror` and the HTTP/3-specific ones below - is invoked with the `Http3Session` as `this`. diff --git a/lib/internal/quic/http3.js b/lib/internal/quic/http3.js index d54a832dd58a..2cfabfa787dd 100644 --- a/lib/internal/quic/http3.js +++ b/lib/internal/quic/http3.js @@ -206,21 +206,6 @@ class Http3Session { */ get settings() { return this.#session.applicationOptions; } - /** @type {string|undefined} */ - get servername() { return this.#session.servername; } - - /** @type {string|undefined} */ - get alpnProtocol() { return this.#session.alpnProtocol; } - - /** @type {object|undefined} */ - get certificate() { return this.#session.certificate; } - - /** @type {object|undefined} */ - get peerCertificate() { return this.#session.peerCertificate; } - - /** @type {object|undefined} */ - get ephemeralKeyInfo() { return this.#session.ephemeralKeyInfo; } - /** @type {quic.QuicSession.Stats} */ get stats() { return this.#session.stats; } @@ -283,6 +268,9 @@ class Http3Session { /** @type {Promise} */ get closed() { return this.#session.closed; } + /** @type {boolean} */ + get closing() { return this.#session.closing; } + /** @type {boolean} */ get destroyed() { return this.#session.destroyed; } diff --git a/test/parallel/test-quic-h3-attach.mjs b/test/parallel/test-quic-h3-attach.mjs index c09f9e4499e9..788cecc57aa5 100644 --- a/test/parallel/test-quic-h3-attach.mjs +++ b/test/parallel/test-quic-h3-attach.mjs @@ -69,10 +69,12 @@ assert.throws(() => new Http3Session(), { code: 'ERR_ILLEGAL_CONSTRUCTOR' }); const client = Http3Session.from(quicClient); await client.opened; - assert.strictEqual(client.alpnProtocol, 'h3'); - assert.strictEqual(client.servername, 'localhost'); + // Connection details, TLS included, stay on the QUIC session: + assert.strictEqual(client.quicSession.alpnProtocol, 'h3'); + assert.strictEqual(client.quicSession.servername, 'localhost'); + assert.strictEqual('peerCertificate' in client, false); assert.strictEqual(typeof client.stats.createdAt, 'bigint'); - assert.strictEqual(typeof client.ephemeralKeyInfo, 'object'); + assert.strictEqual(client.closing, client.quicSession.closing); await client.close(); await endpoint.close(); } @@ -126,7 +128,7 @@ const tooLate = { // Further already-settled awaits are still the same checkpoint. await null; const http3 = Http3Session.from(client); - assert.strictEqual(http3.alpnProtocol, 'h3'); + assert.strictEqual(http3.quicSession, client); await http3.close(); await endpoint.close(); }