From 1f983e389ca1e2612af5ab39f062e6bf874af90f Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Fri, 11 Sep 2026 17:01:53 +0200 Subject: [PATCH 01/12] 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 05de774dbd9f..b19e0bcf8974 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. @@ -2704,7 +2708,8 @@ added: * Type: {Object} -The application specific options. +The application specific options, configured for HTTP/3 with +[`new Http3Session()`][]. #### `applicationOptions.maxHeaderPairs` @@ -3104,36 +3109,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` @@ -3555,6 +3531,7 @@ contain: ```mjs const endpoint = await listen(callback, { + alpn: ['h3'], sni: { '*': { keys: [defaultKey], certs: [defaultCert] }, 'api.example.com': { keys: [apiKey], certs: [apiCert], port: 8443 }, @@ -4136,13 +4113,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: @@ -4178,13 +4154,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({ @@ -4229,17 +4205,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`, @@ -4288,6 +4268,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. @@ -4007,7 +3959,7 @@ added: - v24.20.0 --> -* `this` {quic.QuicSession} +* `this` {quic.Http3Session} * `origins` {string\[]} The list of origins the server is authoritative for. ### Callback: `OnKeylogCallback` @@ -4139,10 +4091,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 @@ -4157,7 +4109,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', })); @@ -4212,7 +4164,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, @@ -4291,9 +4243,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])` @@ -4363,7 +4337,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` @@ -4373,28 +4360,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` @@ -4953,6 +4944,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 @@ -4982,12 +4974,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 @@ -4995,15 +4990,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 734a1390e09e..1d61b32bf319 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -464,11 +464,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. @@ -1340,11 +1337,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, @@ -1574,6 +1568,7 @@ let isQuicStream; let isQuicSession; let getQuicSessionHandle; let createApplicationStream; +let setApplicationCallback; let isQuicEndpoint; function maybeGetCloseError(context, status, pendingError) { @@ -2931,6 +2926,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, @@ -2984,6 +2981,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; @@ -3032,7 +3044,6 @@ class QuicSession { return this.#handle.applicationOptions(); } - get localTransportParams() { if (this.#inner.localTransportParams !== undefined) { return this.#inner.localTransportParams; @@ -3095,14 +3106,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); } } @@ -3114,12 +3130,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; + } } } @@ -3275,25 +3303,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); @@ -3364,42 +3373,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. @@ -3835,6 +3808,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. @@ -3907,6 +3884,7 @@ class QuicSession { } inner.onerror = undefined; + inner.onapperror = undefined; inner.onstream = undefined; inner.ondatagram = undefined; inner.ondatagramstatus = undefined; @@ -4769,11 +4747,8 @@ class QuicEndpoint { onhandshake, onnewtoken, onearlyrejected, - onorigin, - ongoaway, onkeylog, onqlog, - onapplication, // Stream-level callbacks applied to each incoming stream. onheaders, ontrailers, @@ -4799,11 +4774,8 @@ class QuicEndpoint { onhandshake, onnewtoken, onearlyrejected, - onorigin, - ongoaway, onkeylog, onqlog, - onapplication, onheaders, ontrailers, oninfo, @@ -5586,12 +5558,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, @@ -5714,11 +5682,8 @@ function processSessionOptions(options, config = kEmptyObject) { onhandshake, onnewtoken, onearlyrejected, - onorigin, - ongoaway, onkeylog, onqlog, - onapplication, onheaders, ontrailers, oninfo, @@ -5844,6 +5809,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 495a20819f66..c42d7e4fe034 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 0e63cec910a8d328b47bac438f14836670084409 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Mon, 28 Sep 2026 19:41:52 +0200 Subject: [PATCH 07/12] 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 967d9e710eb9..7df687ab8e61 100644 --- a/src/quic/endpoint.cc +++ b/src/quic/endpoint.cc @@ -1996,7 +1996,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 9e7e6971eb34..f37f4fd29763 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -2628,7 +2628,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)); @@ -2638,9 +2638,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; @@ -2864,7 +2871,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; @@ -3407,15 +3414,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 70b2ce111dde..67d5f18c0be3 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 954233d3aa4d63240e44d79fefb370ed75f7778c Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Mon, 28 Sep 2026 21:24:16 +0200 Subject: [PATCH 08/12] 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 937a9f4a7d37..85978177ff70 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. @@ -3063,6 +3064,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 @@ -4065,10 +4083,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 @@ -4106,13 +4127,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({ @@ -4157,15 +4178,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. @@ -5001,6 +5018,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 1d61b32bf319..8c14d5878f99 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -20,6 +20,7 @@ const { PromiseResolve, PromiseWithResolvers, SafeSet, + StringPrototypeStartsWith, Symbol, SymbolAsyncDispose, SymbolAsyncIterator, @@ -408,6 +409,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 @@ -1321,6 +1324,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 @@ -4451,6 +4469,7 @@ class QuicEndpoint { stats: undefined, truncatedReads: undefined, onsession: undefined, + autoWrap: undefined, sessionCallbacks: undefined, }; @@ -4756,10 +4775,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 = { @@ -4791,15 +4812,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; @@ -4816,7 +4839,7 @@ class QuicEndpoint { if (options.verifyPeer !== undefined) { session[kVerifyPeer] = options.verifyPeer; } - return session; + return autoWrap ? autoWrapSession(session, alpn) : session; } /** @@ -5050,6 +5073,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, @@ -5063,7 +5088,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 @@ -5545,6 +5570,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. @@ -5627,6 +5653,8 @@ function processSessionOptions(options, config = kEmptyObject) { const tls = processTlsOptions(options, forServer); + if (autoWrap !== undefined) validateBoolean(autoWrap, 'options.autoWrap'); + const actualEndpoint = processEndpointOption(endpoint, reuseEndpoint, forServer, @@ -5657,6 +5685,7 @@ function processSessionOptions(options, config = kEmptyObject) { }, verifyPeer, truncatedReads, + autoWrap: autoWrap ?? true, qlog, maxPayloadSize, unacknowledgedPacketThreshold, @@ -5748,7 +5777,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 157777e5fee1c91b240349c78d542f62fd00a3b6 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Mon, 28 Sep 2026 23:38:01 +0200 Subject: [PATCH 09/12] 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 85978177ff70..efcc71e8024d 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -4250,10 +4250,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: @@ -4307,9 +4308,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(); } From c1d4200f2d49ac1cb04a8658a511d0d86d173c56 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Wed, 7 Oct 2026 18:18:12 +0200 Subject: [PATCH 10/12] quic: split quic connection & session, rename autoWrap to autoStart Signed-off-by: Tim Perry --- benchmark/quic/handshake.js | 7 +- doc/api/quic.md | 932 ++++++++++-------- lib/internal/quic/http3.js | 187 +--- lib/internal/quic/quic.js | 774 ++++++++------- lib/internal/quic/state.js | 110 +-- lib/internal/quic/stats.js | 90 +- lib/internal/quic/symbols.js | 4 + lib/quic.js | 2 + src/quic/README.md | 41 +- src/quic/application.cc | 3 + src/quic/application.h | 9 +- src/quic/bindingdata.cc | 12 - src/quic/bindingdata.h | 4 +- src/quic/endpoint.cc | 15 +- src/quic/http3.cc | 50 +- src/quic/http3.h | 17 +- src/quic/quic.cc | 3 - src/quic/session.cc | 206 ++-- src/quic/session.h | 41 +- src/quic/tlscontext.cc | 3 + src/quic/tlscontext.h | 6 +- test/parallel/test-quic-alpn-h3.mjs | 25 +- .../test-quic-datagram-drop-oldest.mjs | 1 + .../test-quic-diagnostics-channel-session.mjs | 2 +- .../test-quic-early-selection-order.mjs | 2 +- .../parallel/test-quic-edge-destroyed-ops.mjs | 10 +- ...c-endpoint-destroy-cascade-close-frame.mjs | 2 +- test/parallel/test-quic-endpoint-reuse.mjs | 8 +- test/parallel/test-quic-exports.mjs | 3 +- test/parallel/test-quic-h3-attach.mjs | 330 ------- test/parallel/test-quic-h3-auto-start.mjs | 114 +++ test/parallel/test-quic-h3-autowrap.mjs | 81 -- .../parallel/test-quic-h3-callback-errors.mjs | 61 +- test/parallel/test-quic-h3-datagram.mjs | 33 +- test/parallel/test-quic-h3-goaway.mjs | 6 +- .../test-quic-h3-handshake-failure.mjs | 2 +- test/parallel/test-quic-h3-manual-start.mjs | 235 +++++ test/parallel/test-quic-h3-origin.mjs | 12 +- test/parallel/test-quic-h3-qpack-settings.mjs | 34 +- test/parallel/test-quic-h3-settings.mjs | 44 +- test/parallel/test-quic-h3-start-failure.mjs | 68 -- ...st-quic-h3-stream-destroy-no-resurrect.mjs | 3 +- .../test-quic-h3-stream-without-onstream.mjs | 12 +- ...quic-h3-uni-stream-limit-start-failure.mjs | 66 ++ .../test-quic-h3-uni-stream-teardown.mjs | 42 - ...est-quic-h3-zero-rtt-rejected-settings.mjs | 36 +- ...est-quic-internal-endpoint-stats-state.mjs | 16 +- test/parallel/test-quic-key-update-peer.mjs | 2 +- test/parallel/test-quic-key-update.mjs | 2 +- .../test-quic-multipacket-clienthello.mjs | 2 +- test/parallel/test-quic-perf-hooks.mjs | 4 +- .../test-quic-session-application-options.mjs | 122 +-- .../test-quic-session-destroy-reentrant.mjs | 4 +- .../test-quic-session-emit-ordering.mjs | 14 +- ...st-quic-session-preferred-address-ipv6.mjs | 2 +- .../test-quic-session-preferred-address.mjs | 2 +- .../parallel/test-quic-session-properties.mjs | 30 +- .../test-quic-session-stream-lifecycle.mjs | 6 +- .../test-quic-session-transport-params.mjs | 8 +- .../test-quic-stats-tojson-inspect.mjs | 4 +- test/parallel/test-quic-tls-verify-client.mjs | 2 +- .../test-quic-zero-rtt-disabled-server.mjs | 3 +- 62 files changed, 1884 insertions(+), 2087 deletions(-) delete mode 100644 test/parallel/test-quic-h3-attach.mjs create mode 100644 test/parallel/test-quic-h3-auto-start.mjs delete mode 100644 test/parallel/test-quic-h3-autowrap.mjs create mode 100644 test/parallel/test-quic-h3-manual-start.mjs delete mode 100644 test/parallel/test-quic-h3-start-failure.mjs create mode 100644 test/parallel/test-quic-h3-uni-stream-limit-start-failure.mjs delete mode 100644 test/parallel/test-quic-h3-uni-stream-teardown.mjs diff --git a/benchmark/quic/handshake.js b/benchmark/quic/handshake.js index 2f886a54815d..9f0404008e03 100644 --- a/benchmark/quic/handshake.js +++ b/benchmark/quic/handshake.js @@ -11,8 +11,8 @@ const { createPrivateKey } = require('crypto'); const bench = common.createBenchmark(main, { // 'raw' negotiates a non-HTTP ALPN and does no application work. - // 'h3' installs HTTP/3 on every session, so each peer also builds an - // nghttp3 connection and its control/QPACK streams. + // 'h3' negotiates HTTP/3, so the server also builds an nghttp3 connection + // and its control/QPACK streams for every session. protocol: ['raw', 'h3'], concurrency: [1, 10], n: [1000], @@ -23,8 +23,7 @@ async function main({ protocol, concurrency, n }) { const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); const cert = fixtures.readKey('agent1-cert.pem'); - const http3 = protocol === 'h3'; - const alpn = http3 ? 'h3' : 'quic-bench'; + const alpn = protocol === 'h3' ? 'h3' : 'quic-bench'; const endpoint = await listen((session) => { // A benchmark peer never reads these; swallow so a torn-down session diff --git a/doc/api/quic.md b/doc/api/quic.md index efcc71e8024d..d8aeffa95bd8 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -67,18 +67,21 @@ is strongly recommended for users of this module. ## Architecture -The `quic` module is built around three core abstractions: +The `quic` module is built around four core abstractions: * `QuicEndpoint`: represents the local UDP socket binding for QUIC. It is used to send and receive QUIC packets and can be shared across multiple sessions. A single endpoint can be used as both a client and a server simultaneously. -* `QuicSession`: represents a QUIC connection between the local endpoint and - a remote peer. A session is created either by initiating a connection to a +* `QuicConnection`: represents a QUIC connection between the local endpoint + and a remote peer. A connection is created either by initiating it to a remote peer using `quic.connect()` or by accepting an incoming connection from a remote peer via `quic.listen()`. +* `QuicSession` and `Http3Session`: the application protocol session started + on a connection, which carries its data - raw QUIC, or HTTP/3. + * `QuicStream`: represents a QUIC stream within a session. Streams are created by either local or remote peers and can be bidirectional or unidirectional. @@ -239,20 +242,25 @@ counter tracks how many packets have been dropped by the filter. ### Applications -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. +Every `QuicConnection` is associated with a single application protocol. The +application protocol is selected by starting an application session on the +connection, either automatically by using `autoStart` with ALPN negotiation, or +by using `autoStart: false` and calling `.start(connection)` from a session +class. + +The `quic` module is designed to be application-agnostic in general, but also +includes 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 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. +(if any) and includes it in the `ServerHello`. For example, when the `h3` +protocol is negotiated for HTTP/3 and [`sessionOptions.autoStart`][] is enabled +(the default), connections will be exposed as instances of [`Http3Session`][] +which exposes APIs for various HTTP/3-specific features. 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 @@ -263,9 +271,9 @@ API. The QUIC API is designed to be flexible and highly configurable to support a wide range of use cases. Users can configure various aspects of the QUIC transport, TLS handshake, and application behavior via options passed to the `quic.connect()` -and `quic.listen()` functions, as well as dynamically on `QuicEndpoint` and -`QuicSession` instances. The API also provides access to detailed statistics and -events for monitoring and debugging. +and `quic.listen()` functions, as well as dynamically on `QuicEndpoint`, +`QuicConnection`, and session instances. The API also provides access to +detailed statistics and events for monitoring and debugging. QUIC transport parameters are exchanged during the TLS handshake to negotiate various transport-level settings such as maximum stream counts, idle timeouts, @@ -285,7 +293,7 @@ operations. For example, initiating a connection with `quic.connect()` returns a promise for the established session, while incoming sessions on the server side are handled via a callback passed to `quic.listen()`. Within a session, events such as incoming streams, datagrams, and session state changes are handled -via callbacks on the `QuicSession` instance. Promises are used for operations +via callbacks on the session and its connection. Promises are used for operations that have a clear completion point, such as completion of the TLS handshake or graceful closure of a session. @@ -382,11 +390,11 @@ reconnection. Two pieces of state from a prior connection make this possible: -* A **session ticket**, received via the [`session.onsessionticket`][] callback, +* A **session ticket**, received via the [`connection.onsessionticket`][] callback, enables TLS session resumption and 0-RTT encryption. Pass it as the [`sessionOptions.sessionTicket`][] option on a subsequent connection to the same server. -* An **address validation token**, received via the [`session.onnewtoken`][] +* An **address validation token**, received via the [`connection.onnewtoken`][] callback, allows the client to skip the server's address validation step (avoiding a Retry round-trip). Pass it as the [`sessionOptions.token`][] option. @@ -396,7 +404,7 @@ completes is 0-RTT early data. On the server side, `stream.early` is `true` for streams carrying early data. The server can reject the 0-RTT attempt (for example, if its configuration has changed since the ticket was issued). When this happens, all streams opened during the 0-RTT phase are destroyed and -the client's [`session.onearlyrejected`][] callback fires. The connection +the client's [`connection.onearlyrejected`][] callback fires. The connection falls back to a normal 1-RTT handshake and the application can reopen streams. Early data is less secure than data sent after the handshake completes — it @@ -409,7 +417,7 @@ during the early data phase. A typical client session progresses through these stages: 1. Call [`quic.connect()`][] with a server address and options. This returns a - `QuicSession`. + session: `Http3Session` for HTTP/3 or `QuicSession` for other protocols. 2. The TLS handshake runs automatically. `session.opened` resolves when the handshake completes, providing the negotiated ALPN, cipher, and certificate validation results. @@ -424,18 +432,19 @@ streams arrive via the [`session.onstream`][] callback, or, for HTTP/3 sessions with an `onheaders` callback configured, directly through that callback (see the [minimal HTTP/3 server][] example). -[`session.destroy()`][] is available for immediate teardown — all open streams +[`connection.destroy()`][] is available for immediate teardown — all open streams are destroyed and the session is closed without waiting for them to finish. -`QuicEndpoint` and `QuicSession` support `Symbol.asyncDispose`, so they can -be used with `await using` for automatic cleanup. +`QuicEndpoint`, `QuicConnection`, `QuicSession`, and `Http3Session` support +`Symbol.asyncDispose`, so they can be used with `await using` for automatic +cleanup. ### Error handling Errors in the `quic` module are communicated through two complementary mechanisms: the `onerror` callback and the `closed` promise. -Both `QuicSession` and `QuicStream` expose an optional `onerror` callback. +Sessions and `QuicStream` expose an optional `onerror` callback. When a session or stream is destroyed with an error — including errors thrown by other user callbacks — the `onerror` callback is invoked with the error before the object is torn down. Setting `onerror` also marks the `closed` @@ -476,8 +485,9 @@ added: v23.8.0 * `address` {string|net.SocketAddress} * `options` {quic.SessionOptions} -* Returns: {Promise} a promise for a {quic.QuicSession}, or {quic.Http3Session} - if [`sessionOptions.autoWrap`][] is enabled and an HTTP/3 ALPN is negotiated. +* Returns: {Promise} a promise for a {quic.QuicSession} or {quic.Http3Session}, + depending on ALPN negotiation, if [`sessionOptions.autoStart`][] is true + (the default) or for a {quic.QuicConnection} otherwise. Initiate a new client-side session. @@ -946,59 +956,60 @@ added: v23.8.0 * Type: {bigint} The total number of incoming packets dropped by the block list filter. Read only. -## Class: `QuicSession` +## Class: `QuicConnection` -A `QuicSession` represents the local side of a QUIC connection. +A `QuicConnection` represents the local side of a QUIC connection: its TLS +state, network path, transport parameters, statistics, and lifecycle. +Application data is carried by a session started on the connection: a +[`QuicSession`][] for raw QUIC, or an [`Http3Session`][] for HTTP/3. Each +session exposes its connection as `session.connection`. -### `session.applicationOptions` +### Starting a session -* Type: {quic.ApplicationOptions} +By default [`sessionOptions.autoStart`][] is `true`, and sessions are started +and provided automatically by both [`quic.connect()`][] and [`quic.listen()`][] +according to the ALPN protocol negotiated on the connection. -The current application-level options for this session. These include settings -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 attached. Read only. -You can use the callback [`http3session.onsettings`][] to be informed, when settings -from the remote arrive. +If this is set to `false`, both APIs will instead provide a [`QuicConnection`][] +and the session on top must be started manually. When doing so, a server must +start the session synchronously inside the [`quic.listen()`][] callback, and +a client must start one within the tick when its [`connection.opened`][] +promise resolves. A connection with no session started by then is closed with +an error. -### `session.close([options])` +Starting a session throws `ERR_INVALID_STATE` if the connection already has +one, has been destroyed, or if the local session initialization fails. + +### `connection.applicationOptions` -* `options` {Object} - * `code` {bigint|number} The error code to include in the `CONNECTION_CLOSE` - frame sent to the peer. Must be a non-negative 62-bit unsigned varint - (`0n <= code <= 2n ** 62n - 1n`). **Default:** `0` (no error). - * `type` {string} Either `'transport'` or `'application'`. Determines the - error code namespace used in the `CONNECTION_CLOSE` frame. When `'transport'` - (the default), the frame type is `0x1c` and the code is interpreted as a QUIC - transport error. When `'application'`, the frame type is `0x1d` and the code - is application-specific. **Default:** `'transport'`. - * `reason` {string} An optional human-readable reason string included in - the `CONNECTION_CLOSE` frame. Per RFC 9000, this is for diagnostic purposes - only and should not be used for machine-readable error descriptions. -* Returns: {Promise} +* Type: {quic.ApplicationOptions} -Initiate a graceful close of the session. Existing streams will be allowed -to complete but no new streams will be opened. Once all streams have closed, -the session will be destroyed. The returned promise will be fulfilled once -the session has been destroyed. If a non-zero `code` is specified, the -promise will reject with an `ERR_QUIC_TRANSPORT_ERROR` or -`ERR_QUIC_APPLICATION_ERROR` depending on the `type`. +The current application-level options for this connection. 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. +You can use the callback [`http3session.onsettings`][] to be informed, when settings +from the remote arrive. -### `session.opened` +### `connection.opened` - -* Type: {Function|undefined} - -An optional callback invoked when the session is destroyed with an error. -This includes errors caused by user callbacks that throw or reject (see -[Callback error handling][]). The callback receives a single argument: the -error that triggered the destruction. If the `onerror` callback itself throws -or returns a promise that rejects, the error is surfaced as an uncaught -exception. Read/write. - -Can also be set via the `onerror` option in [`quic.connect()`][] or -[`quic.listen()`][]. - -### `session.onstream` - - - -* Type: {quic.OnStreamCallback} - -The callback to invoke when a new stream is initiated by a remote peer. Read/write. - -Setting this on a `QuicSession`, including via the `onstream` option in -[`quic.connect()`][] or [`quic.listen()`][], selects raw QUIC for the session, -so HTTP/3 can no longer be attached to it. - -If no `onstream` callback is set and the stream has no other consumer, an -incoming stream is destroyed on arrival and a warning is emitted. An -`onheaders` callback counts as a consumer when the negotiated application -protocol supports it (e.g. HTTP/3), because it is invoked for every incoming -request stream. Other stream-level callbacks (`ontrailers`, `oninfo`, -`onwanttrailers`) do not, since they are conditional or outbound-only and -would leave the stream unobservable. An HTTP/3 server that handles requests -entirely through `onheaders` does not need to set `onstream`. - -### `session.ondatagram` - - - -* Type: {quic.OnDatagramCallback} - -The callback to invoke when a new datagram is received from a remote peer. Read/write. - -### `session.ondatagramstatus` - - - -* Type: {quic.OnDatagramStatusCallback} - -The callback to invoke when the status of a datagram is updated. Read/write. - -### `session.onearlyrejected` +### `connection.onearlyrejected` + +* Type: {Object|undefined} + * `local` {net.SocketAddress} + * `remote` {net.SocketAddress} + +The local and remote socket addresses associated with the connection. Read only. + +### `connection.remoteTransportParams` + + + +* Type: {quic.TransportParams|null|undefined} + +The transport parameters advertised by the remote peer during the handshake. +Returns `null` if the connection has been destroyed, `undefined` if the +handshake has not yet completed and the remote parameters are not yet +available. Read only. + +### `connection.servername` + + + +* Type: {string|boolean|null} + +The SNI (Server Name Indication) host name associated with the connection. This is +`null` before the client hello is processed. Once the hello has been +processed, this is either the host name string or `false` if the handshake +had no SNI. + +### `connection.alpnProtocol` + + + +* Type: {string|null} + +The negotiated ALPN protocol. This is `null` before the client hello is +processed. Once ALPN has been negotiated, this is the protocol string. ALPN +is mandatory in QUIC so this is never `false` on successful connections, +unlike `node:tls` where this is optional. + +### `connection.certificate` + + + +* Type: {crypto.X509Certificate|undefined} + +The local certificate as a [`crypto.X509Certificate`][] instance. Server +connections return the certificate configured for the negotiated SNI host. +Client connections return `undefined` unless a client certificate was sent. +Returns `undefined` if the connection is destroyed. + +### `connection.peerCertificate` + + + +* Type: {crypto.X509Certificate|undefined} + +The peer's certificate as a [`crypto.X509Certificate`][] instance. Returns +`undefined` if the peer did not present a certificate or the connection is +destroyed. + +### `connection.ephemeralKeyInfo` + + + +* Type: {Object|undefined} + +The ephemeral key information for the connection, with properties such as +`type`, `name`, and `size`. Only available on client connections. Returns +`undefined` for server connections or if the connection is destroyed. + +### `connection.stats` + + + +* Type: {quic.QuicConnection.Stats} + +Return the current statistics for the connection. Read only. + +### `connection.updateKey()` + + + +Initiate a key update for the connection. + +### `connection[Symbol.asyncDispose]()` + + + +Calls `connection.destroy()`. To close gracefully, dispose of the session +started on the connection instead. + +## Class: `QuicSession` + + + +A `QuicSession` is a raw QUIC session, which exchanges application data directly +over the streams and datagrams of its [`QuicConnection`][]. This provides raw +QUIC APIs so that custom application protocols can be implemented on top. + +### `QuicSession.start(connection)` + + + +* `connection` {quic.QuicConnection} The connection to start the session on. +* Returns: {quic.QuicSession} + +Starts a raw QUIC session on a connection that has no session yet. See +[Starting a session][]. + +### Members forwarded to the QUIC connection + + + +Each of the following behaves exactly as the member of the same name on the +underlying [`QuicConnection`][]: `closed`, `closing`, `destroy()`, +`destroyed`, `opened`, and `stats`. + +Any callback set through the `QuicSession` is invoked with the `QuicSession` +as `this`. + +### `session.connection` + + + +* Type: {quic.QuicConnection} + +The QUIC connection on which this session is running. + +### `session.close([options])` + + + +* `options` {Object} + * `code` {bigint|number} The error code to include in the `CONNECTION_CLOSE` + frame sent to the peer. Must be a non-negative 62-bit unsigned varint + (`0n <= code <= 2n ** 62n - 1n`). **Default:** `0` (no error). + * `type` {string} Either `'transport'` or `'application'`. Determines the + error code namespace used in the `CONNECTION_CLOSE` frame. When `'transport'` + (the default), the frame type is `0x1c` and the code is interpreted as a QUIC + transport error. When `'application'`, the frame type is `0x1d` and the code + is application-specific. **Default:** `'transport'`. + * `reason` {string} An optional human-readable reason string included in + the `CONNECTION_CLOSE` frame. Per RFC 9000, this is for diagnostic purposes + only and should not be used for machine-readable error descriptions. +* Returns: {Promise} + +Initiate a graceful close of the session. Existing streams will be allowed +to complete but no new streams will be opened. Once all streams have closed, +the session will be destroyed. The returned promise will be fulfilled once +the session has been destroyed. If a non-zero `code` is specified, the +promise will reject with an `ERR_QUIC_TRANSPORT_ERROR` or +`ERR_QUIC_APPLICATION_ERROR` depending on the `type`. + +### `session.onerror` + + + +* Type: {Function|undefined} + +An optional callback invoked when the session is destroyed with an error. +This includes errors caused by user callbacks that throw or reject (see +[Callback error handling][]). The callback receives a single argument: the +error that triggered the destruction. If the `onerror` callback itself throws +or returns a promise that rejects, the error is surfaced as an uncaught +exception. Read/write. + +Can also be set via the `onerror` option in [`quic.connect()`][] or +[`quic.listen()`][]. + +### `session.onstream` + + + +* Type: {quic.OnStreamCallback} + +The callback to invoke when a new stream is initiated by a remote peer. Read/write. + +If no `onstream` callback is set and the stream has no other consumer, an +incoming stream is destroyed on arrival and a warning is emitted. An +`onheaders` callback counts as a consumer when the negotiated application +protocol supports it (e.g. HTTP/3), because it is invoked for every incoming +request stream. Other stream-level callbacks (`ontrailers`, `oninfo`, +`onwanttrailers`) do not, since they are conditional or outbound-only and +would leave the stream unobservable. An HTTP/3 server that handles requests +entirely through `onheaders` does not need to set `onstream`. + +### `session.ondatagram` + + + +* Type: {quic.OnDatagramCallback} + +The callback to invoke when a new datagram is received from a remote peer. Read/write. + +### `session.ondatagramstatus` + + + +* Type: {quic.OnDatagramStatusCallback} + +The callback to invoke when the status of a datagram is updated. Read/write. + ### `session.createBidirectionalStream([options])` - -* Type: {Object|undefined} - * `local` {net.SocketAddress} - * `remote` {net.SocketAddress} - -The local and remote socket addresses associated with the session. Read only. - -### `session.remoteTransportParams` - - - -* Type: {quic.TransportParams|null|undefined} - -The transport parameters advertised by the remote peer during the handshake. -Returns `null` if the session has been destroyed, `undefined` if the handshake -has not yet completed and the remote parameters are not yet available. Read -only. - ### `session.sendDatagram(datagram[, encoding])` - -* Type: {string|boolean|null} - -The SNI (Server Name Indication) host name associated with the session. This is -`null` before the client hello is processed. Once the hello has been -processed, this is either the host name string or `false` if the handshake -had no SNI. - -### `session.alpnProtocol` - - - -* Type: {string|null} - -The negotiated ALPN protocol. This is `null` before the client hello is -processed. Once ALPN has been negotiated, this is the protocol string. ALPN -is mandatory in QUIC so this is never `false` on successful connections, -unlike `node:tls` where this is optional. - -### `session.certificate` - - - -* Type: {crypto.X509Certificate|undefined} - -The local certificate as a [`crypto.X509Certificate`][] instance. Server -sessions return the certificate configured for the negotiated SNI host. -Client sessions return `undefined` unless a client certificate was sent. -Returns `undefined` if the session is destroyed. - -### `session.peerCertificate` - - - -* Type: {crypto.X509Certificate|undefined} - -The peer's certificate as a [`crypto.X509Certificate`][] instance. Returns -`undefined` if the peer did not present a certificate or the session is -destroyed. - -### `session.ephemeralKeyInfo` - - - -* Type: {Object|undefined} - -The ephemeral key information for the session, with properties such as -`type`, `name`, and `size`. Only available on client sessions. Returns -`undefined` for server sessions or if the session is destroyed. - ### `session.maxDatagramSize` - -* Type: {quic.QuicSession.Stats} - -Return the current statistics for the session. Read only. - -### `session.updateKey()` - - - -Initiate a key update for the session. - ### `session[Symbol.asyncDispose]()` -* Type: {quic.QuicSession|null} +* Type: {quic.QuicSession|quic.Http3Session|null} The session that created this stream, or `null` if the stream has been destroyed. Read only. @@ -2662,7 +2749,7 @@ added: * Type: {Object} The application specific options, configured for HTTP/3 with -[`Http3Session.from()`][]. +[`Http3Session.start()`][]. #### `applicationOptions.maxHeaderPairs` @@ -3064,7 +3151,36 @@ list that the client also supports. This option is required; omitting it throws `ERR_MISSING_OPTION`. -#### `sessionOptions.autoWrap` +#### `sessionOptions.application` + + + +* Type: {quic.ApplicationOptions} + +Application-specific options, such as the HTTP/3 settings of an +[`Http3Session`][], for the session started by [`sessionOptions.autoStart`][]. +When `autoStart` is `false`, pass the settings to [`Http3Session.start()`][] +instead. + +```mjs +const { listen } = await import('node:quic'); + +await listen((session) => { /* ... */ }, { + alpn: ['h3'], + application: { + maxHeaderPairs: 64, + qpackMaxDTableCapacity: 8192, + enableDatagrams: true, + }, + // ... other session options +}); +``` + +#### `sessionOptions.autoStart` * `this` {quic.QuicEndpoint} -* `session` {quic.QuicSession|quic.Http3Session} +* `session` {quic.QuicSession|quic.Http3Session|quic.QuicConnection} The + session started on the new connection, or the connection itself if + [`sessionOptions.autoStart`][] is `false`. 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 @@ -3850,7 +3973,7 @@ never surfaced. added: v23.8.0 --> -* `this` {quic.QuicSession} +* `this` {quic.QuicSession|quic.Http3Session} * `stream` {quic.QuicStream} ### Callback: `OnDatagramCallback` @@ -3859,7 +3982,7 @@ added: v23.8.0 added: v23.8.0 --> -* `this` {quic.QuicSession} +* `this` {quic.QuicSession|quic.Http3Session} * `datagram` {Uint8Array} * `early` {boolean} @@ -3869,7 +3992,7 @@ added: v23.8.0 added: v23.8.0 --> -* `this` {quic.QuicSession} +* `this` {quic.QuicSession|quic.Http3Session} * `id` {bigint} * `status` {string} One of `'acknowledged'`, `'lost'`, or `'abandoned'`. `'acknowledged'` means the peer confirmed receipt. `'lost'` means the @@ -3896,7 +4019,7 @@ may arrive after the connection is established. added: v23.8.0 --> -* `this` {quic.QuicSession} +* `this` {quic.QuicConnection} * `result` {string} One of either `'success'`, `'failure'`, or `'aborted'`. * `newLocalAddress` {net.SocketAddress} The local address of the validated path. * `newRemoteAddress` {net.SocketAddress} The remote address of the validated path. @@ -3914,7 +4037,7 @@ added: v23.8.0 added: v23.8.0 --> -* `this` {quic.QuicSession} +* `this` {quic.QuicConnection} * `ticket` {Object} ### Callback: `OnVersionNegotiationCallback` @@ -3923,7 +4046,7 @@ added: v23.8.0 added: v23.8.0 --> -* `this` {quic.QuicSession} +* `this` {quic.QuicConnection} * `version` {number} The QUIC version that was configured for this session (the version that the server did not support). * `requestedVersions` {number\[]} The versions advertised by the server in @@ -3942,8 +4065,8 @@ callback returns. added: v23.8.0 --> -* `this` {quic.QuicSession} -* `info` {Object} The same object that `session.opened` resolves with. +* `this` {quic.QuicConnection} +* `info` {Object} The same object that `connection.opened` resolves with. * `local` {net.SocketAddress} The local socket address. * `remote` {net.SocketAddress} The remote socket address. * `servername` {string} The SNI server name negotiated during the handshake. @@ -3965,7 +4088,7 @@ added: - v24.20.0 --> -* `this` {quic.QuicSession} +* `this` {quic.QuicConnection} * `token` {Buffer} The NEW\_TOKEN token data. * `address` {SocketAddress} The remote address the token is associated with. @@ -3988,7 +4111,7 @@ added: - v24.20.0 --> -* `this` {quic.QuicSession} +* `this` {quic.QuicConnection} * `line` {string} A single line of [NSS Key Log Format][] text, including a trailing newline character. @@ -4005,7 +4128,7 @@ added: - v24.20.0 --> -* `this` {quic.QuicSession} +* `this` {quic.QuicConnection} * `data` {string} A chunk of [JSON-SEQ][] formatted [qlog][] data. * `fin` {boolean} `true` if this is the final qlog chunk for the session. @@ -4086,12 +4209,12 @@ added: 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`][]). +[`sessionOptions.autoStart`][]). -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. +HTTP/3 can also be configured manually, by setting `autoStart: false` and using +the [`Http3Session.start()`][] API to start HTTP/3 on a QUIC connection. -Attaching the HTTP/3 application enables a number of stream- and +Selecting the HTTP/3 application enables a number of stream- and session-level capabilities that are not available to non-HTTP/3 applications: @@ -4118,11 +4241,13 @@ applications: [`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 + [`application.enableConnectProtocol`][] (see + [`sessionOptions.application`][]). The setting is exchanged but the application is responsible for handling the `:protocol` pseudo-header and any payload framing on top. * **QPACK tuning** — dynamic-table size and blocked-streams limits - via [`application.qpackMaxDTableCapacity`][] and friends. + via [`application.qpackMaxDTableCapacity`][] and friends (see + [`sessionOptions.application`][]). ### Minimal HTTP/3 client @@ -4243,37 +4368,24 @@ Server-side notes: added: REPLACEME --> -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` +An HTTP/3 session, started on a [`QuicConnection`][]. HTTP/3 interprets the +data on the connection's streams and exposes APIs to allow you to use HTTP/3 +over QUIC. 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 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`][]. +updates) are on the QUIC connection, accessible as +[`http3session.connection`][]. -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. 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. - -### `Http3Session.from(session[, options])` +### `Http3Session.start(connection[, options])` -* `session` {quic.QuicSession} The QUIC session to attach HTTP/3 to. +* `connection` {quic.QuicConnection} The connection to start HTTP/3 on. * `options` {Object} * `settings` {quic.ApplicationOptions} The HTTP/3 settings to use. Defaults apply to anything left out. @@ -4282,35 +4394,22 @@ added: REPLACEME * `onsettings` {Function} See [`http3session.onsettings`][]. * Returns: {quic.Http3Session} -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 must be attached synchronously -inside the [`quic.listen()`][] callback, and a client session must be -attached synchronously when the [`session.opened`][] promise resolves (or -before), and in both cases before anything is sent on the session. +Starts HTTP/3 on a connection that has no session yet. See +[Starting a session][]. -Attaching to a session that is already active throws `ERR_INVALID_STATE`, and -leaves the session untouched. The same applies once [`session.onstream`][] has -been set, as that selects raw QUIC for the session. - -### Members forwarded to the QUIC session +### Members forwarded to the QUIC connection Each of the following behaves exactly as the member of the same name on the -underlying [`QuicSession`][]: `close()`, `closed`, `closing`, `destroy()`, +underlying [`QuicConnection`][]: `closed`, `closing`, `destroy()`, `destroyed`, `opened`, and `stats`. +The datagram members `sendDatagram()`, `ondatagram`, `ondatagramstatus`, +`maxDatagramSize`, and `maxPendingDatagrams` behave as on a [`QuicSession`][]. + Any callback set through the `Http3Session` - `onerror` and the HTTP/3-specific ones below - is invoked with the `Http3Session` as `this`. @@ -4323,14 +4422,21 @@ added: REPLACEME * 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. +destroyed with. It behaves as [`session.onerror`][]. Read/write. + +### `http3session.close([options])` + + + +* `options` {Object} The same options as [`session.close()`][]. +* Returns: {Promise} -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. +Initiates a graceful shutdown of the HTTP/3 session, sending a `GOAWAY` frame to +the peer. Requests already in progress are allowed to complete, but no new ones +can be started. Once they have all finished, the connection is closed. The +returned promise behaves as for [`session.close()`][]. ### `http3session.createBidirectionalStream([options])` @@ -4340,8 +4446,8 @@ added: REPLACEME * Returns: {Promise} fulfilled with a {quic.QuicStream} -Opens an HTTP/3 request stream. Equivalent to -[`session.createBidirectionalStream()`][] on the underlying session. +Opens an HTTP/3 request stream. Takes the same options as +[`session.createBidirectionalStream()`][]. HTTP/3 has no server-initiated request streams, so on a server session the returned promise is rejected with `ERR_INVALID_STATE`. @@ -4404,15 +4510,15 @@ added: REPLACEME 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` +### `http3session.connection` -* Type: {quic.QuicSession} +* Type: {quic.QuicConnection} -The QUIC session on which this HTTP/3 session is running. +The QUIC connection on which this HTTP/3 session is running. ### `http3session.settings` @@ -4434,14 +4540,14 @@ added: - v24.20.0 --> -QUIC sessions, streams, and endpoints emit [`PerformanceEntry`][] objects +QUIC connections, streams, and endpoints emit [`PerformanceEntry`][] objects with `entryType` set to `'quic'`. These entries are only created when a [`PerformanceObserver`][] is observing the `'quic'` entry type, ensuring zero overhead when not in use. Each entry provides: -* `name` {string} One of `'QuicEndpoint'`, `'QuicSession'`, or `'QuicStream'`. +* `name` {string} One of `'QuicEndpoint'`, `'QuicConnection'`, or `'QuicStream'`. * `entryType` {string} Always `'quic'`. * `startTime` {number} High-resolution timestamp (ms) when the object was created. * `duration` {number} Lifetime in milliseconds from creation to destruction. @@ -4452,9 +4558,9 @@ Each entry provides: * `detail.stats` {QuicEndpointStats} The endpoint's statistics object (frozen at destruction time). -### `QuicSession` entries +### `QuicConnection` entries -* `detail.stats` {QuicSessionStats} The session's statistics object +* `detail.stats` {quic.QuicConnection.Stats} The connection's statistics object (frozen at destruction time). Includes bytes sent/received, RTT measurements, congestion window, packet counts, and more. * `detail.handshake` {Object|undefined} Timing-relevant handshake metadata, @@ -4463,7 +4569,7 @@ Each entry provides: * `protocol` {string} The negotiated ALPN protocol. * `earlyDataAttempted` {boolean} Whether 0-RTT early data was attempted. * `earlyDataAccepted` {boolean} Whether 0-RTT early data was accepted. -* `detail.path` {Object|undefined} The session's network path, or +* `detail.path` {Object|undefined} The connection's network path, or `undefined` if not yet established. * `local` {net.SocketAddress} * `remote` {net.SocketAddress} @@ -4483,7 +4589,7 @@ import { PerformanceObserver } from 'node:perf_hooks'; const obs = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { console.log(`${entry.name}: ${entry.duration.toFixed(1)}ms`); - if (entry.name === 'QuicSession') { + if (entry.name === 'QuicConnection') { const { stats, handshake } = entry.detail; console.log(` protocol: ${handshake?.protocol}`); console.log(` bytes sent: ${stats.bytesSent}`); @@ -4585,7 +4691,7 @@ added: v23.8.0 --> * `applicationoptions` {quic.ApplicationOptions} Current application options. -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when a locally-initiated stream is opened. @@ -4596,7 +4702,7 @@ added: v23.8.0 --> * `endpoint` {quic.QuicEndpoint} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `address` {net.SocketAddress} The remote server address. * `options` {quic.SessionOptions} @@ -4609,7 +4715,7 @@ added: v23.8.0 --> * `endpoint` {quic.QuicEndpoint} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `address` {net.SocketAddress|undefined} The remote peer address. Published when a server-side session is created for an incoming connection. @@ -4621,7 +4727,7 @@ added: v23.8.0 --> * `stream` {quic.QuicStream} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `direction` {string} Either `'bidi'` or `'uni'`. Published when a locally-initiated stream is opened. @@ -4633,7 +4739,7 @@ added: v23.8.0 --> * `stream` {quic.QuicStream} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `direction` {string} Either `'bidi'` or `'uni'`. Published when a remotely-initiated stream is received. @@ -4646,7 +4752,7 @@ added: v23.8.0 * `id` {bigint} The datagram ID. * `length` {number} The datagram payload size in bytes. -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when a datagram is queued for sending. @@ -4656,7 +4762,7 @@ Published when a datagram is queued for sending. added: v23.8.0 --> -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when a TLS key update is initiated. @@ -4666,7 +4772,7 @@ Published when a TLS key update is initiated. added: v23.8.0 --> -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when a session begins gracefully closing (including when a GOAWAY frame is received from the peer). @@ -4677,9 +4783,9 @@ GOAWAY frame is received from the peer). added: v23.8.0 --> -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `error` {any} The error that caused the close, or `undefined` if clean. -* `stats` {quic.QuicSession.Stats} Final session statistics. +* `stats` {quic.QuicConnection.Stats} Final connection statistics. Published when a session is destroyed. The `stats` object is a snapshot of the final statistics at the time of destruction. @@ -4692,7 +4798,7 @@ added: - v24.20.0 --> -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `error` {any} The error that caused the session to be destroyed. Published when a session is destroyed due to an error. Fires before the @@ -4709,7 +4815,7 @@ added: v23.8.0 * `length` {number} The datagram payload size in bytes. * `early` {boolean} Whether the datagram was received as 0-RTT early data. -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when a datagram is received from the remote peer. @@ -4721,7 +4827,7 @@ added: v23.8.0 * `id` {bigint} The datagram ID. * `status` {string} One of `'acknowledged'`, `'lost'`, or `'abandoned'`. -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when the delivery status of a sent datagram is updated. @@ -4737,7 +4843,7 @@ added: v23.8.0 * `oldLocalAddress` {net.SocketAddress|null} * `oldRemoteAddress` {net.SocketAddress|null} * `preferredAddress` {boolean} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when a path validation attempt completes. @@ -4751,7 +4857,7 @@ added: * `token` {Buffer} The NEW\_TOKEN token data. * `address` {net.SocketAddress} The remote server address. -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when a client session receives a NEW\_TOKEN frame from the server. @@ -4763,7 +4869,7 @@ added: v23.8.0 --> * `ticket` {Object} The opaque session ticket. -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when a new TLS session ticket is received. @@ -4776,7 +4882,7 @@ added: v23.8.0 * `version` {number} The QUIC version that was configured for this session. * `requestedVersions` {number\[]} The versions advertised by the server. * `supportedVersions` {number\[]} The versions supported locally. -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when the client receives a Version Negotiation packet from the server. The session is always destroyed immediately after. @@ -4790,7 +4896,7 @@ added: --> * `origins` {string\[]} The list of origins the server is authoritative for. -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when the session receives an ORIGIN frame (RFC 9412) from the peer. @@ -4801,7 +4907,7 @@ the peer. added: v23.8.0 --> -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `servername` {string} * `protocol` {string} * `cipher` {string} @@ -4821,7 +4927,7 @@ added: - v24.20.0 --> -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `lastStreamId` {bigint} The highest stream ID the peer may have processed. Published when the peer sends an HTTP/3 GOAWAY frame. Streams with IDs @@ -4837,7 +4943,7 @@ added: - v24.20.0 --> -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when the server rejects 0-RTT early data. All streams that were opened during the 0-RTT phase have been destroyed. Useful for diagnosing @@ -4852,7 +4958,7 @@ added: --> * `stream` {quic.QuicStream} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `error` {any} The error that caused the close, or `undefined` if clean. * `stats` {quic.QuicStream.Stats} Final stream statistics. @@ -4868,7 +4974,7 @@ added: --> * `stream` {quic.QuicStream} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `headers` {Object} The initial request or response headers. Published when initial headers are received on a stream. For HTTP/3 @@ -4885,7 +4991,7 @@ added: --> * `stream` {quic.QuicStream} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `trailers` {Object} The trailing headers. Published when trailing headers are received on a stream. @@ -4899,7 +5005,7 @@ added: --> * `stream` {quic.QuicStream} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `headers` {Object} The informational headers. Published when informational (1xx) headers are received on a stream @@ -4914,7 +5020,7 @@ added: --> * `stream` {quic.QuicStream} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `error` {any} The QUIC error associated with the reset. Published when a stream receives a RESET\_STREAM frame from the peer, @@ -4930,7 +5036,7 @@ added: --> * `stream` {quic.QuicStream} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when a stream is flow-control blocked and cannot send data until the peer increases the flow control window. Useful for diagnosing @@ -4961,17 +5067,26 @@ 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 +[Starting a session]: #starting-a-session +[`Http3Session.start()`]: #http3sessionstartconnection-options [`Http3Session`]: #class-http3session [`PerformanceEntry`]: perf_hooks.md#class-performanceentry [`PerformanceObserver`]: perf_hooks.md#class-performanceobserver +[`QuicConnection`]: #class-quicconnection [`QuicEndpoint`]: #class-quicendpoint [`QuicError`]: #class-quicerror [`QuicSession`]: #class-quicsession [`application.enableConnectProtocol`]: #applicationoptionsenableconnectprotocol -[`application.enableDatagrams`]: #applicationoptionsenabledatagrams +[`application.enableDatagrams`]: #sessionoptionsapplication [`application.qpackMaxDTableCapacity`]: #applicationoptionsqpackmaxdtablecapacity [`certificateCompression`]: #sessionoptionscertificatecompression +[`connection.destroy()`]: #connectiondestroyerror-options +[`connection.onearlyrejected`]: #connectiononearlyrejected +[`connection.onkeylog`]: #connectiononkeylog +[`connection.onnewtoken`]: #connectiononnewtoken +[`connection.onqlog`]: #connectiononqlog +[`connection.onsessionticket`]: #connectiononsessionticket +[`connection.opened`]: #connectionopened [`crypto.X509Certificate`]: crypto.md#class-x509certificate [`endpoint.busy`]: #endpointbusy [`endpoint.maxConnectionsPerHost`]: #endpointmaxconnectionsperhost @@ -4990,13 +5105,10 @@ throughput issues caused by flow control. [`endpointOptions.versionNegotiationRate`]: #endpointoptionsversionnegotiationrate [`error.errorCode`]: #errorerrorcode [`fs.promises.open(path, 'r')`]: fs.md#fspromisesopenpath-flags-mode -[`http3session.createBidirectionalStream()`]: #http3sessioncreatebidirectionalstreamoptions -[`http3session.onerror`]: #http3sessiononerror +[`http3session.connection`]: #http3sessionconnection [`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 @@ -5005,20 +5117,14 @@ throughput issues caused by flow control. [`session.close()`]: #sessioncloseoptions [`session.createBidirectionalStream()`]: #sessioncreatebidirectionalstreamoptions [`session.createUnidirectionalStream()`]: #sessioncreateunidirectionalstreamoptions -[`session.destroy()`]: #sessiondestroyerror-options [`session.maxPendingDatagrams`]: #sessionmaxpendingdatagrams [`session.ondatagram`]: #sessionondatagram [`session.ondatagramstatus`]: #sessionondatagramstatus -[`session.onearlyrejected`]: #sessiononearlyrejected [`session.onerror`]: #sessiononerror -[`session.onkeylog`]: #sessiononkeylog -[`session.onnewtoken`]: #sessiononnewtoken -[`session.onqlog`]: #sessiononqlog -[`session.onsessionticket`]: #sessiononsessionticket [`session.onstream`]: #sessiononstream -[`session.opened`]: #sessionopened [`session.sendDatagram()`]: #sessionsenddatagramdatagram-encoding -[`sessionOptions.autoWrap`]: #sessionoptionsautowrap +[`sessionOptions.application`]: #sessionoptionsapplication +[`sessionOptions.autoStart`]: #sessionoptionsautostart [`sessionOptions.cc`]: #sessionoptionscc [`sessionOptions.ciphers`]: #sessionoptionsciphers [`sessionOptions.datagramDropPolicy`]: #sessionoptionsdatagramdroppolicy diff --git a/lib/internal/quic/http3.js b/lib/internal/quic/http3.js index 2cfabfa787dd..053a62c0784e 100644 --- a/lib/internal/quic/http3.js +++ b/lib/internal/quic/http3.js @@ -2,9 +2,8 @@ const { BigInt, - FunctionPrototypeBind, NumberIsInteger, - SymbolAsyncDispose, + PromiseReject, } = primordials; const { @@ -17,29 +16,25 @@ if (!process.features.quic || !getOptionValue('--experimental-quic')) { // Internal, experimental HTTP/3 layer over node:quic. // -// A QuicSession created without an application is a raw QUIC session. -// Attaching an Http3Session makes it an HTTP/3 one, and from then on its -// streams are HTTP/3 request streams. +// An Http3Session is started on a QuicConnection that has no session yet, +// and from then on the connection's streams are HTTP/3 request streams. const { + QuicSessionBase, createApplicationStream, - getQuicSessionHandle, - getQuicSessionState, - isQuicSession, + getApplicationCallback, + isServerConnection, setApplicationCallback, } = require('internal/quic/quic'); const { - kInspect, kPrivateConstructor, } = require('internal/quic/symbols'); const { kEmptyObject } = require('internal/util'); -const { inspect } = require('internal/util/inspect'); const { QUIC_APPLICATION_HTTP3, STREAM_DIRECTION_BIDIRECTIONAL: kStreamDirectionBidirectional, - kHttp3Settings, } = internalBinding('quic'); const { @@ -50,7 +45,6 @@ const { const { codes: { - ERR_ILLEGAL_CONSTRUCTOR, ERR_INVALID_ARG_TYPE, ERR_INVALID_STATE, ERR_OUT_OF_RANGE, @@ -117,28 +111,10 @@ function prepareH3Settings(settings) { }; } -function checkAttachable(session, state) { - if (session.destroyed) { - throw new ERR_INVALID_STATE( - 'An application cannot be attached to a destroyed QUIC session'); - } - if (state.applicationType !== 0) { - throw new ERR_INVALID_STATE( - 'The QUIC session already has an application attached'); - } -} - -class Http3Session { - #session; - #onstream; - #ongoaway; - #onorigin; - #onsettings; - #onerror; - +class Http3Session extends QuicSessionBase { /** - * Attaches HTTP/3 to a QuicSession that has not yet become active. - * @param {QuicSession} session the QUIC session to attach to + * Starts HTTP/3 on a QuicConnection that has no session yet. + * @param {QuicConnection} connection * @param {object} [options] * @param {ApplicationOptions} [options.settings] * @param {Function} [options.ongoaway] @@ -146,17 +122,11 @@ class Http3Session { * @param {Function} [options.onsettings] * @returns {Http3Session} */ - static from(session, options) { - return new Http3Session(kPrivateConstructor, session, options); + static start(connection, options) { + return new Http3Session(kPrivateConstructor, connection, 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); - } + constructor(privateSymbol, connection, options = kEmptyObject) { validateObject(options, 'options'); const { ongoaway, onorigin, onsettings, settings } = options; if (ongoaway !== undefined) validateFunction(ongoaway, 'options.ongoaway'); @@ -164,149 +134,52 @@ class Http3Session { if (onsettings !== undefined) { validateFunction(onsettings, 'options.onsettings'); } - const state = getQuicSessionState(session); - let preparedSettings; if (settings !== undefined) { validateObject(settings, 'options.settings'); preparedSettings = prepareH3Settings(settings); } - // Reading settings could call getters and go into JS, so do this after: - checkAttachable(session, state); + // Reading settings could call getters and go into JS, so start after: + super(privateSymbol, connection, QUIC_APPLICATION_HTTP3, preparedSettings); - const handle = getQuicSessionHandle(session); - if (preparedSettings !== undefined) handle[kHttp3Settings] = preparedSettings; - state.applicationType = QUIC_APPLICATION_HTTP3; - this.#session = session; - if (ongoaway !== undefined) { - this.#ongoaway = ongoaway; - setApplicationCallback(session, 'ongoaway', this.#bind(ongoaway)); - } - if (onorigin !== undefined) { - this.#onorigin = onorigin; - setApplicationCallback(session, 'onorigin', this.#bind(onorigin)); - } - if (onsettings !== undefined) { - this.#onsettings = onsettings; - setApplicationCallback(session, 'onapplication', this.#bind(onsettings)); - } + if (ongoaway !== undefined) this.ongoaway = ongoaway; + if (onorigin !== undefined) this.onorigin = onorigin; + if (onsettings !== undefined) this.onsettings = onsettings; } - /** - * The QUIC session carrying this HTTP/3 session - * @type {QuicSession} - */ - get quicSession() { return this.#session; } - /** * The settings in effect, including any update from the peer's SETTINGS * frame, which may arrive after the session opens. Null once destroyed. * @type {ApplicationOptions|null} */ - get settings() { return this.#session.applicationOptions; } - - /** @type {quic.QuicSession.Stats} */ - get stats() { return this.#session.stats; } - - // Ensure that 'this' in callbacks registered here is the Http3Session - #bind(fn) { - return fn === undefined ? undefined : FunctionPrototypeBind(fn, this); - } - - /** - * 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) { - if (fn !== undefined) validateFunction(fn, 'onstream'); - setApplicationCallback(this.#session, 'onstream', this.#bind(fn)); - this.#onstream = fn; - } + get settings() { return this.connection.applicationOptions; } /** @type {Function|undefined} */ - get ongoaway() { return this.#ongoaway; } - set ongoaway(fn) { - if (fn !== undefined) validateFunction(fn, 'ongoaway'); - setApplicationCallback(this.#session, 'ongoaway', this.#bind(fn)); - this.#ongoaway = fn; - } + get ongoaway() { return getApplicationCallback(this.connection, 'ongoaway'); } + set ongoaway(fn) { setApplicationCallback(this.connection, 'ongoaway', fn, this); } /** @type {Function|undefined} */ - get onorigin() { return this.#onorigin; } - set onorigin(fn) { - if (fn !== undefined) validateFunction(fn, 'onorigin'); - setApplicationCallback(this.#session, 'onorigin', this.#bind(fn)); - this.#onorigin = fn; - } + get onorigin() { return getApplicationCallback(this.connection, 'onorigin'); } + set onorigin(fn) { setApplicationCallback(this.connection, 'onorigin', fn, this); } /** @type {Function|undefined} */ - get onsettings() { return this.#onsettings; } + get onsettings() { return getApplicationCallback(this.connection, 'onapplication'); } set onsettings(fn) { - if (fn !== undefined) validateFunction(fn, 'onsettings'); - setApplicationCallback(this.#session, 'onapplication', this.#bind(fn)); - this.#onsettings = fn; + setApplicationCallback(this.connection, 'onapplication', fn, this, 'onsettings'); } - /** - * 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) { - if (fn !== undefined) validateFunction(fn, 'onerror'); - setApplicationCallback(this.#session, 'onapperror', this.#bind(fn)); - this.#onerror = fn; - } - - /** @type {Promise} */ - get opened() { return this.#session.opened; } - - /** @type {Promise} */ - get closed() { return this.#session.closed; } - - /** @type {boolean} */ - get closing() { return this.#session.closing; } - - /** @type {boolean} */ - get destroyed() { return this.#session.destroyed; } - /** * Opens a request stream. * @returns {Promise} */ - async createBidirectionalStream(options) { - if (getQuicSessionState(this.#session).isServer) { - throw new ERR_INVALID_STATE( - 'Server sessions cannot open HTTP/3 request streams'); - } - return await createApplicationStream( - this.#session, kStreamDirectionBidirectional, options); - } - - close(options) { return this.#session.close(options); } - - destroy(error, options) { return this.#session.destroy(error, options); } - - async [SymbolAsyncDispose]() { await this.close(); } - - [kInspect](depth, options) { - if (depth < 0) { - return 'Http3Session { }'; + createBidirectionalStream(options) { + if (isServerConnection(this.connection)) { + return PromiseReject(new ERR_INVALID_STATE( + 'Server sessions cannot open HTTP/3 request streams')); } - const opts = { - __proto__: null, - ...options, - depth: options.depth == null ? null : options.depth - 1, - }; - return `Http3Session ${inspect({ - quicSession: this.#session, - settings: this.settings, - destroyed: this.destroyed, - }, opts)}`; + return createApplicationStream( + this.connection, kStreamDirectionBidirectional, options); } } diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 8c14d5878f99..414361ad67f8 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -20,7 +20,6 @@ const { PromiseResolve, PromiseWithResolvers, SafeSet, - StringPrototypeStartsWith, Symbol, SymbolAsyncDispose, SymbolAsyncIterator, @@ -70,6 +69,7 @@ const { STREAM_DIRECTION_BIDIRECTIONAL: kStreamDirectionBidirectional, STREAM_DIRECTION_UNIDIRECTIONAL: kStreamDirectionUnidirectional, QUIC_APPLICATION_DEFAULT: kApplicationTypeDefault, + QUIC_APPLICATION_HTTP3: kApplicationTypeHttp3, CLOSECONTEXT_CLOSE: kCloseContextClose, CLOSECONTEXT_BIND_FAILURE: kCloseContextBindFailure, CLOSECONTEXT_LISTEN_FAILURE: kCloseContextListenFailure, @@ -188,6 +188,7 @@ const { kAttachFileHandle, kAvailable, kBlocked, + kClose, kConnect, kDatagram, kDatagramStatus, @@ -213,6 +214,7 @@ const { kPathValidation, kPrivateConstructor, kReset, + kSendDatagram, kSendHeaders, kSessionApplication, kSessionTicket, @@ -225,13 +227,13 @@ const { const { QuicEndpointStats, QuicStreamStats, - QuicSessionStats, + QuicConnectionStats, kCreateDisconnected, } = require('internal/quic/stats'); const { QuicEndpointState, - QuicSessionState, + QuicConnectionState, QuicStreamState, } = require('internal/quic/state'); @@ -404,13 +406,14 @@ const endpointRegistry = new SafeSet(); * @property {'use'|'ignore'|'default'} [preferredAddressPolicy] The preferred address policy * @property {'strict'|'auto'|'manual'} [verifyPeer='auto'] Peer certificate verification policy (client only) * @property {'error'|'ignore'} [truncatedReads] Truncated read policy + * @property {ApplicationOptions} [application] The application options * @property {TransportParams} [transportParams] The transport parameters * @property {string} [servername] The server name identifier (client only) * @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 {boolean} [autoStart] Whether to start the session matching the + * ALPN automatically (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 @@ -939,14 +942,14 @@ setCallbacks({ * @param {number} direction The stream direction (0 == bidi, 1 == uni) */ onStreamCreated(stream, direction) { - const session = this[kOwner]; + const connection = this[kOwner]; // The event is ignored and the stream destroyed if the session has been destroyed. - debug('stream created callback', session, direction); - if (session.destroyed) { + debug('stream created callback', connection, direction); + if (connection.destroyed) { stream.destroy(); return; }; - session[kNewStream](stream, direction); + connection[kNewStream](stream, direction); }, // QuicStream callbacks @@ -1042,7 +1045,7 @@ const kMaxQuicErrorCode = (1n << 62n) - 1n; * `errorCode` as the wire code for the resulting RESET_STREAM / * STOP_SENDING / CONNECTION_CLOSE frame; otherwise the negotiated * application's "internal error" code is used (see - * `QuicSessionState.internalErrorCode`). + * `QuicConnectionState.internalErrorCode`). * * The Node.js error code (`error.code`) defaults to * `'ERR_QUIC_STREAM_ABORTED'` but can be overridden via @@ -1325,40 +1328,27 @@ 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 + * Applies session and stream callbacks from an options object to a + * connection, and the session started on it by autoStart. + * @param {QuicConnection} connection * @param {object} cbs + * @param {QuicSession|Http3Session|QuicConnection} connOrSession */ -function applyCallbacks(session, cbs) { - if (cbs.onerror) session.onerror = cbs.onerror; - if (cbs.onstream) session.onstream = cbs.onstream; - if (cbs.ondatagram) session.ondatagram = cbs.ondatagram; - if (cbs.ondatagramstatus) session.ondatagramstatus = cbs.ondatagramstatus; - if (cbs.onpathvalidation) session.onpathvalidation = cbs.onpathvalidation; - if (cbs.onsessionticket) session.onsessionticket = cbs.onsessionticket; - if (cbs.onversionnegotiation) session.onversionnegotiation = cbs.onversionnegotiation; - if (cbs.onhandshake) session.onhandshake = cbs.onhandshake; - if (cbs.onnewtoken) session.onnewtoken = cbs.onnewtoken; - if (cbs.onearlyrejected) session.onearlyrejected = cbs.onearlyrejected; - if (cbs.onkeylog) session.onkeylog = cbs.onkeylog; - if (cbs.onqlog) session.onqlog = cbs.onqlog; +function applyCallbacks(connection, cbs, connOrSession) { + if (cbs.onerror) connOrSession.onerror = cbs.onerror; + if (cbs.onstream) connOrSession.onstream = cbs.onstream; + if (cbs.ondatagram) connOrSession.ondatagram = cbs.ondatagram; + if (cbs.ondatagramstatus) connOrSession.ondatagramstatus = cbs.ondatagramstatus; + if (cbs.onpathvalidation) connection.onpathvalidation = cbs.onpathvalidation; + if (cbs.onsessionticket) connection.onsessionticket = cbs.onsessionticket; + if (cbs.onversionnegotiation) connection.onversionnegotiation = cbs.onversionnegotiation; + if (cbs.onhandshake) connection.onhandshake = cbs.onhandshake; + if (cbs.onnewtoken) connection.onnewtoken = cbs.onnewtoken; + if (cbs.onearlyrejected) connection.onearlyrejected = cbs.onearlyrejected; + if (cbs.onkeylog) connection.onkeylog = cbs.onkeylog; + if (cbs.onqlog) connection.onqlog = cbs.onqlog; if (cbs.onheaders || cbs.ontrailers || cbs.oninfo || cbs.onwanttrailers) { - session[kStreamCallbacks] = { + connection[kStreamCallbacks] = { __proto__: null, onheaders: cbs.onheaders, ontrailers: cbs.ontrailers, @@ -1574,19 +1564,22 @@ function isSyncIterable(obj) { // Functions used specifically for internal or assertion purposes only. let getQuicStreamState; -let getQuicSessionState; +let getQuicConnectionState; let getQuicEndpointState; let assertIsQuicEndpoint; let assertIsQuicStream; -let assertIsQuicSession; +let assertIsQuicConnection; let assertHeadersSupported; let assertEndpointNotClosedOrClosing; let assertEndpointIsNotBusy; let isQuicStream; -let isQuicSession; -let getQuicSessionHandle; +let isQuicConnection; +let isServerConnection; let createApplicationStream; +let getApplicationCallback; let setApplicationCallback; +let startApplication; +let getApplicationSession; let isQuicEndpoint; function maybeGetCloseError(context, status, pendingError) { @@ -1656,8 +1649,8 @@ class QuicStream { } }; - assertHeadersSupported = function(session) { - if (getQuicSessionState(session).headersSupported === 2) { + assertHeadersSupported = function(connection) { + if (getQuicConnectionState(connection).headersSupported === 2) { throw new ERR_INVALID_STATE( 'The negotiated QUIC application protocol does not support headers'); } @@ -1672,18 +1665,18 @@ class QuicStream { /** * @param {symbol} privateSymbol * @param {object} handle - * @param {QuicSession} session + * @param {QuicConnection} connection * @param {number} direction * @param {boolean} isLocal * @param {'error'|'ignore'} truncatedReads */ - constructor(privateSymbol, handle, session, direction, isLocal, truncatedReads) { + constructor(privateSymbol, handle, connection, direction, isLocal, truncatedReads) { assertPrivateSymbol(privateSymbol); this.#handle = handle; handle[kOwner] = this; const inner = this.#inner; - inner.session = session; + inner.session = connection; inner.direction = direction; inner.isLocal = isLocal; inner.truncatedReads = truncatedReads; @@ -2023,7 +2016,8 @@ class QuicStream { */ get session() { assertIsQuicStream(this); - return this.#inner.session; + const connection = this.#inner.session; + return connection === undefined ? connection : getApplicationSession(connection); } /** @@ -2074,7 +2068,7 @@ class QuicStream { * side of the stream. The wire code is resolved as: * `options.code` -> `error.errorCode` (when `error` is a * `QuicError`) -> the negotiated application's "internal error" - * code from `QuicSessionState.internalErrorCode`. + * code from `QuicConnectionState.internalErrorCode`. * @param {any} error * @param {QuicStreamDestroyOptions} [options] */ @@ -2115,7 +2109,7 @@ class QuicStream { } else if (error !== undefined) { abortCode = QuicError.isQuicError(error) ? error.errorCode : - getQuicSessionState(inner.session).internalErrorCode; + getQuicConnectionState(inner.session).internalErrorCode; } // When destroying with an error, ensure the peer stops sending // data we are about to discard by emitting STOP_SENDING. The @@ -2176,7 +2170,7 @@ class QuicStream { sendHeaders(headers, options = kEmptyObject) { assertIsQuicStream(this); if (this.destroyed) return false; - if (getQuicSessionState(this.#inner.session).headersSupported === 2) { + if (getQuicConnectionState(this.#inner.session).headersSupported === 2) { throw new ERR_INVALID_STATE( 'The negotiated QUIC application protocol does not support headers'); } @@ -2198,7 +2192,7 @@ class QuicStream { sendInformationalHeaders(headers) { assertIsQuicStream(this); if (this.destroyed) return false; - if (getQuicSessionState(this.#inner.session).headersSupported === 2) { + if (getQuicConnectionState(this.#inner.session).headersSupported === 2) { throw new ERR_INVALID_STATE( 'The negotiated QUIC application protocol does not support headers'); } @@ -2219,7 +2213,7 @@ class QuicStream { sendTrailers(headers) { assertIsQuicStream(this); if (this.destroyed) return false; - if (getQuicSessionState(this.#inner.session).headersSupported === 2) { + if (getQuicConnectionState(this.#inner.session).headersSupported === 2) { throw new ERR_INVALID_STATE( 'The negotiated QUIC application protocol does not support headers'); } @@ -2509,13 +2503,13 @@ class QuicStream { // `errorCode`. // 2. Otherwise fall back to the negotiated application's // "internal error" code, surfaced via - // `QuicSessionState.internalErrorCode`. For HTTP/3 this is + // `QuicConnectionState.internalErrorCode`. For HTTP/3 this is // `H3_INTERNAL_ERROR` (0x102); for raw QUIC applications // it falls back to the QUIC transport-layer // `INTERNAL_ERROR` (0x1). const code = QuicError.isQuicError(error) ? error.errorCode : - getQuicSessionState(stream.#inner.session).internalErrorCode; + getQuicConnectionState(stream.#inner.session).internalErrorCode; handle.resetStream(code); if (drainWakeup != null) { markPromiseAsHandled(drainWakeup.promise); @@ -2649,7 +2643,7 @@ class QuicStream { get priority() { assertIsQuicStream(this); if (this.destroyed || - !getQuicSessionState(this.#inner.session).isPrioritySupported) return null; + !getQuicConnectionState(this.#inner.session).isPrioritySupported) return null; const packed = this.#handle.getPriority(); const urgency = packed >> 1; const incremental = !!(packed & 1); @@ -2664,7 +2658,7 @@ class QuicStream { setPriority(options = kEmptyObject) { assertIsQuicStream(this); if (this.destroyed) return; - if (!getQuicSessionState(this.#inner.session).isPrioritySupported) { + if (!getQuicConnectionState(this.#inner.session).isPrioritySupported) { throw new ERR_INVALID_STATE( 'The session does not support stream priority'); } @@ -2694,7 +2688,7 @@ class QuicStream { [kSendHeaders](headers, kind = kHeadersKindInitial, flags = kHeadersFlagsTerminal) { validateObject(headers, 'headers'); - if (getQuicSessionState(this.#inner.session).headersSupported === 2) { + if (getQuicConnectionState(this.#inner.session).headersSupported === 2) { throw new ERR_INVALID_STATE( 'The negotiated QUIC application protocol does not support headers'); } @@ -2924,7 +2918,7 @@ class QuicStream { } } -class QuicSession { +class QuicConnection { /** @type {object|undefined} */ #handle; @@ -2938,14 +2932,16 @@ class QuicSession { handshakeCompleted: false, pendingClose: PromiseWithResolvers(), pendingOpen: PromiseWithResolvers(), - /** @type {QuicSessionState} */ + /** @type {QuicConnectionState} */ state: undefined, - /** @type {QuicSessionStats} */ + /** @type {QuicConnectionStats} */ stats: undefined, streams: new SafeSet(), + // The session started on this connection (a QuicSession or an + // Http3Session), which owns onerror, onstream and the datagram callbacks. + app: undefined, + isServer: false, onerror: undefined, - // The attached application's own error handler, told after onerror. - onapperror: undefined, onstream: undefined, ondatagram: undefined, ondatagramstatus: undefined, @@ -2979,44 +2975,87 @@ class QuicSession { }; static { - isQuicSession = function(val) { + isQuicConnection = function(val) { return val != null && typeof val === 'object' && #handle in val; }; - assertIsQuicSession = function(val) { - if (!isQuicSession(val)) { - throw new ERR_INVALID_THIS('QuicSession'); + assertIsQuicConnection = function(val) { + if (!isQuicConnection(val)) { + throw new ERR_INVALID_THIS('QuicConnection'); } }; - getQuicSessionHandle = (session) => { - assertIsQuicSession(session); - return session.#handle; - }; + isServerConnection = (connection) => connection.#inner.isServer; - createApplicationStream = (session, direction, options) => { - assertIsQuicSession(session); - return session.#createStream(direction, options); + createApplicationStream = (connection, direction, options) => { + assertIsQuicConnection(connection); + return connection.#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; + getApplicationCallback = (connection, name) => connection.#inner[name]; + + // For callbacks owned by the session started on this connection, which + // are invoked with that session as `this`. The option name is the + // session's name for the callback, if it differs. + setApplicationCallback = (connection, name, fn, session, option = name) => { + assertIsQuicConnection(connection); + if (fn !== undefined) { + validateFunction(fn, option); + fn = FunctionPrototypeBind(fn, session); + } + const inner = connection.#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(); + } else if (name === 'ondatagram') { + inner.state.hasDatagramListener = fn !== undefined; + } else if (name === 'ondatagramstatus') { + inner.state.hasDatagramStatusListener = fn !== undefined; + } else if (name === 'onerror' && fn !== undefined) { + // When an onerror handler is provided, mark the pending promises 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 connection. + 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); + } } }; - getQuicSessionState = function(session) { - assertIsQuicSession(session); - return session.#inner.state; + startApplication = (connection, type, app, settings) => { + assertIsQuicConnection(connection); + if (connection.destroyed) { + throw new ERR_INVALID_STATE( + 'A session cannot be started on a destroyed QUIC connection'); + } + const inner = connection.#inner; + if (inner.app !== undefined) { + throw new ERR_INVALID_STATE( + 'The QUIC connection already has a session started'); + } + // Recorded first, so a failed start can't be retried: the connection + // is closed regardless. + inner.app = app; + // With autoStart, the application was installed by ALPN already: + if (inner.state.applicationType === 0 && + !connection.#handle.startApplication(type, settings)) { + throw new ERR_INVALID_STATE( + 'The session could not be started, so the QUIC connection is closing'); + } + }; + + getApplicationSession = (connection) => connection.#inner.app; + + getQuicConnectionState = function(connection) { + assertIsQuicConnection(connection); + return connection.#inner.state; }; } @@ -3027,7 +3066,7 @@ class QuicSession { * @param {{ truncatedReads?: 'error'|'ignore' }} [options] */ constructor(privateSymbol, handle, endpoint, options = kEmptyObject) { - // Instances of QuicSession can only be created internally. + // Instances of QuicConnection can only be created internally. assertPrivateSymbol(privateSymbol); this.#handle = handle; @@ -3035,20 +3074,21 @@ class QuicSession { const inner = this.#inner; inner.endpoint = endpoint; - const { truncatedReads } = options; + const { truncatedReads, isServer } = options; if (truncatedReads !== undefined) inner.truncatedReads = truncatedReads; + inner.isServer = isServer === true; // Move any qlog entries that arrived before the wrapper existed. if (handle._pendingQlog !== undefined) { inner.pendingQlog = handle._pendingQlog; handle._pendingQlog = undefined; } - inner.stats = new QuicSessionStats( + inner.stats = new QuicConnectionStats( kPrivateConstructor, handle.stats, handle.statsByteOffset); - inner.state = new QuicSessionState( + inner.state = new QuicConnectionState( kPrivateConstructor, handle.state, handle.stateByteOffset); if (hasObserver('quic')) { - startPerf(this, kPerfEntry, { type: 'quic', name: 'QuicSession' }); + startPerf(this, kPerfEntry, { type: 'quic', name: 'QuicConnection' }); } debug('session created'); @@ -3106,76 +3146,13 @@ class QuicSession { return this.#handle === undefined || this.#inner.isPendingClose; } - /** @type {Function|undefined} */ - get onerror() { - assertIsQuicSession(this); - return this.#inner.onerror; - } - - set onerror(fn) { - assertIsQuicSession(this); - const inner = this.#inner; - if (fn === undefined) { - inner.onerror = undefined; - } else { - validateFunction(fn, 'onerror'); - inner.onerror = FunctionPrototypeBind(fn, this); - // When an onerror handler is provided, mark the pending promises - // 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. - 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); - } - } - - /** @type {OnStreamCallback} */ - get onstream() { - assertIsQuicSession(this); - return this.#inner.onstream; - } - - 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; - } - } - } - /** * The SNI servername: `null` until known, then the host name string, or * `false` if the handshake produced no SNI. * @type {string|boolean|null} */ get servername() { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (inner.servername !== undefined) return inner.servername; if (this.destroyed) return null; @@ -3192,7 +3169,7 @@ class QuicSession { * @type {string|null} */ get alpnProtocol() { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (inner.alpnProtocol !== undefined) return inner.alpnProtocol; if (this.destroyed) return null; @@ -3201,56 +3178,14 @@ class QuicSession { return value; } - /** @type {OnDatagramCallback} */ - get ondatagram() { - assertIsQuicSession(this); - return this.#inner.ondatagram; - } - - set ondatagram(fn) { - assertIsQuicSession(this); - const inner = this.#inner; - if (fn === undefined) { - inner.ondatagram = undefined; - inner.state.hasDatagramListener = false; - } else { - validateFunction(fn, 'ondatagram'); - inner.ondatagram = FunctionPrototypeBind(fn, this); - inner.state.hasDatagramListener = true; - } - } - - /** - * The ondatagramstatus callback is called when the status of a sent datagram - * is received. This is best-effort only. - * @type {OnDatagramStatusCallback} - */ - get ondatagramstatus() { - assertIsQuicSession(this); - return this.#inner.ondatagramstatus; - } - - set ondatagramstatus(fn) { - assertIsQuicSession(this); - const inner = this.#inner; - if (fn === undefined) { - inner.ondatagramstatus = undefined; - inner.state.hasDatagramStatusListener = false; - } else { - validateFunction(fn, 'ondatagramstatus'); - inner.ondatagramstatus = FunctionPrototypeBind(fn, this); - inner.state.hasDatagramStatusListener = true; - } - } - /** @type {Function|undefined} */ get onpathvalidation() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.onpathvalidation; } set onpathvalidation(fn) { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (fn === undefined) { inner.onpathvalidation = undefined; @@ -3263,12 +3198,12 @@ class QuicSession { } get onkeylog() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.onkeylog; } set onkeylog(fn) { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (fn === undefined) { inner.onkeylog = undefined; @@ -3279,12 +3214,12 @@ class QuicSession { } get onqlog() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.onqlog; } set onqlog(fn) { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (fn === undefined) { inner.onqlog = undefined; @@ -3304,12 +3239,12 @@ class QuicSession { /** @type {Function|undefined} */ get onsessionticket() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.onsessionticket; } set onsessionticket(fn) { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (fn === undefined) { inner.onsessionticket = undefined; @@ -3323,12 +3258,12 @@ class QuicSession { /** @type {Function|undefined} */ get onversionnegotiation() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.onversionnegotiation; } set onversionnegotiation(fn) { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (fn === undefined) { inner.onversionnegotiation = undefined; @@ -3340,12 +3275,12 @@ class QuicSession { /** @type {Function|undefined} */ get onhandshake() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.onhandshake; } set onhandshake(fn) { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (fn === undefined) { inner.onhandshake = undefined; @@ -3357,12 +3292,12 @@ class QuicSession { /** @type {Function|undefined} */ get onnewtoken() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.onnewtoken; } set onnewtoken(fn) { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (fn === undefined) { inner.onnewtoken = undefined; @@ -3376,12 +3311,12 @@ class QuicSession { /** @type {Function|undefined} */ get onearlyrejected() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.onearlyrejected; } set onearlyrejected(fn) { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (fn === undefined) { inner.onearlyrejected = undefined; @@ -3391,39 +3326,12 @@ class QuicSession { } } - /** - * The maximum datagram size the peer will accept, or 0 if datagrams - * are not supported or the handshake has not yet completed. - * @type {bigint} - */ - get maxDatagramSize() { - assertIsQuicSession(this); - return this.#inner.state.maxDatagramSize; - } - - /** - * Maximum number of datagrams that can be queued while inside a - * ngtcp2 callback scope. When the queue is full, the oldest - * datagram is dropped and reported as lost. Default is 128. - * @type {number} - */ - get maxPendingDatagrams() { - assertIsQuicSession(this); - return this.#inner.state.maxPendingDatagrams; - } - - set maxPendingDatagrams(val) { - assertIsQuicSession(this); - validateInteger(val, 'maxPendingDatagrams', 0, 0xFFFF); - this.#inner.state.maxPendingDatagrams = val; - } - /** * The statistics collected for this session. - * @type {QuicSessionStats} + * @type {QuicConnectionStats} */ get stats() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.stats; } @@ -3433,7 +3341,7 @@ class QuicSession { * @type {QuicEndpoint|null} */ get endpoint() { - assertIsQuicSession(this); + assertIsQuicConnection(this); if (this.destroyed) return null; return this.#inner.endpoint; } @@ -3443,7 +3351,7 @@ class QuicSession { * @type {QuicSessionPath | undefined} */ get path() { - assertIsQuicSession(this); + assertIsQuicConnection(this); if (this.destroyed) return undefined; return this.#inner.path ??= { __proto__: null, @@ -3460,7 +3368,7 @@ class QuicSession { * @type {crypto.X509Certificate|undefined} */ get certificate() { - assertIsQuicSession(this); + assertIsQuicConnection(this); if (this.destroyed) return undefined; if (this.#inner.certificate === undefined) { const handle = this.#handle.getCertificate(); @@ -3475,7 +3383,7 @@ class QuicSession { * @type {crypto.X509Certificate|undefined} */ get peerCertificate() { - assertIsQuicSession(this); + assertIsQuicConnection(this); if (this.destroyed) return undefined; if (this.#inner.peerCertificate === undefined) { const handle = this.#handle.getPeerCertificate(); @@ -3493,7 +3401,7 @@ class QuicSession { * @type {object|undefined} */ get ephemeralKeyInfo() { - assertIsQuicSession(this); + assertIsQuicConnection(this); if (this.destroyed) return undefined; return this.#inner.ephemeralKeyInfo ??= this.#handle.getEphemeralKey(); } @@ -3581,43 +3489,6 @@ class QuicSession { return stream; } - /** - * Creates a new bidirectional stream on this session. If the session - * does not allow new streams to be opened, an error will be thrown. - * @param {OpenStreamOptions} [options] - * @returns {Promise} - */ - async createBidirectionalStream(options = kEmptyObject) { - assertIsQuicSession(this); - this.#assertOwnsStreams(); - return await this.#createStream(kStreamDirectionBidirectional, options); - } - - /** - * Creates a new unidirectional stream on this session. If the session - * does not allow new streams to be opened, an error will be thrown. - * @param {OpenStreamOptions} [options] - * @returns {Promise} - */ - async createUnidirectionalStream(options = kEmptyObject) { - assertIsQuicSession(this); - this.#assertOwnsStreams(); - return await this.#createStream(kStreamDirectionUnidirectional, options); - } - - // If a non-default application type is used, this session does not own the - // streams - streams created here would not integrate into the application - // and will generally stall or crash. Once an application is attached, the - // application owns all stream creation. - #assertOwnsStreams() { - if (this.#inner.state.applicationType > kApplicationTypeDefault) { - throw new ERR_INVALID_STATE( - 'Raw QUIC streams cannot be created on a session with a non-default ' + - 'application attached. Create streams through the application ' + - 'interface (e.g. Http3Session) instead'); - } - } - /** * Send a datagram. The id of the sent datagram will be returned. The status * of the sent datagram will be reported via the datagram-status event if @@ -3638,8 +3509,8 @@ class QuicSession { * @param {string} [encoding] The encoding to use if datagram is a string * @returns {Promise} The datagram ID */ - async sendDatagram(datagram, encoding = 'utf8') { - assertIsQuicSession(this); + async [kSendDatagram](datagram, encoding = 'utf8') { + assertIsQuicConnection(this); if (this.#isClosedOrClosing) { throw new ERR_INVALID_STATE('Session is closed'); } @@ -3697,7 +3568,7 @@ class QuicSession { * Initiate a key update. */ updateKey() { - assertIsQuicSession(this); + assertIsQuicConnection(this); if (this.#isClosedOrClosing) { throw new ERR_INVALID_STATE('Session is closed'); } @@ -3730,8 +3601,8 @@ class QuicSession { * string included in the CONNECTION_CLOSE frame (diagnostic only). * @returns {Promise} */ - close(options = kEmptyObject) { - assertIsQuicSession(this); + [kClose](options = kEmptyObject) { + assertIsQuicConnection(this); options = validateCloseOptions(options); const inner = this.#inner; if (!this.#isClosedOrClosing) { @@ -3760,7 +3631,7 @@ class QuicSession { /** @type {Promise} */ get opened() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.pendingOpen.promise; } @@ -3770,13 +3641,13 @@ class QuicSession { * @type {Promise} */ get closed() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.pendingClose.promise; } /** @type {boolean} */ get destroyed() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#handle === undefined; } @@ -3796,7 +3667,7 @@ class QuicSession { * string included in the CONNECTION_CLOSE frame (diagnostic only). */ destroy(error, options) { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; // Two distinct guards (see also `QuicStream.destroy`): // * `#destroying` flips synchronously here so any re-entrant call @@ -3826,10 +3697,6 @@ 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. @@ -3902,7 +3769,6 @@ class QuicSession { } inner.onerror = undefined; - inner.onapperror = undefined; inner.onstream = undefined; inner.ondatagram = undefined; inner.ondatagramstatus = undefined; @@ -3959,7 +3825,7 @@ class QuicSession { }); } if (typeof inner.ongoaway === 'function') { - safeCallbackInvoke(inner.ongoaway, this, lastStreamId); + safeCallbackInvoke(inner.ongoaway, inner.app, lastStreamId); } } @@ -4059,7 +3925,7 @@ class QuicSession { session: this, }); } - safeCallbackInvoke(inner.ondatagram, this, u8, early); + safeCallbackInvoke(inner.ondatagram, inner.app, u8, early); } /** @@ -4080,7 +3946,7 @@ class QuicSession { session: this, }); } - safeCallbackInvoke(inner.ondatagramstatus, this, id, status); + safeCallbackInvoke(inner.ondatagramstatus, inner.app, id, status); } /** @@ -4151,7 +4017,7 @@ class QuicSession { } const inner = this.#inner; if (typeof inner.onapplication === 'function') - safeCallbackInvoke(inner.onapplication, this, applicationoptions); + safeCallbackInvoke(inner.onapplication, inner.app, applicationoptions); } /** @@ -4230,7 +4096,7 @@ class QuicSession { session: this, }); } - safeCallbackInvoke(inner.onorigin, this, origins); + safeCallbackInvoke(inner.onorigin, inner.app, origins); } /** @@ -4340,7 +4206,7 @@ class QuicSession { // outbound-only (onwanttrailers) and do not expose the stream, so they // do not count as a consumer. if (typeof this[kStreamCallbacks]?.onheaders !== 'function') return false; - return getQuicSessionState(this).streamCallbacksSupported === 1; + return getQuicConnectionState(this).streamCallbacksSupported === 1; } /** @@ -4366,8 +4232,8 @@ class QuicSession { // (HTTP/3: H3_REQUEST_REJECTED), reset the stream with it so the peer // learns the request was not processed (RFC 9114 section 4.1.1). // Other applications have no such semantic and are torn down as before. - const rejectedCode = getQuicSessionState(this).requestRejectedCode; - if (getQuicSessionState(this).streamCallbacksSupported === 1) { + const rejectedCode = getQuicConnectionState(this).requestRejectedCode; + if (getQuicConnectionState(this).streamCallbacksSupported === 1) { stream.destroy(undefined, { code: rejectedCode }); } else { stream.destroy(); @@ -4407,7 +4273,7 @@ class QuicSession { // stream callbacks were applied above and the application (e.g. // HTTP/3) drives the stream, so there is nothing to invoke here. if (typeof inner.onstream === 'function') { - safeCallbackInvoke(inner.onstream, this, stream); + safeCallbackInvoke(inner.onstream, inner.app, stream); } } @@ -4417,7 +4283,7 @@ class QuicSession { [kInspect](depth, options) { if (depth < 0) { - return 'QuicSession { }'; + return 'QuicConnection { }'; } const opts = { @@ -4435,7 +4301,7 @@ class QuicSession { streams, } = this.#inner; - return `QuicSession ${inspect({ + return `QuicConnection ${inspect({ closed: this.closed, closing, destroyed: this.destroyed, @@ -4447,7 +4313,186 @@ class QuicSession { }, opts)}`; } + async [SymbolAsyncDispose]() { this.destroy(); } +} + +/** + * The members shared by every session started on a QuicConnection, e.g. a + * QuicSession or an Http3Session. + */ +class QuicSessionBase { + #connection; + + constructor(privateSymbol, connection, type, settings) { + assertPrivateSymbol(privateSymbol); + if (!isQuicConnection(connection)) { + throw new ERR_INVALID_ARG_TYPE('connection', 'QuicConnection', connection); + } + this.#connection = connection; + startApplication(connection, type, this, settings); + } + + /** @type {QuicConnection} */ + get connection() { return this.#connection; } + + /** @type {QuicConnectionStats} */ + get stats() { return this.#connection.stats; } + + /** @type {Function|undefined} */ + get onerror() { return getApplicationCallback(this.#connection, 'onerror'); } + set onerror(fn) { setApplicationCallback(this.#connection, 'onerror', fn, this); } + + /** @type {OnStreamCallback} */ + get onstream() { return getApplicationCallback(this.#connection, 'onstream'); } + set onstream(fn) { setApplicationCallback(this.#connection, 'onstream', fn, this); } + + /** @type {OnDatagramCallback} */ + get ondatagram() { return getApplicationCallback(this.#connection, 'ondatagram'); } + set ondatagram(fn) { setApplicationCallback(this.#connection, 'ondatagram', fn, this); } + + /** + * The ondatagramstatus callback is called when the status of a sent datagram + * is received. This is best-effort only. + * @type {OnDatagramStatusCallback} + */ + get ondatagramstatus() { return getApplicationCallback(this.#connection, 'ondatagramstatus'); } + set ondatagramstatus(fn) { setApplicationCallback(this.#connection, 'ondatagramstatus', fn, this); } + + /** + * The maximum datagram size the peer will accept, or 0 if datagrams + * are not supported or the handshake has not yet completed. + * @type {bigint} + */ + get maxDatagramSize() { + return getQuicConnectionState(this.#connection).maxDatagramSize; + } + + /** + * Maximum number of datagrams that can be queued while inside a + * ngtcp2 callback scope. When the queue is full, the oldest + * datagram is dropped and reported as lost. Default is 128. + * @type {number} + */ + get maxPendingDatagrams() { + return getQuicConnectionState(this.#connection).maxPendingDatagrams; + } + + set maxPendingDatagrams(val) { + validateInteger(val, 'maxPendingDatagrams', 0, 0xFFFF); + getQuicConnectionState(this.#connection).maxPendingDatagrams = val; + } + + /** + * Send a datagram. The id of the sent datagram will be returned, and its + * status reported via ondatagramstatus if possible. + * @param {ArrayBufferView|string|Promise} datagram The datagram payload + * @param {string} [encoding] The encoding to use if datagram is a string + * @returns {Promise} The datagram ID + */ + sendDatagram(datagram, encoding) { + return this.#connection[kSendDatagram](datagram, encoding); + } + + /** @type {Promise} */ + get opened() { return this.#connection.opened; } + + /** @type {Promise} */ + get closed() { return this.#connection.closed; } + + /** @type {boolean} */ + get closing() { return this.#connection.closing; } + + /** @type {boolean} */ + get destroyed() { return this.#connection.destroyed; } + + /** + * Gracefully closes the session and its connection. + * @param {object} [options] + * @returns {Promise} + */ + close(options) { return this.#connection[kClose](options); } + + destroy(error, options) { return this.#connection.destroy(error, options); } + async [SymbolAsyncDispose]() { await this.close(); } + + [kInspect](depth, options) { + const name = this.constructor.name; + if (depth < 0) { + return `${name} { }`; + } + const opts = { + __proto__: null, + ...options, + depth: options.depth == null ? null : options.depth - 1, + }; + return `${name} ${inspect({ + connection: this.#connection, + destroyed: this.destroyed, + }, opts)}`; + } +} + +/** + * A raw QUIC session, which carries application data directly over the + * streams and datagrams of its connection. + */ +class QuicSession extends QuicSessionBase { + /** + * Starts a raw QUIC session on a connection that has none yet. + * @param {QuicConnection} connection + * @returns {QuicSession} + */ + static start(connection) { + return new QuicSession(kPrivateConstructor, connection); + } + + constructor(privateSymbol, connection) { + super(privateSymbol, connection, kApplicationTypeDefault); + } + + /** + * Creates a new bidirectional stream on this session. If the session + * does not allow new streams to be opened, an error will be thrown. + * @param {OpenStreamOptions} [options] + * @returns {Promise} + */ + createBidirectionalStream(options) { + return createApplicationStream( + this.connection, kStreamDirectionBidirectional, options); + } + + /** + * Creates a new unidirectional stream on this session. If the session + * does not allow new streams to be opened, an error will be thrown. + * @param {OpenStreamOptions} [options] + * @returns {Promise} + */ + createUnidirectionalStream(options) { + return createApplicationStream( + this.connection, kStreamDirectionUnidirectional, options); + } + +} + +/** + * Provides a new connection as the session for the application autoStart + * installed by ALPN, if any. The http3 module is loaded lazily, as it depends + * on this one. + * @param {QuicConnection} connection + * @returns {QuicSession|Http3Session|QuicConnection} + */ +function autoStartSession(connection) { + switch (getQuicConnectionState(connection).applicationType) { + case kApplicationTypeHttp3: { + const { Http3Session } = require('internal/quic/http3'); + return Http3Session.start(connection); + } + case kApplicationTypeDefault: + return QuicSession.start(connection); + default: + return connection; + } } // The QuicEndpoint represents a local UDP port binding. It can act as both a @@ -4469,7 +4514,6 @@ class QuicEndpoint { stats: undefined, truncatedReads: undefined, onsession: undefined, - autoWrap: undefined, sessionCallbacks: undefined, }; @@ -4601,12 +4645,12 @@ class QuicEndpoint { }; } - #newSession(handle, options) { - const session = new QuicSession(kPrivateConstructor, handle, this, options); - this.#inner.sessions.add(session); + #newConnection(handle, options) { + const connection = new QuicConnection(kPrivateConstructor, handle, this, options); + this.#inner.sessions.add(connection); // Set default pending datagram queue size. - session.maxPendingDatagrams = kDefaultMaxPendingDatagrams; - return session; + getQuicConnectionState(connection).maxPendingDatagrams = kDefaultMaxPendingDatagrams; + return connection; } /** @@ -4775,12 +4819,10 @@ 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 = { @@ -4812,17 +4854,15 @@ class QuicEndpoint { * Initiates a session with a remote endpoint. * @param {object} address * @param {SessionOptions} [options] - * @param {string} alpn The client's offered ALPN - * @returns {QuicSession|Http3Session} + * @returns {QuicSession|Http3Session|QuicConnection} */ - [kConnect](address, options, alpn) { + [kConnect](address, options) { assertEndpointNotClosedOrClosing(this); assertEndpointIsNotBusy(this); validateObject(options, 'options'); const { sessionTicket, truncatedReads, - autoWrap, ...rest } = options; @@ -4831,15 +4871,16 @@ class QuicEndpoint { if (handle === undefined) { throw new ERR_QUIC_CONNECTION_FAILED(); } - const session = this.#newSession(handle, { __proto__: null, truncatedReads }); + const connection = this.#newConnection(handle, { __proto__: null, truncatedReads }); + const connOrSession = autoStartSession(connection); // Set callbacks before any async work to avoid missing events // that fire during or immediately after the handshake. - applyCallbacks(session, options); + applyCallbacks(connection, options, connOrSession); // Store the verifyPeer policy for use in the handshake handler. if (options.verifyPeer !== undefined) { - session[kVerifyPeer] = options.verifyPeer; + connection[kVerifyPeer] = options.verifyPeer; } - return autoWrap ? autoWrapSession(session, alpn) : session; + return connOrSession; } /** @@ -4937,17 +4978,17 @@ class QuicEndpoint { // `destroy()`, which trips the `#destroying` guard and leaves the // C++ side asserting an inconsistent destroyed state. const closeOptions = errorToCloseOptions(error); - for (const session of inner.sessions) { + for (const connection of inner.sessions) { // Mark each cascaded session's `closed` as handled before // destroying it. This prevents unhandled-rejection warnings when // the session is collateral damage from an endpoint-level destroy // (e.g. a synchronous throw out of a user `onsession` callback // routed through safeCallbackInvoke). The rejection is still // observable to any caller that explicitly awaits `session.closed`. - markPromiseAsHandled(session.closed); - session.destroy( + markPromiseAsHandled(connection.closed); + connection.destroy( error, - session[kHandshakeCompleted] ? closeOptions : undefined); + connection[kHandshakeCompleted] ? closeOptions : undefined); } if (!this.#isClosedOrClosing) { // Trigger a graceful close of the endpoint that'll ensure that the @@ -5065,22 +5106,21 @@ class QuicEndpoint { const inner = this.#inner; assert(typeof inner.onsession === 'function', 'onsession callback not specified'); - const session = this.#newSession(handle, - { __proto__: null, truncatedReads: inner.truncatedReads }); + const connection = this.#newConnection( + handle, { __proto__: null, truncatedReads: inner.truncatedReads, isServer: true }); + const connOrSession = autoStartSession(connection); // Apply session callbacks stored at listen time before notifying // the onsession callback, to avoid missing events that fire // during or immediately after the handshake. if (inner.sessionCallbacks) { - applyCallbacks(session, inner.sessionCallbacks); + applyCallbacks(connection, inner.sessionCallbacks, connOrSession); } - const wrapped = inner.autoWrap ? - autoWrapSession(session, session.alpnProtocol) : session; if (onEndpointServerSessionChannel.hasSubscribers) { onEndpointServerSessionChannel.publish({ __proto__: null, endpoint: this, - session, - address: session.path?.remote, + session: connection, + address: connection.path?.remote, }); } // Route through safeCallbackInvoke so that a synchronous throw or a @@ -5088,13 +5128,13 @@ 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, wrapped); + safeCallbackInvoke(inner.onsession, this, connOrSession); } // Called by the QuicSession when it closes to remove itself from // the active sessions tracked by the QuicEndpoint. - [kRemoveSession](session) { - this.#inner.sessions.delete(session); + [kRemoveSession](connection) { + this.#inner.sessions.delete(connection); } [kInspect](depth, options) { @@ -5539,6 +5579,13 @@ function getPreferredAddressPolicy(policy = 'default') { throw new ERR_INVALID_ARG_VALUE('options.preferredAddressPolicy', policy); } +function assertAutoStartOption(value, name) { + if (value !== undefined) { + throw new ERR_INVALID_ARG_VALUE(`options.${name}`, value, + 'is only supported with autoStart'); + } +} + /** * @param {SessionOptions} options * @param {ProcessSessionOptions} [config] @@ -5570,7 +5617,10 @@ function processSessionOptions(options, config = kEmptyObject) { streamIdleTimeout, verifyPeer = 'auto', truncatedReads = 'error', - autoWrap, + autoStart, + // HTTP/3 application-specific options. Nested under `application` + // to separate protocol-specific settings from transport-level ones. + application, // Session callbacks that can be set at construction time to avoid // race conditions with events that fire during or immediately // after the handshake. @@ -5651,9 +5701,18 @@ function processSessionOptions(options, config = kEmptyObject) { } } - const tls = processTlsOptions(options, forServer); - - if (autoWrap !== undefined) validateBoolean(autoWrap, 'options.autoWrap'); + if (autoStart !== undefined) { + validateBoolean(autoStart, 'options.autoStart'); + // These configure the session that autoStart starts, so without it they'd + // have nothing to apply to: + if (!autoStart) { + assertAutoStartOption(onerror, 'onerror'); + assertAutoStartOption(onstream, 'onstream'); + assertAutoStartOption(ondatagram, 'ondatagram'); + assertAutoStartOption(ondatagramstatus, 'ondatagramstatus'); + assertAutoStartOption(application, 'application'); + } + } const actualEndpoint = processEndpointOption(endpoint, reuseEndpoint, @@ -5672,7 +5731,7 @@ function processSessionOptions(options, config = kEmptyObject) { preferredAddressIpv6: preferredAddressIpv6?.[kSocketAddressHandle], }, tls: { - ...tls, + ...processTlsOptions(options, forServer), // Forward strict mode to C++ so SSL_VERIFY_PEER is set on the // client SSL_CTX. For 'auto' and 'manual' modes, the handshake // completes regardless and the result is handled in JS. @@ -5685,7 +5744,7 @@ function processSessionOptions(options, config = kEmptyObject) { }, verifyPeer, truncatedReads, - autoWrap: autoWrap ?? true, + autoStart, qlog, maxPayloadSize, unacknowledgedPacketThreshold, @@ -5701,6 +5760,7 @@ function processSessionOptions(options, config = kEmptyObject) { drainingPeriodMultiplier, maxDatagramSendAttempts, streamIdleTimeout, + application, onerror, onstream, ondatagram, @@ -5777,20 +5837,20 @@ async function connect(address, options = kEmptyObject) { }); } - const session = endpoint[kConnect](address[kSocketAddressHandle], rest, - options.alpn); + const connOrSession = endpoint[kConnect](address[kSocketAddressHandle], rest); if (onEndpointClientSessionChannel.hasSubscribers) { onEndpointClientSessionChannel.publish({ __proto__: null, endpoint, - session, + // As on every other quic channel, the session reported is the connection + session: isQuicConnection(connOrSession) ? connOrSession : connOrSession.connection, address, options, }); } - return session; + return connOrSession; } ObjectDefineProperties(QuicEndpoint, { @@ -5802,13 +5862,13 @@ ObjectDefineProperties(QuicEndpoint, { value: QuicEndpointStats, }, }); -ObjectDefineProperties(QuicSession, { +ObjectDefineProperties(QuicConnection, { Stats: { __proto__: null, writable: false, configurable: false, enumerable: true, - value: QuicSessionStats, + value: QuicConnectionStats, }, }); ObjectDefineProperties(QuicStream, { @@ -5827,6 +5887,7 @@ module.exports = { listen, connect, QuicEndpoint, + QuicConnection, QuicError, QuicSession, QuicStream, @@ -5836,13 +5897,14 @@ module.exports = { DEFAULT_CIPHERS, DEFAULT_GROUPS, // Internal only, for the HTTP/3 layer's integration with node:quic. + QuicSessionBase, createApplicationStream, - getQuicSessionHandle, - isQuicSession, + getApplicationCallback, + isServerConnection, setApplicationCallback, // These are exported only for internal testing purposes. getQuicStreamState, - getQuicSessionState, + getQuicConnectionState, getQuicEndpointState, listEndpoints, }; diff --git a/lib/internal/quic/state.js b/lib/internal/quic/state.js index 64745114426a..38c438731304 100644 --- a/lib/internal/quic/state.js +++ b/lib/internal/quic/state.js @@ -79,9 +79,7 @@ const { IDX_STATE_SESSION_HEADERS_SUPPORTED, IDX_STATE_SESSION_STREAM_CALLBACKS_SUPPORTED, IDX_STATE_SESSION_WRAPPED, - IDX_STATE_SESSION_IS_SERVER, IDX_STATE_SESSION_APPLICATION_TYPE, - IDX_STATE_SESSION_APPLICATION_INSTALLED, IDX_STATE_SESSION_NO_ERROR_CODE, IDX_STATE_SESSION_INTERNAL_ERROR_CODE, IDX_STATE_SESSION_REQUEST_REJECTED_CODE, @@ -129,9 +127,7 @@ assert(IDX_STATE_SESSION_PRIORITY_SUPPORTED !== undefined); assert(IDX_STATE_SESSION_HEADERS_SUPPORTED !== undefined); assert(IDX_STATE_SESSION_STREAM_CALLBACKS_SUPPORTED !== undefined); assert(IDX_STATE_SESSION_WRAPPED !== undefined); -assert(IDX_STATE_SESSION_IS_SERVER !== undefined); assert(IDX_STATE_SESSION_APPLICATION_TYPE !== undefined); -assert(IDX_STATE_SESSION_APPLICATION_INSTALLED !== undefined); assert(IDX_STATE_SESSION_NO_ERROR_CODE !== undefined); assert(IDX_STATE_SESSION_INTERNAL_ERROR_CODE !== undefined); assert(IDX_STATE_SESSION_REQUEST_REJECTED_CODE !== undefined); @@ -330,7 +326,7 @@ class QuicEndpointState { } } -class QuicSessionState { +class QuicConnectionState { /** @type {DataView} */ #handle; /** @type {number} */ @@ -382,58 +378,58 @@ class QuicSessionState { /** @type {boolean} */ get hasApplicationListener() { - return this.#getListenerFlag(QuicSessionState.#LISTENER_APPLICATION); + return this.#getListenerFlag(QuicConnectionState.#LISTENER_APPLICATION); } set hasApplicationListener(val) { - this.#setListenerFlag(QuicSessionState.#LISTENER_APPLICATION, val); + this.#setListenerFlag(QuicConnectionState.#LISTENER_APPLICATION, val); } /** @type {boolean} */ get hasPathValidationListener() { - return this.#getListenerFlag(QuicSessionState.#LISTENER_PATH_VALIDATION); + return this.#getListenerFlag(QuicConnectionState.#LISTENER_PATH_VALIDATION); } set hasPathValidationListener(val) { - this.#setListenerFlag(QuicSessionState.#LISTENER_PATH_VALIDATION, val); + this.#setListenerFlag(QuicConnectionState.#LISTENER_PATH_VALIDATION, val); } /** @type {boolean} */ get hasDatagramListener() { - return this.#getListenerFlag(QuicSessionState.#LISTENER_DATAGRAM); + return this.#getListenerFlag(QuicConnectionState.#LISTENER_DATAGRAM); } set hasDatagramListener(val) { - this.#setListenerFlag(QuicSessionState.#LISTENER_DATAGRAM, val); + this.#setListenerFlag(QuicConnectionState.#LISTENER_DATAGRAM, val); } /** @type {boolean} */ get hasDatagramStatusListener() { - return this.#getListenerFlag(QuicSessionState.#LISTENER_DATAGRAM_STATUS); + return this.#getListenerFlag(QuicConnectionState.#LISTENER_DATAGRAM_STATUS); } set hasDatagramStatusListener(val) { - this.#setListenerFlag(QuicSessionState.#LISTENER_DATAGRAM_STATUS, val); + this.#setListenerFlag(QuicConnectionState.#LISTENER_DATAGRAM_STATUS, val); } /** @type {boolean} */ get hasSessionTicketListener() { - return this.#getListenerFlag(QuicSessionState.#LISTENER_SESSION_TICKET); + return this.#getListenerFlag(QuicConnectionState.#LISTENER_SESSION_TICKET); } set hasSessionTicketListener(val) { - this.#setListenerFlag(QuicSessionState.#LISTENER_SESSION_TICKET, val); + this.#setListenerFlag(QuicConnectionState.#LISTENER_SESSION_TICKET, val); } /** @type {boolean} */ get hasNewTokenListener() { - return this.#getListenerFlag(QuicSessionState.#LISTENER_NEW_TOKEN); + return this.#getListenerFlag(QuicConnectionState.#LISTENER_NEW_TOKEN); } set hasNewTokenListener(val) { - this.#setListenerFlag(QuicSessionState.#LISTENER_NEW_TOKEN, val); + this.#setListenerFlag(QuicConnectionState.#LISTENER_NEW_TOKEN, val); } /** @type {boolean} */ get hasOriginListener() { - return this.#getListenerFlag(QuicSessionState.#LISTENER_ORIGIN); + return this.#getListenerFlag(QuicConnectionState.#LISTENER_ORIGIN); } set hasOriginListener(val) { - this.#setListenerFlag(QuicSessionState.#LISTENER_ORIGIN, val); + this.#setListenerFlag(QuicConnectionState.#LISTENER_ORIGIN, val); } /** @type {boolean} */ @@ -493,27 +489,25 @@ class QuicSessionState { } /** - * Whether the installed application protocol supports headers. - * Returns 1 (supported) or 2 (not supported), or undefined until an - * application is installed. - * @type {number|undefined} + * Whether the negotiated application protocol supports headers. + * Returns 0 (unknown), 1 (supported), or 2 (not supported). + * @type {number} */ get headersSupported() { const handle = this.#handle; - if (handle === undefined || !this.hasApplication) return undefined; + if (handle === undefined) return undefined; return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_HEADERS_SUPPORTED); } /** - * Whether the installed application dispatches the session-level + * Whether the negotiated application dispatches the session-level * stream callbacks (onheaders et al) for incoming streams. - * Returns 1 (supported) or 2 (not supported), or undefined until an - * application is installed. - * @type {number|undefined} + * Returns 0 (unknown), 1 (supported), or 2 (not supported). + * @type {number} */ get streamCallbacksSupported() { const handle = this.#handle; - if (handle === undefined || !this.hasApplication) return undefined; + if (handle === undefined) return undefined; return DataViewPrototypeGetUint8( handle, this.#offset + IDX_STATE_SESSION_STREAM_CALLBACKS_SUPPORTED); } @@ -525,72 +519,46 @@ class QuicSessionState { return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_WRAPPED) !== 0; } - /** - * The application the session is running, as an `Application::Type` value, - * or 0 for none. Set as soon as an application is requested, before it is - * installed. - * @type {number|undefined} - */ + /** @type {number} */ get applicationType() { const handle = this.#handle; if (handle === undefined) return undefined; return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_APPLICATION_TYPE); } - set applicationType(val) { - const handle = this.#handle; - if (handle === undefined) return; - DataViewPrototypeSetUint8(handle, this.#offset + IDX_STATE_SESSION_APPLICATION_TYPE, val); - } - - // True once an application has actually been attached (not just requested) - get hasApplication() { - const handle = this.#handle; - if (handle === undefined) return false; - return DataViewPrototypeGetUint8( - handle, this.#offset + IDX_STATE_SESSION_APPLICATION_INSTALLED) !== 0; - } - - /** @type {boolean|undefined} */ - get isServer() { - const handle = this.#handle; - if (handle === undefined) return undefined; - return DataViewPrototypeGetUint8( - handle, this.#offset + IDX_STATE_SESSION_IS_SERVER) !== 0; - } - /** - * The application protocol's "no error" code, populated by the C++ - * layer when the application is installed. For raw QUIC this is `0n`; - * for HTTP/3 this is `0x100n` (`H3_NO_ERROR`). + * The negotiated application protocol's "no error" code, populated + * by the C++ layer when the application is selected during ALPN + * negotiation. For raw QUIC this is `0n`; for HTTP/3 this is + * `0x100n` (`H3_NO_ERROR`). * @type {bigint} */ get noErrorCode() { const handle = this.#handle; - if (handle === undefined || !this.hasApplication) return undefined; + if (handle === undefined) return undefined; return DataViewPrototypeGetBigUint64( handle, this.#offset + IDX_STATE_SESSION_NO_ERROR_CODE, kIsLittleEndian); } /** - * The application protocol's "internal error" code, populated by - * the C++ layer when the application is installed. Used as the wire - * code for `RESET_STREAM` frames when a stream is aborted without a - * more specific code. + * The negotiated application protocol's "internal error" code, + * populated by the C++ layer when the application is selected + * during ALPN negotiation. Used as the wire code for `RESET_STREAM` + * frames when a stream is aborted without a more specific code. * For raw QUIC this is `0x1n` (NGTCP2_INTERNAL_ERROR); for HTTP/3 * this is `0x102n` (`H3_INTERNAL_ERROR`). * @type {bigint} */ get internalErrorCode() { const handle = this.#handle; - if (handle === undefined || !this.hasApplication) return undefined; + if (handle === undefined) return undefined; return DataViewPrototypeGetBigUint64( handle, this.#offset + IDX_STATE_SESSION_INTERNAL_ERROR_CODE, kIsLittleEndian); } get requestRejectedCode() { const handle = this.#handle; - if (handle === undefined || !this.hasApplication) return undefined; + if (handle === undefined) return undefined; return DataViewPrototypeGetBigUint64( handle, this.#offset + IDX_STATE_SESSION_REQUEST_REJECTED_CODE, kIsLittleEndian); } @@ -687,11 +655,11 @@ class QuicSessionState { [kInspect](depth, options) { if (this.#handle === undefined) { - return 'QuicSessionState { }'; + return 'QuicConnectionState { }'; } if (depth < 0) { - return 'QuicSessionState { }'; + return 'QuicConnectionState { }'; } const opts = { @@ -725,7 +693,7 @@ class QuicSessionState { maxPendingDatagrams, } = this; - return `QuicSessionState ${inspect({ + return `QuicConnectionState ${inspect({ hasPathValidationListener, hasDatagramListener, hasDatagramStatusListener, @@ -1059,7 +1027,7 @@ class QuicStreamState { module.exports = { QuicEndpointState, - QuicSessionState, + QuicConnectionState, QuicStreamState, }; diff --git a/lib/internal/quic/stats.js b/lib/internal/quic/stats.js index f2fefbcb74cd..7304e2a45119 100644 --- a/lib/internal/quic/stats.js +++ b/lib/internal/quic/stats.js @@ -190,10 +190,10 @@ assert(IDX_STATS_SESSION_COUNT !== undefined); const kCreateDisconnected = Symbol('kCreateDisconnected'); let assertIsQuicEndpointStats; -let assertIsQuicSessionStats; +let assertIsQuicConnectionStats; let assertIsQuicStreamStats; let isQuicEndpointStats; -let isQuicSessionStats; +let isQuicConnectionStats; let isQuicStreamStats; function assertIsPrivateConstructor(privateSymbol) { @@ -477,20 +477,20 @@ class QuicEndpointStats { } } -class QuicSessionStats { +class QuicConnectionStats { /** @type {BigUint64Array} */ #handle; #disconnected = false; #offset = 0; static { - isQuicSessionStats = function(val) { + isQuicConnectionStats = function(val) { return val != null && typeof val === 'object' && #handle in val; }; - assertIsQuicSessionStats = function(val) { - if (!isQuicSessionStats(val)) { - throw new ERR_INVALID_THIS('QuicSessionStats'); + assertIsQuicConnectionStats = function(val) { + if (!isQuicConnectionStats(val)) { + throw new ERR_INVALID_THIS('QuicConnectionStats'); } }; } @@ -503,7 +503,7 @@ class QuicSessionStats { */ constructor(privateSymbol, view, byteOffset = 0) { // We use the kPrivateConstructor symbol to restrict the ability to - // create new instances of QuicSessionStats to internal code. + // create new instances of QuicConnectionStats to internal code. assertIsPrivateConstructor(privateSymbol); if (isArrayBuffer(view)) { this.#handle = new BigUint64Array(view); @@ -515,185 +515,185 @@ class QuicSessionStats { /** @type {bigint} */ get createdAt() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_CREATED_AT]; } /** @type {bigint} */ get destroyedAt() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_DESTROYED_AT]; } /** @type {bigint} */ get closingAt() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_CLOSING_AT]; } /** @type {bigint} */ get handshakeCompletedAt() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_HANDSHAKE_COMPLETED_AT]; } /** @type {bigint} */ get handshakeConfirmedAt() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_HANDSHAKE_CONFIRMED_AT]; } /** @type {bigint} */ get bytesReceived() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_BYTES_RECEIVED]; } /** @type {bigint} */ get bidiInStreamCount() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_BIDI_IN_STREAM_COUNT]; } /** @type {bigint} */ get bidiOutStreamCount() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_BIDI_OUT_STREAM_COUNT]; } /** @type {bigint} */ get uniInStreamCount() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_UNI_IN_STREAM_COUNT]; } /** @type {bigint} */ get uniOutStreamCount() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_UNI_OUT_STREAM_COUNT]; } /** @type {bigint} */ get maxBytesInFlight() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_MAX_BYTES_IN_FLIGHT]; } /** @type {bigint} */ get bytesInFlight() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_BYTES_IN_FLIGHT]; } /** @type {bigint} */ get blockCount() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_BLOCK_COUNT]; } /** @type {bigint} */ get cwnd() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_CWND]; } /** @type {bigint} */ get latestRtt() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_LATEST_RTT]; } /** @type {bigint} */ get minRtt() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_MIN_RTT]; } /** @type {bigint} */ get rttVar() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_RTTVAR]; } /** @type {bigint} */ get smoothedRtt() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_SMOOTHED_RTT]; } /** @type {bigint} */ get ssthresh() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_SSTHRESH]; } get pktSent() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_PKT_SENT]; } get bytesSent() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_BYTES_SENT]; } get pktRecv() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_PKT_RECV]; } get bytesRecv() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_BYTES_RECV]; } get pktLost() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_PKT_LOST]; } get bytesLost() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_BYTES_LOST]; } get pingRecv() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_PING_RECV]; } get pktDiscarded() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_PKT_DISCARDED]; } /** @type {bigint} */ get datagramsReceived() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_DATAGRAMS_RECEIVED]; } /** @type {bigint} */ get datagramsSent() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_DATAGRAMS_SENT]; } /** @type {bigint} */ get datagramsAcknowledged() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_DATAGRAMS_ACKNOWLEDGED]; } /** @type {bigint} */ get datagramsLost() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_DATAGRAMS_LOST]; } /** @type {bigint} */ get streamsIdleTimedOut() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_STREAMS_IDLE_TIMED_OUT]; } @@ -703,7 +703,7 @@ class QuicSessionStats { } toJSON() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); const { createdAt, closingAt, @@ -778,7 +778,7 @@ class QuicSessionStats { [kInspect](depth, options) { if (depth < 0) { - return 'QuicSessionStats { }'; + return 'QuicConnectionStats { }'; } const opts = { @@ -821,7 +821,7 @@ class QuicSessionStats { streamsIdleTimedOut, } = this; - return `QuicSessionStats ${inspect({ + return `QuicConnectionStats ${inspect({ connected: this.isConnected, createdAt, closingAt, @@ -858,7 +858,7 @@ class QuicSessionStats { } /** - * True if this QuicSessionStats object is still connected to the underlying + * True if this QuicConnectionStats object is still connected to the underlying * Session stats source. If this returns false, then the stats object is * no longer being updated and should be considered stale. * @type {boolean} @@ -1108,7 +1108,7 @@ class QuicStreamStats { module.exports = { QuicEndpointStats, - QuicSessionStats, + QuicConnectionStats, QuicStreamStats, kCreateDisconnected, }; diff --git a/lib/internal/quic/symbols.js b/lib/internal/quic/symbols.js index 1ed44ebe2b13..1e6f9443cb26 100644 --- a/lib/internal/quic/symbols.js +++ b/lib/internal/quic/symbols.js @@ -30,6 +30,7 @@ const { const kAttachFileHandle = Symbol('kAttachFileHandle'); const kAvailable = Symbol('kAvailable'); const kBlocked = Symbol('kBlocked'); +const kClose = Symbol('kClose'); const kConnect = Symbol('kConnect'); const kDrain = Symbol('kDrain'); const kDatagram = Symbol('kDatagram'); @@ -55,6 +56,7 @@ const kPrivateConstructor = Symbol('kPrivateConstructor'); const kRemoveSession = Symbol('kRemoveSession'); const kRemoveStream = Symbol('kRemoveStream'); const kReset = Symbol('kReset'); +const kSendDatagram = Symbol('kSendDatagram'); const kSendHeaders = Symbol('kSendHeaders'); const kSessionApplication = Symbol('kSessionApplication'); const kSessionTicket = Symbol('kSessionTicket'); @@ -66,6 +68,7 @@ module.exports = { kAttachFileHandle, kAvailable, kBlocked, + kClose, kConnect, kDatagram, kDatagramStatus, @@ -93,6 +96,7 @@ module.exports = { kRemoveSession, kRemoveStream, kReset, + kSendDatagram, kSendHeaders, kSessionApplication, kSessionTicket, diff --git a/lib/quic.js b/lib/quic.js index f013dabf6356..bca4df67bbcc 100644 --- a/lib/quic.js +++ b/lib/quic.js @@ -9,6 +9,7 @@ const { connect, listen, listEndpoints, + QuicConnection, QuicEndpoint, QuicError, QuicSession, @@ -41,6 +42,7 @@ module.exports = { listen, listEndpoints, Http3Session, + QuicConnection, QuicEndpoint, QuicError, QuicSession, diff --git a/src/quic/README.md b/src/quic/README.md index c42d7e4fe034..986f2279add2 100644 --- a/src/quic/README.md +++ b/src/quic/README.md @@ -137,18 +137,25 @@ re-reading from the source. `Session::Application` is a virtual interface that the Session delegates protocol-specific behavior to. Two implementations exist: -* **`DefaultApplication`** (`application.cc`): Raw QUIC streams, with no - framing of its own. Maintains its own stream scheduling queue. Streams are - scheduled via an intrusive linked list. - -* **`Http3ApplicationImpl`** (`http3.cc`): Wraps `nghttp3_conn` for HTTP/3 - framing, header compression (QPACK), server push, and stream - prioritization. Manages unidirectional control streams internally. - -A Session starts without an Application. JavaScript schedules an attach (that -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. +* **`DefaultApplication`** (`application.cc`): Used for non-HTTP/3 ALPN + protocols. Maintains its own stream scheduling queue. Streams are scheduled + via an intrusive linked list. + +* **`Http3ApplicationImpl`** (`http3.cc`): Used when ALPN negotiates `h3`. + Wraps `nghttp3_conn` for HTTP/3 framing, header compression (QPACK), + server push, and stream prioritization. Manages unidirectional control + streams internally. + +When the `autoStart` option is true (the default) the Application is +selected as soon as the ALPN protocol is known: immediately for clients, +and for servers from the `OnClientHello` TLS callback (see +[Server handshake ordering](#server-handshake-ordering)). + +When `autoStart` is false, the Application is selected and started via +the session start APIs ( (`QuicSession.start()` or `Http3Session.start()`)). +A session must be started by the end of the tick when the connection is +opened (the server session callback or the client `opened` promise) - if +not, the connection is closed automatically. ### Allocator @@ -190,10 +197,11 @@ address validation (retry tokens, LRU cache), then calls `Session::Create()` ### Server handshake ordering A server has to make several decisions from the ClientHello, and the order -matters: the ALPN protocol depends on which identity SNI selected, and -JavaScript needs a `QuicSession` object before any 0-RTT request arrives on -it. TLS is therefore stopped at the ClientHello, before the point where a -session ticket could be accepted and early data could start flowing: +matters: the ALPN protocol depends on which identity SNI selected, the +Application depends on the ALPN protocol, and JavaScript needs a +`QuicSession` object before any 0-RTT request arrives on it. TLS is +therefore stopped at the ClientHello, before the point where a session +ticket could be accepted and early data could start flowing: ```text ngtcp2_conn_read_pkt() @@ -201,6 +209,7 @@ ngtcp2_conn_read_pkt() → TLSContext::OnClientHello() // SSL_CTX_set_client_hello_cb ├── SelectSNIContext() + SSL_set_SSL_CTX() ├── crypto::SelectNextProtocol() // against that identity's list + ├── Session::InstallApplicationForAlpn() └── return SSL_CLIENT_HELLO_RETRY // handshake suspended here ← SSL_ERROR_WANT_CLIENT_HELLO_CB // ngtcp2 treats this as "not done" ← 0 diff --git a/src/quic/application.cc b/src/quic/application.cc index 8e9766bf52da..212a9984d1ac 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -270,6 +270,9 @@ class DefaultApplication final : public Session::Application { // condition (code 0 would be treated as a clean close). session().DestroyAllStreams( QuicError::ForApplication(GetInternalErrorCode())); + if (!session().is_destroyed()) { + session().EmitEarlyDataRejected(); + } } bool ReceiveStreamOpen(stream_id id) override { diff --git a/src/quic/application.h b/src/quic/application.h index 7a698bacc910..fe0e0a7dd700 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -35,7 +35,14 @@ class Session::Application : public MemoryRetainer { // options passed at construction time since some options can be negotiated. virtual const Options& options() const = 0; - using Type = ApplicationType; + // The type of Application, exposed via the session state so JS + // can observe which Application was selected after ALPN negotiation. + // This is used primarily for testing/debugging. + enum class Type : uint8_t { + NONE = 0, // Not yet selected (server pre-negotiation) + DEFAULT = 1, // DefaultApplication (non-h3 ALPN) + HTTP3 = 2, // Http3ApplicationImpl (h3 / h3-XX ALPN) + }; virtual Type type() const = 0; virtual bool Start(); diff --git a/src/quic/bindingdata.cc b/src/quic/bindingdata.cc index e8e22efd29a8..e49da50f3f03 100644 --- a/src/quic/bindingdata.cc +++ b/src/quic/bindingdata.cc @@ -31,7 +31,6 @@ using v8::Isolate; using v8::Local; using v8::Object; using v8::String; -using v8::Symbol; using v8::Value; namespace quic { @@ -439,17 +438,6 @@ Local BindingData::transport_params_template() const { transport_params_template_); } -Local BindingData::http3_settings_symbol() { - if (http3_settings_symbol_.IsEmpty()) { - auto symbol = Symbol::New( - env()->isolate(), - FIXED_ONE_BYTE_STRING(env()->isolate(), "quic.http3.settings")); - http3_settings_symbol_.Reset(env()->isolate(), symbol); - return symbol; - } - return http3_settings_symbol_.Get(env()->isolate()); -} - void BindingData::set_application_options_template( Local tmpl) { application_options_template_.Reset(env()->isolate(), tmpl); diff --git a/src/quic/bindingdata.h b/src/quic/bindingdata.h index fc8db6ac5e37..3215ae3dd4fe 100644 --- a/src/quic/bindingdata.h +++ b/src/quic/bindingdata.h @@ -77,6 +77,7 @@ struct QuicAllocState; V(allow, "allow") \ V(application, "application") \ V(authoritative, "authoritative") \ + V(auto_start, "autoStart") \ V(bbr, "bbr") \ V(ca, "ca") \ V(cc_algorithm, "cc") \ @@ -328,8 +329,6 @@ class BindingData final void set_transport_params_template(v8::Local tmpl); v8::Local transport_params_template() const; - v8::Local http3_settings_symbol(); - void set_application_options_template(v8::Local tmpl); v8::Local application_options_template() const; @@ -353,7 +352,6 @@ class BindingData final v8::Global transport_params_template_; v8::Global application_options_template_; - v8::Global http3_settings_symbol_; #define V(name, _) v8::Global name##_callback_; QUIC_JS_CALLBACKS(V) diff --git a/src/quic/endpoint.cc b/src/quic/endpoint.cc index 7df687ab8e61..5c968ca6d6aa 100644 --- a/src/quic/endpoint.cc +++ b/src/quic/endpoint.cc @@ -1996,12 +1996,7 @@ void Endpoint::SocketAddressInfoTraits::Touch(const SocketAddress& address, // JavaScript call outs void Endpoint::EmitNewSession(const BaseObjectPtr& session) { - 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; - } + if (!env()->can_call_into_js()) return; CallbackScope scope(this); session->set_wrapped(); Local arg = session->object(); @@ -2014,12 +2009,12 @@ void Endpoint::EmitNewSession(const BaseObjectPtr& session) { // exists but it is in a destroyed state. Care should be taken accessing // session after this point. - // At this point, the session is active and JS has had its chance to request - // an application. We attach the application now, and then deliver any qlog - // written during the ClientHello - the only output that can predate this. + // Deliver any qlog written while processing the packets that carried the + // ClientHello, which is the only output that can predate this callback. if (!session->is_destroyed()) { - session->EnsureApplication(); session->FlushPendingQlog(); + // JS has had its chance to start a session; without one, this closes it. + session->RequireApplication(); } } diff --git a/src/quic/http3.cc b/src/quic/http3.cc index 7970d2b98b42..ad0d6e383fe3 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -22,12 +22,9 @@ namespace node { using v8::Array; using v8::Global; -using v8::HandleScope; using v8::Integer; using v8::Local; using v8::LocalVector; -using v8::Object; -using v8::TryCatch; using v8::Value; namespace quic { @@ -212,6 +209,9 @@ class Http3ApplicationImpl final : public Session::Application { started_ = false; session().DestroyAllStreams( QuicError::ForApplication(GetInternalErrorCode())); + if (!session().is_destroyed()) { + session().EmitEarlyDataRejected(); + } } bool ReceiveStreamOpen(stream_id id) override { @@ -1523,46 +1523,10 @@ class Http3ApplicationImpl final : public Session::Application { nullptr}; }; -Session::Application_Options Http3SettingsFromHandle(const Session& session) { - Environment* env = session.env(); - HandleScope scope(env->isolate()); - - Local stored; - if (!session.object() - ->Get(env->context(), BindingData::Get(env).http3_settings_symbol()) - .ToLocal(&stored) || - !stored->IsObject()) { - return Session::Application_Options::kDefault; - } - - Session::Application_Options options; - { - TryCatch try_catch(env->isolate()); - if (Session::Application_Options::From(env, stored).To(&options)) { - return options; - } - } - return Session::Application_Options::kDefault; -} - -void InitHttp3PerContext(Realm* realm, Local target) { - Environment* env = realm->env(); - // The application id JavaScript writes into the session's application_type - // to request HTTP/3, and the symbol it leaves the settings under. - constexpr int QUIC_APPLICATION_HTTP3 = - static_cast(Session::Application::Type::HTTP3); - NODE_DEFINE_CONSTANT(target, QUIC_APPLICATION_HTTP3); - target - ->Set(realm->context(), - FIXED_ONE_BYTE_STRING(env->isolate(), "kHttp3Settings"), - BindingData::Get(env).http3_settings_symbol()) - .Check(); -} - -std::unique_ptr CreateHttp3Application(Session* session) { - Debug(session, "Installing HTTP/3 application"); - return std::make_unique( - session, Http3SettingsFromHandle(*session)); +std::unique_ptr CreateHttp3Application( + Session* session, const Session::Application_Options& options) { + Debug(session, "Selecting HTTP/3 application"); + return std::make_unique(session, options); } } // namespace quic diff --git a/src/quic/http3.h b/src/quic/http3.h index 3097b8e85c54..09033ca78b3d 100644 --- a/src/quic/http3.h +++ b/src/quic/http3.h @@ -2,23 +2,18 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#include #include #include "application.h" #include "session.h" -namespace node { -class Realm; -namespace quic { +namespace node::quic { // Create an HTTP/3 Application implementation for the given session. -std::unique_ptr CreateHttp3Application(Session* session); +// Uses the Application_Options from the session's config for HTTP/3 +// specific settings (qpack, max header length, etc.). +std::unique_ptr CreateHttp3Application( + Session* session, const Session::Application_Options& options); -Session::Application_Options Http3SettingsFromHandle(const Session& session); - -void InitHttp3PerContext(Realm* realm, v8::Local target); - -} // namespace quic -} // namespace node +} // namespace node::quic #endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS diff --git a/src/quic/quic.cc b/src/quic/quic.cc index df10a97b85d0..9ed44cb56675 100644 --- a/src/quic/quic.cc +++ b/src/quic/quic.cc @@ -1,7 +1,6 @@ #include "guard.h" #ifndef OPENSSL_NO_QUIC -#include #include #include #include @@ -10,7 +9,6 @@ #include #include "bindingdata.h" #include "endpoint.h" -#include "http3.h" #include "node_external_reference.h" #include @@ -50,7 +48,6 @@ void CreatePerContextProperties(Local target, Endpoint::InitPerContext(realm, target); Session::InitPerContext(realm, target); Stream::InitPerContext(realm, target); - InitHttp3PerContext(realm, target); } void RegisterExternalReferences(ExternalReferenceRegistry* registry) { diff --git a/src/quic/session.cc b/src/quic/session.cc index f37f4fd29763..0218d8b0450f 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -138,9 +138,7 @@ uint64_t MaxDatagramPayload(uint64_t max_frame_size) { V(HEADERS_SUPPORTED, headers_supported, uint8_t) \ V(STREAM_CALLBACKS_SUPPORTED, stream_callbacks_supported, uint8_t) \ V(WRAPPED, wrapped, uint8_t) \ - V(IS_SERVER, is_server, uint8_t) \ V(APPLICATION_TYPE, application_type, uint8_t) \ - V(APPLICATION_INSTALLED, application_installed, uint8_t) \ V(NO_ERROR_CODE, no_error_code, error_code) \ V(INTERNAL_ERROR_CODE, internal_error_code, error_code) \ V(REQUEST_REJECTED_CODE, request_rejected_code, error_code) \ @@ -201,7 +199,8 @@ uint64_t MaxDatagramPayload(uint64_t max_frame_size) { V(SendDatagram, sendDatagram, SIDE_EFFECT) \ V(LocalTransportParams, localTransportParams, NO_SIDE_EFFECT) \ V(RemoteTransportParams, remoteTransportParams, NO_SIDE_EFFECT) \ - V(ApplicationOptions, applicationOptions, NO_SIDE_EFFECT) + V(ApplicationOptions, applicationOptions, NO_SIDE_EFFECT) \ + V(StartApplication, startApplication, SIDE_EFFECT) struct Session::State final { #define V(_, name, type) type name; @@ -625,7 +624,8 @@ Maybe Session::Options::From(Environment* env, !SET(keep_alive_timeout) || !SET(max_stream_window) || !SET(max_window) || !SET(max_payload_size) || !SET(unacknowledged_packet_threshold) || !SET(cc_algorithm) || !SET(draining_period_multiplier) || - !SET(max_datagram_send_attempts) || !SET(stream_idle_timeout)) { + !SET(max_datagram_send_attempts) || !SET(stream_idle_timeout) || + !SET(auto_start)) { return Nothing(); } @@ -654,6 +654,20 @@ Maybe Session::Options::From(Environment* env, } } + // Parse the application-specific options (HTTP/3 qpack settings, etc.). + // These are used if the negotiated ALPN selects Http3ApplicationImpl. + { + Local app_val; + if (params->Get(env->context(), state.application_string()) + .ToLocal(&app_val) && + !app_val->IsUndefined()) { + if (!Application_Options::From(env, app_val) + .To(&options.application_options)) { + return Nothing(); + } + } + } + // Parse the SNI map from the tls options. { Local tls_val; @@ -1226,6 +1240,25 @@ struct Session::Impl final : public MemoryRetainer { } } + JS_METHOD(StartApplication) { + auto env = Environment::GetCurrent(args); + Session* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + if (session->is_destroyed()) return args.GetReturnValue().Set(false); + CHECK(!session->has_application()); + CHECK(args[0]->IsUint32()); + auto type = static_cast(args[0].As()->Value()); + Application_Options options = Application_Options::kDefault; + if (!args[1]->IsUndefined() && + !Application_Options::From(env, args[1]).To(&options)) { + return; + } + session->SetApplication(type == Application::Type::HTTP3 + ? CreateHttp3Application(session, options) + : CreateDefaultApplication(session, options)); + args.GetReturnValue().Set(!session->flags_.application_start_failed); + } + JS_METHOD(ApplicationOptions) { auto env = Environment::GetCurrent(args); Session* session; @@ -1233,15 +1266,10 @@ struct Session::Impl final : public MemoryRetainer { Local obj; if (!session->has_application()) { - // Not installed yet. If an attach has been scheduled, its settings are - // already known and can be reported before the install happens. - if (session->application_type() != Application::Type::HTTP3) { - return args.GetReturnValue().SetUndefined(); - } - if (Http3SettingsFromHandle(*session).ToObject(env).ToLocal(&obj)) { - args.GetReturnValue().Set(obj); - } - return; + // The application has not yet been selected (ALPN negotiation is not + // yet complete on the server) or the session has been destroyed. In + // either case, the application options are not available. + return args.GetReturnValue().SetUndefined(); } auto& options = session->application().options(); if (options.ToObject(env).ToLocal(&obj)) { @@ -1440,6 +1468,8 @@ struct Session::Impl final : public MemoryRetainer { if (level != NGTCP2_ENCRYPTION_LEVEL_1RTT) return NGTCP2_SUCCESS; + // A client that hasn't started its session yet can still start one + // until the handshake completes, see SetApplication(). session->keys_ready_ = true; if (!session->impl_->application_) return NGTCP2_SUCCESS; @@ -1502,7 +1532,6 @@ struct Session::Impl final : public MemoryRetainer { // application for processing. If it ends up being a user stream, the // application will handle creating the Stream handle and passing that off // to the JavaScript side. - CHECK(session->impl_->application_); if (!session->application().ReceiveStreamData( stream_id, data, datalen, data_flags, stream_user_data)) { return NGTCP2_ERR_CALLBACK_FAILURE; @@ -1527,10 +1556,10 @@ struct Session::Impl final : public MemoryRetainer { if (level != NGTCP2_ENCRYPTION_LEVEL_0RTT) return NGTCP2_SUCCESS; } - session->keys_ready_ = true; - // A session with no application installed has nothing to start; whichever - // one is installed later starts itself, see EnsureApplication(). - if (!session->impl_->application_) return NGTCP2_SUCCESS; + // application_ may be null if ALPN selection hasn't happened yet + // (e.g., ALPN mismatch causes the handshake to fail during key + // installation). Without an application, we can't start. + if (!session->impl_->application_) return NGTCP2_ERR_CALLBACK_FAILURE; Debug(session, "Receiving TX key for level %s for dcid %s", @@ -1594,7 +1623,6 @@ struct Session::Impl final : public MemoryRetainer { static int on_stream_open(ngtcp2_conn* conn, stream_id id, void* user_data) { NGTCP2_CALLBACK_SCOPE(session) - CHECK(session->impl_->application_); if (!session->application().ReceiveStreamOpen(id)) { return NGTCP2_ERR_CALLBACK_FAILURE; } @@ -1641,9 +1669,6 @@ struct Session::Impl final : public MemoryRetainer { if (session->impl_->application_) { session->application().EarlyDataRejected(); } - if (!session->is_destroyed()) { - session->EmitEarlyDataRejected(); - } return NGTCP2_SUCCESS; } @@ -2272,7 +2297,12 @@ Session::Session(Endpoint* endpoint, DCHECK(impl_); STAT_RECORD_TIMESTAMP(Stats, created_at); - impl_->state()->is_server = config.side == Side::SERVER ? 1 : 0; + // For clients, select the Application immediately - the ALPN is + // known upfront from the options. For servers, application_ stays + // null until the ClientHello names a protocol. + if (config.side == Side::CLIENT && config.options.auto_start) { + InstallApplicationForAlpn(DecodeAlpn(config.options.tls_options.alpn)); + } // For client sessions with a session ticket and early data enabled, // defer the handshake until the first stream or datagram is sent. @@ -2444,10 +2474,8 @@ void Session::Close(CloseMethod method) { // Signal application-level graceful shutdown (e.g., HTTP/3 GOAWAY). // BeginShutdown can trigger callbacks that re-enter JS and destroy // this session, so check is_destroyed() after it returns. - if (impl_->application_) { - application().BeginShutdown(); - if (is_destroyed()) return; - } + application().BeginShutdown(); + if (is_destroyed()) return; // If there are no open streams, then we can close immediately and // not worry about waiting around. @@ -2466,7 +2494,7 @@ void Session::Close(CloseMethod method) { // writable stream with a closed read side is the normal request/ // response pattern (server received full request, still sending // response). The application protocol handles stream completion. - if (!stream_fin_managed_by_application()) { + if (!application().stream_fin_managed_by_application()) { Session::SendPendingDataScope send_scope(this); for (auto& [id, stream] : impl_->streams_) { if (stream->is_writable() && !stream->is_readable()) { @@ -2598,20 +2626,38 @@ bool Session::has_application() const { return !is_destroyed() && impl_->application_ != nullptr; } -Session::ApplicationType Session::application_type() const { - if (is_destroyed()) return Application::Type::NONE; - return static_cast(impl_->state()->application_type); -} - Session::Application& Session::application() const { DCHECK(!is_destroyed()); DCHECK(impl_->application_); return *impl_->application_; } -bool Session::stream_fin_managed_by_application() const { - return impl_->application_ != nullptr && - impl_->application_->stream_fin_managed_by_application(); +std::string_view Session::DecodeAlpn(std::string_view wire) { + // ALPN wire format is length-prefixed: [len][name]. Extract the first entry. + if (wire.size() >= 2) { + uint8_t len = static_cast(wire[0]); + if (len > 0 && static_cast(len + 1) <= wire.size()) { + return wire.substr(1, len); + } + } + return {}; +} + +std::unique_ptr Session::SelectApplicationFromAlpn( + std::string_view alpn) { + // h3 and h3-XX variants use Http3ApplicationImpl. + // Everything else uses DefaultApplication. + if (alpn == "h3" || (alpn.size() > 3 && alpn.substr(0, 3) == "h3-")) { + return CreateHttp3Application(this, config().options.application_options); + } + return CreateDefaultApplication(this, config().options.application_options); +} + +void Session::InstallApplicationForAlpn(std::string_view alpn) { + // Acting on the ClientHello twice would install a second Application over + // a live one; TLSSession::EarlySelection is what prevents that. + CHECK(!has_application()); + SetApplication(SelectApplicationFromAlpn(alpn)); } void Session::SetEarlyRemoteTransportParams(std::span params) { @@ -2621,41 +2667,21 @@ void Session::SetEarlyRemoteTransportParams(std::span params) { *this, params.data(), params.size())); } -// This method is called at any point where we need an application to be -// attached. It checks whether JS has requested a specific implementation, -// and either installs that, or the default (raw QUIC) application. -bool Session::EnsureApplication() { - if (is_destroyed()) [[unlikely]] - return false; - if (impl_->application_) [[likely]] - return !flags_.application_start_failed; - - if (application_type() == Application::Type::HTTP3) { - SetApplication(CreateHttp3Application(this)); - } else { - SetApplication( - CreateDefaultApplication(this, Application_Options::kDefault)); - } - - // If the keys are already ready, that means we should start immediately. - // 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; +bool Session::RequireApplication() { + if (is_destroyed()) return false; + if (has_application()) return !flags_.application_start_failed; + // Nothing can use a connection that no session was started on. Inside an + // ngtcp2 callback, returning false fails the handshake instead. + Debug(this, "No application started"); + if (!flags_.in_ngtcp2_callback_scope) { + SetLastError(QuicError::ForTransport(NGTCP2_CONNECTION_REFUSED)); + Close(); } - return true; + return false; } void Session::SetApplication(std::unique_ptr app) { DCHECK(!impl_->application_); - DCHECK(app); impl_->state()->application_type = static_cast(app->type()); impl_->state()->headers_supported = static_cast( app->SupportsHeaders() ? HeadersSupportState::SUPPORTED @@ -2672,7 +2698,15 @@ void Session::SetApplication(std::unique_ptr app) { impl_->state()->internal_error_code = app->GetInternalErrorCode(); impl_->state()->request_rejected_code = app->GetRequestRejectedCode(); impl_->application_ = std::move(app); - impl_->state()->application_installed = 1; + + // A client can start its session after its keys were installed (from JS + // run when the handshake completes), in which case the key callbacks that + // would start the Application have already run, so we have to retrigger + // app.start() here: + if (keys_ready_ && !application().Start()) { + Debug(this, "Application start failed"); + flags_.application_start_failed = 1; + } } const SocketAddress& Session::remote_address() const { @@ -2863,11 +2897,12 @@ bool Session::AfterNgtcp2Read(int err) { if (is_destroyed()) return true; // The ClientHello has been processed: SNI and ALPN are selected and - // the Application is installed, but the handshake is stopped short - // of ticket decryption, so no early data exists yet. Surface the - // session, then let the handshake run on. The guard makes this fire - // exactly once, on whichever packet completed the ClientHello, so a - // ClientHello split across datagrams is handled correctly. + // the Application is installed (unless JS is to start one), but the + // handshake is stopped short of ticket decryption, so no early data + // exists yet. Surface the session, then let the handshake run on. The + // guard makes this fire exactly once, on whichever packet completed + // the ClientHello, so a ClientHello split across datagrams is handled + // correctly. if (is_server() && tls_session().early_selection() == TLSSession::EarlySelection::kSelected) { endpoint().EmitNewSession(BaseObjectPtr(this)); @@ -3107,9 +3142,6 @@ datagram_id Session::SendDatagram(Store&& data) { return 0; } - if (!EnsureApplication()) [[unlikely]] - return 0; - const ngtcp2_transport_params* tp = remote_transport_params(); uint64_t max_datagram_size = MaxDatagramPayload(tp->max_datagram_frame_size); @@ -3216,9 +3248,6 @@ MaybeLocal Session::OpenStream(Direction direction, if (!can_create_streams()) [[unlikely]] return {}; - if (!EnsureApplication()) [[unlikely]] - return {}; - // If can_open_streams() returns false, we are able to create streams but // they will remain in a pending state. The implication is that the session // TLS handshake is still progressing. Note that when a pending stream is @@ -3414,13 +3443,14 @@ void Session::StreamDataBlocked(stream_id id) { void Session::CollectSessionTicketAppData( SessionTicket::AppData* app_data) const { - if (!has_application()) [[unlikely]] - return; + DCHECK(!is_destroyed()); application().CollectSessionTicketAppData(app_data); } SessionTicket::AppData::Status Session::ExtractSessionTicketAppData( const SessionTicket::AppData& app_data, Flag flag) { + DCHECK(!is_destroyed()); + // Renew, so the client stops offering a ticket that is never accepted. if (!has_application()) [[unlikely]] { return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; } @@ -3895,11 +3925,9 @@ bool Session::HandshakeCompleted() { EmitHandshakeComplete(); - if (is_destroyed()) return false; - - // Handshake is completed, session.opened has been emitted finished & any - // following microtasks - time up, we now need an Application to continue. - if (!EnsureApplication()) return false; + // The handshake is complete and session.opened has resolved, with its + // microtasks run, so the session needs its Application now. + if (!RequireApplication()) return false; return true; } @@ -4256,8 +4284,9 @@ void Session::EmitApplication() { if (!env()->can_call_into_js()) return; if (!has_application()) { - // The application has not yet been installed, or the session has been - // destroyed. In either case, the application options are not available. + // The application has not yet been selected (ALPN negotiation is not + // yet complete on the server) or the session has been destroyed. In + // either case, the application options are not available. // Should not happen, but we bail out return; } @@ -4462,12 +4491,15 @@ void Session::InitPerContext(Realm* realm, Local target) { static_cast(Direction::UNIDIRECTIONAL); static constexpr auto QUIC_APPLICATION_DEFAULT = static_cast(Application::Type::DEFAULT); + static constexpr auto QUIC_APPLICATION_HTTP3 = + static_cast(Application::Type::HTTP3); static constexpr auto QUIC_PROTO_MAX = NGTCP2_PROTO_VER_MAX; static constexpr auto QUIC_PROTO_MIN = NGTCP2_PROTO_VER_MIN; NODE_DEFINE_CONSTANT(target, STREAM_DIRECTION_BIDIRECTIONAL); NODE_DEFINE_CONSTANT(target, STREAM_DIRECTION_UNIDIRECTIONAL); NODE_DEFINE_CONSTANT(target, QUIC_APPLICATION_DEFAULT); + NODE_DEFINE_CONSTANT(target, QUIC_APPLICATION_HTTP3); NODE_DEFINE_CONSTANT(target, DEFAULT_MAX_HEADER_LIST_PAIRS); NODE_DEFINE_CONSTANT(target, DEFAULT_MAX_HEADER_LENGTH); NODE_DEFINE_CONSTANT(target, QUIC_PROTO_MAX); diff --git a/src/quic/session.h b/src/quic/session.h index 67d5f18c0be3..12ed0ba69d6d 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -105,12 +105,6 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // of a QUIC Session. class Application; - enum class ApplicationType : uint8_t { - NONE = 0, // None installed yet - DEFAULT = 1, // DefaultApplication (raw QUIC streams) - HTTP3 = 2, // Http3ApplicationImpl - }; - // A block of pending outbound stream data, passed between the application // layer (which fills it via GetStreamData) and the send pump (which hands // it to ngtcp2_conn_writev_stream and commits the accepted length). @@ -164,6 +158,14 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // so that it cannot be garbage collected. BaseObjectPtr cid_factory_ref; + // Application-specific options (used for HTTP/3 if the negotiated + // ALPN selects Http3ApplicationImpl). + Application_Options application_options = Application_Options::kDefault; + + // When true, the Application is selected by the negotiated ALPN. When + // false, JavaScript starts one explicitly. + bool auto_start = true; + // When true, QLog output will be enabled for the session. bool qlog = false; @@ -349,11 +351,8 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { TLSSession& tls_session() const; bool has_application() const; Application& application() const; - const Config& config() const; const Options& options() const; - - ApplicationType application_type() const; const SocketAddress& remote_address() const; const SocketAddress& local_address() const; @@ -428,17 +427,23 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // (ngtcp2_conn_read_pkt or ngtcp2_conn_continue_handshake). bool AfterNgtcp2Read(int err); - // Attach the Application to the session. Must be called before any + // Decode the first ALPN protocol name from wire format (length-prefixed). + static std::string_view DecodeAlpn(std::string_view wire); + + // Select the Application implementation based on the negotiated ALPN. + // h3 (and h3-XX variants) map to Http3ApplicationImpl; all others map + // to DefaultApplication. Sets the application_type state field. + std::unique_ptr SelectApplicationFromAlpn(std::string_view alpn); + + // Install the Application on the session. Must be called before any // application data is received. void SetApplication(std::unique_ptr app); - // Attach the Application that JavaScript requested in the session's shared - // state, or the DefaultApplication if it named none. Called at every point - // an Application is first needed - a stream created on the session, a - // datagram sent, or the session handed to JavaScript - so one is always in - // place before anything can arrive from the peer. False if the application - // could not be started, which is fatal to the session. - bool EnsureApplication(); + void InstallApplicationForAlpn(std::string_view alpn); + + // Called once an Application is required. False, closing the session, if + // none has been started or it could not be started. + bool RequireApplication(); // ngtcp2 ignores the duplicate when the TLS stack reports these again. void SetEarlyRemoteTransportParams(std::span params); @@ -526,8 +531,6 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { void ShutdownStream(stream_id id, QuicError error = QuicError()); void ShutdownStreamWrite(stream_id id, QuicError code = QuicError()); - bool stream_fin_managed_by_application() const; - // Use the configured CID::Factory to generate a new CID. CID new_cid(size_t len = CID::kMaxLength) const; diff --git a/src/quic/tlscontext.cc b/src/quic/tlscontext.cc index 2f7bfda5b845..6f1ea7f22cdb 100644 --- a/src/quic/tlscontext.cc +++ b/src/quic/tlscontext.cc @@ -397,6 +397,9 @@ crypto::ClientHelloResult TLSContext::OnClientHello( Debug(&session, "ALPN negotiation succeeded: %s", *negotiated); tls_session.set_alpn(*negotiated); + if (session.options().auto_start) { + session.InstallApplicationForAlpn(*negotiated); + } session.set_hello_processed(); // Stop here. Session::AfterNgtcp2Read surfaces the server session to diff --git a/src/quic/tlscontext.h b/src/quic/tlscontext.h index 16e2ef76e3a5..35ddb35955ee 100644 --- a/src/quic/tlscontext.h +++ b/src/quic/tlscontext.h @@ -367,9 +367,9 @@ class TLSContext final : public MemoryRetainer, // connection cannot be served at all. TLSContext* SelectSNIContext(std::string_view servername); - // Performs the server's early selection: SNI, then ALPN, then the - // Application, and then suspends the handshake. See the comment on - // TLSSession::EarlySelection. + // Performs the server's early selection: SNI, then ALPN, then (with + // autoStart) the Application, and then suspends the handshake. See the + // comment on TLSSession::EarlySelection. static crypto::ClientHelloResult OnClientHello( const crypto::ClientHelloContext& hello); diff --git a/test/parallel/test-quic-alpn-h3.mjs b/test/parallel/test-quic-alpn-h3.mjs index e72cf05687df..4398c5437d04 100644 --- a/test/parallel/test-quic-alpn-h3.mjs +++ b/test/parallel/test-quic-alpn-h3.mjs @@ -14,20 +14,18 @@ const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); const cert = fixtures.readKey('agent1-cert.pem'); -// 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. +// Test h3 ALPN negotiation with Http3ApplicationImpl. +// Both server and client use the h3 ALPN. const serverOpened = Promise.withResolvers(); const serverEndpoint = await listen(mustCall(async (serverSession) => { - assert.strictEqual(serverSession.alpnProtocol, 'h3'); const info = await serverSession.opened; assert.strictEqual(info.protocol, 'h3'); serverOpened.resolve(); + serverSession.close(); }), { alpn: ['h3'], - autoWrap: false, sni: { '*': { keys: [key], certs: [cert] } }, }); @@ -35,22 +33,15 @@ assert.notStrictEqual(serverEndpoint.address, undefined); const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', - autoWrap: false, servername: 'localhost', verifyPeer: 'manual', }); -const info = await clientSession.opened; -assert.strictEqual(info.protocol, 'h3'); -await serverOpened.promise; - -// Not an HTTP/3 request stream, so it cannot carry headers. -const stream = await clientSession.createBidirectionalStream(); -assert.throws(() => stream.sendHeaders({ ':status': '200' }), { - code: 'ERR_INVALID_STATE', - message: /does not support headers/, -}); +async function checkClient() { + const info = await clientSession.opened; + assert.strictEqual(info.protocol, 'h3'); +} -stream.destroy(); +await Promise.all([serverOpened.promise, checkClient()]); await clientSession.close(); await serverEndpoint.close(); diff --git a/test/parallel/test-quic-datagram-drop-oldest.mjs b/test/parallel/test-quic-datagram-drop-oldest.mjs index 3c1b92be411e..37863b5e62a3 100644 --- a/test/parallel/test-quic-datagram-drop-oldest.mjs +++ b/test/parallel/test-quic-datagram-drop-oldest.mjs @@ -62,6 +62,7 @@ const clientSession = await connect(serverEndpoint.address, { await clientSession.opened; +assert.strictEqual(clientSession.maxPendingDatagrams, 128); clientSession.maxPendingDatagrams = 2; // Send 5 datagrams. With drop-oldest and queue size 2: diff --git a/test/parallel/test-quic-diagnostics-channel-session.mjs b/test/parallel/test-quic-diagnostics-channel-session.mjs index bd5ae93cd3cf..2f292f9deabb 100644 --- a/test/parallel/test-quic-diagnostics-channel-session.mjs +++ b/test/parallel/test-quic-diagnostics-channel-session.mjs @@ -38,7 +38,7 @@ const clientSession = await connect(serverEndpoint.address); await clientSession.opened; // Trigger a key update to fire a key update event. -clientSession.updateKey(); +clientSession.connection.updateKey(); await clientSession.closed; await serverEndpoint.close(); diff --git a/test/parallel/test-quic-early-selection-order.mjs b/test/parallel/test-quic-early-selection-order.mjs index f7cc89fb04c0..74a0af30577d 100644 --- a/test/parallel/test-quic-early-selection-order.mjs +++ b/test/parallel/test-quic-early-selection-order.mjs @@ -110,7 +110,7 @@ const decoder = new TextDecoder(); const info = await cs.opened; assert.strictEqual(info.protocol, 'quic-test'); // Validate the HRR happened: we fell back to 2nd group - assert.strictEqual(cs.ephemeralKeyInfo.name, 'secp521r1'); + assert.strictEqual(cs.connection.ephemeralKeyInfo.name, 'secp521r1'); await serverDone.promise; cs.close(); diff --git a/test/parallel/test-quic-edge-destroyed-ops.mjs b/test/parallel/test-quic-edge-destroyed-ops.mjs index b109a3ca998d..8ced22105ad8 100644 --- a/test/parallel/test-quic-edge-destroyed-ops.mjs +++ b/test/parallel/test-quic-edge-destroyed-ops.mjs @@ -35,11 +35,11 @@ clientSession.destroy(); assert.strictEqual(clientSession.destroyed, true); // Properties should return null/undefined gracefully. -assert.strictEqual(clientSession.endpoint, null); -assert.strictEqual(clientSession.path, undefined); -assert.strictEqual(clientSession.certificate, undefined); -assert.strictEqual(clientSession.peerCertificate, undefined); -assert.strictEqual(clientSession.ephemeralKeyInfo, undefined); +assert.strictEqual(clientSession.connection.endpoint, null); +assert.strictEqual(clientSession.connection.path, undefined); +assert.strictEqual(clientSession.connection.certificate, undefined); +assert.strictEqual(clientSession.connection.peerCertificate, undefined); +assert.strictEqual(clientSession.connection.ephemeralKeyInfo, undefined); // destroy() again is idempotent. clientSession.destroy(); diff --git a/test/parallel/test-quic-endpoint-destroy-cascade-close-frame.mjs b/test/parallel/test-quic-endpoint-destroy-cascade-close-frame.mjs index ac0143a2a3cc..54e90f9e0a12 100644 --- a/test/parallel/test-quic-endpoint-destroy-cascade-close-frame.mjs +++ b/test/parallel/test-quic-endpoint-destroy-cascade-close-frame.mjs @@ -49,7 +49,7 @@ const serverError = new Error('cascade close frame test'); // (which is the regression this test is designed to catch). const serverHandshake = Promise.withResolvers(); const onsession = mustCall((serverSession) => { - serverSession.onhandshake = mustCall(() => { + serverSession.connection.onhandshake = mustCall(() => { serverHandshake.resolve(); }); }); diff --git a/test/parallel/test-quic-endpoint-reuse.mjs b/test/parallel/test-quic-endpoint-reuse.mjs index 7171a248024a..d9c6997193c0 100644 --- a/test/parallel/test-quic-endpoint-reuse.mjs +++ b/test/parallel/test-quic-endpoint-reuse.mjs @@ -34,7 +34,8 @@ const { listen, connect } = await import('../common/quic.mjs'); // findSuitableEndpoint returns the first available non-listening // non-closing endpoint. After client1 is created, its endpoint // is available for client2. - assert.strictEqual(client1.endpoint, client2.endpoint); // Client sessions should share an endpoint + // Client sessions should share an endpoint + assert.strictEqual(client1.connection.endpoint, client2.connection.endpoint); await client1.close(); await client2.close(); @@ -57,7 +58,8 @@ const { listen, connect } = await import('../common/quic.mjs'); }); await client2.opened; - assert.notStrictEqual(client1.endpoint, client2.endpoint); // Client sessions should have separate endpoints + // Client sessions should have separate endpoints + assert.notStrictEqual(client1.connection.endpoint, client2.connection.endpoint); await client1.close(); await client2.close(); @@ -77,7 +79,7 @@ const { listen, connect } = await import('../common/quic.mjs'); // the server endpoint is in the registry. Self-connect is excluded // because the client's DCID associations would collide with the // server's session routing on the same endpoint. - assert.notStrictEqual(client.endpoint, serverEndpoint); // Client should not reuse the server endpoint + assert.notStrictEqual(client.connection.endpoint, serverEndpoint); // Client should not reuse the server endpoint await client.close(); await serverEndpoint.close(); diff --git a/test/parallel/test-quic-exports.mjs b/test/parallel/test-quic-exports.mjs index 71771624e31e..3092b9e931e0 100644 --- a/test/parallel/test-quic-exports.mjs +++ b/test/parallel/test-quic-exports.mjs @@ -12,10 +12,11 @@ const quic = await import('node:quic'); assert.strictEqual(typeof quic.connect, 'function'); assert.strictEqual(typeof quic.listen, 'function'); assert.strictEqual(typeof quic.QuicEndpoint, 'function'); +assert.strictEqual(typeof quic.QuicConnection, 'function'); assert.strictEqual(typeof quic.QuicSession, 'function'); assert.strictEqual(typeof quic.QuicStream, 'function'); assert.strictEqual(typeof quic.QuicEndpoint.Stats, 'function'); -assert.strictEqual(typeof quic.QuicSession.Stats, 'function'); +assert.strictEqual(typeof quic.QuicConnection.Stats, 'function'); assert.strictEqual(typeof quic.QuicStream.Stats, 'function'); assert.strictEqual(typeof quic.constants, 'object'); assert.strictEqual(typeof quic.constants.cc, 'object'); diff --git a/test/parallel/test-quic-h3-attach.mjs b/test/parallel/test-quic-h3-attach.mjs deleted file mode 100644 index 788cecc57aa5..000000000000 --- a/test/parallel/test-quic-h3-attach.mjs +++ /dev/null @@ -1,330 +0,0 @@ -// Flags: --experimental-quic --experimental-stream-iter --no-warnings - -// Test: attaching HTTP/3 to a node:quic session - when it is allowed, what -// it validates, and how it behaves when the window has closed. - -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 { bytes } = await import('stream/iter'); - -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, 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 = Http3Session.from(quicSession); - assert.strictEqual(session.quicSession, quicSession); - - // Can only attach once: - assert.throws(() => Http3Session.from(quicSession), - { code: 'ERR_INVALID_STATE' }); - - // Incoming streams are now reported through the Http3Session only: - assert.throws(() => { quicSession.onstream = () => {}; }, { - code: 'ERR_INVALID_STATE', - message: /cannot be set on a session/, - }); - // 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(() => Http3Session.from(quicClient, null), - { code: 'ERR_INVALID_ARG_TYPE' }); - assert.throws(() => Http3Session.from(quicClient, { ongoaway: 5 }), - { code: 'ERR_INVALID_ARG_TYPE' }); - - const client = Http3Session.from(quicClient); - await client.opened; - - // 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(client.closing, client.quicSession.closing); - await client.close(); - await endpoint.close(); -} - -const tooLate = { - code: 'ERR_INVALID_STATE', - message: /already has an application/, -}; - -// 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(() => Http3Session.from(quicSession), tooLate); - done.resolve(); - })); - }), serverOpts); - const client = await connect(endpoint.address, clientOpts); - await client.opened; - await done.promise; - await client.close(); - await endpoint.close(); -} - -// Client: the window stays open across the microtask checkpoint that resolves -// `opened`, so the negotiated ALPN can be read and acted on before attaching. -{ - const endpoint = await listen(mustCall((quicSession) => { - Http3Session.from(quicSession); - }), serverOpts); - const client = await connect(endpoint.address, clientOpts); - const info = await client.opened; - assert.strictEqual(info.protocol, 'h3'); - assert.strictEqual(client.alpnProtocol, 'h3'); - // Further already-settled awaits are still the same checkpoint. - await null; - const http3 = Http3Session.from(client); - assert.strictEqual(http3.quicSession, client); - await http3.close(); - await endpoint.close(); -} - -// Client: yielding to the event loop closes the window, and it is rejected -// twice over - a failed attach must not poison the session into reporting -// some other reason. -{ - const endpoint = await listen(mustCall((quicSession) => { - Http3Session.from(quicSession); - }), serverOpts); - const client = await connect(endpoint.address, clientOpts); - await client.opened; - await new Promise(setImmediate); - assert.throws(() => Http3Session.from(client), tooLate); - assert.throws(() => Http3Session.from(client), tooLate); - - await client.close(); - await endpoint.close(); -} - -// Client: an attach once any stream exists is rejected, even pre-handshake. -{ - const serverGot = Promise.withResolvers(); - const endpoint = await listen(mustCall((quicSession) => { - quicSession.onstream = mustCall(async (stream) => { - assert.strictEqual(dec.decode(await bytes(stream)), 'x'); - quicSession.close(); - serverGot.resolve(); - }); - }), serverOpts); - const client = await connect(endpoint.address, clientOpts); - const raw = await client.createUnidirectionalStream({ body: enc.encode('x') }); - assert.throws(() => Http3Session.from(client), tooLate); - await client.opened; - await serverGot.promise; - await raw.closed; - await client.close(); - await endpoint.close(); -} - -// Client: sending a datagram attaches the application. -{ - const dgramOpts = { transportParams: { maxDatagramFrameSize: 100 } }; - const endpoint = await listen(mustCall((quicSession) => { - quicSession.closed.catch(() => {}); - }), { ...serverOpts, ...dgramOpts }); - const client = await connect(endpoint.address, { ...clientOpts, ...dgramOpts }); - await client.opened; - await client.sendDatagram(enc.encode('x')); - 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(); -} - -// A client that opens no stream gets its application installed when the -// handshake completes, which may be well after the attach. The settings it -// asked for should survive the gap, with anything else left as default. -{ - const settings = { - maxHeaderPairs: 33n, - qpackBlockedStreams: 77n, - enableConnectProtocol: false, - }; - const endpoint = await listen(mustCall((quicSession) => { - Http3Session.from(quicSession); - }), serverOpts); - const client = Http3Session.from( - await connect(endpoint.address, clientOpts), { settings }); - - await client.opened; - const applied = client.settings; - assert.strictEqual(applied.maxHeaderPairs, 33n); - assert.strictEqual(applied.qpackBlockedStreams, 77n); - assert.strictEqual(applied.enableConnectProtocol, false); - assert.strictEqual(applied.qpackMaxDtableCapacity, 4096n); - await client.close(); - await endpoint.close(); -} - -// Parsing the settings runs their property getters, i.e. arbitrary JS, part -// way through the attach. We should safely handle even the weirdest things -// you could do as part of that: -{ - // Server: the getter destroys the session. - const done = Promise.withResolvers(); - const endpoint = await listen(mustCall((quicSession) => { - const settings = { - get maxHeaderPairs() { quicSession.destroy(); return 10n; }, - }; - assert.throws(() => Http3Session.from(quicSession, { settings }), { - code: 'ERR_INVALID_STATE', - message: /destroyed/, - }); - done.resolve(); - }), serverOpts); - const client = await connect(endpoint.address, clientOpts); - await done.promise; - client.destroy(); - await endpoint.close(); -} -{ - // Client: the getter creates a stream. - const endpoint = await listen(mustCall((quicSession) => { - quicSession.onstream = mustCall(async (stream) => { - assert.strictEqual(dec.decode(await bytes(stream)), 'x'); - quicSession.close(); - }); - }), serverOpts); - - const client = await connect(endpoint.address, clientOpts); - let raw; - const settings = { - get maxHeaderPairs() { - raw = client.createUnidirectionalStream({ body: enc.encode('x') }); - return 10n; - }, - }; - assert.throws(() => Http3Session.from(client, { settings }), tooLate); - await (await raw).closed; - await client.close(); - await endpoint.close(); -} -{ - // 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) => { - Http3Session.from(quicSession); - }), serverOpts); - - const client = await connect(endpoint.address, clientOpts); - let inner; - const settings = { - get maxHeaderPairs() { inner = Http3Session.from(client); return 10n; }, - }; - assert.throws(() => Http3Session.from(client, { settings }), { - code: 'ERR_INVALID_STATE', - message: /already has an application/, - }); - assert.ok(inner instanceof Http3Session); - assert.throws(() => Http3Session.from(client), { - code: 'ERR_INVALID_STATE', - message: /already has an application/, - }); - await client.opened; - await client.close(); - await endpoint.close(); -} - -// HTTP/3 has no server-initiated request streams, so a server session must -// refuse to open one however it is asked. -{ - const refused = Promise.withResolvers(); - const endpoint = await listen(mustCall((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 = 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-auto-start.mjs b/test/parallel/test-quic-h3-auto-start.mjs new file mode 100644 index 000000000000..9e920ec09c66 --- /dev/null +++ b/test/parallel/test-quic-h3-auto-start.mjs @@ -0,0 +1,114 @@ +// Flags: --experimental-quic --no-warnings + +// Test: a session matching the ALPN is started automatically, unless +// autoStart 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, QuicSession, +} = 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 start a session by ALPN. +{ + const seen = []; + const endpoint = await listen(mustCall((session) => { + seen.push(session.constructor); + session.onerror = () => {}; + }, 4), { + alpn: ['h3', 'h3-29', 'other'], + sni: { '*': { keys: [key], certs: [cert] } }, + }); + + // One protocol: started up front, before the handshake. + const single = await connect(endpoint.address, { ...clientOpts, alpn: 'h3' }); + assert.ok(isHttp3(single)); + assert.throws(() => QuicSession.start(single.connection), + { code: 'ERR_INVALID_STATE' }); + await single.opened; + await single.close(); + + // The ALPN is read once, so the session always matches what TLS offered. + let reads = 0; + const once = await connect(endpoint.address, { + ...clientOpts, + get alpn() { return reads++ === 0 ? 'h3' : 'other'; }, + }); + assert.strictEqual(reads, 1); + assert.ok(isHttp3(once)); + assert.strictEqual((await once.opened).protocol, 'h3'); + await once.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(); + + // Any other protocol is a raw QuicSession on both sides. + const other = await connect(endpoint.address, { ...clientOpts, alpn: 'other' }); + assert.ok(other instanceof QuicSession); + await other.opened; + await other.close(); + + await endpoint.close(); + assert.deepStrictEqual(seen, [Http3Session, Http3Session, Http3Session, QuicSession]); +} + +// The application option gives the settings for an automatic Http3Session. +{ + const endpoint = await listen(mustCall((session) => { + assert.strictEqual(session.settings.maxHeaderPairs, 33n); + }), { + alpn: ['h3'], + application: { maxHeaderPairs: 33 }, + sni: { '*': { keys: [key], certs: [cert] } }, + }); + const client = await connect(endpoint.address, { + ...clientOpts, + alpn: 'h3', + application: { qpackBlockedStreams: 7n }, + }); + assert.strictEqual(client.settings.qpackBlockedStreams, 7n); + await client.opened; + await client.close(); + await endpoint.close(); + + await assert.rejects(connect('127.0.0.1:1', { + ...clientOpts, + alpn: 'h3', + application: { maxHeaderPairs: 'lots' }, + }), { name: 'TypeError', message: /maxHeaderPairs/ }); +} + +// Session options configure the session autoStart starts, so without it they +// are rejected rather than ignored. +for (const name of ['onerror', 'onstream', 'ondatagram', 'ondatagramstatus', + 'application']) { + await assert.rejects(connect('127.0.0.1:1', { + ...clientOpts, + alpn: 'h3', + autoStart: false, + [name]: name === 'application' ? {} : () => {}, + }), { code: 'ERR_INVALID_ARG_VALUE', message: new RegExp(`options\\.${name}`) }); +} + +// Autostart option rejects invalid values +for (const autoStart of [1, 'yes', null]) { + await assert.rejects(connect('127.0.0.1:1', { ...clientOpts, alpn: 'h3', autoStart }), + { code: 'ERR_INVALID_ARG_TYPE', message: /options\.autoStart/ }); +} diff --git a/test/parallel/test-quic-h3-autowrap.mjs b/test/parallel/test-quic-h3-autowrap.mjs deleted file mode 100644 index f4f81d86f058..000000000000 --- a/test/parallel/test-quic-h3-autowrap.mjs +++ /dev/null @@ -1,81 +0,0 @@ -// 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-callback-errors.mjs b/test/parallel/test-quic-h3-callback-errors.mjs index 67f8dcc6daa5..b6f3d62afc7c 100644 --- a/test/parallel/test-quic-h3-callback-errors.mjs +++ b/test/parallel/test-quic-h3-callback-errors.mjs @@ -2,7 +2,6 @@ // 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 @@ -16,7 +15,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')); @@ -191,9 +190,8 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }, }); - const quicSession = await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', - autoWrap: false, servername: 'example.com', verifyPeer: 'manual', transportParams: { maxIdleTimeout: 1 }, @@ -201,10 +199,8 @@ async function makeServer(onheadersHandler, extraOpts = {}) { assert.strictEqual(error.message, 'onorigin error'); }), }); - const clientSession = Http3Session.from(quicSession, { - onorigin: mustCall(function() { - throw new Error('onorigin error'); - }), + clientSession.onorigin = mustCall(function() { + throw new Error('onorigin error'); }); await clientSession.opened; @@ -290,52 +286,3 @@ 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-datagram.mjs b/test/parallel/test-quic-h3-datagram.mjs index b30eb10e728f..505d9d34206b 100644 --- a/test/parallel/test-quic-h3-datagram.mjs +++ b/test/parallel/test-quic-h3-datagram.mjs @@ -15,7 +15,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'); const { setTimeout: sleep } = await import('timers/promises'); @@ -31,10 +31,7 @@ const decoder = new TextDecoder(); const clientGotDatagram = Promise.withResolvers(); const serverDone = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = Http3Session.from(quicSession, { - settings: { enableDatagrams: true }, - }); + const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { await stream.closed; }); @@ -44,8 +41,8 @@ const decoder = new TextDecoder(); serverDone.resolve(); }), { alpn: ['h3'], - autoWrap: false, sni: { '*': { keys: [key], certs: [cert] } }, + application: { enableDatagrams: true }, transportParams: { maxDatagramFrameSize: 100 }, // Server echoes received datagram back to client. ondatagram: mustCall(function(data) { @@ -65,11 +62,11 @@ const decoder = new TextDecoder(); }), }); - const quicSession = await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', - autoWrap: false, servername: 'localhost', verifyPeer: 'manual', + application: { enableDatagrams: true }, transportParams: { maxDatagramFrameSize: 100 }, // Client receives datagram from server. ondatagram: mustCall(function(data) { @@ -81,7 +78,6 @@ const decoder = new TextDecoder(); clientGotDatagram.resolve(); }), }); - const clientSession = Http3Session.from(quicSession, { settings: { enableDatagrams: true } }); await clientSession.opened; // Datagrams work alongside H3 request/response. @@ -98,7 +94,7 @@ const decoder = new TextDecoder(); }); // Send datagram from client. - await clientSession.quicSession.sendDatagram(new Uint8Array([10, 20, 30])); + await clientSession.sendDatagram(new Uint8Array([10, 20, 30])); // H3 response body is received. const body = await bytes(stream); @@ -120,11 +116,7 @@ const decoder = new TextDecoder(); { const serverDone = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - // Server explicitly disables H3 datagrams. - const ss = Http3Session.from(quicSession, { - settings: { enableDatagrams: false }, - }); + const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { await stream.closed; ss.close(); @@ -132,8 +124,9 @@ const decoder = new TextDecoder(); }); }), { alpn: ['h3'], - autoWrap: false, sni: { '*': { keys: [key], certs: [cert] } }, + // Server explicitly disables H3 datagrams. + application: { enableDatagrams: false }, // But transport-level datagrams ARE supported. transportParams: { maxDatagramFrameSize: 100 }, // Server should NOT receive any datagrams. @@ -145,14 +138,13 @@ const decoder = new TextDecoder(); }), }); - const quicSession = await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', - autoWrap: false, servername: 'localhost', verifyPeer: 'manual', + application: { enableDatagrams: true }, transportParams: { maxDatagramFrameSize: 100 }, }); - const clientSession = Http3Session.from(quicSession, { settings: { enableDatagrams: true } }); await clientSession.opened; const stream = await clientSession.createBidirectionalStream({ @@ -175,8 +167,7 @@ const decoder = new TextDecoder(); // Attempt to send a datagram. Since the peer's H3 SETTINGS // indicate h3_datagram=0, this should return 0 (not sent). - const dgId = - await clientSession.quicSession.sendDatagram(new Uint8Array([1, 2, 3])); + const dgId = await clientSession.sendDatagram(new Uint8Array([1, 2, 3])); assert.strictEqual(dgId, 0n); await Promise.all([stream.closed, serverDone.promise]); diff --git a/test/parallel/test-quic-h3-goaway.mjs b/test/parallel/test-quic-h3-goaway.mjs index 41a1976ddaa8..fa1ad2902521 100644 --- a/test/parallel/test-quic-h3-goaway.mjs +++ b/test/parallel/test-quic-h3-goaway.mjs @@ -67,13 +67,13 @@ dc.subscribe('quic.session.goaway', mustCall((msg) => { }, 2), }); - const quicSession = await connect(serverEndpoint.address, { + const connection = await connect(serverEndpoint.address, { alpn: 'h3', - autoWrap: false, + autoStart: false, servername: 'localhost', verifyPeer: 'manual', }); - const clientSession = Http3Session.from(quicSession, { + const clientSession = Http3Session.start(connection, { // Ongoaway fires when the peer sends GOAWAY. ongoaway: mustCall(function(lastStreamId) { assert.strictEqual(lastStreamId, -1n); diff --git a/test/parallel/test-quic-h3-handshake-failure.mjs b/test/parallel/test-quic-h3-handshake-failure.mjs index 05fd7b344be1..b439180fd664 100644 --- a/test/parallel/test-quic-h3-handshake-failure.mjs +++ b/test/parallel/test-quic-h3-handshake-failure.mjs @@ -38,11 +38,11 @@ const serverEndpoint = await listen(async (serverSession) => { // 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 = 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. + alpn: 'h3', }); // Close immediately — don't wait for handshake. diff --git a/test/parallel/test-quic-h3-manual-start.mjs b/test/parallel/test-quic-h3-manual-start.mjs new file mode 100644 index 000000000000..7cc3d76ebec3 --- /dev/null +++ b/test/parallel/test-quic-h3-manual-start.mjs @@ -0,0 +1,235 @@ +// Flags: --experimental-quic --no-warnings + +// Test: starting HTTP/3 on a node:quic connection - when it is allowed, what +// it validates, and how it behaves when the window has closed. + +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, QuicSession } = 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 serverOpts = { + alpn: ['h3'], + autoStart: false, + sni: { '*': { keys: [key], certs: [cert] } }, +}; +const clientOpts = { + alpn: 'h3', + autoStart: false, + servername: 'localhost', + verifyPeer: 'manual', +}; + +// Only a QuicConnection can carry a session, started with start(): +for (const Session of [Http3Session, QuicSession]) { + assert.throws(() => Session.start({}), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => new Session(), { code: 'ERR_ILLEGAL_CONSTRUCTOR' }); +} + +// Validate manually starting both sides of an HTTP/3 session: +{ + const endpoint = await listen(mustCall((connection) => { + const session = Http3Session.start(connection); + assert.strictEqual(session.connection, connection); + + // Can only start once: + assert.throws(() => Http3Session.start(connection), + { code: 'ERR_INVALID_STATE' }); + }), serverOpts); + + const quicClient = await connect(endpoint.address, clientOpts); + + // Options are validated before anything is recorded, so a session can + // still be started after these failures: + assert.throws(() => Http3Session.start(quicClient, null), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => Http3Session.start(quicClient, { ongoaway: 5 }), + { code: 'ERR_INVALID_ARG_TYPE' }); + + const client = Http3Session.start(quicClient); + await client.opened; + + // Connection details, TLS included, stay on the QUIC connection: + assert.strictEqual(client.connection.alpnProtocol, 'h3'); + assert.strictEqual(client.connection.servername, 'localhost'); + assert.strictEqual('peerCertificate' in client, false); + assert.strictEqual(typeof client.stats.createdAt, 'bigint'); + assert.strictEqual(client.closing, client.connection.closing); + await client.close(); + await endpoint.close(); +} + +const alreadyStarted = { + code: 'ERR_INVALID_STATE', + message: /already has a session started/, +}; +const connectionRefused = { code: 'ERR_QUIC_TRANSPORT_ERROR', message: /CONNECTION_REFUSED/ }; +const destroyed = { code: 'ERR_INVALID_STATE', message: /destroyed/ }; + +// A raw QuicSession started first rules out HTTP/3. +{ + const endpoint = await listen(mustCall((connection) => { + QuicSession.start(connection); + assert.throws(() => Http3Session.start(connection), alreadyStarted); + }), serverOpts); + const connection = await connect(endpoint.address, clientOpts); + const client = QuicSession.start(connection); + assert.throws(() => Http3Session.start(connection), alreadyStarted); + await client.opened; + await client.close(); + await endpoint.close(); +} + +// Server: a connection with no session started by the end of the session +// callback is closed with an error, so a deferred start is too late. +{ + const done = Promise.withResolvers(); + const endpoint = await listen(mustCall((connection) => { + setImmediate(mustCall(() => { + assert.throws(() => Http3Session.start(connection), destroyed); + done.resolve(); + })); + }), serverOpts); + const client = await connect(endpoint.address, clientOpts); + await assert.rejects(client.opened, connectionRefused); + await done.promise; + await endpoint.close(); +} + +// Client: the window stays open during the tick that resolves `opened`, so +// the negotiated ALPN can be read and acted on before starting. +{ + const endpoint = await listen(mustCall((connection) => { + Http3Session.start(connection); + }), serverOpts); + const client = await connect(endpoint.address, clientOpts); + const info = await client.opened; + assert.strictEqual(info.protocol, 'h3'); + assert.strictEqual(client.alpnProtocol, 'h3'); + // Further already-settled awaits are still the same checkpoint. + await null; + const http3 = Http3Session.start(client); + assert.strictEqual(http3.connection, client); + await http3.close(); + await endpoint.close(); +} + +// Client: yielding to the event loop closes the window, closing the +// connection with an error as no session was started on it. +{ + const endpoint = await listen(mustCall((connection) => { + Http3Session.start(connection).closed.catch(() => {}); + }), serverOpts); + const client = await connect(endpoint.address, clientOpts); + await client.opened; + await new Promise(setImmediate); + await assert.rejects(client.closed, { + code: 'ERR_QUIC_TRANSPORT_ERROR', + message: /INTERNAL_ERROR/, + }); + assert.throws(() => Http3Session.start(client), destroyed); + await endpoint.close(); +} + +// Settings are validated before anything is recorded, so a rejected value +// names the property at fault and leaves the connection still startable. +{ + const endpoint = await listen(mustCall((connection) => { + Http3Session.start(connection); + }), serverOpts); + const client = await connect(endpoint.address, clientOpts); + for (const settings of [42, true, 'nope', null]) { + assert.throws(() => Http3Session.start(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.start(client, { settings }), (err) => { + assert.strictEqual(err.code, expected.code); + assert.match(err.message, /options\.settings\./); + return true; + }); + } + // Numbers are accepted for bigint settings, which stay in effect through the + // handshake, with anything else left as default: + const http3 = Http3Session.start(client, { settings: { maxHeaderPairs: 12 } }); + assert.strictEqual(http3.settings.maxHeaderPairs, 12n); + await http3.opened; + assert.strictEqual(http3.settings.maxHeaderPairs, 12n); + assert.strictEqual(http3.settings.qpackMaxDtableCapacity, 4096n); + await http3.close(); + await endpoint.close(); +} + +// Parsing the settings runs their property getters, i.e. arbitrary JS, part +// way through the start. We should safely handle even the weirdest things +// you could do as part of that: +{ + // Server: the getter destroys the connection. + const done = Promise.withResolvers(); + const endpoint = await listen(mustCall((connection) => { + const settings = { + get maxHeaderPairs() { connection.destroy(); return 10n; }, + }; + assert.throws(() => Http3Session.start(connection, { settings }), { + code: 'ERR_INVALID_STATE', + message: /destroyed/, + }); + done.resolve(); + }), serverOpts); + const client = await connect(endpoint.address, clientOpts); + await done.promise; + client.destroy(); + await endpoint.close(); +} +{ + // Client: the getter starts another Http3Session. That inner start is the + // one that sticks; the outer one finds the connection already taken. + const endpoint = await listen(mustCall((connection) => { + Http3Session.start(connection); + }), serverOpts); + + const client = await connect(endpoint.address, clientOpts); + let inner; + const settings = { + get maxHeaderPairs() { inner = Http3Session.start(client); return 10n; }, + }; + assert.throws(() => Http3Session.start(client, { settings }), alreadyStarted); + assert.ok(inner instanceof Http3Session); + assert.throws(() => Http3Session.start(client), alreadyStarted); + await inner.opened; + await inner.close(); + await endpoint.close(); +} + +// HTTP/3 has no server-initiated request streams, so a server session must +// refuse to open one however it is asked. +{ + const refused = Promise.withResolvers(); + const endpoint = await listen(mustCall((connection) => { + const server = Http3Session.start(connection); + refused.resolve(assert.rejects(server.createBidirectionalStream(), { + code: 'ERR_INVALID_STATE', + message: /Server sessions cannot open HTTP\/3 request streams/, + })); + }), serverOpts); + const client = Http3Session.start(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-origin.mjs b/test/parallel/test-quic-h3-origin.mjs index fe5ceed5da22..d085a2153e93 100644 --- a/test/parallel/test-quic-h3-origin.mjs +++ b/test/parallel/test-quic-h3-origin.mjs @@ -51,13 +51,13 @@ const decoder = new TextDecoder(); }), }); - const quicSession = await connect(serverEndpoint.address, { + const connection = await connect(serverEndpoint.address, { alpn: 'h3', - autoWrap: false, + autoStart: false, servername: 'example.com', verifyPeer: 'manual', }); - const clientSession = Http3Session.from(quicSession, { + const clientSession = Http3Session.start(connection, { // Client receives ORIGIN frame via onorigin callback. onorigin: mustCall(function(origins) { assert.ok(Array.isArray(origins)); @@ -134,13 +134,13 @@ const decoder = new TextDecoder(); }), }); - const quicSession = await connect(serverEndpoint.address, { + const connection = await connect(serverEndpoint.address, { alpn: 'h3', - autoWrap: false, + autoStart: false, servername: 'custom-port.example.com', verifyPeer: 'manual', }); - const clientSession = Http3Session.from(quicSession, { + const clientSession = Http3Session.start(connection, { onorigin: mustCall(function(origins) { assert.ok(Array.isArray(origins)); diff --git a/test/parallel/test-quic-h3-qpack-settings.mjs b/test/parallel/test-quic-h3-qpack-settings.mjs index 91f27d6b545a..10718307ab26 100644 --- a/test/parallel/test-quic-h3-qpack-settings.mjs +++ b/test/parallel/test-quic-h3-qpack-settings.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'); @@ -50,16 +50,13 @@ async function makeRequest(clientSession, path) { const serverDone = Promise.withResolvers(); let requestCount = 0; - const serverEndpoint = await listen(mustCall(async (quicSession) => { - // Server disables QPACK dynamic table. - const ss = Http3Session.from(quicSession, { - settings: { qpackMaxDTableCapacity: 0, qpackBlockedStreams: 0 }, - }); + const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(2); }), { alpn: ['h3'], - autoWrap: false, sni: { '*': { keys: [key], certs: [cert] } }, + // Server disables QPACK dynamic table. + application: { qpackMaxDTableCapacity: 0, qpackBlockedStreams: 0 }, onheaders: mustCall(function(headers) { this.sendHeaders({ ':status': '200' }); this.writer.writeSync(encoder.encode(headers[':path'])); @@ -70,15 +67,12 @@ async function makeRequest(clientSession, path) { }, 2), }); - // Client also disables QPACK dynamic table. - const quicSession = await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', - autoWrap: false, servername: 'localhost', verifyPeer: 'manual', - }); - const clientSession = Http3Session.from(quicSession, { - settings: { qpackMaxDTableCapacity: 0, qpackBlockedStreams: 0 }, + // Client also disables QPACK dynamic table. + application: { qpackMaxDTableCapacity: 0, qpackBlockedStreams: 0 }, }); await clientSession.opened; @@ -97,15 +91,12 @@ async function makeRequest(clientSession, path) { const serverDone = Promise.withResolvers(); let requestCount = 0; - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = Http3Session.from(quicSession, { - settings: { qpackMaxDTableCapacity: 8192, qpackBlockedStreams: 200 }, - }); + const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(2); }), { alpn: ['h3'], - autoWrap: false, sni: { '*': { keys: [key], certs: [cert] } }, + application: { qpackMaxDTableCapacity: 8192, qpackBlockedStreams: 200 }, onheaders: mustCall(function(headers) { this.sendHeaders({ ':status': '200' }); this.writer.writeSync(encoder.encode(headers[':path'])); @@ -116,14 +107,11 @@ async function makeRequest(clientSession, path) { }, 2), }); - const quicSession = await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', - autoWrap: false, servername: 'localhost', verifyPeer: 'manual', - }); - const clientSession = Http3Session.from(quicSession, { - settings: { qpackMaxDTableCapacity: 8192, qpackBlockedStreams: 200 }, + application: { qpackMaxDTableCapacity: 8192, qpackBlockedStreams: 200 }, }); await clientSession.opened; diff --git a/test/parallel/test-quic-h3-settings.mjs b/test/parallel/test-quic-h3-settings.mjs index e17d9baeb260..ca2c037ce8a8 100644 --- a/test/parallel/test-quic-h3-settings.mjs +++ b/test/parallel/test-quic-h3-settings.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'); @@ -29,11 +29,7 @@ const decoder = new TextDecoder(); { const serverDone = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - // Allow 5 header pairs: 4 pseudo-headers + 1 custom. - const ss = Http3Session.from(quicSession, { - settings: { maxHeaderPairs: 5 }, - }); + const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { await stream.closed; ss.close(); @@ -41,8 +37,9 @@ const decoder = new TextDecoder(); }); }), { alpn: ['h3'], - autoWrap: false, sni: { '*': { keys: [key], certs: [cert] } }, + // Allow 5 header pairs: 4 pseudo-headers + 1 custom. + application: { maxHeaderPairs: 5 }, onheaders: mustCall(function(headers) { assert.strictEqual(headers[':method'], 'GET'); assert.strictEqual(headers[':path'], '/limited'); @@ -96,12 +93,7 @@ const decoder = new TextDecoder(); const serverDone = Promise.withResolvers(); const longValue = 'x'.repeat(200); - 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 = Http3Session.from(quicSession, { - settings: { maxHeaderLength: 100 }, - }); + const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { await stream.closed; ss.close(); @@ -109,8 +101,10 @@ const decoder = new TextDecoder(); }); }), { alpn: ['h3'], - autoWrap: false, sni: { '*': { keys: [key], certs: [cert] } }, + // Limit total header bytes. The 4 pseudo-headers fit within 100 + // bytes, but adding x-long (6 + 200 = 206 bytes) exceeds it. + application: { maxHeaderLength: 100 }, onheaders: mustCall(function(headers) { assert.strictEqual(headers[':method'], 'GET'); assert.strictEqual(headers[':path'], '/length-limited'); @@ -155,14 +149,11 @@ const decoder = new TextDecoder(); { const serverDone = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (quicSession) => { - const ss = Http3Session.from(quicSession, { - settings: { enableConnectProtocol: true, enableDatagrams: true }, - onsettings: mustCall((appopt) => { - assert.strictEqual(appopt.enableDatagrams, true); - assert.strictEqual(appopt.enableConnectProtocol, false); - // Must be false, as this is only sent from server side - }), + const serverEndpoint = await listen(mustCall(async (ss) => { + ss.onsettings = mustCall((appopt) => { + assert.strictEqual(appopt.enableDatagrams, true); + assert.strictEqual(appopt.enableConnectProtocol, false); + // Must be false, as this is only sent from server side }); ss.onstream = mustCall(async (stream) => { await stream.closed; @@ -171,8 +162,8 @@ const decoder = new TextDecoder(); }); }), { alpn: ['h3'], - autoWrap: false, sni: { '*': { keys: [key], certs: [cert] } }, + application: { enableConnectProtocol: true, enableDatagrams: true }, onheaders: mustCall(function(headers) { this.sendHeaders({ ':status': '200' }); this.writer.writeSync(encoder.encode('settings-ok')); @@ -180,14 +171,11 @@ const decoder = new TextDecoder(); }), }); - const quicSession = await connect(serverEndpoint.address, { + const clientSession = await connect(serverEndpoint.address, { alpn: 'h3', - autoWrap: false, servername: 'localhost', verifyPeer: 'manual', - }); - const clientSession = Http3Session.from(quicSession, { - settings: { enableConnectProtocol: true, enableDatagrams: true }, + application: { enableConnectProtocol: true, enableDatagrams: true }, }); clientSession.onsettings = mustCall((appopt) => { assert.strictEqual(appopt.enableConnectProtocol, true); diff --git a/test/parallel/test-quic-h3-start-failure.mjs b/test/parallel/test-quic-h3-start-failure.mjs deleted file mode 100644 index 113d99805d73..000000000000 --- a/test/parallel/test-quic-h3-start-failure.mjs +++ /dev/null @@ -1,68 +0,0 @@ -// 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(); -} 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 4311aafddd5c..4b76c4e24e83 100644 --- a/test/parallel/test-quic-h3-stream-destroy-no-resurrect.mjs +++ b/test/parallel/test-quic-h3-stream-destroy-no-resurrect.mjs @@ -93,8 +93,7 @@ for (let i = 0; i < kRequests; i++) { } // Exactly one locally-opened stream per request. -assert.strictEqual(Number(clientSession.stats.bidiOutStreamCount), - kRequests); +assert.strictEqual(Number(clientSession.stats.bidiOutStreamCount), kRequests); await clientSession.close(); await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-stream-without-onstream.mjs b/test/parallel/test-quic-h3-stream-without-onstream.mjs index 8e96a4d4ba06..f7b7cb3358fb 100644 --- a/test/parallel/test-quic-h3-stream-without-onstream.mjs +++ b/test/parallel/test-quic-h3-stream-without-onstream.mjs @@ -151,11 +151,7 @@ const kNonConsumerCallbacks = ['oninfo', 'ontrailers', 'onwanttrailers']; // session actually attaches to a received stream. const bootstrap = await listen(mustCall((session) => { session.onerror = () => {}; - session.onstream = () => {}; - }), { - alpn: ['h3'], - sni: { '*': { keys: [key], certs: [cert] } }, - }); + }), { alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onstream: () => {} }); const bootSession = await connect(bootstrap.address, { alpn: 'h3', servername: 'localhost', @@ -175,14 +171,14 @@ const kNonConsumerCallbacks = ['oninfo', 'ontrailers', 'onwanttrailers']; const applied = Promise.withResolvers(); 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 = await connect(serverEndpoint.address, { 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 new file mode 100644 index 000000000000..dadd150cfa97 --- /dev/null +++ b/test/parallel/test-quic-h3-uni-stream-limit-start-failure.mjs @@ -0,0 +1,66 @@ +// 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'); + +// Server who allows no unidirectional streams +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(), +}); + +// Expect an autostart client to cleanly fail +await assert.rejects(async () => { + const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', + servername: 'localhost', + verifyPeer: 'manual', + }); + await clientSession.opened; +}, { code: 'ERR_QUIC_TRANSPORT_ERROR' }); + +await serverEndpoint.close(); + +// Expect manual session start to cleanly fail: +{ + const endpoint = await listen(async (serverSession) => { + await serverSession.closed.catch(() => {}); + }, { + alpn: ['h3'], + sni: { '*': { keys: [key], certs: [cert] } }, + transportParams: { initialMaxStreamsUni: 0 }, + }); + const connection = await connect(endpoint.address, { + alpn: 'h3', + autoStart: false, + servername: 'localhost', + verifyPeer: 'manual', + }); + await connection.opened; + const failed = { + code: 'ERR_INVALID_STATE', + message: /could not be started/, + }; + assert.throws(() => Http3Session.start(connection), failed); + await assert.rejects(connection.closed, { code: 'ERR_QUIC_TRANSPORT_ERROR' }); + await endpoint.close(); +} diff --git a/test/parallel/test-quic-h3-uni-stream-teardown.mjs b/test/parallel/test-quic-h3-uni-stream-teardown.mjs deleted file mode 100644 index e6fb49e6ea77..000000000000 --- a/test/parallel/test-quic-h3-uni-stream-teardown.mjs +++ /dev/null @@ -1,42 +0,0 @@ -// Flags: --experimental-quic --no-warnings - -// Regression test for https://github.com/nodejs/node/issues/65408. -// A client-created unidirectional stream is not a valid HTTP/3 request -// stream, and data arriving on one could crash during process teardown. -// HTTP/3 frames its own streams, so the QUIC session now refuses to open -// streams directly at all, which puts that state out of reach. - -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 { createPrivateKey } = await import('node:crypto'); -const { listen, connect } = await import('node:quic'); - -const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); -const cert = fixtures.readKey('agent1-cert.pem'); - -const endpoint = await listen(mustNotCall(), { - alpn: ['h3'], - sni: { '*': { keys: [key], certs: [cert] } }, -}); - -const session = await connect(endpoint.address, { - alpn: 'h3', - servername: 'localhost', - verifyPeer: 'manual', -}); - -const refused = { - code: 'ERR_INVALID_STATE', - message: /Raw QUIC streams cannot be created/, -}; -await assert.rejects(session.quicSession.createUnidirectionalStream(), refused); -await assert.rejects(session.quicSession.createBidirectionalStream(), refused); - -endpoint.destroy(); -await endpoint.closed; 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 f2c519750b4f..5541213d62cd 100644 --- a/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs +++ b/test/parallel/test-quic-h3-zero-rtt-rejected-settings.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, randomBytes } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -27,21 +27,19 @@ const sni = { '*': { keys: [key], certs: [cert] } }; const decoder = new TextDecoder(); // Helper: establish an H3 session, get a ticket, close. -async function getTicket({ settings, ...endpointOptions }) { +async function getTicket(endpointOptions) { let savedTicket; let savedToken; const gotTicket = Promise.withResolvers(); const gotToken = Promise.withResolvers(); - const ep = await listen(mustCall(async (quicSession) => { - const ss = Http3Session.from(quicSession, { settings }); + const ep = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { await stream.closed; ss.close(); }); }), { alpn: ['h3'], - autoWrap: false, sni, ...endpointOptions, onheaders: mustCall(function(headers) { @@ -51,9 +49,8 @@ async function getTicket({ settings, ...endpointOptions }) { }), }); - const cs = Http3Session.from(await connect(ep.address, { + const cs = await connect(ep.address, { alpn: 'h3', - autoWrap: false, servername: 'localhost', verifyPeer: 'manual', ...endpointOptions, @@ -67,7 +64,7 @@ async function getTicket({ settings, ...endpointOptions }) { savedToken = token; gotToken.resolve(); }), - }), { settings }); + }); await cs.opened; await Promise.all([gotTicket.promise, gotToken.promise]); @@ -95,28 +92,23 @@ async function getTicket({ settings, ...endpointOptions }) { // recreated (EarlyDataRejected destroys the nghttp3 connection). // The initial 0-RTT stream may not survive this transition, so we // only verify earlyDataAccepted is false and close cleanly. -async function attemptRejected0RTT({ settings, ...endpointOptions }, - ticket, token) { - const ep = await listen(mustCall(async (quicSession) => { - const ss = Http3Session.from(quicSession, { settings }); +async function attemptRejected0RTT(endpointOptions, ticket, token) { + const ep = await listen(mustCall(async (ss) => { await ss.closed; }), { alpn: ['h3'], - autoWrap: false, sni, ...endpointOptions, }); - const quicSession = await connect(ep.address, { + const cs = await connect(ep.address, { alpn: 'h3', - autoWrap: false, servername: 'localhost', verifyPeer: 'manual', ...endpointOptions, sessionTicket: ticket, token, }); - 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 @@ -149,13 +141,13 @@ const tokenSecret = randomBytes(16); { const { ticket, token } = await getTicket({ endpoint: { tokenSecret }, - settings: { enableConnectProtocol: true }, + application: { enableConnectProtocol: true }, }); await attemptRejected0RTT({ endpoint: { tokenSecret }, // EnableConnectProtocol reduced from true to false. - settings: { enableConnectProtocol: false }, + application: { enableConnectProtocol: false }, }, ticket, token); } @@ -163,13 +155,13 @@ const tokenSecret = randomBytes(16); { const { ticket, token } = await getTicket({ endpoint: { tokenSecret }, - settings: { enableDatagrams: true }, + application: { enableDatagrams: true }, }); await attemptRejected0RTT({ endpoint: { tokenSecret }, // EnableDatagrams reduced from true to false. - settings: { enableDatagrams: false }, + application: { enableDatagrams: false }, }, ticket, token); } @@ -177,12 +169,12 @@ const tokenSecret = randomBytes(16); { const { ticket, token } = await getTicket({ endpoint: { tokenSecret }, - settings: { maxFieldSectionSize: 10000 }, + application: { maxFieldSectionSize: 10000 }, }); await attemptRejected0RTT({ endpoint: { tokenSecret }, // MaxFieldSectionSize reduced from 10000 to 100. - settings: { maxFieldSectionSize: 100 }, + application: { maxFieldSectionSize: 100 }, }, ticket, token); } diff --git a/test/parallel/test-quic-internal-endpoint-stats-state.mjs b/test/parallel/test-quic-internal-endpoint-stats-state.mjs index e348fd0feb65..0843af71938c 100644 --- a/test/parallel/test-quic-internal-endpoint-stats-state.mjs +++ b/test/parallel/test-quic-internal-endpoint-stats-state.mjs @@ -9,11 +9,11 @@ if (!hasQuic) { const { QuicEndpoint } = await import('node:quic'); const { - QuicSessionState, + QuicConnectionState, QuicStreamState, } = (await import('internal/quic/state')).default; const { - QuicSessionStats, + QuicConnectionStats, QuicStreamStats, } = (await import('internal/quic/stats')).default; const { @@ -145,7 +145,7 @@ const { // temporarily while the rest of the functionality is being // implemented. const streamState = new QuicStreamState(kPrivateConstructor, new ArrayBuffer(1024)); -const sessionState = new QuicSessionState(kPrivateConstructor, new ArrayBuffer(1024)); +const sessionState = new QuicConnectionState(kPrivateConstructor, new ArrayBuffer(1024)); assert.strictEqual(streamState.pending, false); assert.strictEqual(streamState.finSent, false); @@ -172,13 +172,7 @@ assert.strictEqual(sessionState.isHandshakeCompleted, false); assert.strictEqual(sessionState.isHandshakeConfirmed, false); assert.strictEqual(sessionState.isStreamOpenAllowed, false); assert.strictEqual(sessionState.isPrioritySupported, false); -// No application has been installed on this session, so everything an -// application populates reads as undefined rather than as a stand-in value. -assert.strictEqual(sessionState.headersSupported, undefined); -assert.strictEqual(sessionState.streamCallbacksSupported, undefined); -assert.strictEqual(sessionState.noErrorCode, undefined); -assert.strictEqual(sessionState.internalErrorCode, undefined); -assert.strictEqual(sessionState.requestRejectedCode, undefined); +assert.strictEqual(sessionState.headersSupported, 0); assert.strictEqual(sessionState.isWrapped, false); assert.strictEqual(sessionState.maxDatagramSize, 0); assert.strictEqual(sessionState.lastDatagramId, 0n); @@ -190,7 +184,7 @@ assert.strictEqual(typeof inspect(streamState), 'string'); assert.strictEqual(typeof inspect(sessionState), 'string'); const streamStats = new QuicStreamStats(kPrivateConstructor, new ArrayBuffer(1024)); -const sessionStats = new QuicSessionStats(kPrivateConstructor, new ArrayBuffer(1024)); +const sessionStats = new QuicConnectionStats(kPrivateConstructor, new ArrayBuffer(1024)); assert.strictEqual(streamStats.createdAt, 0n); assert.strictEqual(streamStats.openedAt, 0n); assert.strictEqual(streamStats.receivedAt, 0n); diff --git a/test/parallel/test-quic-key-update-peer.mjs b/test/parallel/test-quic-key-update-peer.mjs index 1a0fed1a1672..58ce1aa47f84 100644 --- a/test/parallel/test-quic-key-update-peer.mjs +++ b/test/parallel/test-quic-key-update-peer.mjs @@ -21,7 +21,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { await serverSession.opened; // Server initiates key update. - serverSession.updateKey(); + serverSession.connection.updateKey(); serverSession.onstream = mustCall(async (stream) => { const data = await bytes(stream); diff --git a/test/parallel/test-quic-key-update.mjs b/test/parallel/test-quic-key-update.mjs index b01288e90f41..d128f07bc162 100644 --- a/test/parallel/test-quic-key-update.mjs +++ b/test/parallel/test-quic-key-update.mjs @@ -36,7 +36,7 @@ const clientSession = await connect(serverEndpoint.address); await clientSession.opened; // Initiate key update before sending data. -clientSession.updateKey(); +clientSession.connection.updateKey(); // Open a stream and send data — should work with new keys. const stream = await clientSession.createBidirectionalStream(); diff --git a/test/parallel/test-quic-multipacket-clienthello.mjs b/test/parallel/test-quic-multipacket-clienthello.mjs index 2d701a899fc4..e101cee72a36 100644 --- a/test/parallel/test-quic-multipacket-clienthello.mjs +++ b/test/parallel/test-quic-multipacket-clienthello.mjs @@ -24,7 +24,7 @@ const streamReceived = Promise.withResolvers(); const endpoint = await listen(mustCall(async (session) => { const info = await session.opened; - assert.strictEqual(session.alpnProtocol, alpn); + assert.strictEqual(session.connection.alpnProtocol, alpn); assert.strictEqual(info.cipherVersion, 'TLSv1.3'); session.onstream = mustCall(async (stream) => { diff --git a/test/parallel/test-quic-perf-hooks.mjs b/test/parallel/test-quic-perf-hooks.mjs index fb9f90c5f472..322ec84b6146 100644 --- a/test/parallel/test-quic-perf-hooks.mjs +++ b/test/parallel/test-quic-perf-hooks.mjs @@ -63,11 +63,11 @@ obs.disconnect(); // Verify we got all expected entry types. const endpointEntries = entries.filter((e) => e.name === 'QuicEndpoint'); -const sessionEntries = entries.filter((e) => e.name === 'QuicSession'); +const sessionEntries = entries.filter((e) => e.name === 'QuicConnection'); const streamEntries = entries.filter((e) => e.name === 'QuicStream'); assert.ok(endpointEntries.length >= 1, `Expected QuicEndpoint entries, got ${endpointEntries.length}`); -assert.ok(sessionEntries.length >= 2, `Expected >= 2 QuicSession entries, got ${sessionEntries.length}`); +assert.ok(sessionEntries.length >= 2, `Expected >= 2 QuicConnection entries, got ${sessionEntries.length}`); assert.ok(streamEntries.length >= 2, `Expected >= 2 QuicStream entries, got ${streamEntries.length}`); // Verify common fields on all entries. diff --git a/test/parallel/test-quic-session-application-options.mjs b/test/parallel/test-quic-session-application-options.mjs index 4523423a7593..5f98cc6a1d25 100644 --- a/test/parallel/test-quic-session-application-options.mjs +++ b/test/parallel/test-quic-session-application-options.mjs @@ -1,9 +1,9 @@ // Flags: --experimental-quic --experimental-stream-iter --no-warnings // Test: session.applicationOptions -// Verifies that the settings an HTTP/3 session was given are reported back by -// applicationOptions as a null-prototype object on both peers, and become -// null once the session is gone. +// Verifies that applicationOptions is available after ALPN negotiation +// completes (i.e., once the application has been selected), returns a +// null-prototype object, and reflects the configured values. import { hasQuic, skip, mustCall } from '../common/index.mjs'; import assert from 'node:assert'; @@ -13,9 +13,8 @@ if (!hasQuic) { } const { listen, connect } = await import('../common/quic.mjs'); -const { Http3Session } = await import('node:quic'); -const customSettings = { +const customAppOptions = { maxHeaderPairs: 50n, maxHeaderLength: 8192n, maxFieldSectionSize: 16384n, @@ -26,72 +25,79 @@ const customSettings = { enableDatagrams: false, }; -// Both peers advertise the same settings, so the values stay put when the -// peer's SETTINGS frame is applied on top of them. -function check(settings, side) { - assert.ok(settings != null, `${side} settings should be available`); - assert.strictEqual(typeof settings, 'object'); - assert.strictEqual(Object.getPrototypeOf(settings), null); - assert.strictEqual(settings.maxHeaderPairs, customSettings.maxHeaderPairs); - assert.strictEqual(settings.maxHeaderLength, customSettings.maxHeaderLength); - assert.strictEqual(settings.maxFieldSectionSize, - customSettings.maxFieldSectionSize); - assert.strictEqual(settings.qpackMaxDtableCapacity, - customSettings.qpackMaxDTableCapacity); - assert.strictEqual(settings.qpackEncoderMaxDtableCapacity, - customSettings.qpackEncoderMaxDTableCapacity); - assert.strictEqual(settings.qpackBlockedStreams, - customSettings.qpackBlockedStreams); - assert.strictEqual(settings.enableConnectProtocol, - customSettings.enableConnectProtocol); - assert.strictEqual(settings.enableDatagrams, customSettings.enableDatagrams); -} - const serverDone = Promise.withResolvers(); -const serverEndpoint = await listen(mustCall((quicSession) => { - const server = Http3Session.from(quicSession, { settings: customSettings }); - server.onstream = mustCall(async (stream) => { - check(quicSession.applicationOptions, 'server'); +const serverEndpoint = await listen(mustCall((serverSession) => { + serverSession.onstream = mustCall(async (stream) => { + // After the stream arrives, the handshake and ALPN negotiation are + // complete, so applicationOptions should be available. + const opts = serverSession.connection.applicationOptions; + + assert.ok(opts != null, 'server applicationOptions should be available after handshake'); + assert.strictEqual(typeof opts, 'object'); + assert.strictEqual(Object.getPrototypeOf(opts), null); + + // Verify configured values are reflected. + assert.strictEqual(opts.maxHeaderPairs, BigInt(customAppOptions.maxHeaderPairs)); + assert.strictEqual(opts.maxHeaderLength, BigInt(customAppOptions.maxHeaderLength)); + assert.strictEqual(opts.maxFieldSectionSize, + BigInt(customAppOptions.maxFieldSectionSize)); + assert.strictEqual(opts.qpackMaxDtableCapacity, + BigInt(customAppOptions.qpackMaxDTableCapacity)); + assert.strictEqual(opts.qpackEncoderMaxDtableCapacity, + BigInt(customAppOptions.qpackEncoderMaxDTableCapacity)); + assert.strictEqual(opts.qpackBlockedStreams, + BigInt(customAppOptions.qpackBlockedStreams)); + assert.strictEqual(opts.enableConnectProtocol, + customAppOptions.enableConnectProtocol); + assert.strictEqual(opts.enableDatagrams, customAppOptions.enableDatagrams); + + stream.writer.endSync(); await stream.closed; - server.close(); + serverSession.close(); serverDone.resolve(); }); }), { - alpn: ['h3'], - autoWrap: false, - onheaders: mustCall(function() { - this.sendHeaders({ ':status': '200' }); - this.writer.endSync(); - }), + application: customAppOptions, }); -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 -// completes, and before any SETTINGS frame from the peer can have arrived. -check(client.quicSession.applicationOptions, 'client'); -await client.opened; -check(client.quicSession.applicationOptions, 'client'); - -// Exchange a request to let the server side run its assertions. -const stream = await client.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(), +const clientSession = await connect(serverEndpoint.address, { + application: customAppOptions, }); +await clientSession.opened; + +// After opened, ALPN negotiation is complete and applicationOptions +// should be available on the client session. +const clientOpts = clientSession.connection.applicationOptions; +assert.ok(clientOpts != null, 'client applicationOptions should be available after handshake'); +assert.strictEqual(typeof clientOpts, 'object'); +assert.strictEqual(Object.getPrototypeOf(clientOpts), null); + +// Verify configured values on the client side. +assert.strictEqual(clientOpts.maxHeaderPairs, BigInt(customAppOptions.maxHeaderPairs)); +assert.strictEqual(clientOpts.maxHeaderLength, BigInt(customAppOptions.maxHeaderLength)); +assert.strictEqual(clientOpts.maxFieldSectionSize, + customAppOptions.maxFieldSectionSize); +assert.strictEqual(clientOpts.qpackMaxDtableCapacity, + customAppOptions.qpackMaxDTableCapacity); +assert.strictEqual(clientOpts.qpackEncoderMaxDtableCapacity, + customAppOptions.qpackEncoderMaxDTableCapacity); +assert.strictEqual(clientOpts.qpackBlockedStreams, + customAppOptions.qpackBlockedStreams); +assert.strictEqual(clientOpts.enableConnectProtocol, + customAppOptions.enableConnectProtocol); +assert.strictEqual(clientOpts.enableDatagrams, customAppOptions.enableDatagrams); + +// Exchange data to let the server side run its assertions. +const stream = await clientSession.createBidirectionalStream(); +stream.writer.endSync(); // eslint-disable-next-line no-unused-vars for await (const _ of stream) { /* drain */ } await Promise.all([stream.closed, serverDone.promise]); -await client.close(); -assert.strictEqual(client.quicSession.applicationOptions, null); +// After close, applicationOptions should return null. +await clientSession.close(); +assert.strictEqual(clientSession.connection.applicationOptions, null); await serverEndpoint.close(); diff --git a/test/parallel/test-quic-session-destroy-reentrant.mjs b/test/parallel/test-quic-session-destroy-reentrant.mjs index e429987aab5b..ef9ffc7ed2b4 100644 --- a/test/parallel/test-quic-session-destroy-reentrant.mjs +++ b/test/parallel/test-quic-session-destroy-reentrant.mjs @@ -59,10 +59,10 @@ const transportParams = { maxIdleTimeout: 1 }; // `onerror` handler), the `#destroying` guard makes the second call // a true no-op so each channel publishes exactly once. const errSub = mustCall((msg) => { - assert.strictEqual(msg.session, clientSession); + assert.strictEqual(msg.session, clientSession.connection); }); const closedSub = mustCall((msg) => { - assert.strictEqual(msg.session, clientSession); + assert.strictEqual(msg.session, clientSession.connection); }); diagnostics_channel.subscribe('quic.session.error', errSub); diagnostics_channel.subscribe('quic.session.closed', closedSub); diff --git a/test/parallel/test-quic-session-emit-ordering.mjs b/test/parallel/test-quic-session-emit-ordering.mjs index ac5707cee4c2..3a05331737f1 100644 --- a/test/parallel/test-quic-session-emit-ordering.mjs +++ b/test/parallel/test-quic-session-emit-ordering.mjs @@ -12,7 +12,7 @@ if (!hasQuic) { const { createRequire } = await import('node:module'); const require = createRequire(import.meta.url); -const { getQuicSessionState } = require('internal/quic/quic'); +const { getQuicConnectionState } = require('internal/quic/quic'); const { listen, connect } = await import('../common/quic.mjs'); const sessionSeen = Promise.withResolvers(); @@ -21,19 +21,19 @@ const serverEndpoint = await listen(mustCall((serverSession) => { // All assertions run synchronously in the onsession emit frame. // The TLS details from the ClientHello are readable on the session. - assert.strictEqual(serverSession.servername, 'localhost'); - assert.strictEqual(serverSession.alpnProtocol, 'quic-test'); + assert.strictEqual(serverSession.connection.servername, 'localhost'); + assert.strictEqual(serverSession.connection.alpnProtocol, 'quic-test'); // The client's transport params arrived in the first flight and have // been processed by the time the session is surfaced. - const params = serverSession.remoteTransportParams; + const params = serverSession.connection.remoteTransportParams; assert.notStrictEqual(params, undefined); assert.notStrictEqual(params, null); assert.ok(params.initialMaxStreamsBidi >= 0n); - // ALPN negotiation completed, but no application has been installed yet - // (type 0): the window to attach one is still open in this frame. - assert.strictEqual(getQuicSessionState(serverSession).applicationType, 0); + // ALPN negotiation has completed: headers support is resolved (2 = + // unsupported, confirming non-h3 test ALPN) + assert.strictEqual(getQuicConnectionState(serverSession.connection).headersSupported, 2); sessionSeen.resolve(); })); diff --git a/test/parallel/test-quic-session-preferred-address-ipv6.mjs b/test/parallel/test-quic-session-preferred-address-ipv6.mjs index 8c1d9c474ccf..1eb28f8933c0 100644 --- a/test/parallel/test-quic-session-preferred-address-ipv6.mjs +++ b/test/parallel/test-quic-session-preferred-address-ipv6.mjs @@ -85,7 +85,7 @@ const clientSession = await connect(serverEndpoint.address, { }, 4), onpathvalidation: mustCall((result, newLocal, newRemote, oldLocal, oldRemote, preferred) => { assert.strictEqual(result, 'success'); - assertEqualAddress(newLocal, clientSession.endpoint.address); + assertEqualAddress(newLocal, clientSession.connection.endpoint.address); assertEqualAddress(newRemote, preferredEndpoint.address); assert.strictEqual(oldLocal, null); assert.strictEqual(oldRemote, null); diff --git a/test/parallel/test-quic-session-preferred-address.mjs b/test/parallel/test-quic-session-preferred-address.mjs index 92194cf42a87..8086c88137c7 100644 --- a/test/parallel/test-quic-session-preferred-address.mjs +++ b/test/parallel/test-quic-session-preferred-address.mjs @@ -70,7 +70,7 @@ const clientSession = await connect(serverEndpoint.address, { }, 4), onpathvalidation: mustCall((result, newLocal, newRemote, oldLocal, oldRemote, preferred) => { assert.strictEqual(result, 'success'); - assertEqualAddress(newLocal, clientSession.endpoint.address); + assertEqualAddress(newLocal, clientSession.connection.endpoint.address); assertEqualAddress(newRemote, preferredEndpoint.address); assert.strictEqual(oldLocal, null); assert.strictEqual(oldRemote, null); diff --git a/test/parallel/test-quic-session-properties.mjs b/test/parallel/test-quic-session-properties.mjs index 8d757303e966..c640117a9610 100644 --- a/test/parallel/test-quic-session-properties.mjs +++ b/test/parallel/test-quic-session-properties.mjs @@ -35,16 +35,16 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { await serverSession.opened; // PATH-03/06: Server path has local and remote. - const path = serverSession.path; + const path = serverSession.connection.path; assert.ok(path); assert.ok(path.local); assert.ok(path.remote); // Cached. - assert.strictEqual(serverSession.path, path); + assert.strictEqual(serverSession.connection.path, path); // Own certificate. - const cert = serverSession.certificate; + const cert = serverSession.connection.certificate; assert.ok(cert instanceof X509Certificate); assert.strictEqual(cert.subject, expectedCert.subject); assert.strictEqual(cert.issuer, expectedCert.issuer); @@ -52,10 +52,10 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { // Peer certificate (client's cert — not set in this // test since we don't use verifyClient, so it's undefined). - assert.strictEqual(serverSession.peerCertificate, undefined); + assert.strictEqual(serverSession.connection.peerCertificate, undefined); // Cached. - assert.strictEqual(serverSession.certificate, cert); + assert.strictEqual(serverSession.connection.certificate, cert); await serverSession.close(); serverDone.resolve(); @@ -65,37 +65,37 @@ const clientSession = await connect(serverEndpoint.address); await clientSession.opened; // PATH-03/06: Client path. -const path = clientSession.path; +const path = clientSession.connection.path; assert.ok(path); assert.ok(path.local); assert.ok(path.remote); // Cached. -assert.strictEqual(clientSession.path, path); +assert.strictEqual(clientSession.connection.path, path); // Peer certificate (server's cert). -const peerCert = clientSession.peerCertificate; +const peerCert = clientSession.connection.peerCertificate; assert.ok(peerCert instanceof X509Certificate); assert.strictEqual(peerCert.subject, expectedCert.subject); assert.strictEqual(peerCert.issuer, expectedCert.issuer); assert.strictEqual(peerCert.fingerprint256, expectedCert.fingerprint256); // Ephemeral key info (client only). -const keyInfo = clientSession.ephemeralKeyInfo; +const keyInfo = clientSession.connection.ephemeralKeyInfo; assert.ok(keyInfo); // Cached. -assert.strictEqual(clientSession.peerCertificate, peerCert); -assert.strictEqual(clientSession.ephemeralKeyInfo, keyInfo); +assert.strictEqual(clientSession.connection.peerCertificate, peerCert); +assert.strictEqual(clientSession.connection.ephemeralKeyInfo, keyInfo); await Promise.all([clientSession.closed, serverDone.promise]); // Returns undefined after destroy. -assert.strictEqual(clientSession.path, undefined); +assert.strictEqual(clientSession.connection.path, undefined); // Returns undefined after destroy. -assert.strictEqual(clientSession.certificate, undefined); -assert.strictEqual(clientSession.peerCertificate, undefined); -assert.strictEqual(clientSession.ephemeralKeyInfo, undefined); +assert.strictEqual(clientSession.connection.certificate, undefined); +assert.strictEqual(clientSession.connection.peerCertificate, undefined); +assert.strictEqual(clientSession.connection.ephemeralKeyInfo, undefined); await serverEndpoint.close(); diff --git a/test/parallel/test-quic-session-stream-lifecycle.mjs b/test/parallel/test-quic-session-stream-lifecycle.mjs index 54419ad1006c..b7073d3efc42 100644 --- a/test/parallel/test-quic-session-stream-lifecycle.mjs +++ b/test/parallel/test-quic-session-stream-lifecycle.mjs @@ -20,7 +20,7 @@ const serverDone = Promise.withResolvers(); // Create a server endpoint const serverEndpoint = await quic.listen(mustCall(async (serverSession) => { await serverSession.opened; - assert.ok(serverSession.endpoint !== null); + assert.ok(serverSession.connection.endpoint !== null); assert.strictEqual(serverSession.destroyed, false); const stats = serverSession.stats; @@ -55,7 +55,7 @@ const clientSession = await quic.connect(serverEndpoint.address, { }); assert.strictEqual(clientSession.destroyed, false); -assert.ok(clientSession.endpoint !== null); +assert.ok(clientSession.connection.endpoint !== null); assert.strictEqual(clientSession.stats.isConnected, true); const clientInfo = await clientSession.opened; @@ -86,7 +86,7 @@ assert.strictEqual(stream.stats.isConnected, true); // Destroying the session should destroy it and the stream, and clear its properties. clientSession.destroy(); assert.strictEqual(clientSession.destroyed, true); -assert.strictEqual(clientSession.endpoint, null); +assert.strictEqual(clientSession.connection.endpoint, null); assert.strictEqual(clientSession.stats.isConnected, false); assert.strictEqual(typeof clientSession.stats.cwnd, 'bigint'); assert.strictEqual(typeof clientSession.stats.streamsIdleTimedOut, 'bigint'); diff --git a/test/parallel/test-quic-session-transport-params.mjs b/test/parallel/test-quic-session-transport-params.mjs index 6fd92419196f..a4f6b7e138cf 100644 --- a/test/parallel/test-quic-session-transport-params.mjs +++ b/test/parallel/test-quic-session-transport-params.mjs @@ -30,7 +30,7 @@ let serverRemoteParams; const serverEndpoint = await listen(mustCall((serverSession) => { // localTransportParams should be available immediately. - serverLocalParams = serverSession.localTransportParams; + serverLocalParams = serverSession.connection.localTransportParams; assert.ok(serverLocalParams != null, 'server localTransportParams should be available immediately'); assert.strictEqual(typeof serverLocalParams, 'object'); assert.strictEqual(Object.getPrototypeOf(serverLocalParams), null); @@ -53,7 +53,7 @@ const serverEndpoint = await listen(mustCall((serverSession) => { serverSession.onstream = mustCall(async (stream) => { // After the stream arrives, the handshake is complete and // remoteTransportParams should be available. - serverRemoteParams = serverSession.remoteTransportParams; + serverRemoteParams = serverSession.connection.remoteTransportParams; assert.ok(serverRemoteParams != null, 'server remoteTransportParams should be available after handshake'); assert.strictEqual(typeof serverRemoteParams, 'object'); @@ -86,7 +86,7 @@ await clientSession.opened; // After opened, the handshake is complete. Both local and remote // transport params should be available on the client session. -const clientLocalParams = clientSession.localTransportParams; +const clientLocalParams = clientSession.connection.localTransportParams; assert.ok(clientLocalParams != null, 'client localTransportParams should be available'); assert.strictEqual(typeof clientLocalParams, 'object'); assert.strictEqual(Object.getPrototypeOf(clientLocalParams), null); @@ -97,7 +97,7 @@ assert.strictEqual(clientLocalParams.initialMaxStreamsBidi, assert.strictEqual(clientLocalParams.initialMaxData, BigInt(clientTransportParams.initialMaxData)); -const clientRemoteParams = clientSession.remoteTransportParams; +const clientRemoteParams = clientSession.connection.remoteTransportParams; assert.ok(clientRemoteParams != null, 'client remoteTransportParams should be available after handshake'); assert.strictEqual(typeof clientRemoteParams, 'object'); diff --git a/test/parallel/test-quic-stats-tojson-inspect.mjs b/test/parallel/test-quic-stats-tojson-inspect.mjs index 560acda49172..b11ec1e3208a 100644 --- a/test/parallel/test-quic-stats-tojson-inspect.mjs +++ b/test/parallel/test-quic-stats-tojson-inspect.mjs @@ -24,7 +24,7 @@ const serverEndpoint = await listen(mustCall((serverSession) => { assert.strictEqual(typeof sessionStatsJson.bytesSent, 'string'); const sessionStatsInspect = inspect(serverSession.stats); - assert.ok(sessionStatsInspect.includes('QuicSessionStats')); + assert.ok(sessionStatsInspect.includes('QuicConnectionStats')); serverSession.onstream = mustCall(async (stream) => { for await (const _ of stream) { /* drain */ } // eslint-disable-line no-unused-vars @@ -52,7 +52,7 @@ assert.ok(clientStatsJson); assert.strictEqual(typeof clientStatsJson.createdAt, 'string'); const clientStatsInspect = inspect(clientSession.stats); -assert.ok(clientStatsInspect.includes('QuicSessionStats')); +assert.ok(clientStatsInspect.includes('QuicConnectionStats')); const stream = await clientSession.createBidirectionalStream({ body: new TextEncoder().encode('test'), diff --git a/test/parallel/test-quic-tls-verify-client.mjs b/test/parallel/test-quic-tls-verify-client.mjs index 46f0808c34a3..4a8d7c20aa3c 100644 --- a/test/parallel/test-quic-tls-verify-client.mjs +++ b/test/parallel/test-quic-tls-verify-client.mjs @@ -27,7 +27,7 @@ const clientCert = fixtures.readKey('agent2-cert.pem'); const serverEndpoint = await listen(mustCall(async (serverSession) => { await serverSession.opened; // The server should see the client's certificate. - assert.ok(serverSession.peerCertificate); + assert.ok(serverSession.connection.peerCertificate); await serverSession.close(); }), { sni: { '*': { keys: [serverKey], certs: [serverCert] } }, diff --git a/test/parallel/test-quic-zero-rtt-disabled-server.mjs b/test/parallel/test-quic-zero-rtt-disabled-server.mjs index abc983ecf688..c2164d7c8b35 100644 --- a/test/parallel/test-quic-zero-rtt-disabled-server.mjs +++ b/test/parallel/test-quic-zero-rtt-disabled-server.mjs @@ -6,7 +6,7 @@ // The connection should still succeed (fallback to 1-RTT), and // earlyDataAccepted should be false. -import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs'; +import { hasQuic, skip, mustNotCall } from '../common/index.mjs'; import assert from 'node:assert'; import * as fixtures from '../common/fixtures.mjs'; @@ -78,7 +78,6 @@ const cs2 = await connect(serverEndpoint2.address, { verifyPeer: 'manual', sessionTicket: savedTicket, token: savedToken, - onearlyrejected: mustCall(), }); // The deferred handshake needs a send to trigger. Use sendDatagram From ca63d860221e13e8f601fbe4734dad0705c9ed24 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Wed, 7 Oct 2026 18:20:15 +0200 Subject: [PATCH 11/12] quic: fix bug in setSNIContexts that lost ALPN config --- lib/internal/quic/quic.js | 6 +++++- test/parallel/test-quic-sni-setcontexts.mjs | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 414361ad67f8..f63dd66e21c3 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -4515,6 +4515,8 @@ class QuicEndpoint { truncatedReads: undefined, onsession: undefined, sessionCallbacks: undefined, + // The encoded ALPN this endpoint listens with. + alpn: undefined, }; static { @@ -4823,6 +4825,7 @@ class QuicEndpoint { } = options; inner.truncatedReads = truncatedReads; + inner.alpn = rest.tls.alpn; // Store session and stream callbacks to apply to each new incoming session. inner.sessionCallbacks = { @@ -5030,7 +5033,8 @@ class QuicEndpoint { if (identity.certs === undefined) { throw new ERR_MISSING_ARGS(`entries['${hostname}'].certs`); } - processed[hostname] = identity; + // These identities offer the same protocols as the ones given to listen(): + processed[hostname] = { __proto__: null, ...identity, alpn: this.#inner.alpn }; } this.#handle.setSNIContexts(processed, replace); diff --git a/test/parallel/test-quic-sni-setcontexts.mjs b/test/parallel/test-quic-sni-setcontexts.mjs index 56200bd192ed..be30db5d9abe 100644 --- a/test/parallel/test-quic-sni-setcontexts.mjs +++ b/test/parallel/test-quic-sni-setcontexts.mjs @@ -48,7 +48,10 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { } endpoint.setSNIContexts( - { '*': { keys: [key2], certs: [cert2] } }, + { + '*': { keys: [key2], certs: [cert2] }, + 'localhost': { keys: [key2], certs: [cert2] }, + }, { replace: true }, ); From e24abc9cc16568f4b1240c80a94390efc90e2fca Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Wed, 7 Oct 2026 21:57:33 +0200 Subject: [PATCH 12/12] quic: fix QUIC code broken by V8 update --- src/quic/application.cc | 2 +- src/quic/transportparams.cc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/quic/application.cc b/src/quic/application.cc index 212a9984d1ac..11753aeb90f8 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -156,7 +156,7 @@ MaybeLocal Session::Application_Options::ToObject( static_assert(std::size(values) == std::size(names)); auto obj = tmpl->NewInstance(env->context(), values); - if (obj->SetPrototypeV2(env->context(), Null(env->isolate())).IsNothing()) { + if (obj->SetPrototype(env->context(), Null(env->isolate())).IsNothing()) { return {}; } return obj; diff --git a/src/quic/transportparams.cc b/src/quic/transportparams.cc index 3d2e333b32c5..45e6abc31098 100644 --- a/src/quic/transportparams.cc +++ b/src/quic/transportparams.cc @@ -485,7 +485,7 @@ v8::MaybeLocal TransportParams::ToObject(Environment* env) const { } auto obj = tmpl->NewInstance(env->context(), values); - if (obj->SetPrototypeV2(env->context(), Null(env->isolate())).IsNothing()) { + if (obj->SetPrototype(env->context(), Null(env->isolate())).IsNothing()) { return {}; } return obj;