From a4d21723d433255dd0e5fbec16c13ac4dd2aec73 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 5 Apr 2026 19:53:56 -0700 Subject: [PATCH 01/34] quic: update js impl to signal early data attempted/rejected Signed-off-by: James M Snell --- doc/api/quic.md | 1 + lib/internal/quic/quic.js | 22 +++++++++++++++++----- test/parallel/test-quic-handshake.mjs | 3 +++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/doc/api/quic.md b/doc/api/quic.md index 45aa2409fd1cc9..719882d6d7977f 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -1647,6 +1647,7 @@ added: v23.8.0 * `cipherVersion` {string} * `validationErrorReason` {string} * `validationErrorCode` {number} +* `earlyDataAttempted` {boolean} * `earlyDataAccepted` {boolean} ### Callback: `OnBlockedCallback` diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index fa0cfcf278538e..17659f3abcb94b 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -437,14 +437,20 @@ setCallbacks({ * @param {string} cipherVersion * @param {string} validationErrorReason * @param {number} validationErrorCode + * @param {boolean} earlyDataAttempted + * @param {boolean} earlyDataAccepted */ onSessionHandshake(servername, protocol, cipher, cipherVersion, validationErrorReason, - validationErrorCode) { + validationErrorCode, + earlyDataAttempted, + earlyDataAccepted) { debug('session handshake callback', servername, protocol, cipher, cipherVersion, - validationErrorReason, validationErrorCode); + validationErrorReason, validationErrorCode, + earlyDataAttempted, earlyDataAccepted); this[kOwner][kHandshake](servername, protocol, cipher, cipherVersion, - validationErrorReason, validationErrorCode); + validationErrorReason, validationErrorCode, + earlyDataAttempted, earlyDataAccepted); }, /** @@ -981,6 +987,7 @@ class QuicStream { return this; const opts = { + __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; @@ -1524,12 +1531,13 @@ class QuicSession { * @param {number} validationErrorCode */ [kHandshake](servername, protocol, cipher, cipherVersion, validationErrorReason, - validationErrorCode) { + validationErrorCode, earlyDataAttempted, earlyDataAccepted) { if (this.destroyed || !this.#pendingOpen.resolve) return; const addr = this.#handle.getRemoteAddress(); const info = { + __proto__: null, local: this.#endpoint.address, remote: addr !== undefined ? new InternalSocketAddress(addr) : @@ -1540,6 +1548,8 @@ class QuicSession { cipherVersion, validationErrorReason, validationErrorCode, + earlyDataAttempted, + earlyDataAccepted, }; this.#pendingOpen.resolve?.(info); @@ -1589,6 +1599,7 @@ class QuicSession { return this; const opts = { + __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; @@ -2103,6 +2114,7 @@ class QuicEndpoint { return this; const opts = { + __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; @@ -2370,7 +2382,7 @@ function getPreferredAddressPolicy(policy = 'default') { * @param {{forServer: boolean, addressFamily: string}} [config] * @returns {SessionOptions} */ -function processSessionOptions(options, config = {}) { +function processSessionOptions(options, config = { __proto__: null }) { validateObject(options, 'options'); const { endpoint, diff --git a/test/parallel/test-quic-handshake.mjs b/test/parallel/test-quic-handshake.mjs index 7374d4c929398e..38bf55dd9fd15b 100644 --- a/test/parallel/test-quic-handshake.mjs +++ b/test/parallel/test-quic-handshake.mjs @@ -23,6 +23,9 @@ const check = { // The negotiated cipher suite cipher: 'TLS_AES_128_GCM_SHA256', cipherVersion: 'TLSv1.3', + // No session ticket provided, so early data was not attempted + earlyDataAttempted: false, + earlyDataAccepted: false, }; // The opened promise should resolve when the handshake is complete. From 3da23710a37dab0118c4508b01216032c589a47b Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 5 Apr 2026 20:11:12 -0700 Subject: [PATCH 02/34] quic: add rejectUnauthorized and enableEarlyData options Signed-off-by: James M Snell Assisted-by: Opencode:Opus 4.6 --- doc/api/quic.md | 27 +++++++++ lib/internal/quic/quic.js | 6 ++ test/parallel/test-quic-enable-early-data.mjs | 55 ++++++++++++++++++ .../test-quic-reject-unauthorized.mjs | 56 +++++++++++++++++++ 4 files changed, 144 insertions(+) create mode 100644 test/parallel/test-quic-enable-early-data.mjs create mode 100644 test/parallel/test-quic-reject-unauthorized.mjs diff --git a/doc/api/quic.md b/doc/api/quic.md index 719882d6d7977f..703eb897ebf373 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -1218,6 +1218,19 @@ added: v23.8.0 The CRL to use for client sessions. For server sessions, CRLs are specified per-identity in the [`sessionOptions.sni`][] map. +#### `sessionOptions.enableEarlyData` + + + +* Type: {boolean} **Default:** `true` + +When `true`, enables TLS 0-RTT early data for this session. Early data +allows the client to send application data before the TLS handshake +completes, reducing latency on reconnection when a valid session ticket +is available. Set to `false` to disable early data support. + #### `sessionOptions.groups` + +* Type: {boolean} **Default:** `true` + +If `true`, the peer certificate is verified against the list of supplied CAs. +An error is emitted if verification fails; the error can be inspected via +the `validationErrorReason` and `validationErrorCode` fields in the +handshake callback. If `false`, peer certificate verification errors are +ignored. + #### `sessionOptions.verifyClient` + +* Type: {object|undefined} + +The most recently received NEW\_TOKEN token from the server, if any. +The object has `token` {Buffer} and `address` {SocketAddress} properties. +The token can be passed as the `token` option on a future connection to +the same server to skip address validation. + ### `session.updateKey()` + +* Type: {ArrayBufferView} + +An opaque address validation token previously received from the server +via `session.token`. Providing a valid token on reconnection allows +the client to skip the server's address validation, reducing handshake +latency. + #### `sessionOptions.transportParams` +### Channel: `quic.session.new.token` + + + +Published when a client session receives a NEW\_TOKEN frame from the +server. The message contains `token` {Buffer}, `address` {SocketAddress}, +and `session` {quic.QuicSession}. + ### Channel: `quic.session.ticket` + +* Type: {Object|null} + * `level` {string} One of `'high'`, `'default'`, or `'low'`. + * `incremental` {boolean} Whether the stream data should be interleaved + with other streams of the same priority level. + +The current priority of the stream. Returns `null` if the session does not +support priority (e.g. non-HTTP/3) or if the stream has been destroyed. +Read only. Use [`stream.setPriority()`][] to change the priority. + +### `stream.setPriority([options])` + + + +* `options` {Object} + * `level` {string} The priority level. One of `'high'`, `'default'`, or + `'low'`. **Default:** `'default'`. + * `incremental` {boolean} When `true`, data from this stream may be + interleaved with data from other streams of the same priority level. + **Default:** `false`. + +Sets the priority of the stream. Has no effect if the session does not +support priority or if the stream has been destroyed. + ### `stream.readable` [`sessionOptions.sni`]: #sessionoptionssni-server-only +[`stream.setPriority()`]: #streamsetpriorityoptions From 2e332cd962559e9e79fde5968a78449de47d0e1f Mon Sep 17 00:00:00 2001 From: James M Snell Date: Wed, 8 Apr 2026 20:06:31 -0700 Subject: [PATCH 09/34] quic: limit priority call to server side Signed-off-by: James M Snell --- src/quic/http3.cc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/quic/http3.cc b/src/quic/http3.cc index a51c7b2fa5872c..bf6e198ed9fd0e 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -510,6 +510,10 @@ class Http3ApplicationImpl final : public Session::Application { } StreamPriorityResult GetStreamPriority(const Stream& stream) override { + // nghttp3_conn_get_stream_priority is only available on the server side. + if (!session().is_server()) { + return {StreamPriority::DEFAULT, StreamPriorityFlags::NON_INCREMENTAL}; + } nghttp3_pri pri; if (nghttp3_conn_get_stream_priority(*this, &pri, stream.id()) == 0) { StreamPriority level; From 95758b300c6f511e6c508c7605573583901ed681 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Wed, 8 Apr 2026 20:07:02 -0700 Subject: [PATCH 10/34] quic: apply multiple js side cleanups Signed-off-by: James M Snell --- lib/internal/quic/quic.js | 150 +++++++++++++++++++------------------- 1 file changed, 76 insertions(+), 74 deletions(-) diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 6010ab83a51d13..75737c0e2a99f1 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -11,6 +11,7 @@ const { BigInt, ObjectDefineProperties, ObjectKeys, + PromiseWithResolvers, SafeSet, SymbolAsyncDispose, Uint8Array, @@ -603,10 +604,37 @@ function validateBody(body) { ], body); } -// Functions used specifically for internal testing purposes only. +// Functions used specifically for internal or assertion purposes only. let getQuicStreamState; let getQuicSessionState; let getQuicEndpointState; +let assertIsQuicEndpoint; +let assertEndpointNotClosedOrClosing; +let assertEndpointIsNotBusy; + +function maybeGetCloseError(context, status, pendingError) { + switch (context) { + case kCloseContextClose: { + return pendingError; + } + case kCloseContextBindFailure: { + return new ERR_QUIC_ENDPOINT_CLOSED('Bind failure', status); + } + case kCloseContextListenFailure: { + return new ERR_QUIC_ENDPOINT_CLOSED('Listen failure', status); + } + case kCloseContextReceiveFailure: { + return new ERR_QUIC_ENDPOINT_CLOSED('Receive failure', status); + } + case kCloseContextSendFailure: { + return new ERR_QUIC_ENDPOINT_CLOSED('Send failure', status); + } + case kCloseContextStartFailure: { + return new ERR_QUIC_ENDPOINT_CLOSED('Start failure', status); + } + } + // Otherwise return undefined. +} class QuicStream { /** @type {object} */ @@ -628,7 +656,7 @@ class QuicStream { /** @type {OnTrailersCallback|undefined} */ #ontrailers = undefined; /** @type {Promise} */ - #pendingClose = Promise.withResolvers(); // eslint-disable-line node-core/prefer-primordials + #pendingClose = PromiseWithResolvers(); // eslint-disable-line node-core/prefer-primordials #reader; /** @type {ReadableStream} */ #readable; @@ -1043,9 +1071,9 @@ class QuicSession { /** @type {object|undefined} */ #handle; /** @type {PromiseWithResolvers} */ - #pendingClose = Promise.withResolvers(); // eslint-disable-line node-core/prefer-primordials + #pendingClose = PromiseWithResolvers(); // eslint-disable-line node-core/prefer-primordials /** @type {PromiseWithResolvers} */ - #pendingOpen = Promise.withResolvers(); // eslint-disable-line node-core/prefer-primordials + #pendingOpen = PromiseWithResolvers(); // eslint-disable-line node-core/prefer-primordials /** @type {QuicSessionState} */ #state; /** @type {QuicSessionStats} */ @@ -1730,7 +1758,7 @@ class QuicEndpoint { * the endpoint closes abruptly due to an error). * @type {PromiseWithResolvers} */ - #pendingClose = Promise.withResolvers(); // eslint-disable-line node-core/prefer-primordials + #pendingClose = PromiseWithResolvers(); // eslint-disable-line node-core/prefer-primordials /** * If destroy() is called with an error, the error is stored here and used to reject * the pendingClose promise when [kFinishClose] is called. @@ -1762,15 +1790,27 @@ class QuicEndpoint { static { getQuicEndpointState = function(endpoint) { - QuicEndpoint.#assertIsQuicEndpoint(endpoint); + assertIsQuicEndpoint(endpoint); return endpoint.#state; }; - } - static #assertIsQuicEndpoint(val) { - if (val == null || !(#handle in val)) { - throw new ERR_INVALID_THIS('QuicEndpoint'); - } + assertIsQuicEndpoint = function(val) { + if (val == null || !(#handle in val)) { + throw new ERR_INVALID_THIS('QuicEndpoint'); + } + }; + + assertEndpointNotClosedOrClosing = function(endpoint) { + if (endpoint.#isClosedOrClosing) { + throw new ERR_INVALID_STATE('Endpoint is closed'); + } + }; + + assertEndpointIsNotBusy = function(endpoint) { + if (endpoint.#state.isBusy) { + throw new ERR_INVALID_STATE('Endpoint is busy'); + } + }; } /** @@ -1864,7 +1904,7 @@ class QuicEndpoint { * @type {QuicEndpointStats} */ get stats() { - QuicEndpoint.#assertIsQuicEndpoint(this); + assertIsQuicEndpoint(this); return this.#stats; } @@ -1878,7 +1918,7 @@ class QuicEndpoint { * @type {boolean} */ get busy() { - QuicEndpoint.#assertIsQuicEndpoint(this); + assertIsQuicEndpoint(this); return this.#busy; } @@ -1886,10 +1926,8 @@ class QuicEndpoint { * @type {boolean} */ set busy(val) { - QuicEndpoint.#assertIsQuicEndpoint(this); - if (this.#isClosedOrClosing) { - throw new ERR_INVALID_STATE('Endpoint is closed'); - } + assertIsQuicEndpoint(this); + assertEndpointNotClosedOrClosing(this); // The val is allowed to be any truthy value // Non-op if there is no change if (!!val !== this.#busy) { @@ -1910,7 +1948,7 @@ class QuicEndpoint { * @type {SocketAddress|undefined} */ get address() { - QuicEndpoint.#assertIsQuicEndpoint(this); + assertIsQuicEndpoint(this); if (this.#isClosedOrClosing) return undefined; if (this.#address === undefined) { const addr = this.#handle.address(); @@ -1925,15 +1963,11 @@ class QuicEndpoint { * @param {SessionOptions} [options] */ [kListen](onsession, options) { - if (this.#isClosedOrClosing) { - throw new ERR_INVALID_STATE('Endpoint is closed'); - } + assertEndpointNotClosedOrClosing(this); + assertEndpointIsNotBusy(this); if (this.#listening) { throw new ERR_INVALID_STATE('Endpoint is already listening'); } - if (this.#state.isBusy) { - throw new ERR_INVALID_STATE('Endpoint is busy'); - } validateObject(options, 'options'); this.#onsession = onsession.bind(this); @@ -1949,12 +1983,8 @@ class QuicEndpoint { * @returns {QuicSession} */ [kConnect](address, options) { - if (this.#isClosedOrClosing) { - throw new ERR_INVALID_STATE('Endpoint is closed'); - } - if (this.#state.isBusy) { - throw new ERR_INVALID_STATE('Endpoint is busy'); - } + assertEndpointNotClosedOrClosing(this); + assertEndpointIsNotBusy(this); validateObject(options, 'options'); const { sessionTicket, ...rest } = options; @@ -1963,9 +1993,7 @@ class QuicEndpoint { if (handle === undefined) { throw new ERR_QUIC_CONNECTION_FAILED(); } - const session = this.#newSession(handle); - - return session; + return this.#newSession(handle); } /** @@ -1977,8 +2005,9 @@ class QuicEndpoint { * @returns {Promise} Returns this.closed */ close() { - QuicEndpoint.#assertIsQuicEndpoint(this); + assertIsQuicEndpoint(this); if (!this.#isClosedOrClosing) { + debug('gracefully closing the endpoint'); if (onEndpointClosingChannel.hasSubscribers) { onEndpointClosingChannel.publish({ endpoint: this, @@ -1986,10 +2015,7 @@ class QuicEndpoint { }); } this.#isPendingClose = true; - - debug('gracefully closing the endpoint'); - - this.#handle?.closeGracefully(); + this.#handle.closeGracefully(); } return this.closed; } @@ -2001,7 +2027,7 @@ class QuicEndpoint { * @type {Promise} */ get closed() { - QuicEndpoint.#assertIsQuicEndpoint(this); + assertIsQuicEndpoint(this); return this.#pendingClose.promise; } @@ -2010,13 +2036,13 @@ class QuicEndpoint { * @type {boolean} */ get closing() { - QuicEndpoint.#assertIsQuicEndpoint(this); + assertIsQuicEndpoint(this); return this.#isPendingClose; } /** @type {boolean} */ get destroyed() { - QuicEndpoint.#assertIsQuicEndpoint(this); + assertIsQuicEndpoint(this); return this.#handle === undefined; } @@ -2029,7 +2055,7 @@ class QuicEndpoint { * @returns {Promise} Returns this.closed */ destroy(error) { - QuicEndpoint.#assertIsQuicEndpoint(this); + assertIsQuicEndpoint(this); debug('destroying the endpoint'); if (!this.#isClosedOrClosing) { this.#pendingError = error; @@ -2056,7 +2082,7 @@ class QuicEndpoint { * @param {{replace?: boolean}} [options] */ setSNIContexts(entries, options = kEmptyObject) { - QuicEndpoint.#assertIsQuicEndpoint(this); + assertIsQuicEndpoint(this); if (this.#handle === undefined) { throw new ERR_INVALID_STATE('Endpoint is destroyed'); } @@ -2083,30 +2109,6 @@ class QuicEndpoint { this.#handle.setSNIContexts(processed, replace); } - #maybeGetCloseError(context, status) { - switch (context) { - case kCloseContextClose: { - return this.#pendingError; - } - case kCloseContextBindFailure: { - return new ERR_QUIC_ENDPOINT_CLOSED('Bind failure', status); - } - case kCloseContextListenFailure: { - return new ERR_QUIC_ENDPOINT_CLOSED('Listen failure', status); - } - case kCloseContextReceiveFailure: { - return new ERR_QUIC_ENDPOINT_CLOSED('Receive failure', status); - } - case kCloseContextSendFailure: { - return new ERR_QUIC_ENDPOINT_CLOSED('Send failure', status); - } - case kCloseContextStartFailure: { - return new ERR_QUIC_ENDPOINT_CLOSED('Start failure', status); - } - } - // Otherwise return undefined. - } - [kFinishClose](context, status) { if (this.#handle === undefined) return; debug('endpoint is finishing close', context, status); @@ -2137,7 +2139,7 @@ class QuicEndpoint { // set. Or, if context indicates an error condition that caused the endpoint // to be closed, the status will indicate the error code. In either case, // we will reject the pending close promise at this point. - const maybeCloseError = this.#maybeGetCloseError(context, status); + const maybeCloseError = maybeGetCloseError(context, status, this.#pendingError); if (maybeCloseError !== undefined) { if (onEndpointErrorChannel.hasSubscribers) { onEndpointErrorChannel.publish({ @@ -2581,8 +2583,8 @@ async function connect(address, options = kEmptyObject) { ObjectDefineProperties(QuicEndpoint, { Stats: { __proto__: null, - writable: true, - configurable: true, + writable: false, + configurable: false, enumerable: true, value: QuicEndpointStats, }, @@ -2590,8 +2592,8 @@ ObjectDefineProperties(QuicEndpoint, { ObjectDefineProperties(QuicSession, { Stats: { __proto__: null, - writable: true, - configurable: true, + writable: false, + configurable: false, enumerable: true, value: QuicSessionStats, }, @@ -2599,8 +2601,8 @@ ObjectDefineProperties(QuicSession, { ObjectDefineProperties(QuicStream, { Stats: { __proto__: null, - writable: true, - configurable: true, + writable: false, + configurable: false, enumerable: true, value: QuicStreamStats, }, From 84ec886cb9c615e1a778f99281181212f711cfdd Mon Sep 17 00:00:00 2001 From: James M Snell Date: Wed, 8 Apr 2026 21:06:17 -0700 Subject: [PATCH 11/34] quic: fix session close logic Signed-off-by: James M Snell --- lib/internal/quic/quic.js | 8 +- src/quic/session.cc | 82 ++++++------- test/parallel/test-quic-stream-priority.mjs | 128 ++++++-------------- 3 files changed, 74 insertions(+), 144 deletions(-) diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 75737c0e2a99f1..a9ecfbaba9dafd 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -656,7 +656,7 @@ class QuicStream { /** @type {OnTrailersCallback|undefined} */ #ontrailers = undefined; /** @type {Promise} */ - #pendingClose = PromiseWithResolvers(); // eslint-disable-line node-core/prefer-primordials + #pendingClose = PromiseWithResolvers(); #reader; /** @type {ReadableStream} */ #readable; @@ -1071,9 +1071,9 @@ class QuicSession { /** @type {object|undefined} */ #handle; /** @type {PromiseWithResolvers} */ - #pendingClose = PromiseWithResolvers(); // eslint-disable-line node-core/prefer-primordials + #pendingClose = PromiseWithResolvers(); /** @type {PromiseWithResolvers} */ - #pendingOpen = PromiseWithResolvers(); // eslint-disable-line node-core/prefer-primordials + #pendingOpen = PromiseWithResolvers(); /** @type {QuicSessionState} */ #state; /** @type {QuicSessionStats} */ @@ -1758,7 +1758,7 @@ class QuicEndpoint { * the endpoint closes abruptly due to an error). * @type {PromiseWithResolvers} */ - #pendingClose = PromiseWithResolvers(); // eslint-disable-line node-core/prefer-primordials + #pendingClose = PromiseWithResolvers(); /** * If destroy() is called with an error, the error is stored here and used to reject * the pendingClose promise when [kFinishClose] is called. diff --git a/src/quic/session.cc b/src/quic/session.cc index efc6be2f846880..bfc75d3ec00749 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -590,42 +590,6 @@ struct Session::Impl final : public MemoryRetainer { inline bool is_closing() const { return state_->closing; } - /** - * @returns {boolean} Returns true if the Session can be destroyed - * immediately. - */ - bool Close() { - if (state_->closing) return true; - state_->closing = 1; - STAT_RECORD_TIMESTAMP(Stats, closing_at); - - // Iterate through all of the known streams and close them. The streams - // will remove themselves from the Session as soon as they are closed. - // Note: we create a copy because the streams will remove themselves - // while they are cleaning up which will invalidate the iterator. - StreamsMap streams = streams_; - for (auto& stream : streams) stream.second->Destroy(last_error_); - DCHECK(streams.empty()); - - // Clear the pending streams. - while (!pending_bidi_stream_queue_.IsEmpty()) { - pending_bidi_stream_queue_.PopFront()->reject(last_error_); - } - while (!pending_uni_stream_queue_.IsEmpty()) { - pending_uni_stream_queue_.PopFront()->reject(last_error_); - } - - // If we are able to send packets, we should try sending a connection - // close packet to the remote peer. - if (!state_->silent_close) { - session_->SendConnectionClose(); - } - - timer_.Close(); - - return !state_->wrapped; - } - ~Impl() { // Ensure that Close() was called before dropping DCHECK(is_closing()); @@ -1509,22 +1473,48 @@ void Session::FinishClose() { DCHECK(!is_destroyed()); DCHECK(impl_->state_->closing); - // If impl_->Close() returns true, then the session can be destroyed - // immediately without round-tripping through JavaScript. - if (impl_->Close()) { - return Destroy(); + // Clear the graceful_close flag to prevent RemoveStream() from + // re-entering FinishClose() when we destroy streams below. + impl_->state_->graceful_close = 0; + + // Destroy all open streams immediately. We copy the map because + // streams remove themselves during destruction. + StreamsMap streams = impl_->streams_; + for (auto& stream : streams) { + stream.second->Destroy(impl_->last_error_); + } + + // Clear pending stream queues. + while (!impl_->pending_bidi_stream_queue_.IsEmpty()) { + impl_->pending_bidi_stream_queue_.PopFront()->reject(impl_->last_error_); + } + while (!impl_->pending_uni_stream_queue_.IsEmpty()) { + impl_->pending_uni_stream_queue_.PopFront()->reject(impl_->last_error_); } - // Otherwise, we emit a close callback so that the JavaScript side can - // clean up anything it needs to clean up before destroying. - EmitClose(); + // Send CONNECTION_CLOSE unless this is a silent close. + if (!impl_->state_->silent_close) { + SendConnectionClose(); + } + + impl_->timer_.Close(); + + // If the session was passed to JavaScript, we need to round-trip + // through JS so it can clean up before we destroy. The JS side + // will synchronously call destroy(), which calls Session::Destroy(). + if (impl_->state_->wrapped) { + EmitClose(impl_->last_error_); + } else { + Destroy(); + } } void Session::Destroy() { - // Destroy() should be called only after, and as a result of, Close() - // being called first. DCHECK(impl_); - DCHECK(impl_->state_->closing); + // Ensure the closing flag is set for the ~Impl() DCHECK. Normally + // this is set by Session::Close(), but JS destroy() can be called + // directly without going through Close() first. + impl_->state_->closing = 1; Debug(this, "Session destroyed"); impl_.reset(); if (qlog_stream_ || keylog_stream_) { diff --git a/test/parallel/test-quic-stream-priority.mjs b/test/parallel/test-quic-stream-priority.mjs index a9467f6432832a..20f01c482e9575 100644 --- a/test/parallel/test-quic-stream-priority.mjs +++ b/test/parallel/test-quic-stream-priority.mjs @@ -14,59 +14,38 @@ const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); const cert = fixtures.readKey('agent1-cert.pem'); -// ============================================================================ +const serverOpened = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall((serverSession) => { + serverSession.opened.then(mustCall(() => { + serverOpened.resolve(); + serverSession.close(); + })); +}), { + sni: { '*': { keys: [key], certs: [cert] } }, + alpn: ['quic-test'], +}); + +const clientSession = await connect(serverEndpoint.address, { + alpn: 'quic-test', +}); +await clientSession.opened; +await serverOpened.promise; + // Test 1: Priority returns null for non-HTTP/3 sessions { - const done = Promise.withResolvers(); - - const serverEndpoint = await listen(mustCall((serverSession) => { - serverSession.onstream = mustCall((stream) => { - // Non-H3 session should not support priority - assert.strictEqual(stream.priority, null); - // setPriority should be a no-op (not throw) - stream.setPriority({ level: 'high', incremental: true }); - assert.strictEqual(stream.priority, null); - serverSession.close(); - done.resolve(); - }); - }), { - sni: { '*': { keys: [key], certs: [cert] } }, - alpn: ['quic-test'], - }); - - const clientSession = await connect(serverEndpoint.address, { - alpn: 'quic-test', - }); - await clientSession.opened; - const stream = await clientSession.createBidirectionalStream(); - // Client side, non-H3 — priority should be null + // Catch the closed rejection when the session closes with open streams + stream.closed.catch(() => {}); assert.strictEqual(stream.priority, null); - await done.promise; - clientSession.close(); + // setPriority should be a no-op (not throw) + stream.setPriority({ level: 'high', incremental: true }); + assert.strictEqual(stream.priority, null); } -// ============================================================================ // Test 2: Validation of createStream priority/incremental options { - const done = Promise.withResolvers(); - - const serverEndpoint = await listen(mustCall((serverSession) => { - serverSession.opened.then(() => { - done.resolve(); - serverSession.close(); - }).then(mustCall()); - }), { - sni: { '*': { keys: [key], certs: [cert] } }, - }); - - const clientSession = await connect(serverEndpoint.address, { - servername: 'localhost', - }); - await clientSession.opened; - - // Invalid priority level await assert.rejects( clientSession.createBidirectionalStream({ priority: 'urgent' }), { code: 'ERR_INVALID_ARG_VALUE' }, @@ -75,8 +54,6 @@ const cert = fixtures.readKey('agent1-cert.pem'); clientSession.createBidirectionalStream({ priority: 42 }), { code: 'ERR_INVALID_ARG_VALUE' }, ); - - // Invalid incremental value await assert.rejects( clientSession.createBidirectionalStream({ incremental: 'yes' }), { code: 'ERR_INVALID_ARG_TYPE' }, @@ -85,55 +62,18 @@ const cert = fixtures.readKey('agent1-cert.pem'); clientSession.createBidirectionalStream({ incremental: 1 }), { code: 'ERR_INVALID_ARG_TYPE' }, ); - - await done.promise; - clientSession.close(); } -// ============================================================================ -// Test 3: Validation of setPriority options +// Test 3: setPriority is a no-op on non-H3 sessions (does not throw +// even with invalid arguments, because it returns early) { - const done = Promise.withResolvers(); - - const serverEndpoint = await listen(mustCall((serverSession) => { - serverSession.onstream = mustCall((stream) => { - // Valid setPriority calls should not throw - stream.setPriority({ level: 'high' }); - stream.setPriority({ level: 'low', incremental: true }); - stream.setPriority({ level: 'default', incremental: false }); - - // Invalid level - assert.throws( - () => stream.setPriority({ level: 'urgent' }), - { code: 'ERR_INVALID_ARG_VALUE' }, - ); - - // Invalid incremental - assert.throws( - () => stream.setPriority({ incremental: 'yes' }), - { code: 'ERR_INVALID_ARG_TYPE' }, - ); - - // Not an object - assert.throws( - () => stream.setPriority('high'), - { code: 'ERR_INVALID_ARG_TYPE' }, - ); - - serverSession.close(); - done.resolve(); - }); - }), { - sni: { '*': { keys: [key], certs: [cert] } }, - }); - - const clientSession = await connect(serverEndpoint.address, { - servername: 'localhost', - }); - await clientSession.opened; - - await clientSession.createBidirectionalStream(); - - await done.promise; - clientSession.close(); + const stream = await clientSession.createBidirectionalStream(); + stream.closed.catch(() => {}); + stream.setPriority({ level: 'high' }); + stream.setPriority({ level: 'low', incremental: true }); + stream.setPriority({ level: 'default', incremental: false }); + stream.setPriority({ level: 'urgent' }); + stream.setPriority('high'); } + +clientSession.close(); From f5ca80dfb8607274e38afbb49261f8bba6372c48 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Wed, 8 Apr 2026 21:16:07 -0700 Subject: [PATCH 12/34] quic: make setPriority throw when priority is not supported Signed-off-by: James M Snell --- lib/internal/quic/quic.js | 7 +++-- test/parallel/test-quic-stream-priority.mjs | 32 +++++++++++++-------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index a9ecfbaba9dafd..eba20929acc6dd 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -920,8 +920,11 @@ class QuicStream { */ setPriority(options = kEmptyObject) { QuicStream.#assertIsQuicStream(this); - if (this.destroyed || - !getQuicSessionState(this.#session).isPrioritySupported) return; + if (this.destroyed) return; + if (!getQuicSessionState(this.#session).isPrioritySupported) { + throw new ERR_INVALID_STATE( + 'The session does not support stream priority'); + } validateObject(options, 'options'); const { level = 'default', diff --git a/test/parallel/test-quic-stream-priority.mjs b/test/parallel/test-quic-stream-priority.mjs index 20f01c482e9575..3b56f73df06774 100644 --- a/test/parallel/test-quic-stream-priority.mjs +++ b/test/parallel/test-quic-stream-priority.mjs @@ -32,16 +32,17 @@ const clientSession = await connect(serverEndpoint.address, { await clientSession.opened; await serverOpened.promise; -// Test 1: Priority returns null for non-HTTP/3 sessions +// Test 1: Priority getter returns null for non-HTTP/3 sessions. +// setPriority throws because the session doesn't support priority. { const stream = await clientSession.createBidirectionalStream(); - // Catch the closed rejection when the session closes with open streams stream.closed.catch(() => {}); assert.strictEqual(stream.priority, null); - // setPriority should be a no-op (not throw) - stream.setPriority({ level: 'high', incremental: true }); - assert.strictEqual(stream.priority, null); + assert.throws( + () => stream.setPriority({ level: 'high', incremental: true }), + { code: 'ERR_INVALID_STATE' }, + ); } // Test 2: Validation of createStream priority/incremental options @@ -64,16 +65,23 @@ await serverOpened.promise; ); } -// Test 3: setPriority is a no-op on non-H3 sessions (does not throw -// even with invalid arguments, because it returns early) +// Test 3: setPriority throws on non-H3 sessions regardless of arguments { const stream = await clientSession.createBidirectionalStream(); stream.closed.catch(() => {}); - stream.setPriority({ level: 'high' }); - stream.setPriority({ level: 'low', incremental: true }); - stream.setPriority({ level: 'default', incremental: false }); - stream.setPriority({ level: 'urgent' }); - stream.setPriority('high'); + + assert.throws( + () => stream.setPriority({ level: 'high' }), + { code: 'ERR_INVALID_STATE' }, + ); + assert.throws( + () => stream.setPriority({ level: 'low', incremental: true }), + { code: 'ERR_INVALID_STATE' }, + ); + assert.throws( + () => stream.setPriority(), + { code: 'ERR_INVALID_STATE' }, + ); } clientSession.close(); From c754433d3b120279f71b064ced1426ae25a341b2 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Thu, 9 Apr 2026 05:30:03 -0700 Subject: [PATCH 13/34] quic: add http3 graceful shutdown support Signed-off-by: James M Snell --- src/quic/application.cc | 8 ++++++++ src/quic/application.h | 9 +++++++++ src/quic/defs.h | 16 ++++++++-------- src/quic/http3.cc | 4 ++++ src/quic/session.cc | 29 +++++++++++++++++------------ 5 files changed, 46 insertions(+), 20 deletions(-) diff --git a/src/quic/application.cc b/src/quic/application.cc index 52bdc9ea1252c8..dc4a0a36c8937f 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -156,6 +156,14 @@ bool Session::Application::SupportsHeaders() const { return false; } +void Session::Application::BeginShutdown() { + // By default, nothing to do. +} + +void Session::Application::CompleteShutdown() { + // by default, nothing to do. +} + bool Session::Application::CanAddHeader(size_t current_count, size_t current_headers_length, size_t this_header_length) { diff --git a/src/quic/application.h b/src/quic/application.h index e58f18e7718041..a487676210fe6b 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -118,6 +118,15 @@ class Session::Application : public MemoryRetainer { // do not support headers should return false (the default). virtual bool SupportsHeaders() const; + // Initiates application-level graceful shutdown signaling (e.g., + // HTTP/3 GOAWAY). Called when Session::Close(GRACEFUL) is invoked. + virtual void BeginShutdown(); + + // Completes the application-level graceful shutdown. Called from + // FinishClose() before CONNECTION_CLOSE is sent. For HTTP/3, this + // sends the final GOAWAY with the actual last accepted stream ID. + virtual void CompleteShutdown(); + // Set the priority level of the stream if supported by the application. Not // all applications support priorities, in which case this function is a // non-op. diff --git a/src/quic/defs.h b/src/quic/defs.h index ac2cdb13a5756d..e491971641b672 100644 --- a/src/quic/defs.h +++ b/src/quic/defs.h @@ -30,7 +30,7 @@ namespace node::quic { DISALLOW_COPY(Name) \ DISALLOW_MOVE(Name) -template +template bool SetOption(Environment* env, Opt* options, const v8::Local& object, @@ -44,7 +44,7 @@ bool SetOption(Environment* env, return true; } -template +template bool SetOption(Environment* env, Opt* options, const v8::Local& object, @@ -57,7 +57,7 @@ bool SetOption(Environment* env, return true; } -template +template bool SetOption(Environment* env, Opt* options, const v8::Local& object, @@ -83,7 +83,7 @@ bool SetOption(Environment* env, return true; } -template +template bool SetOption(Environment* env, Opt* options, const v8::Local& object, @@ -127,22 +127,22 @@ bool SetOption(Environment* env, // objects. The stats themselves are maintained in an AliasedStruct within // each of the relevant classes. -template +template void IncrementStat(Stats* stats, uint64_t amt = 1) { stats->*member += amt; } -template +template void RecordTimestampStat(Stats* stats) { stats->*member = uv_hrtime(); } -template +template void SetStat(Stats* stats, uint64_t val) { stats->*member = val; } -template +template uint64_t GetStat(Stats* stats) { return stats->*member; } diff --git a/src/quic/http3.cc b/src/quic/http3.cc index bf6e198ed9fd0e..7c3ae10ec296df 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -203,6 +203,10 @@ class Http3ApplicationImpl final : public Session::Application { return ret; } + void BeginShutdown() override { nghttp3_conn_submit_shutdown_notice(*this); } + + void CompleteShutdown() override { nghttp3_conn_shutdown(*this); } + bool ReceiveStreamData(int64_t stream_id, const uint8_t* data, size_t datalen, diff --git a/src/quic/session.cc b/src/quic/session.cc index bfc75d3ec00749..da1f48b59f2291 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -209,7 +209,7 @@ void ngtcp2_debug_log(void* user_data, const char* fmt, ...) { va_end(ap); } -template +template bool SetOption(Environment* env, Opt* options, const Local& object, @@ -224,7 +224,7 @@ bool SetOption(Environment* env, return true; } -template +template bool SetOption(Environment* env, Opt* options, const Local& object, @@ -239,7 +239,7 @@ bool SetOption(Environment* env, return true; } -template +template bool SetOption(Environment* env, Opt* options, const Local& object, @@ -254,7 +254,7 @@ bool SetOption(Environment* env, return true; } -template +template bool SetOption(Environment* env, Opt* options, const Local& object, @@ -1445,19 +1445,22 @@ void Session::Close(CloseMethod method) { return FinishClose(); } case CloseMethod::GRACEFUL: { - // If there are no open streams, then we can close just immediately and + // If we are already closing gracefully, do nothing. + if (impl_->state_->graceful_close) [[unlikely]] { + return; + } + impl_->state_->graceful_close = 1; + + // Signal application-level graceful shutdown (e.g., HTTP/3 GOAWAY). + application().BeginShutdown(); + + // If there are no open streams, then we can close immediately and // not worry about waiting around. if (impl_->streams_.empty()) { impl_->state_->silent_close = 0; - impl_->state_->graceful_close = 0; return FinishClose(); } - // If we are already closing gracefully, do nothing. - if (impl_->state_->graceful_close) [[unlikely]] { - return; - } - impl_->state_->graceful_close = 1; Debug(this, "Gracefully closing session (waiting on %zu streams)", impl_->streams_.size()); @@ -1492,8 +1495,10 @@ void Session::FinishClose() { impl_->pending_uni_stream_queue_.PopFront()->reject(impl_->last_error_); } - // Send CONNECTION_CLOSE unless this is a silent close. + // Send final application-level shutdown and CONNECTION_CLOSE + // unless this is a silent close. if (!impl_->state_->silent_close) { + application().CompleteShutdown(); SendConnectionClose(); } From 91dd5d5f8c39a4d2239d798e9bb0b31af2dd1eeb Mon Sep 17 00:00:00 2001 From: James M Snell Date: Thu, 9 Apr 2026 05:44:37 -0700 Subject: [PATCH 14/34] quic: implement keepAlive option and ping support Signed-off-by: James M Snell --- doc/api/quic.md | 14 ++++++++++++++ lib/internal/quic/quic.js | 4 ++++ src/quic/bindingdata.h | 1 + src/quic/session.cc | 19 ++++++++++++------- src/quic/session.h | 5 +++++ 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/doc/api/quic.md b/doc/api/quic.md index 7ab4b05c118ef9..a56af74624c3fe 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -1403,6 +1403,20 @@ added: v23.8.0 Specifies the maximum number of milliseconds a TLS handshake is permitted to take to complete before timing out. +#### `sessionOptions.keepAlive` + + + +* Type: {bigint|number} +* **Default:** `0` (disabled) + +Specifies the keep-alive timeout in milliseconds. When set to a non-zero +value, PING frames will be sent automatically to keep the connection alive +before the idle timeout fires. The value should be less than the effective +idle timeout (`maxIdleTimeout` transport parameter) to be useful. + #### `sessionOptions.servername` (client only) -* `datagram` {string|ArrayBufferView} -* Returns: {bigint} +* `datagram` {string|ArrayBufferView|Promise} +* `encoding` {string} The encoding to use if `datagram` is a string. + **Default:** `'utf8'`. +* Returns: {Promise} for a {bigint} datagram ID. -Sends an unreliable datagram to the remote peer, returning the datagram ID. -If the datagram payload is specified as an `ArrayBufferView`, then ownership of -that view will be transferred to the underlying stream. +Sends an unreliable datagram to the remote peer, returning a promise for +the datagram ID. + +If `datagram` is a string, it will be encoded using the specified `encoding`. + +If `datagram` is an `ArrayBufferView`, the underlying `ArrayBuffer` will be +transferred if possible (taking ownership to prevent mutation after send). +If the buffer is not transferable (e.g., a `SharedArrayBuffer` or a view +over a subset of a larger buffer such as a pooled `Buffer`), the data will +be copied instead. + +If `datagram` is a `Promise`, it will be awaited before sending. If the +session closes while awaiting, `0n` is returned silently (datagrams are +inherently unreliable). + +If the datagram payload is zero-length (empty string after encoding, detached +buffer, or zero-length view), `0n` is returned and no datagram is sent. + +Datagrams cannot be fragmented — each must fit within a single QUIC packet. +The maximum datagram size is determined by the peer's +`maxDatagramFrameSize` transport parameter (which the peer advertises during +the handshake). If the peer sets this to `0`, datagrams are not supported +and `0n` will be returned. If the datagram exceeds the peer's limit, it +will be silently dropped and `0n` returned. The local +`maxDatagramFrameSize` transport parameter (default: `1200` bytes) controls +what this endpoint advertises to the peer as its own maximum. + +### `session.maxDatagramSize` + + + +* Type: {bigint} + +The maximum datagram payload size in bytes that the peer will accept, +as advertised in the peer's `maxDatagramFrameSize` transport parameter. +Returns `0n` if the peer does not support datagrams or if the handshake +has not yet completed. Datagrams larger than this value will not be sent. ### `session.stats` @@ -1679,6 +1717,13 @@ added: v23.8.0 --> * Type: {bigint|number} +* **Default:** `1200` + +The maximum size in bytes of a DATAGRAM frame payload that this endpoint +is willing to receive. Set to `0` to disable datagram support. The peer +will not send datagrams larger than this value. The actual maximum size of +a datagram that can be _sent_ is determined by the peer's +`maxDatagramFrameSize`, not this endpoint's value. ## Callbacks diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 24b8e8cd2522fd..f827eefbb88265 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -5,15 +5,23 @@ /* c8 ignore start */ const { + ArrayBufferPrototypeGetByteLength, ArrayBufferPrototypeTransfer, ArrayIsArray, ArrayPrototypePush, BigInt, + DataViewPrototypeGetBuffer, + DataViewPrototypeGetByteLength, + DataViewPrototypeGetByteOffset, ObjectDefineProperties, ObjectKeys, PromiseWithResolvers, SafeSet, SymbolAsyncDispose, + TypedArrayPrototypeGetBuffer, + TypedArrayPrototypeGetByteLength, + TypedArrayPrototypeGetByteOffset, + TypedArrayPrototypeSlice, Uint8Array, } = primordials; @@ -66,6 +74,8 @@ const { const { isArrayBuffer, isArrayBufferView, + isDataView, + isPromise, isSharedArrayBuffer, } = require('util/types'); @@ -190,6 +200,8 @@ const onSessionVersionNegotiationChannel = dc.channel('quic.session.version.nego const onSessionOriginChannel = dc.channel('quic.session.receive.origin'); const onSessionHandshakeChannel = dc.channel('quic.session.handshake'); +const kNilDatagramId = 0n; + /** * @typedef {import('../socketaddress.js').SocketAddress} SocketAddress * @typedef {import('../crypto/keys.js').KeyObject} KeyObject @@ -427,7 +439,7 @@ setCallbacks({ * @param {boolean} early */ onSessionDatagram(uint8Array, early) { - debug('session datagram callback', uint8Array.byteLength, early); + debug('session datagram callback', TypedArrayPrototypeGetByteLength(uint8Array), early); this[kOwner][kDatagram](uint8Array, early); }, @@ -1100,6 +1112,8 @@ class QuicSession { #onstream = undefined; /** @type {OnDatagramCallback|undefined} */ #ondatagram = undefined; + /** @type {OnDatagramStatusCallback|undefined} */ + #ondatagramstatus = undefined; /** @type {object|undefined} */ #sessionticket = undefined; /** @type {object|undefined} */ @@ -1200,6 +1214,38 @@ class QuicSession { } } + /** + * The ondatagramstatus callback is called when the status of a sent datagram + * is received. This is best-effort only. + * @type {OnDatagramStatusCallback} + */ + get ondatagramstatus() { + QuicSession.#assertIsQuicSession(this); + return this.#ondatagramstatus; + } + + set ondatagramstatus(fn) { + QuicSession.#assertIsQuicSession(this); + if (fn === undefined) { + this.#ondatagramstatus = undefined; + this.#state.hasDatagramStatusListener = false; + } else { + validateFunction(fn, 'ondatagramstatus'); + this.#ondatagramstatus = fn.bind(this); + this.#state.hasDatagramStatusListener = true; + } + } + + /** + * 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() { + QuicSession.#assertIsQuicSession(this); + return this.#state.maxDatagramSize; + } + /** * The statistics collected for this session. * @type {QuicSessionStats} @@ -1312,42 +1358,93 @@ class QuicSession { * of the sent datagram will be reported via the datagram-status event if * possible. * - * If a string is given it will be encoded as UTF-8. + * If a string is given it will be encoded using the specified encoding. * - * If an ArrayBufferView is given, the view will be copied. - * @param {ArrayBufferView|string} datagram The datagram payload - * @returns {Promise} + * If an ArrayBufferView is given, the underlying ArrayBuffer will be + * transferred if possible, otherwise the data will be copied. + * + * If a Promise is given, it will be awaited before sending. If the + * session closes while awaiting, 0n is returned silently. + * @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 */ - async sendDatagram(datagram) { + async sendDatagram(datagram, encoding = 'utf8') { QuicSession.#assertIsQuicSession(this); if (this.#isClosedOrClosing) { throw new ERR_INVALID_STATE('Session is closed'); } + let offset, length, buffer; + + const maxDatagramSize = this.#state.maxDatagramSize; + + // The peer max datagram size is either unknown or they have explicitly + // indicated that they do not support datagrams by setting it to 0. In + // either case, we do not send the datagram. + if (maxDatagramSize === 0n) return kNilDatagramId; + + if (isPromise(datagram)) { + datagram = await datagram; + // Session may have closed while awaiting. Since datagrams are + // inherently unreliable, silently return rather than throwing. + if (this.#isClosedOrClosing) return kNilDatagramId; + } + if (typeof datagram === 'string') { - datagram = Buffer.from(datagram, 'utf8'); + datagram = Buffer.from(datagram, encoding); + length = TypedArrayPrototypeGetByteLength(datagram); + if (length === 0) return kNilDatagramId; } else { if (!isArrayBufferView(datagram)) { throw new ERR_INVALID_ARG_TYPE('datagram', ['ArrayBufferView', 'string'], datagram); } - const length = datagram.byteLength; - const offset = datagram.byteOffset; - datagram = new Uint8Array(ArrayBufferPrototypeTransfer(datagram.buffer), - length, offset); + if (isDataView(datagram)) { + offset = DataViewPrototypeGetByteOffset(datagram); + length = DataViewPrototypeGetByteLength(datagram); + buffer = DataViewPrototypeGetBuffer(datagram); + } else { + offset = TypedArrayPrototypeGetByteOffset(datagram); + length = TypedArrayPrototypeGetByteLength(datagram); + buffer = TypedArrayPrototypeGetBuffer(datagram); + } + + // If the view has zero length (e.g. detached buffer), there's + // nothing to send. + if (length === 0) return kNilDatagramId; + + if (isSharedArrayBuffer(buffer) || + offset !== 0 || + length !== ArrayBufferPrototypeGetByteLength(buffer)) { + // Copy if the buffer is not transferable (SharedArrayBuffer) + // or if the view is over a subset of the buffer (e.g. a + // Node.js Buffer from the pool). + datagram = TypedArrayPrototypeSlice( + new Uint8Array(buffer), offset, offset + length); + } else { + datagram = new Uint8Array( + ArrayBufferPrototypeTransfer(buffer), offset, length); + } } - debug(`sending datagram with ${datagram.byteLength} bytes`); + // The peer max datagram size is less than the datagram we want to send, + // so... don't send it. + if (length > maxDatagramSize) return kNilDatagramId; const id = this.#handle.sendDatagram(datagram); - if (onSessionSendDatagramChannel.hasSubscribers) { + if (id !== kNilDatagramId && onSessionSendDatagramChannel.hasSubscribers) { onSessionSendDatagramChannel.publish({ + __proto__: null, id, - length: datagram.byteLength, + length, session: this, }); } + + debug(`datagram ${id} sent with ${length} bytes`); + return id; } /** @@ -1472,6 +1569,7 @@ class QuicSession { this.#onstream = undefined; this.#ondatagram = undefined; + this.#ondatagramstatus = undefined; this.#sessionticket = undefined; this.#token = undefined; @@ -1531,19 +1629,20 @@ class QuicSession { } /** - * @param {Uint8Array} u8 - * @param {boolean} early + * @param {Uint8Array} u8 The datagram payload + * @param {boolean} early A boolean indicating whether this datagram was received before the handshake completed */ [kDatagram](u8, early) { - // The datagram event should only be called if the session was created with + // The datagram event should only be called if the session has // an ondatagram callback. The callback should always exist here. - assert(this.#ondatagram, 'Unexpected datagram event'); + assert(typeof this.#ondatagram === 'function', 'Unexpected datagram event'); if (this.destroyed) return; - const length = u8.byteLength; + const length = TypedArrayPrototypeGetByteLength(u8); this.#ondatagram(u8, early); if (onSessionReceiveDatagramChannel.hasSubscribers) { onSessionReceiveDatagramChannel.publish({ + __proto__: null, length, early, session: this, @@ -1556,9 +1655,15 @@ class QuicSession { * @param {'lost'|'acknowledged'} status */ [kDatagramStatus](id, status) { + // The datagram status event should only be called if the session has + // an ondatagramstatus callback. The callback should always exist here. + assert(typeof this.#ondatagramstatus === 'function', 'Unexpected datagram status event'); if (this.destroyed) return; + this.#ondatagramstatus(id, status); + if (onSessionReceiveDatagramStatusChannel.hasSubscribers) { onSessionReceiveDatagramStatusChannel.publish({ + __proto__: null, id, status, session: this, diff --git a/lib/internal/quic/state.js b/lib/internal/quic/state.js index 73356ffd8b901e..dab4a581b7eb52 100644 --- a/lib/internal/quic/state.js +++ b/lib/internal/quic/state.js @@ -52,6 +52,7 @@ const { IDX_STATE_SESSION_PATH_VALIDATION, IDX_STATE_SESSION_VERSION_NEGOTIATION, IDX_STATE_SESSION_DATAGRAM, + IDX_STATE_SESSION_DATAGRAM_STATUS, IDX_STATE_SESSION_SESSION_TICKET, IDX_STATE_SESSION_CLOSING, IDX_STATE_SESSION_GRACEFUL_CLOSE, @@ -64,6 +65,7 @@ const { IDX_STATE_SESSION_HEADERS_SUPPORTED, IDX_STATE_SESSION_WRAPPED, IDX_STATE_SESSION_APPLICATION_TYPE, + IDX_STATE_SESSION_MAX_DATAGRAM_SIZE, IDX_STATE_SESSION_LAST_DATAGRAM_ID, IDX_STATE_ENDPOINT_BOUND, @@ -91,6 +93,7 @@ const { assert(IDX_STATE_SESSION_PATH_VALIDATION !== undefined); assert(IDX_STATE_SESSION_VERSION_NEGOTIATION !== undefined); assert(IDX_STATE_SESSION_DATAGRAM !== undefined); +assert(IDX_STATE_SESSION_DATAGRAM_STATUS !== undefined); assert(IDX_STATE_SESSION_SESSION_TICKET !== undefined); assert(IDX_STATE_SESSION_CLOSING !== undefined); assert(IDX_STATE_SESSION_GRACEFUL_CLOSE !== undefined); @@ -103,6 +106,7 @@ assert(IDX_STATE_SESSION_PRIORITY_SUPPORTED !== undefined); assert(IDX_STATE_SESSION_HEADERS_SUPPORTED !== undefined); assert(IDX_STATE_SESSION_WRAPPED !== undefined); assert(IDX_STATE_SESSION_APPLICATION_TYPE !== undefined); +assert(IDX_STATE_SESSION_MAX_DATAGRAM_SIZE !== undefined); assert(IDX_STATE_SESSION_LAST_DATAGRAM_ID !== undefined); assert(IDX_STATE_ENDPOINT_BOUND !== undefined); assert(IDX_STATE_ENDPOINT_RECEIVING !== undefined); @@ -285,6 +289,18 @@ class QuicSessionState { DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_DATAGRAM, val ? 1 : 0); } + /** @type {boolean} */ + get hasDatagramStatusListener() { + if (this.#handle.byteLength === 0) return undefined; + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_DATAGRAM_STATUS); + } + + /** @type {boolean} */ + set hasDatagramStatusListener(val) { + if (this.#handle.byteLength === 0) return; + DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_DATAGRAM_STATUS, val ? 1 : 0); + } + /** @type {boolean} */ get hasSessionTicketListener() { if (this.#handle.byteLength === 0) return undefined; @@ -367,6 +383,12 @@ class QuicSessionState { return DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_APPLICATION_TYPE); } + /** @type {bigint} */ + get maxDatagramSize() { + if (this.#handle.byteLength === 0) return undefined; + return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_SESSION_MAX_DATAGRAM_SIZE); + } + /** @type {bigint} */ get lastDatagramId() { if (this.#handle.byteLength === 0) return undefined; @@ -384,6 +406,7 @@ class QuicSessionState { hasPathValidationListener: this.hasPathValidationListener, hasVersionNegotiationListener: this.hasVersionNegotiationListener, hasDatagramListener: this.hasDatagramListener, + hasDatagramStatusListener: this.hasDatagramStatusListener, hasSessionTicketListener: this.hasSessionTicketListener, isClosing: this.isClosing, isGracefulClose: this.isGracefulClose, @@ -396,6 +419,7 @@ class QuicSessionState { isPrioritySupported: this.isPrioritySupported, headersSupported: this.headersSupported, isWrapped: this.isWrapped, + maxDatagramSize: `${this.maxDatagramSize}`, lastDatagramId: `${this.lastDatagramId}`, }; } @@ -417,6 +441,7 @@ class QuicSessionState { hasPathValidationListener: this.hasPathValidationListener, hasVersionNegotiationListener: this.hasVersionNegotiationListener, hasDatagramListener: this.hasDatagramListener, + hasDatagramStatusListener: this.hasDatagramStatusListener, hasSessionTicketListener: this.hasSessionTicketListener, isClosing: this.isClosing, isGracefulClose: this.isGracefulClose, @@ -430,6 +455,7 @@ class QuicSessionState { headersSupported: this.headersSupported, isWrapped: this.isWrapped, applicationType: this.applicationType, + maxDatagramSize: this.maxDatagramSize, lastDatagramId: this.lastDatagramId, }, opts)}`; } diff --git a/src/quic/session.cc b/src/quic/session.cc index b371d0dc7ba6ac..0726099929c9e3 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -61,6 +61,7 @@ namespace quic { V(PATH_VALIDATION, path_validation, uint8_t) \ V(VERSION_NEGOTIATION, version_negotiation, uint8_t) \ V(DATAGRAM, datagram, uint8_t) \ + V(DATAGRAM_STATUS, datagram_status, uint8_t) \ V(SESSION_TICKET, session_ticket, uint8_t) \ V(CLOSING, closing, uint8_t) \ V(GRACEFUL_CLOSE, graceful_close, uint8_t) \ @@ -73,6 +74,7 @@ namespace quic { V(HEADERS_SUPPORTED, headers_supported, uint8_t) \ V(WRAPPED, wrapped, uint8_t) \ V(APPLICATION_TYPE, application_type, uint8_t) \ + V(MAX_DATAGRAM_SIZE, max_datagram_size, uint64_t) \ V(LAST_DATAGRAM_ID, last_datagram_id, datagram_id) #define SESSION_STATS(V) \ @@ -219,7 +221,7 @@ void ngtcp2_debug_log(void* user_data, const char* fmt, ...) { va_end(ap); } -template +template bool SetOption(Environment* env, Opt* options, const Local& object, @@ -234,7 +236,7 @@ bool SetOption(Environment* env, return true; } -template +template bool SetOption(Environment* env, Opt* options, const Local& object, @@ -249,7 +251,7 @@ bool SetOption(Environment* env, return true; } -template +template bool SetOption(Environment* env, Opt* options, const Local& object, @@ -264,7 +266,7 @@ bool SetOption(Environment* env, return true; } -template +template bool SetOption(Environment* env, Opt* options, const Local& object, @@ -1858,17 +1860,22 @@ datagram_id Session::SendDatagram(Store&& data) { const ngtcp2_transport_params* tp = remote_transport_params(); uint64_t max_datagram_size = tp->max_datagram_frame_size; + // These size and length checks should have been caught by the JavaScript + // side, but handle it gracefully here just in case. We might have some future + // case where datagram frames are sent from C++ code directly, so it's good to + // have these checks as a backstop regardless. + if (max_datagram_size == 0) { Debug(this, "Datagrams are disabled"); return 0; } - if (data.length() > max_datagram_size) { + if (data.length() > max_datagram_size) [[unlikely]] { Debug(this, "Ignoring oversized datagram"); return 0; } - if (data.length() == 0) { + if (data.length() == 0) [[unlikely]] { Debug(this, "Ignoring empty datagram"); return 0; } @@ -1879,6 +1886,11 @@ datagram_id Session::SendDatagram(Store&& data) { ngtcp2_vec vec = data; PathStorage path; int flags = NGTCP2_WRITE_DATAGRAM_FLAG_MORE; + // There's always the slightest possibility that the datagram ID could wrap + // around, but that's a lot of datagrams and we would have to be sending + // them at a very high rate for a very long time, so we'll just let it + // wrap around naturally if it ever does. If anyone accomplishes that feat, + // we can throw them a party. datagram_id did = impl_->state_->last_datagram_id + 1; Debug(this, "Sending %zu-byte datagram %" PRIu64, data.length(), did); @@ -1987,7 +1999,7 @@ datagram_id Session::SendDatagram(Store&& data) { break; } } - SetLastError(QuicError::ForTransport(nwrite)); + SetLastError(QuicError::ForNgtcp2Error(nwrite)); Close(CloseMethod::SILENT); return 0; } @@ -2479,7 +2491,9 @@ void Session::DatagramStatus(datagram_id datagramId, break; } } - EmitDatagramStatus(datagramId, status); + if (impl_->state_->datagram_status) { + EmitDatagramStatus(datagramId, status); + } } void Session::DatagramReceived(const uint8_t* data, @@ -2520,6 +2534,11 @@ bool Session::HandshakeCompleted() { STAT_RECORD_TIMESTAMP(Stats, handshake_completed_at); SetStreamOpenAllowed(); + // Capture the peer's max datagram frame size from the remote transport + // parameters so JavaScript can check it without a C++ round-trip. + const ngtcp2_transport_params* tp = remote_transport_params(); + impl_->state_->max_datagram_size = tp->max_datagram_frame_size; + // If early data was attempted but rejected by the server, // tell ngtcp2 so it can retransmit the data as 1-RTT. // The status of early data will only be rejected if an diff --git a/test/parallel/test-quic-internal-endpoint-stats-state.mjs b/test/parallel/test-quic-internal-endpoint-stats-state.mjs index c557b61bcc651d..e3bb1796776f26 100644 --- a/test/parallel/test-quic-internal-endpoint-stats-state.mjs +++ b/test/parallel/test-quic-internal-endpoint-stats-state.mjs @@ -154,8 +154,11 @@ assert.strictEqual(sessionState.isStatelessReset, false); assert.strictEqual(sessionState.isHandshakeCompleted, false); assert.strictEqual(sessionState.isHandshakeConfirmed, false); assert.strictEqual(sessionState.isStreamOpenAllowed, false); +assert.strictEqual(sessionState.hasDatagramStatusListener, false); assert.strictEqual(sessionState.isPrioritySupported, false); +assert.strictEqual(sessionState.headersSupported, 0); assert.strictEqual(sessionState.isWrapped, false); +assert.strictEqual(sessionState.maxDatagramSize, 0n); assert.strictEqual(sessionState.lastDatagramId, 0n); assert.strictEqual(typeof streamState.toJSON(), 'object'); From 4b063b2ba85e1215afe92752b67025f7a5a828b7 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Thu, 9 Apr 2026 13:45:02 -0700 Subject: [PATCH 28/34] quic: fixup session state toJSON reporting --- lib/internal/quic/state.js | 3 +-- lib/internal/quic/stats.js | 4 ---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/internal/quic/state.js b/lib/internal/quic/state.js index dab4a581b7eb52..9746b308e3bf74 100644 --- a/lib/internal/quic/state.js +++ b/lib/internal/quic/state.js @@ -412,13 +412,13 @@ class QuicSessionState { isGracefulClose: this.isGracefulClose, isSilentClose: this.isSilentClose, isStatelessReset: this.isStatelessReset, - isDestroyed: this.isDestroyed, isHandshakeCompleted: this.isHandshakeCompleted, isHandshakeConfirmed: this.isHandshakeConfirmed, isStreamOpenAllowed: this.isStreamOpenAllowed, isPrioritySupported: this.isPrioritySupported, headersSupported: this.headersSupported, isWrapped: this.isWrapped, + applicationType: this.applicationType, maxDatagramSize: `${this.maxDatagramSize}`, lastDatagramId: `${this.lastDatagramId}`, }; @@ -447,7 +447,6 @@ class QuicSessionState { isGracefulClose: this.isGracefulClose, isSilentClose: this.isSilentClose, isStatelessReset: this.isStatelessReset, - isDestroyed: this.isDestroyed, isHandshakeCompleted: this.isHandshakeCompleted, isHandshakeConfirmed: this.isHandshakeConfirmed, isStreamOpenAllowed: this.isStreamOpenAllowed, diff --git a/lib/internal/quic/stats.js b/lib/internal/quic/stats.js index 946f1f81d59206..2119f8996db582 100644 --- a/lib/internal/quic/stats.js +++ b/lib/internal/quic/stats.js @@ -491,10 +491,8 @@ class QuicSessionStats { // support BigInts. createdAt: `${this.createdAt}`, closingAt: `${this.closingAt}`, - destroyedAt: `${this.destroyedAt}`, handshakeCompletedAt: `${this.handshakeCompletedAt}`, handshakeConfirmedAt: `${this.handshakeConfirmedAt}`, - gracefulClosingAt: `${this.gracefulClosingAt}`, bytesReceived: `${this.bytesReceived}`, bidiInStreamCount: `${this.bidiInStreamCount}`, bidiOutStreamCount: `${this.bidiOutStreamCount}`, @@ -537,10 +535,8 @@ class QuicSessionStats { connected: this.isConnected, createdAt: this.createdAt, closingAt: this.closingAt, - destroyedAt: this.destroyedAt, handshakeCompletedAt: this.handshakeCompletedAt, handshakeConfirmedAt: this.handshakeConfirmedAt, - gracefulClosingAt: this.gracefulClosingAt, bytesReceived: this.bytesReceived, bidiInStreamCount: this.bidiInStreamCount, bidiOutStreamCount: this.bidiOutStreamCount, From 385bfb2f67868e9425fc1392679ee59f99aba529 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Thu, 9 Apr 2026 14:14:41 -0700 Subject: [PATCH 29/34] quic: implement session.path as documented --- lib/internal/quic/quic.js | 17 +++++++++++++++++ src/quic/session.cc | 24 ++++++++++++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index f827eefbb88265..aa0b864259aada 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -1114,6 +1114,8 @@ class QuicSession { #ondatagram = undefined; /** @type {OnDatagramStatusCallback|undefined} */ #ondatagramstatus = undefined; + /** @type {{ local: SocketAddress, remote: SocketAddress }|undefined} */ + #path = undefined; /** @type {object|undefined} */ #sessionticket = undefined; /** @type {object|undefined} */ @@ -1266,6 +1268,20 @@ class QuicSession { return this.#endpoint; } + /** + * The local and remote socket addresses associated with the session. + * @type {{ local: SocketAddress, remote: SocketAddress } | undefined} + */ + get path() { + QuicSession.#assertIsQuicSession(this); + if (this.destroyed) return undefined; + return this.#path ??= { + __proto__: null, + local: new InternalSocketAddress(this.#handle.getLocalAddress()), + remote: new InternalSocketAddress(this.#handle.getRemoteAddress()), + }; + } + /** * @param {number} direction * @param {OpenStreamOptions} options @@ -1570,6 +1586,7 @@ class QuicSession { this.#onstream = undefined; this.#ondatagram = undefined; this.#ondatagramstatus = undefined; + this.#path = undefined; this.#sessionticket = undefined; this.#token = undefined; diff --git a/src/quic/session.cc b/src/quic/session.cc index 0726099929c9e3..bfd6d36e53a130 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -115,6 +115,7 @@ namespace quic { #define SESSION_JS_METHODS(V) \ V(Destroy, destroy, SIDE_EFFECT) \ V(GetRemoteAddress, getRemoteAddress, NO_SIDE_EFFECT) \ + V(GetLocalAddress, getLocalAddress, NO_SIDE_EFFECT) \ V(GetCertificate, getCertificate, NO_SIDE_EFFECT) \ V(GetEphemeralKeyInfo, getEphemeralKey, NO_SIDE_EFFECT) \ V(GetPeerCertificate, getPeerCertificate, NO_SIDE_EFFECT) \ @@ -221,7 +222,7 @@ void ngtcp2_debug_log(void* user_data, const char* fmt, ...) { va_end(ap); } -template +template bool SetOption(Environment* env, Opt* options, const Local& object, @@ -236,7 +237,7 @@ bool SetOption(Environment* env, return true; } -template +template bool SetOption(Environment* env, Opt* options, const Local& object, @@ -251,7 +252,7 @@ bool SetOption(Environment* env, return true; } -template +template bool SetOption(Environment* env, Opt* options, const Local& object, @@ -266,7 +267,7 @@ bool SetOption(Environment* env, return true; } -template +template bool SetOption(Environment* env, Opt* options, const Local& object, @@ -712,6 +713,21 @@ struct Session::Impl final : public MemoryRetainer { ->object()); } + JS_METHOD(GetLocalAddress) { + auto env = Environment::GetCurrent(args); + Session* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + + if (session->is_destroyed()) { + return THROW_ERR_INVALID_STATE(env, "Session is destroyed"); + } + + auto address = session->local_address(); + args.GetReturnValue().Set( + SocketAddressBase::Create(env, std::make_shared(address)) + ->object()); + } + JS_METHOD(GetCertificate) { auto env = Environment::GetCurrent(args); Session* session; From 630c3550e7ae3222179846fe2416414b9b5169ec Mon Sep 17 00:00:00 2001 From: James M Snell Date: Thu, 9 Apr 2026 16:07:48 -0700 Subject: [PATCH 30/34] quic: add proper support for session callbacks, info details --- doc/api/quic.md | 129 ++++++- lib/internal/quic/quic.js | 338 +++++++++++++++--- lib/internal/quic/state.js | 115 +++--- src/quic/session.cc | 66 +++- ...est-quic-internal-endpoint-stats-state.mjs | 5 +- test/parallel/test-quic-new-token.mjs | 36 +- 6 files changed, 543 insertions(+), 146 deletions(-) diff --git a/doc/api/quic.md b/doc/api/quic.md index ce6687ba369ed1..ea0e5435cb2c5e 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -385,6 +385,36 @@ 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. +### `session.opened` + + + +* Type: {Promise} for an {Object} + * `local` {net.SocketAddress} The local socket address. + * `remote` {net.SocketAddress} The remote socket address. + * `servername` {string} The SNI server name negotiated during the handshake. + * `protocol` {string} The ALPN protocol negotiated during the handshake. + * `cipher` {string} The name of the negotiated TLS cipher suite. + * `cipherVersion` {string} The TLS protocol version of the cipher suite + (e.g., `'TLSv1.3'`). + * `validationErrorReason` {string} If certificate validation failed, the + reason string. Empty string if validation succeeded. + * `validationErrorCode` {number} If certificate validation failed, the + error code. `0` if validation succeeded. + * `earlyDataAttempted` {boolean} Whether 0-RTT early data was attempted. + * `earlyDataAccepted` {boolean} Whether 0-RTT early data was accepted by + the server. + +A promise that is fulfilled once the TLS handshake completes successfully. +The resolved value contains information about the established session +including the negotiated protocol, cipher suite, certificate validation +status, and 0-RTT early data status. + +If the handshake fails or the session is destroyed before the handshake +completes, the promise will be rejected. + ### `session.closed` + +* Type: {quic.OnNewTokenCallback} + +The callback to invoke when a NEW\_TOKEN token is received from the server. +The token can be passed as the `token` option on a future connection to +the same server to skip address validation. Read/write. + +### `session.onorigin` + + + +* 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.createBidirectionalStream([options])` + +* Type: {Object|undefined} + +The local certificate as an object with properties such as `subject`, +`issuer`, `valid_from`, `valid_to`, `fingerprint`, etc. Returns `undefined` +if the session is destroyed or no certificate is available. + +### `session.peerCertificate` + + + +* Type: {Object|undefined} + +The peer's certificate as an object with properties such as `subject`, +`issuer`, `valid_from`, `valid_to`, `fingerprint`, etc. Returns `undefined` +if the session is destroyed or the peer did not present a certificate. + +### `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: {object|undefined} - -The most recently received NEW\_TOKEN token from the server, if any. -The object has `token` {Buffer} and `address` {SocketAddress} properties. -The token can be passed as the `token` option on a future connection to -the same server to skip address validation. - ### `session.updateKey()` + +* `this` {quic.QuicSession} +* `token` {Buffer} The NEW\_TOKEN token data. +* `address` {SocketAddress} The remote address the token is associated with. + +### Callback: `OnOriginCallback` + + + +* `this` {quic.QuicSession} +* `origins` {string\[]} The list of origins the server is authoritative for. + ### Callback: `OnBlockedCallback` +[`session.onnewtoken`]: #sessiononnewtoken [`sessionOptions.sni`]: #sessionoptionssni-server-only [`stream.setPriority()`]: #streamsetpriorityoptions diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index aa0b864259aada..850ae027545726 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -489,11 +489,8 @@ setCallbacks({ onSessionPathValidation(result, newLocalAddress, newRemoteAddress, oldLocalAddress, oldRemoteAddress, preferredAddress) { debug('session path validation callback', this[kOwner]); - this[kOwner][kPathValidation](result, - new InternalSocketAddress(newLocalAddress), - new InternalSocketAddress(newRemoteAddress), - new InternalSocketAddress(oldLocalAddress), - new InternalSocketAddress(oldRemoteAddress), + this[kOwner][kPathValidation](result, newLocalAddress, newRemoteAddress, + oldLocalAddress, oldRemoteAddress, preferredAddress); }, @@ -1114,12 +1111,23 @@ class QuicSession { #ondatagram = undefined; /** @type {OnDatagramStatusCallback|undefined} */ #ondatagramstatus = undefined; + /** @type {Function|undefined} */ + #onpathvalidation = undefined; + /** @type {Function|undefined} */ + #onsessionticket = undefined; + /** @type {Function|undefined} */ + #onversionnegotiation = undefined; + /** @type {Function|undefined} */ + #onhandshake = undefined; + /** @type {Function|undefined} */ + #onnewtoken = undefined; + /** @type {Function|undefined} */ + #onorigin = undefined; /** @type {{ local: SocketAddress, remote: SocketAddress }|undefined} */ #path = undefined; - /** @type {object|undefined} */ - #sessionticket = undefined; - /** @type {object|undefined} */ - #token = undefined; + #certificate = undefined; + #peerCertificate = undefined; + #ephemeralKeyInfo = undefined; static { getQuicSessionState = function(session) { @@ -1150,9 +1158,6 @@ class QuicSession { this.#handle[kOwner] = this; this.#stats = new QuicSessionStats(kPrivateConstructor, handle.stats); this.#state = new QuicSessionState(kPrivateConstructor, handle.state); - this.#state.hasVersionNegotiationListener = true; - this.#state.hasPathValidationListener = true; - this.#state.hasSessionTicketListener = true; debug('session created'); } @@ -1162,26 +1167,6 @@ class QuicSession { return this.#handle === undefined || this.#isPendingClose; } - /** - * Get the session ticket associated with this session, if any. - * @type {object|undefined} - */ - get sessionticket() { - QuicSession.#assertIsQuicSession(this); - return this.#sessionticket; - } - - /** - * Get the NEW_TOKEN token received from the server, if any. - * This token can be passed as the `token` option on a future - * connection to the same server to skip address validation. - * @type {object|undefined} - */ - get token() { - QuicSession.#assertIsQuicSession(this); - return this.#token; - } - /** @type {OnStreamCallback} */ get onstream() { QuicSession.#assertIsQuicSession(this); @@ -1238,6 +1223,110 @@ class QuicSession { } } + /** @type {Function|undefined} */ + get onpathvalidation() { + QuicSession.#assertIsQuicSession(this); + return this.#onpathvalidation; + } + + set onpathvalidation(fn) { + QuicSession.#assertIsQuicSession(this); + if (fn === undefined) { + this.#onpathvalidation = undefined; + this.#state.hasPathValidationListener = false; + } else { + validateFunction(fn, 'onpathvalidation'); + this.#onpathvalidation = fn.bind(this); + this.#state.hasPathValidationListener = true; + } + } + + /** @type {Function|undefined} */ + get onsessionticket() { + QuicSession.#assertIsQuicSession(this); + return this.#onsessionticket; + } + + set onsessionticket(fn) { + QuicSession.#assertIsQuicSession(this); + if (fn === undefined) { + this.#onsessionticket = undefined; + this.#state.hasSessionTicketListener = false; + } else { + validateFunction(fn, 'onsessionticket'); + this.#onsessionticket = fn.bind(this); + this.#state.hasSessionTicketListener = true; + } + } + + /** @type {Function|undefined} */ + get onversionnegotiation() { + QuicSession.#assertIsQuicSession(this); + return this.#onversionnegotiation; + } + + set onversionnegotiation(fn) { + QuicSession.#assertIsQuicSession(this); + if (fn === undefined) { + this.#onversionnegotiation = undefined; + } else { + validateFunction(fn, 'onversionnegotiation'); + this.#onversionnegotiation = fn.bind(this); + } + } + + /** @type {Function|undefined} */ + get onhandshake() { + QuicSession.#assertIsQuicSession(this); + return this.#onhandshake; + } + + set onhandshake(fn) { + QuicSession.#assertIsQuicSession(this); + if (fn === undefined) { + this.#onhandshake = undefined; + } else { + validateFunction(fn, 'onhandshake'); + this.#onhandshake = fn.bind(this); + } + } + + /** @type {Function|undefined} */ + get onnewtoken() { + QuicSession.#assertIsQuicSession(this); + return this.#onnewtoken; + } + + set onnewtoken(fn) { + QuicSession.#assertIsQuicSession(this); + if (fn === undefined) { + this.#onnewtoken = undefined; + this.#state.hasNewTokenListener = false; + } else { + validateFunction(fn, 'onnewtoken'); + this.#onnewtoken = fn.bind(this); + this.#state.hasNewTokenListener = true; + } + } + + /** @type {Function|undefined} */ + get onorigin() { + QuicSession.#assertIsQuicSession(this); + return this.#onorigin; + } + + set onorigin(fn) { + QuicSession.#assertIsQuicSession(this); + if (fn === undefined) { + this.#onorigin = undefined; + this.#state.hasOriginListener = false; + } else { + validateFunction(fn, 'onorigin'); + this.#onorigin = fn.bind(this); + this.#state.hasOriginListener = true; + } + } + /** * The maximum datagram size the peer will accept, or 0 if datagrams * are not supported or the handshake has not yet completed. @@ -1282,6 +1371,39 @@ class QuicSession { }; } + /** + * The local certificate as an object, or undefined if not available. + * @type {object|undefined} + */ + get certificate() { + QuicSession.#assertIsQuicSession(this); + if (this.destroyed) return undefined; + return this.#certificate ??= this.#handle.getCertificate(); + } + + /** + * The peer's certificate as an object, or undefined if the peer did + * not present a certificate or the session is destroyed. + * @type {object|undefined} + */ + get peerCertificate() { + QuicSession.#assertIsQuicSession(this); + if (this.destroyed) return undefined; + return this.#peerCertificate ??= this.#handle.getPeerCertificate(); + } + + /** + * The ephemeral key info for the session. Only available on client + * sessions. Returns undefined for server sessions or if the session + * is destroyed. + * @type {object|undefined} + */ + get ephemeralKeyInfo() { + QuicSession.#assertIsQuicSession(this); + if (this.destroyed) return undefined; + return this.#ephemeralKeyInfo ??= this.#handle.getEphemeralKey(); + } + /** * @param {number} direction * @param {OpenStreamOptions} options @@ -1586,9 +1708,17 @@ class QuicSession { this.#onstream = undefined; this.#ondatagram = undefined; this.#ondatagramstatus = undefined; + this.#onpathvalidation = undefined; + this.#onsessionticket = undefined; + this.#onversionnegotiation = undefined; + this.#onhandshake = undefined; + this.#onnewtoken = undefined; + this.#onorigin = undefined; this.#path = undefined; - this.#sessionticket = undefined; - this.#token = undefined; + this.#certificate = undefined; + this.#peerCertificate = undefined; + this.#ephemeralKeyInfo = undefined; + // Destroy the underlying C++ handle this.#handle.destroy(); @@ -1698,14 +1828,23 @@ class QuicSession { */ [kPathValidation](result, newLocalAddress, newRemoteAddress, oldLocalAddress, oldRemoteAddress, preferredAddress) { + assert(typeof this.#onpathvalidation === 'function', + 'Unexpected path validation event'); if (this.destroyed) return; + const newLocal = new InternalSocketAddress(newLocalAddress); + const newRemote = new InternalSocketAddress(newRemoteAddress); + const oldLocal = new InternalSocketAddress(oldLocalAddress); + const oldRemote = new InternalSocketAddress(oldRemoteAddress); + this.#onpathvalidation(result, newLocal, newRemote, + oldLocal, oldRemote, preferredAddress); if (onSessionPathValidationChannel.hasSubscribers) { onSessionPathValidationChannel.publish({ + __proto__: null, result, - newLocalAddress, - newRemoteAddress, - oldLocalAddress, - oldRemoteAddress, + newLocalAddress: newLocal, + newRemoteAddress: newRemote, + oldLocalAddress: oldLocal, + oldRemoteAddress: oldRemote, preferredAddress, session: this, }); @@ -1716,10 +1855,13 @@ class QuicSession { * @param {object} ticket */ [kSessionTicket](ticket) { + assert(typeof this.#onsessionticket === 'function', + 'Unexpected session ticket event'); if (this.destroyed) return; - this.#sessionticket = ticket; + this.#onsessionticket(ticket); if (onSessionTicketChannel.hasSubscribers) { onSessionTicketChannel.publish({ + __proto__: null, ticket, session: this, }); @@ -1731,13 +1873,16 @@ class QuicSession { * @param {SocketAddress} address */ [kNewToken](token, address) { + assert(typeof this.#onnewtoken === 'function', + 'Unexpected new token event'); if (this.destroyed) return; - this.#token = { token, address }; - // TODO(@jasnell): This really should be an event + const addr = new InternalSocketAddress(address); + this.#onnewtoken(token, addr); if (onSessionNewTokenChannel.hasSubscribers) { onSessionNewTokenChannel.publish({ + __proto__: null, token, - address, + address: addr, session: this, }); } @@ -1750,9 +1895,15 @@ class QuicSession { */ [kVersionNegotiation](version, requestedVersions, supportedVersions) { if (this.destroyed) return; + if (this.#onversionnegotiation) { + this.#onversionnegotiation(version, requestedVersions, supportedVersions); + } + // Version negotiation is always a fatal event - the session must be + // destroyed regardless of whether the callback is set. this.destroy(new ERR_QUIC_VERSION_NEGOTIATION_ERROR()); if (onSessionVersionNegotiationChannel.hasSubscribers) { onSessionVersionNegotiationChannel.publish({ + __proto__: null, version, requestedVersions, supportedVersions, @@ -1766,9 +1917,13 @@ class QuicSession { * @param {string[]} origins */ [kOrigin](origins) { + assert(typeof this.#onorigin === 'function', + 'Unexpected origin event'); if (this.destroyed) return; + this.#onorigin(origins); if (onSessionOriginChannel.hasSubscribers) { onSessionOriginChannel.publish({ + __proto__: null, origins, session: this, }); @@ -1805,12 +1960,17 @@ class QuicSession { earlyDataAccepted, }; + if (this.#onhandshake) { + this.#onhandshake(info); + } + this.#pendingOpen.resolve?.(info); this.#pendingOpen.resolve = undefined; this.#pendingOpen.reject = undefined; if (onSessionHandshakeChannel.hasSubscribers) { onSessionHandshakeChannel.publish({ + __proto__: null, session: this, ...info, }); @@ -1939,6 +2099,7 @@ class QuicEndpoint { * @type {OnSessionCallback} */ #onsession = undefined; + #sessionCallbacks = undefined; static { getQuicEndpointState = function(endpoint) { @@ -2123,8 +2284,35 @@ class QuicEndpoint { validateObject(options, 'options'); this.#onsession = onsession.bind(this); + const { + onstream, + ondatagram, + ondatagramstatus, + onpathvalidation, + onsessionticket, + onversionnegotiation, + onhandshake, + onnewtoken, + onorigin, + ...rest + } = options; + + // Store session callbacks to apply to each new incoming session. + this.#sessionCallbacks = { + __proto__: null, + onstream, + ondatagram, + ondatagramstatus, + onpathvalidation, + onsessionticket, + onversionnegotiation, + onhandshake, + onnewtoken, + onorigin, + }; + debug('endpoint listening as a server'); - this.#handle.listen(options); + this.#handle.listen(rest); this.#listening = true; } @@ -2138,14 +2326,38 @@ class QuicEndpoint { assertEndpointNotClosedOrClosing(this); assertEndpointIsNotBusy(this); validateObject(options, 'options'); - const { sessionTicket, ...rest } = options; + const { + sessionTicket, + onstream, + ondatagram, + ondatagramstatus, + onpathvalidation, + onsessionticket, + onversionnegotiation, + onhandshake, + onnewtoken, + onorigin, + ...rest + } = options; debug('endpoint connecting as a client'); const handle = this.#handle.connect(address, rest, sessionTicket); if (handle === undefined) { throw new ERR_QUIC_CONNECTION_FAILED(); } - return this.#newSession(handle); + const session = this.#newSession(handle); + // Set callbacks before any async work to avoid missing events + // that fire during or immediately after the handshake. + if (onstream) session.onstream = onstream; + if (ondatagram) session.ondatagram = ondatagram; + if (ondatagramstatus) session.ondatagramstatus = ondatagramstatus; + if (onpathvalidation) session.onpathvalidation = onpathvalidation; + if (onsessionticket) session.onsessionticket = onsessionticket; + if (onversionnegotiation) session.onversionnegotiation = onversionnegotiation; + if (onhandshake) session.onhandshake = onhandshake; + if (onnewtoken) session.onnewtoken = onnewtoken; + if (onorigin) session.onorigin = onorigin; + return session; } /** @@ -2319,6 +2531,21 @@ class QuicEndpoint { [kNewSession](handle) { const session = this.#newSession(handle); + // 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 (this.#sessionCallbacks) { + const cbs = this.#sessionCallbacks; + 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.onorigin) session.onorigin = cbs.onorigin; + } if (onEndpointServerSessionChannel.hasSubscribers) { onEndpointServerSessionChannel.publish({ endpoint: this, @@ -2640,6 +2867,18 @@ function processSessionOptions(options, config = { __proto__: null }) { maxStreamWindow, maxWindow, cc, + // Session callbacks that can be set at construction time to avoid + // race conditions with events that fire during or immediately + // after the handshake. + onstream, + ondatagram, + ondatagramstatus, + onpathvalidation, + onsessionticket, + onversionnegotiation, + onhandshake, + onnewtoken, + onorigin, } = options; const { @@ -2677,6 +2916,15 @@ function processSessionOptions(options, config = { __proto__: null }) { sessionTicket, token, cc, + onstream, + ondatagram, + ondatagramstatus, + onpathvalidation, + onsessionticket, + onversionnegotiation, + onhandshake, + onnewtoken, + onorigin, }; } diff --git a/lib/internal/quic/state.js b/lib/internal/quic/state.js index 9746b308e3bf74..1734b92a6b81a5 100644 --- a/lib/internal/quic/state.js +++ b/lib/internal/quic/state.js @@ -5,11 +5,26 @@ const { DataView, DataViewPrototypeGetBigInt64, DataViewPrototypeGetBigUint64, + DataViewPrototypeGetUint32, DataViewPrototypeGetUint8, + DataViewPrototypeSetUint32, DataViewPrototypeSetUint8, + Float32Array, JSONStringify, + Uint8Array, } = primordials; +// Determine native byte order. The shared state buffer is written by +// C++ in native byte order, so DataView reads must match. +const kIsLittleEndian = (() => { + // -1 as float32 is 0xBF800000. On little-endian, the bytes are + // [0x00, 0x00, 0x80, 0xBF], so byte[3] is 0xBF (non-zero). + // On big-endian, the bytes are [0xBF, 0x80, 0x00, 0x00], so byte[3] is 0. + const buf = new Float32Array(1); + buf[0] = -1; + return new Uint8Array(buf.buffer)[3] !== 0; +})(); + const { getOptionValue, } = require('internal/options'); @@ -49,11 +64,7 @@ const { // prevent further updates to the buffer. const { - IDX_STATE_SESSION_PATH_VALIDATION, - IDX_STATE_SESSION_VERSION_NEGOTIATION, - IDX_STATE_SESSION_DATAGRAM, - IDX_STATE_SESSION_DATAGRAM_STATUS, - IDX_STATE_SESSION_SESSION_TICKET, + IDX_STATE_SESSION_LISTENER_FLAGS, IDX_STATE_SESSION_CLOSING, IDX_STATE_SESSION_GRACEFUL_CLOSE, IDX_STATE_SESSION_SILENT_CLOSE, @@ -90,11 +101,7 @@ const { IDX_STATE_STREAM_WANTS_TRAILERS, } = internalBinding('quic'); -assert(IDX_STATE_SESSION_PATH_VALIDATION !== undefined); -assert(IDX_STATE_SESSION_VERSION_NEGOTIATION !== undefined); -assert(IDX_STATE_SESSION_DATAGRAM !== undefined); -assert(IDX_STATE_SESSION_DATAGRAM_STATUS !== undefined); -assert(IDX_STATE_SESSION_SESSION_TICKET !== undefined); +assert(IDX_STATE_SESSION_LISTENER_FLAGS !== undefined); assert(IDX_STATE_SESSION_CLOSING !== undefined); assert(IDX_STATE_SESSION_GRACEFUL_CLOSE !== undefined); assert(IDX_STATE_SESSION_SILENT_CLOSE !== undefined); @@ -184,7 +191,7 @@ class QuicEndpointState { */ get pendingCallbacks() { if (this.#handle.byteLength === 0) return undefined; - return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_ENDPOINT_PENDING_CALLBACKS); + return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_ENDPOINT_PENDING_CALLBACKS, kIsLittleEndian); } toString() { @@ -253,64 +260,76 @@ class QuicSessionState { this.#handle = new DataView(buffer); } - /** @type {boolean} */ - get hasPathValidationListener() { + // Listener flags are packed into a single uint32_t bitfield. The bit + // positions must match the SessionListenerFlags enum in session.cc. + static #LISTENER_PATH_VALIDATION = 1 << 0; + static #LISTENER_DATAGRAM = 1 << 1; + static #LISTENER_DATAGRAM_STATUS = 1 << 2; + static #LISTENER_SESSION_TICKET = 1 << 3; + static #LISTENER_NEW_TOKEN = 1 << 4; + static #LISTENER_ORIGIN = 1 << 5; + + #getListenerFlag(flag) { if (this.#handle.byteLength === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_PATH_VALIDATION); + return !!(DataViewPrototypeGetUint32( + this.#handle, IDX_STATE_SESSION_LISTENER_FLAGS, kIsLittleEndian) & flag); } - /** @type {boolean} */ - set hasPathValidationListener(val) { + #setListenerFlag(flag, val) { if (this.#handle.byteLength === 0) return; - DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_PATH_VALIDATION, val ? 1 : 0); + const current = DataViewPrototypeGetUint32( + this.#handle, IDX_STATE_SESSION_LISTENER_FLAGS, kIsLittleEndian); + DataViewPrototypeSetUint32( + this.#handle, IDX_STATE_SESSION_LISTENER_FLAGS, + val ? (current | flag) : (current & ~flag), kIsLittleEndian); } /** @type {boolean} */ - get hasVersionNegotiationListener() { - if (this.#handle.byteLength === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_VERSION_NEGOTIATION); + get hasPathValidationListener() { + return this.#getListenerFlag(QuicSessionState.#LISTENER_PATH_VALIDATION); } - - /** @type {boolean} */ - set hasVersionNegotiationListener(val) { - if (this.#handle.byteLength === 0) return; - DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_VERSION_NEGOTIATION, val ? 1 : 0); + set hasPathValidationListener(val) { + this.#setListenerFlag(QuicSessionState.#LISTENER_PATH_VALIDATION, val); } /** @type {boolean} */ get hasDatagramListener() { - if (this.#handle.byteLength === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_DATAGRAM); + return this.#getListenerFlag(QuicSessionState.#LISTENER_DATAGRAM); } - - /** @type {boolean} */ set hasDatagramListener(val) { - if (this.#handle.byteLength === 0) return; - DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_DATAGRAM, val ? 1 : 0); + this.#setListenerFlag(QuicSessionState.#LISTENER_DATAGRAM, val); } /** @type {boolean} */ get hasDatagramStatusListener() { - if (this.#handle.byteLength === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_DATAGRAM_STATUS); + return this.#getListenerFlag(QuicSessionState.#LISTENER_DATAGRAM_STATUS); } - - /** @type {boolean} */ set hasDatagramStatusListener(val) { - if (this.#handle.byteLength === 0) return; - DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_DATAGRAM_STATUS, val ? 1 : 0); + this.#setListenerFlag(QuicSessionState.#LISTENER_DATAGRAM_STATUS, val); } /** @type {boolean} */ get hasSessionTicketListener() { - if (this.#handle.byteLength === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_SESSION_TICKET); + return this.#getListenerFlag(QuicSessionState.#LISTENER_SESSION_TICKET); + } + set hasSessionTicketListener(val) { + this.#setListenerFlag(QuicSessionState.#LISTENER_SESSION_TICKET, val); } /** @type {boolean} */ - set hasSessionTicketListener(val) { - if (this.#handle.byteLength === 0) return; - DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_SESSION_TICKET, val ? 1 : 0); + get hasNewTokenListener() { + return this.#getListenerFlag(QuicSessionState.#LISTENER_NEW_TOKEN); + } + set hasNewTokenListener(val) { + this.#setListenerFlag(QuicSessionState.#LISTENER_NEW_TOKEN, val); + } + + /** @type {boolean} */ + get hasOriginListener() { + return this.#getListenerFlag(QuicSessionState.#LISTENER_ORIGIN); + } + set hasOriginListener(val) { + this.#setListenerFlag(QuicSessionState.#LISTENER_ORIGIN, val); } /** @type {boolean} */ @@ -386,13 +405,13 @@ class QuicSessionState { /** @type {bigint} */ get maxDatagramSize() { if (this.#handle.byteLength === 0) return undefined; - return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_SESSION_MAX_DATAGRAM_SIZE); + return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_SESSION_MAX_DATAGRAM_SIZE, kIsLittleEndian); } /** @type {bigint} */ get lastDatagramId() { if (this.#handle.byteLength === 0) return undefined; - return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_SESSION_LAST_DATAGRAM_ID); + return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_SESSION_LAST_DATAGRAM_ID, kIsLittleEndian); } toString() { @@ -404,10 +423,11 @@ class QuicSessionState { return { __proto__: null, hasPathValidationListener: this.hasPathValidationListener, - hasVersionNegotiationListener: this.hasVersionNegotiationListener, hasDatagramListener: this.hasDatagramListener, hasDatagramStatusListener: this.hasDatagramStatusListener, hasSessionTicketListener: this.hasSessionTicketListener, + hasNewTokenListener: this.hasNewTokenListener, + hasOriginListener: this.hasOriginListener, isClosing: this.isClosing, isGracefulClose: this.isGracefulClose, isSilentClose: this.isSilentClose, @@ -439,10 +459,11 @@ class QuicSessionState { return `QuicSessionState ${inspect({ hasPathValidationListener: this.hasPathValidationListener, - hasVersionNegotiationListener: this.hasVersionNegotiationListener, hasDatagramListener: this.hasDatagramListener, hasDatagramStatusListener: this.hasDatagramStatusListener, hasSessionTicketListener: this.hasSessionTicketListener, + hasNewTokenListener: this.hasNewTokenListener, + hasOriginListener: this.hasOriginListener, isClosing: this.isClosing, isGracefulClose: this.isGracefulClose, isSilentClose: this.isSilentClose, @@ -488,7 +509,7 @@ class QuicStreamState { /** @type {bigint} */ get id() { if (this.#handle.byteLength === 0) return undefined; - return DataViewPrototypeGetBigInt64(this.#handle, IDX_STATE_STREAM_ID); + return DataViewPrototypeGetBigInt64(this.#handle, IDX_STATE_STREAM_ID, kIsLittleEndian); } /** @type {boolean} */ diff --git a/src/quic/session.cc b/src/quic/session.cc index bfd6d36e53a130..61d52aa8d4c77a 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -57,12 +57,44 @@ using v8::Value; namespace quic { +// Listener flags are packed into a single uint32_t bitfield to reduce +// the size of the shared state buffer. Each bit indicates whether a +// corresponding JS callback is registered. +enum class SessionListenerFlags : uint32_t { + PATH_VALIDATION = 1 << 0, + DATAGRAM = 1 << 1, + DATAGRAM_STATUS = 1 << 2, + SESSION_TICKET = 1 << 3, + NEW_TOKEN = 1 << 4, + ORIGIN = 1 << 5, +}; + +inline SessionListenerFlags operator|(SessionListenerFlags a, + SessionListenerFlags b) { + return static_cast(static_cast(a) | + static_cast(b)); +} + +inline SessionListenerFlags operator&(SessionListenerFlags a, + SessionListenerFlags b) { + return static_cast(static_cast(a) & + static_cast(b)); +} + +inline SessionListenerFlags operator&(uint32_t a, SessionListenerFlags b) { + return static_cast(a & static_cast(b)); +} + +inline bool operator!(SessionListenerFlags a) { + return static_cast(a) == 0; +} + +inline bool HasListenerFlag(uint32_t flags, SessionListenerFlags flag) { + return !!(flags & flag); +} + #define SESSION_STATE(V) \ - V(PATH_VALIDATION, path_validation, uint8_t) \ - V(VERSION_NEGOTIATION, version_negotiation, uint8_t) \ - V(DATAGRAM, datagram, uint8_t) \ - V(DATAGRAM_STATUS, datagram_status, uint8_t) \ - V(SESSION_TICKET, session_ticket, uint8_t) \ + V(LISTENER_FLAGS, listener_flags, uint32_t) \ V(CLOSING, closing, uint8_t) \ V(GRACEFUL_CLOSE, graceful_close, uint8_t) \ V(SILENT_CLOSE, silent_close, uint8_t) \ @@ -2313,7 +2345,9 @@ bool Session::is_in_draining_period() const { } bool Session::wants_session_ticket() const { - return !is_destroyed() && impl_->state_->session_ticket == 1; + return !is_destroyed() && + HasListenerFlag(impl_->state_->listener_flags, + SessionListenerFlags::SESSION_TICKET); } void Session::SetStreamOpenAllowed() { @@ -2507,7 +2541,8 @@ void Session::DatagramStatus(datagram_id datagramId, break; } } - if (impl_->state_->datagram_status) { + if (HasListenerFlag(impl_->state_->listener_flags, + SessionListenerFlags::DATAGRAM_STATUS)) { EmitDatagramStatus(datagramId, status); } } @@ -2518,7 +2553,10 @@ void Session::DatagramReceived(const uint8_t* data, DCHECK(!is_destroyed()); // If there is nothing watching for the datagram on the JavaScript side, // or if the datagram is zero-length, we just drop it on the floor. - if (impl_->state_->datagram == 0 || datalen == 0) return; + if (!HasListenerFlag(impl_->state_->listener_flags, + SessionListenerFlags::DATAGRAM) || + datalen == 0) + return; Debug(this, "Session is receiving datagram of size %zu", datalen); auto& stats_ = impl_->stats_; @@ -2821,7 +2859,8 @@ void Session::EmitPathValidation(PathValidationResult result, if (!env()->can_call_into_js()) return; - if (impl_->state_->path_validation == 0) [[likely]] { + if (!HasListenerFlag(impl_->state_->listener_flags, + SessionListenerFlags::PATH_VALIDATION)) [[likely]] { return; } @@ -2865,7 +2904,8 @@ void Session::EmitSessionTicket(Store&& ticket) { // If there is nothing listening for the session ticket, don't bother // emitting. - if (impl_->state_->session_ticket == 0) [[likely]] { + if (!HasListenerFlag(impl_->state_->listener_flags, + SessionListenerFlags::SESSION_TICKET)) [[likely]] { Debug(this, "Session ticket was discarded"); return; } @@ -2889,6 +2929,9 @@ void Session::EmitSessionTicket(Store&& ticket) { void Session::EmitNewToken(const uint8_t* token, size_t len) { DCHECK(!is_destroyed()); + if (!HasListenerFlag(impl_->state_->listener_flags, + SessionListenerFlags::NEW_TOKEN)) + return; if (!env()->can_call_into_js()) return; CallbackScope cb_scope(this); @@ -2962,6 +3005,9 @@ void Session::EmitVersionNegotiation(const ngtcp2_pkt_hd& hd, void Session::EmitOrigins(std::vector&& origins) { DCHECK(!is_destroyed()); + if (!HasListenerFlag(impl_->state_->listener_flags, + SessionListenerFlags::ORIGIN)) + return; if (!env()->can_call_into_js()) return; CallbackScope cb_scope(this); diff --git a/test/parallel/test-quic-internal-endpoint-stats-state.mjs b/test/parallel/test-quic-internal-endpoint-stats-state.mjs index e3bb1796776f26..7a84858bf15623 100644 --- a/test/parallel/test-quic-internal-endpoint-stats-state.mjs +++ b/test/parallel/test-quic-internal-endpoint-stats-state.mjs @@ -144,9 +144,11 @@ assert.strictEqual(streamState.wantsBlock, false); assert.strictEqual(streamState.wantsReset, false); assert.strictEqual(sessionState.hasPathValidationListener, false); -assert.strictEqual(sessionState.hasVersionNegotiationListener, false); assert.strictEqual(sessionState.hasDatagramListener, false); +assert.strictEqual(sessionState.hasDatagramStatusListener, false); assert.strictEqual(sessionState.hasSessionTicketListener, false); +assert.strictEqual(sessionState.hasNewTokenListener, false); +assert.strictEqual(sessionState.hasOriginListener, false); assert.strictEqual(sessionState.isClosing, false); assert.strictEqual(sessionState.isGracefulClose, false); assert.strictEqual(sessionState.isSilentClose, false); @@ -154,7 +156,6 @@ assert.strictEqual(sessionState.isStatelessReset, false); assert.strictEqual(sessionState.isHandshakeCompleted, false); assert.strictEqual(sessionState.isHandshakeConfirmed, false); assert.strictEqual(sessionState.isStreamOpenAllowed, false); -assert.strictEqual(sessionState.hasDatagramStatusListener, false); assert.strictEqual(sessionState.isPrioritySupported, false); assert.strictEqual(sessionState.headersSupported, 0); assert.strictEqual(sessionState.isWrapped, false); diff --git a/test/parallel/test-quic-new-token.mjs b/test/parallel/test-quic-new-token.mjs index 1f48d00cf35c63..9580b220ecac18 100644 --- a/test/parallel/test-quic-new-token.mjs +++ b/test/parallel/test-quic-new-token.mjs @@ -23,17 +23,13 @@ await assert.rejects(connect({ port: 1234 }, { }); // After a successful handshake, the server automatically sends a -// NEW_TOKEN frame. The client should receive it and make it -// available via session.token. +// NEW_TOKEN frame. The client should receive it via the onnewtoken +// callback set at connection time. -const serverOpened = Promise.withResolvers(); const clientToken = Promise.withResolvers(); const serverEndpoint = await listen(mustCall((serverSession) => { - serverSession.opened.then(mustCall((info) => { - serverOpened.resolve(); - // Don't close immediately — give time for NEW_TOKEN to be sent - })); + serverSession.opened.then(mustCall()); }), { sni: { '*': { keys: [key], certs: [cert] } }, alpn: ['quic-test'], @@ -42,28 +38,16 @@ const serverEndpoint = await listen(mustCall((serverSession) => { const clientSession = await connect(serverEndpoint.address, { alpn: 'quic-test', servername: 'localhost', + // Set onnewtoken at connection time to avoid missing the event. + onnewtoken: mustCall(function(token, address) { + assert.ok(Buffer.isBuffer(token), 'token should be a Buffer'); + assert.ok(token.length > 0, 'token should not be empty'); + assert.ok(address !== undefined, 'address should be defined'); + clientToken.resolve(); + }), }); await clientSession.opened; -await serverOpened.promise; - -// Wait briefly for the NEW_TOKEN frame to arrive. The server submits -// it during handshake confirmation, but it may take an additional -// packet exchange to reach the client. -const checkToken = () => { - if (clientSession.token !== undefined) { - clientToken.resolve(); - } else { - setTimeout(checkToken, 10); - } -}; -checkToken(); - await clientToken.promise; -const { token, address } = clientSession.token; -assert.ok(Buffer.isBuffer(token), 'token should be a Buffer'); -assert.ok(token.length > 0, 'token should not be empty'); -assert.ok(address !== undefined, 'address should be defined'); - clientSession.close(); From f3327ea5cec205b734cdd25ceb5b679f4efd1854 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Thu, 9 Apr 2026 17:21:37 -0700 Subject: [PATCH 31/34] quic: add stream headers support --- doc/api/quic.md | 154 +++++++++++++++++++ lib/internal/quic/quic.js | 279 ++++++++++++++++++++++++++++++----- lib/internal/quic/symbols.js | 6 +- src/quic/http3.cc | 5 +- 4 files changed, 400 insertions(+), 44 deletions(-) diff --git a/doc/api/quic.md b/doc/api/quic.md index ea0e5435cb2c5e..f40b2656e74093 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -558,17 +558,26 @@ added: v23.8.0 * `options` {Object} * `body` {ArrayBuffer | ArrayBufferView | Blob} + * `headers` {Object} Initial request or response headers to send. Only + used when the session supports headers (e.g. HTTP/3). If `body` is not + specified and `headers` is provided, the stream is treated as + headers-only (terminal). * `priority` {string} The priority level of the stream. One of `'high'`, `'default'`, or `'low'`. **Default:** `'default'`. * `incremental` {boolean} When `true`, data from this stream may be interleaved with data from other streams of the same priority level. When `false`, the stream should be completed before same-priority peers. **Default:** `false`. + * `onheaders` {quic.OnHeadersCallback} Callback for received headers. + * `ontrailers` {quic.OnTrailersCallback} Callback for received trailers. + * `onwanttrailers` {Function} Callback when trailers should be sent. * Returns: {Promise} for a {quic.QuicStream} Open a new bidirectional stream. If the `body` option is not specified, the outgoing stream will be half-closed. The `priority` and `incremental` options are only used when the session supports priority (e.g. HTTP/3). +The `headers`, `onheaders`, `ontrailers`, and `onwanttrailers` options +are only used when the session supports headers (e.g. HTTP/3). ### `session.createUnidirectionalStream([options])` @@ -578,12 +587,16 @@ added: v23.8.0 * `options` {Object} * `body` {ArrayBuffer | ArrayBufferView | Blob} + * `headers` {Object} Initial request headers to send. * `priority` {string} The priority level of the stream. One of `'high'`, `'default'`, or `'low'`. **Default:** `'default'`. * `incremental` {boolean} When `true`, data from this stream may be interleaved with data from other streams of the same priority level. When `false`, the stream should be completed before same-priority peers. **Default:** `false`. + * `onheaders` {quic.OnHeadersCallback} Callback for received headers. + * `ontrailers` {quic.OnTrailersCallback} Callback for received trailers. + * `onwanttrailers` {Function} Callback when trailers should be sent. * Returns: {Promise} for a {quic.QuicStream} Open a new unidirectional stream. If the `body` option is not specified, @@ -982,6 +995,124 @@ added: v23.8.0 The callback to invoke when the stream is reset. Read/write. +### `stream.headers` + + + +* Type: {Object|undefined} + +The buffered initial headers received on this stream, or `undefined` if the +application does not support headers or no headers have been received yet. +For server-side streams, this contains the request headers (e.g., `:method`, +`:path`, `:scheme`). For client-side streams, this contains the response +headers (e.g., `:status`). + +Header names are lowercase strings. Multi-value headers are represented as +arrays. The object has `__proto__: null`. + +### `stream.onheaders` + + + +* Type: {quic.OnHeadersCallback} + +The callback to invoke when headers are received on the stream. The callback +receives `(headers, kind)` where `headers` is an object (same format as +`stream.headers`) and `kind` is one of `'initial'` or `'informational'` +(for 1xx responses). Throws `ERR_INVALID_STATE` if set on a session that +does not support headers. Read/write. + +### `stream.ontrailers` + + + +* Type: {quic.OnTrailersCallback} + +The callback to invoke when trailing headers are received from the peer. +The callback receives `(trailers)` where `trailers` is an object in the +same format as `stream.headers`. Throws `ERR_INVALID_STATE` if set on a +session that does not support headers. Read/write. + +### `stream.onwanttrailers` + + + +* Type: {Function} + +The callback to invoke when the application is ready for trailing headers +to be sent. This is called synchronously — the user must call +[`stream.sendTrailers()`][] within this callback. Throws +`ERR_INVALID_STATE` if set on a session that does not support headers. +Read/write. + +### `stream.pendingTrailers` + + + +* Type: {Object|undefined} + +Set trailing headers to be sent automatically when the application requests +them. This is an alternative to the [`stream.onwanttrailers`][] callback +for cases where the trailers are known before the body completes. Throws +`ERR_INVALID_STATE` if set on a session that does not support headers. +Read/write. + +### `stream.sendHeaders(headers[, options])` + + + +* `headers` {Object} Header object with string keys and string or + string-array values. Pseudo-headers (`:method`, `:path`, etc.) must + appear before regular headers. +* `options` {Object} + * `terminal` {boolean} If `true`, the stream is closed for sending + after the headers (no body will follow). **Default:** `false`. +* Returns: {boolean} + +Sends initial or response headers on the stream. For client-side streams, +this sends request headers. For server-side streams, this sends response +headers. Throws `ERR_INVALID_STATE` if the session does not support headers. + +### `stream.sendInformationalHeaders(headers)` + + + +* `headers` {Object} Header object. Must include `:status` with a 1xx + value (e.g., `{ ':status': '103', 'link': '; rel=preload' }`). +* Returns: {boolean} + +Sends informational (1xx) response headers. Server only. Throws +`ERR_INVALID_STATE` if the session does not support headers. + +### `stream.sendTrailers(headers)` + + + +* `headers` {Object} Trailing header object. Pseudo-headers must not be + included in trailers. +* Returns: {boolean} + +Sends trailing headers on the stream. Must be called synchronously during +the [`stream.onwanttrailers`][] callback, or set ahead of time via +[`stream.pendingTrailers`][]. Throws `ERR_INVALID_STATE` if the session +does not support headers. + ### `stream.priority` + +* `this` {quic.QuicStream} +* `headers` {Object} Header object with lowercase string keys and + string or string-array values. +* `kind` {string} One of `'initial'` or `'informational'`. + +### Callback: `OnTrailersCallback` + + + +* `this` {quic.QuicStream} +* `trailers` {Object} Trailing header object. + ## Diagnostic Channels ### Channel: `quic.endpoint.created` @@ -2081,4 +2232,7 @@ added: v23.8.0 [`session.onnewtoken`]: #sessiononnewtoken [`sessionOptions.sni`]: #sessionoptionssni-server-only +[`stream.onwanttrailers`]: #streamonwanttrailers +[`stream.pendingTrailers`]: #streampendingtrailers +[`stream.sendTrailers()`]: #streamsendtrailersheaders [`stream.setPriority()`]: #streamsetpriorityoptions diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 850ae027545726..4107627f8d5528 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -64,9 +64,9 @@ const { CLOSECONTEXT_RECEIVE_FAILURE: kCloseContextReceiveFailure, CLOSECONTEXT_SEND_FAILURE: kCloseContextSendFailure, CLOSECONTEXT_START_FAILURE: kCloseContextStartFailure, - // QUIC_STREAM_HEADERS_KIND_HINTS and QUIC_STREAM_HEADERS_KIND_TRAILING - // are also available for hints (103) and trailing headers respectively. QUIC_STREAM_HEADERS_KIND_INITIAL: kHeadersKindInitial, + QUIC_STREAM_HEADERS_KIND_HINTS: kHeadersKindHints, + QUIC_STREAM_HEADERS_KIND_TRAILING: kHeadersKindTrailing, QUIC_STREAM_HEADERS_FLAGS_NONE: kHeadersFlagsNone, QUIC_STREAM_HEADERS_FLAGS_TERMINAL: kHeadersFlagsTerminal, } = internalBinding('quic'); @@ -146,9 +146,8 @@ const { kRemoveStream, kNewStream, kNewToken, - kOnHeaders, - kOnTrailers, kOrigin, + kStreamCallbacks, kPathValidation, kPrivateConstructor, kReset, @@ -677,6 +676,12 @@ class QuicStream { #onheaders = undefined; /** @type {OnTrailersCallback|undefined} */ #ontrailers = undefined; + /** @type {Function|undefined} */ + #onwanttrailers = undefined; + /** @type {object|undefined} */ + #headers = undefined; + /** @type {object|undefined} */ + #pendingTrailers = undefined; /** @type {Promise} */ #pendingClose = PromiseWithResolvers(); #reader; @@ -782,15 +787,21 @@ class QuicStream { } /** @type {OnHeadersCallback} */ - get [kOnHeaders]() { + get onheaders() { + QuicStream.#assertIsQuicStream(this); return this.#onheaders; } - set [kOnHeaders](fn) { + set onheaders(fn) { + QuicStream.#assertIsQuicStream(this); if (fn === undefined) { this.#onheaders = undefined; this.#state[kWantsHeaders] = false; } else { + if (getQuicSessionState(this.#session).headersSupported === 2) { + throw new ERR_INVALID_STATE( + 'The negotiated QUIC application protocol does not support headers'); + } validateFunction(fn, 'onheaders'); this.#onheaders = fn.bind(this); this.#state[kWantsHeaders] = true; @@ -798,19 +809,81 @@ class QuicStream { } /** @type {OnTrailersCallback} */ - get [kOnTrailers]() { return this.#ontrailers; } + get ontrailers() { + QuicStream.#assertIsQuicStream(this); + return this.#ontrailers; + } - set [kOnTrailers](fn) { + set ontrailers(fn) { + QuicStream.#assertIsQuicStream(this); if (fn === undefined) { this.#ontrailers = undefined; this.#state[kWantsTrailers] = false; } else { + if (getQuicSessionState(this.#session).headersSupported === 2) { + throw new ERR_INVALID_STATE( + 'The negotiated QUIC application protocol does not support headers'); + } validateFunction(fn, 'ontrailers'); this.#ontrailers = fn.bind(this); this.#state[kWantsTrailers] = true; } } + /** @type {Function|undefined} */ + get onwanttrailers() { + QuicStream.#assertIsQuicStream(this); + return this.#onwanttrailers; + } + + set onwanttrailers(fn) { + QuicStream.#assertIsQuicStream(this); + if (fn === undefined) { + this.#onwanttrailers = undefined; + } else { + if (getQuicSessionState(this.#session).headersSupported === 2) { + throw new ERR_INVALID_STATE( + 'The negotiated QUIC application protocol does not support headers'); + } + validateFunction(fn, 'onwanttrailers'); + this.#onwanttrailers = fn.bind(this); + } + } + + /** + * The buffered initial headers received on this stream, or undefined + * if the application does not support headers or no headers have + * been received yet. + * @type {object|undefined} + */ + get headers() { + QuicStream.#assertIsQuicStream(this); + return this.#headers; + } + + /** + * Set trailing headers to be sent when nghttp3 asks for them. + * @type {object|undefined} + */ + get pendingTrailers() { + QuicStream.#assertIsQuicStream(this); + return this.#pendingTrailers; + } + + set pendingTrailers(headers) { + QuicStream.#assertIsQuicStream(this); + if (headers === undefined) { + this.#pendingTrailers = undefined; + return; + } + if (getQuicSessionState(this.#session).headersSupported === 2) { + throw new ERR_INVALID_STATE( + 'The negotiated QUIC application protocol does not support headers'); + } + validateObject(headers, 'headers'); + this.#pendingTrailers = headers; + } + /** * The statistics collected for this stream. * @type {QuicStreamStats} @@ -890,6 +963,68 @@ class QuicStream { this.#handle.attachSource(validateBody(outbound)); } + /** + * Send initial or response headers on this stream. Throws if the + * application does not support headers. + * @param {object} headers + * @param {{ terminal?: boolean }} [options] + * @returns {boolean} + */ + sendHeaders(headers, options = kEmptyObject) { + QuicStream.#assertIsQuicStream(this); + if (this.destroyed) return false; + if (getQuicSessionState(this.#session).headersSupported === 2) { + throw new ERR_INVALID_STATE( + 'The negotiated QUIC application protocol does not support headers'); + } + validateObject(headers, 'headers'); + const { terminal = false } = options; + const headerString = buildNgHeaderString( + headers, assertValidPseudoHeader, true); + const flags = terminal ? kHeadersFlagsTerminal : kHeadersFlagsNone; + return this.#handle.sendHeaders(kHeadersKindInitial, headerString, flags); + } + + /** + * Send informational (1xx) headers on this stream. Server only. + * Throws if the application does not support headers. + * @param {object} headers + * @returns {boolean} + */ + sendInformationalHeaders(headers) { + QuicStream.#assertIsQuicStream(this); + if (this.destroyed) return false; + if (getQuicSessionState(this.#session).headersSupported === 2) { + throw new ERR_INVALID_STATE( + 'The negotiated QUIC application protocol does not support headers'); + } + validateObject(headers, 'headers'); + const headerString = buildNgHeaderString( + headers, assertValidPseudoHeader, true); + return this.#handle.sendHeaders( + kHeadersKindHints, headerString, kHeadersFlagsNone); + } + + /** + * Send trailing headers on this stream. Must be called synchronously + * during the onwanttrailers callback, or set via pendingTrailers before + * the body completes. Throws if the application does not support headers. + * @param {object} headers + * @returns {boolean} + */ + sendTrailers(headers) { + QuicStream.#assertIsQuicStream(this); + if (this.destroyed) return false; + if (getQuicSessionState(this.#session).headersSupported === 2) { + throw new ERR_INVALID_STATE( + 'The negotiated QUIC application protocol does not support headers'); + } + validateObject(headers, 'headers'); + const headerString = buildNgHeaderString(headers); + return this.#handle.sendHeaders( + kHeadersKindTrailing, headerString, kHeadersFlagsNone); + } + /** * Tells the peer to stop sending data for this stream. The optional error * code will be sent to the peer as part of the request. If the stream is @@ -1017,6 +1152,9 @@ class QuicStream { this.#onreset = undefined; this.#onheaders = undefined; this.#ontrailers = undefined; + this.#onwanttrailers = undefined; + this.#headers = undefined; + this.#pendingTrailers = undefined; this.#handle = undefined; } @@ -1035,8 +1173,6 @@ class QuicStream { } [kHeaders](headers, kind) { - // The headers event should only be called if the stream was created with - // an onheaders callback. The callback should always exist here. assert(this.#onheaders, 'Unexpected stream headers event'); assert(ArrayIsArray(headers)); assert(headers.length % 2 === 0); @@ -1051,19 +1187,43 @@ class QuicStream { } } + // Buffer initial headers so stream.headers returns them. + if (kind === 'initial' && this.#headers === undefined) { + this.#headers = block; + } + this.#onheaders(block, kind); } - [kTrailers]() { - // The trailers event should only be called if the stream was created with - // an ontrailers callback. The callback should always exist here. - assert(this.#ontrailers, 'Unexpected stream trailers event'); - // TODO(@jasnell): Complete the trailers flow. The ontrailers callback - // should provide a mechanism for the user to send trailing headers - // using kHeadersKindTrailing. This will be implemented when the - // QuicSession/QuicStream API model (EventEmitter or Promise-based) - // is finalized. - this.#ontrailers(); + [kTrailers](headers) { + if (this.destroyed) return; + + // If we received trailers from the peer, dispatch them. + if (headers !== undefined) { + if (this.#ontrailers) { + assert(ArrayIsArray(headers)); + assert(headers.length % 2 === 0); + const block = { __proto__: null }; + for (let n = 0; n + 1 < headers.length; n += 2) { + if (block[headers[n]] !== undefined) { + block[headers[n]] = [block[headers[n]], headers[n + 1]]; + } else { + block[headers[n]] = headers[n + 1]; + } + } + this.#ontrailers(block); + } + return; + } + + // Otherwise, nghttp3 is asking us to provide trailers to send. + // Check for pre-set pendingTrailers first, then the callback. + if (this.#pendingTrailers) { + this.sendTrailers(this.#pendingTrailers); + this.#pendingTrailers = undefined; + } else if (this.#onwanttrailers) { + this.#onwanttrailers(); + } } [kInspect](depth, options) { @@ -1425,11 +1585,11 @@ class QuicSession { body, priority = 'default', incremental = false, - [kHeaders]: headers, + headers, + onheaders, + ontrailers, + onwanttrailers, } = options; - if (headers !== undefined) { - validateObject(headers, 'options.headers'); - } validateOneOf(priority, 'options.priority', ['default', 'low', 'high']); validateBoolean(incremental, 'options.incremental'); @@ -1446,21 +1606,18 @@ class QuicSession { handle.setPriority((urgency << 1) | (incremental ? 1 : 0)); } - if (headers !== undefined) { - if (this.#state.headersSupported === 2) { - throw new ERR_INVALID_STATE( - 'The negotiated QUIC application protocol does not support headers'); - } - // If headers are specified and there's no body, then we assume - // that the headers are terminal. - handle.sendHeaders(kHeadersKindInitial, buildNgHeaderString(headers), - validatedBody === undefined ? - kHeadersFlagsTerminal : kHeadersFlagsNone); - } - const stream = new QuicStream(kPrivateConstructor, handle, this, direction); this.#streams.add(stream); + // Set stream callbacks before sending headers to avoid missing events. + if (onheaders) stream.onheaders = onheaders; + if (ontrailers) stream.ontrailers = ontrailers; + if (onwanttrailers) stream.onwanttrailers = onwanttrailers; + + if (headers !== undefined) { + stream.sendHeaders(headers, { terminal: validatedBody === undefined }); + } + if (onSessionOpenStreamChannel.hasSubscribers) { onSessionOpenStreamChannel.publish({ stream, @@ -1483,6 +1640,8 @@ class QuicSession { } /** + * 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} */ @@ -1993,6 +2152,15 @@ class QuicSession { } this.#streams.add(stream); + // Apply default stream callbacks set at listen time before + // notifying onstream, so the user sees them already set. + const scbs = this[kStreamCallbacks]; + if (scbs) { + if (scbs.onheaders) stream.onheaders = scbs.onheaders; + if (scbs.ontrailers) stream.ontrailers = scbs.ontrailers; + if (scbs.onwanttrailers) stream.onwanttrailers = scbs.onwanttrailers; + } + this.#onstream(stream); if (onSessionReceivedStreamChannel.hasSubscribers) { @@ -2294,10 +2462,14 @@ class QuicEndpoint { onhandshake, onnewtoken, onorigin, + // Stream-level callbacks applied to each incoming stream. + onheaders, + ontrailers, + onwanttrailers, ...rest } = options; - // Store session callbacks to apply to each new incoming session. + // Store session and stream callbacks to apply to each new incoming session. this.#sessionCallbacks = { __proto__: null, onstream, @@ -2309,6 +2481,9 @@ class QuicEndpoint { onhandshake, onnewtoken, onorigin, + onheaders, + ontrailers, + onwanttrailers, }; debug('endpoint listening as a server'); @@ -2337,6 +2512,10 @@ class QuicEndpoint { onhandshake, onnewtoken, onorigin, + // Stream-level callbacks. + onheaders, + ontrailers, + onwanttrailers, ...rest } = options; @@ -2357,6 +2536,15 @@ class QuicEndpoint { if (onhandshake) session.onhandshake = onhandshake; if (onnewtoken) session.onnewtoken = onnewtoken; if (onorigin) session.onorigin = onorigin; + // Store stream-level callbacks for application to client-created streams. + if (onheaders || ontrailers || onwanttrailers) { + session[kStreamCallbacks] = { + __proto__: null, + onheaders, + ontrailers, + onwanttrailers, + }; + } return session; } @@ -2545,6 +2733,16 @@ class QuicEndpoint { if (cbs.onhandshake) session.onhandshake = cbs.onhandshake; if (cbs.onnewtoken) session.onnewtoken = cbs.onnewtoken; if (cbs.onorigin) session.onorigin = cbs.onorigin; + // Store stream-level callbacks on the session for application + // to each new incoming stream. + if (cbs.onheaders || cbs.ontrailers || cbs.onwanttrailers) { + session[kStreamCallbacks] = { + __proto__: null, + onheaders: cbs.onheaders, + ontrailers: cbs.ontrailers, + onwanttrailers: cbs.onwanttrailers, + }; + } } if (onEndpointServerSessionChannel.hasSubscribers) { onEndpointServerSessionChannel.publish({ @@ -2879,6 +3077,10 @@ function processSessionOptions(options, config = { __proto__: null }) { onhandshake, onnewtoken, onorigin, + // Stream-level callbacks. + onheaders, + ontrailers, + onwanttrailers, } = options; const { @@ -2925,6 +3127,9 @@ function processSessionOptions(options, config = { __proto__: null }) { onhandshake, onnewtoken, onorigin, + onheaders, + ontrailers, + onwanttrailers, }; } diff --git a/lib/internal/quic/symbols.js b/lib/internal/quic/symbols.js index 98c16c115b9f99..f13abfac6274d1 100644 --- a/lib/internal/quic/symbols.js +++ b/lib/internal/quic/symbols.js @@ -34,8 +34,7 @@ const kListen = Symbol('kListen'); const kNewSession = Symbol('kNewSession'); const kNewStream = Symbol('kNewStream'); const kNewToken = Symbol('kNewToken'); -const kOnHeaders = Symbol('kOnHeaders'); -const kOnTrailers = Symbol('kOwnTrailers'); +const kStreamCallbacks = Symbol('kStreamCallbacks'); const kOrigin = Symbol('kOrigin'); const kOwner = Symbol('kOwner'); const kPathValidation = Symbol('kPathValidation'); @@ -64,8 +63,7 @@ module.exports = { kNewSession, kNewStream, kNewToken, - kOnHeaders, - kOnTrailers, + kStreamCallbacks, kOrigin, kOwner, kPathValidation, diff --git a/src/quic/http3.cc b/src/quic/http3.cc index a3a7e628374456..2c6d8403654306 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -678,8 +678,7 @@ class Http3ApplicationImpl final : public Session::Application { } void OnBeginHeaders(int64_t stream_id) { - auto stream = session().FindStream(stream_id); - // If the stream does not exist or is destroyed, ignore! + auto stream = FindOrCreateStream(conn_.get(), &session(), stream_id); if (!stream) [[unlikely]] return; Debug(&session(), @@ -729,7 +728,7 @@ class Http3ApplicationImpl final : public Session::Application { } void OnBeginTrailers(int64_t stream_id) { - auto stream = session().FindStream(stream_id); + auto stream = FindOrCreateStream(conn_.get(), &session(), stream_id); if (!stream) [[unlikely]] return; Debug(&session(), From 988cc03fa337d0efee4ce4cb121ae722ace9ddb3 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Thu, 9 Apr 2026 17:38:13 -0700 Subject: [PATCH 32/34] quic: update js impl to use primordials consistently --- lib/internal/quic/quic.js | 56 +++++++++++++--------- lib/internal/quic/state.js | 98 ++++++++++++++++++++------------------ lib/internal/quic/stats.js | 3 ++ 3 files changed, 88 insertions(+), 69 deletions(-) diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 4107627f8d5528..7d990a5e0708ae 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -13,6 +13,7 @@ const { DataViewPrototypeGetBuffer, DataViewPrototypeGetByteLength, DataViewPrototypeGetByteOffset, + FunctionPrototypeBind, ObjectDefineProperties, ObjectKeys, PromiseWithResolvers, @@ -597,24 +598,35 @@ function validateBody(body) { // With a SharedArrayBuffer, we always copy. We cannot transfer // and it's likely unsafe to use the underlying buffer directly. if (isSharedArrayBuffer(body)) { - return new Uint8Array(body).slice(); + return TypedArrayPrototypeSlice(new Uint8Array(body)); } if (isArrayBufferView(body)) { - const size = body.byteLength; - const offset = body.byteOffset; + let size, offset, buffer; + if (isDataView(body)) { + size = DataViewPrototypeGetByteLength(body); + offset = DataViewPrototypeGetByteOffset(body); + buffer = DataViewPrototypeGetBuffer(body); + } else { + size = TypedArrayPrototypeGetByteLength(body); + offset = TypedArrayPrototypeGetByteOffset(body); + buffer = TypedArrayPrototypeGetBuffer(body); + } // We have to be careful in this case. If the ArrayBufferView is a // subset of the underlying ArrayBuffer, transferring the entire // ArrayBuffer could be incorrect if other views are also using it. // So if offset > 0 or size != buffer.byteLength, we'll copy the // subset into a new ArrayBuffer instead of transferring. - if (isSharedArrayBuffer(body.buffer) || - offset !== 0 || size !== body.buffer.byteLength) { - return new Uint8Array(body, offset, size).slice(); + if (isSharedArrayBuffer(buffer) || + offset !== 0 || + size !== ArrayBufferPrototypeGetByteLength(buffer)) { + return TypedArrayPrototypeSlice( + new Uint8Array(buffer, offset, size)); } // It's still possible that the ArrayBuffer is being used elsewhere, // but we really have no way of knowing. We'll just have to trust // the caller in this case. - return new Uint8Array(ArrayBufferPrototypeTransfer(body.buffer), offset, size); + return new Uint8Array( + ArrayBufferPrototypeTransfer(buffer), offset, size); } if (isBlob(body)) return body[kBlobHandle]; @@ -763,7 +775,7 @@ class QuicStream { this.#state.wantsBlock = false; } else { validateFunction(fn, 'onblocked'); - this.#onblocked = fn.bind(this); + this.#onblocked = FunctionPrototypeBind(fn, this); this.#state.wantsBlock = true; } } @@ -781,7 +793,7 @@ class QuicStream { this.#state.wantsReset = false; } else { validateFunction(fn, 'onreset'); - this.#onreset = fn.bind(this); + this.#onreset = FunctionPrototypeBind(fn, this); this.#state.wantsReset = true; } } @@ -803,7 +815,7 @@ class QuicStream { 'The negotiated QUIC application protocol does not support headers'); } validateFunction(fn, 'onheaders'); - this.#onheaders = fn.bind(this); + this.#onheaders = FunctionPrototypeBind(fn, this); this.#state[kWantsHeaders] = true; } } @@ -825,7 +837,7 @@ class QuicStream { 'The negotiated QUIC application protocol does not support headers'); } validateFunction(fn, 'ontrailers'); - this.#ontrailers = fn.bind(this); + this.#ontrailers = FunctionPrototypeBind(fn, this); this.#state[kWantsTrailers] = true; } } @@ -846,7 +858,7 @@ class QuicStream { 'The negotiated QUIC application protocol does not support headers'); } validateFunction(fn, 'onwanttrailers'); - this.#onwanttrailers = fn.bind(this); + this.#onwanttrailers = FunctionPrototypeBind(fn, this); } } @@ -1339,7 +1351,7 @@ class QuicSession { this.#onstream = undefined; } else { validateFunction(fn, 'onstream'); - this.#onstream = fn.bind(this); + this.#onstream = FunctionPrototypeBind(fn, this); } } @@ -1356,7 +1368,7 @@ class QuicSession { this.#state.hasDatagramListener = false; } else { validateFunction(fn, 'ondatagram'); - this.#ondatagram = fn.bind(this); + this.#ondatagram = FunctionPrototypeBind(fn, this); this.#state.hasDatagramListener = true; } } @@ -1378,7 +1390,7 @@ class QuicSession { this.#state.hasDatagramStatusListener = false; } else { validateFunction(fn, 'ondatagramstatus'); - this.#ondatagramstatus = fn.bind(this); + this.#ondatagramstatus = FunctionPrototypeBind(fn, this); this.#state.hasDatagramStatusListener = true; } } @@ -1396,7 +1408,7 @@ class QuicSession { this.#state.hasPathValidationListener = false; } else { validateFunction(fn, 'onpathvalidation'); - this.#onpathvalidation = fn.bind(this); + this.#onpathvalidation = FunctionPrototypeBind(fn, this); this.#state.hasPathValidationListener = true; } } @@ -1414,7 +1426,7 @@ class QuicSession { this.#state.hasSessionTicketListener = false; } else { validateFunction(fn, 'onsessionticket'); - this.#onsessionticket = fn.bind(this); + this.#onsessionticket = FunctionPrototypeBind(fn, this); this.#state.hasSessionTicketListener = true; } } @@ -1431,7 +1443,7 @@ class QuicSession { this.#onversionnegotiation = undefined; } else { validateFunction(fn, 'onversionnegotiation'); - this.#onversionnegotiation = fn.bind(this); + this.#onversionnegotiation = FunctionPrototypeBind(fn, this); } } @@ -1447,7 +1459,7 @@ class QuicSession { this.#onhandshake = undefined; } else { validateFunction(fn, 'onhandshake'); - this.#onhandshake = fn.bind(this); + this.#onhandshake = FunctionPrototypeBind(fn, this); } } @@ -1464,7 +1476,7 @@ class QuicSession { this.#state.hasNewTokenListener = false; } else { validateFunction(fn, 'onnewtoken'); - this.#onnewtoken = fn.bind(this); + this.#onnewtoken = FunctionPrototypeBind(fn, this); this.#state.hasNewTokenListener = true; } } @@ -1482,7 +1494,7 @@ class QuicSession { this.#state.hasOriginListener = false; } else { validateFunction(fn, 'onorigin'); - this.#onorigin = fn.bind(this); + this.#onorigin = FunctionPrototypeBind(fn, this); this.#state.hasOriginListener = true; } } @@ -2450,7 +2462,7 @@ class QuicEndpoint { throw new ERR_INVALID_STATE('Endpoint is already listening'); } validateObject(options, 'options'); - this.#onsession = onsession.bind(this); + this.#onsession = FunctionPrototypeBind(onsession, this); const { onstream, diff --git a/lib/internal/quic/state.js b/lib/internal/quic/state.js index 1734b92a6b81a5..8436a5acb092bf 100644 --- a/lib/internal/quic/state.js +++ b/lib/internal/quic/state.js @@ -5,6 +5,7 @@ const { DataView, DataViewPrototypeGetBigInt64, DataViewPrototypeGetBigUint64, + DataViewPrototypeGetByteLength, DataViewPrototypeGetUint32, DataViewPrototypeGetUint8, DataViewPrototypeSetUint32, @@ -155,31 +156,31 @@ class QuicEndpointState { /** @type {boolean} */ get isBound() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_BOUND); } /** @type {boolean} */ get isReceiving() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_RECEIVING); } /** @type {boolean} */ get isListening() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_LISTENING); } /** @type {boolean} */ get isClosing() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_CLOSING); } /** @type {boolean} */ get isBusy() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_BUSY); } @@ -190,7 +191,7 @@ class QuicEndpointState { * @type {bigint} */ get pendingCallbacks() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_ENDPOINT_PENDING_CALLBACKS, kIsLittleEndian); } @@ -199,7 +200,7 @@ class QuicEndpointState { } toJSON() { - if (this.#handle.byteLength === 0) return {}; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return {}; return { __proto__: null, isBound: this.isBound, @@ -215,11 +216,12 @@ class QuicEndpointState { if (depth < 0) return this; - if (this.#handle.byteLength === 0) { + if (DataViewPrototypeGetByteLength(this.#handle) === 0) { return 'QuicEndpointState { }'; } const opts = { + __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; @@ -237,7 +239,7 @@ class QuicEndpointState { [kFinishClose]() { // Snapshot the state into a new DataView since the underlying // buffer will be destroyed. - if (this.#handle.byteLength === 0) return; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; this.#handle = new DataView(new ArrayBuffer(0)); } } @@ -270,13 +272,13 @@ class QuicSessionState { static #LISTENER_ORIGIN = 1 << 5; #getListenerFlag(flag) { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!(DataViewPrototypeGetUint32( this.#handle, IDX_STATE_SESSION_LISTENER_FLAGS, kIsLittleEndian) & flag); } #setListenerFlag(flag, val) { - if (this.#handle.byteLength === 0) return; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; const current = DataViewPrototypeGetUint32( this.#handle, IDX_STATE_SESSION_LISTENER_FLAGS, kIsLittleEndian); DataViewPrototypeSetUint32( @@ -334,49 +336,49 @@ class QuicSessionState { /** @type {boolean} */ get isClosing() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_CLOSING); } /** @type {boolean} */ get isGracefulClose() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_GRACEFUL_CLOSE); } /** @type {boolean} */ get isSilentClose() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_SILENT_CLOSE); } /** @type {boolean} */ get isStatelessReset() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_STATELESS_RESET); } /** @type {boolean} */ get isHandshakeCompleted() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_HANDSHAKE_COMPLETED); } /** @type {boolean} */ get isHandshakeConfirmed() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_HANDSHAKE_CONFIRMED); } /** @type {boolean} */ get isStreamOpenAllowed() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_STREAM_OPEN_ALLOWED); } /** @type {boolean} */ get isPrioritySupported() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_PRIORITY_SUPPORTED); } @@ -386,31 +388,31 @@ class QuicSessionState { * @type {number} */ get headersSupported() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_HEADERS_SUPPORTED); } /** @type {boolean} */ get isWrapped() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_WRAPPED); } /** @type {number} */ get applicationType() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_APPLICATION_TYPE); } /** @type {bigint} */ get maxDatagramSize() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_SESSION_MAX_DATAGRAM_SIZE, kIsLittleEndian); } /** @type {bigint} */ get lastDatagramId() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_SESSION_LAST_DATAGRAM_ID, kIsLittleEndian); } @@ -419,7 +421,7 @@ class QuicSessionState { } toJSON() { - if (this.#handle.byteLength === 0) return {}; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return {}; return { __proto__: null, hasPathValidationListener: this.hasPathValidationListener, @@ -448,11 +450,12 @@ class QuicSessionState { if (depth < 0) return this; - if (this.#handle.byteLength === 0) { + if (DataViewPrototypeGetByteLength(this.#handle) === 0) { return 'QuicSessionState { }'; } const opts = { + __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; @@ -483,7 +486,7 @@ class QuicSessionState { [kFinishClose]() { // Snapshot the state into a new DataView since the underlying // buffer will be destroyed. - if (this.#handle.byteLength === 0) return; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; this.#handle = new DataView(new ArrayBuffer(0)); } } @@ -508,104 +511,104 @@ class QuicStreamState { /** @type {bigint} */ get id() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return DataViewPrototypeGetBigInt64(this.#handle, IDX_STATE_STREAM_ID, kIsLittleEndian); } /** @type {boolean} */ get pending() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_PENDING); } /** @type {boolean} */ get finSent() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_FIN_SENT); } /** @type {boolean} */ get finReceived() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_FIN_RECEIVED); } /** @type {boolean} */ get readEnded() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_READ_ENDED); } /** @type {boolean} */ get writeEnded() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WRITE_ENDED); } /** @type {boolean} */ get reset() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_RESET); } /** @type {boolean} */ get hasOutbound() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_HAS_OUTBOUND); } /** @type {boolean} */ get hasReader() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_HAS_READER); } /** @type {boolean} */ get wantsBlock() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_BLOCK); } /** @type {boolean} */ set wantsBlock(val) { - if (this.#handle.byteLength === 0) return; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_BLOCK, val ? 1 : 0); } /** @type {boolean} */ get [kWantsHeaders]() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_HEADERS); } /** @type {boolean} */ set [kWantsHeaders](val) { - if (this.#handle.byteLength === 0) return; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_HEADERS, val ? 1 : 0); } /** @type {boolean} */ get wantsReset() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_RESET); } /** @type {boolean} */ set wantsReset(val) { - if (this.#handle.byteLength === 0) return; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_RESET, val ? 1 : 0); } /** @type {boolean} */ get [kWantsTrailers]() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_TRAILERS); } /** @type {boolean} */ set [kWantsTrailers](val) { - if (this.#handle.byteLength === 0) return; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_TRAILERS, val ? 1 : 0); } @@ -614,7 +617,7 @@ class QuicStreamState { } toJSON() { - if (this.#handle.byteLength === 0) return {}; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return {}; return { __proto__: null, id: `${this.id}`, @@ -635,11 +638,12 @@ class QuicStreamState { if (depth < 0) return this; - if (this.#handle.byteLength === 0) { + if (DataViewPrototypeGetByteLength(this.#handle) === 0) { return 'QuicStreamState { }'; } const opts = { + __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; @@ -662,7 +666,7 @@ class QuicStreamState { [kFinishClose]() { // Snapshot the state into a new DataView since the underlying // buffer will be destroyed. - if (this.#handle.byteLength === 0) return; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; this.#handle = new DataView(new ArrayBuffer(0)); } } diff --git a/lib/internal/quic/stats.js b/lib/internal/quic/stats.js index 2119f8996db582..dea729e9b82cb4 100644 --- a/lib/internal/quic/stats.js +++ b/lib/internal/quic/stats.js @@ -275,6 +275,7 @@ class QuicEndpointStats { return this; const opts = { + __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; @@ -527,6 +528,7 @@ class QuicSessionStats { return this; const opts = { + __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; @@ -690,6 +692,7 @@ class QuicStreamStats { return this; const opts = { + __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; From f0af79ef21155f292675db0cdbed03c06027063b Mon Sep 17 00:00:00 2001 From: James M Snell Date: Thu, 9 Apr 2026 17:59:36 -0700 Subject: [PATCH 33/34] quic: apply multiple quality improvements to js side --- doc/api/quic.md | 2 +- lib/internal/quic/quic.js | 175 +++++++----------- lib/internal/quic/state.js | 4 +- lib/internal/quic/stats.js | 16 +- ...est-quic-internal-endpoint-stats-state.mjs | 2 +- 5 files changed, 81 insertions(+), 118 deletions(-) diff --git a/doc/api/quic.md b/doc/api/quic.md index f40b2656e74093..b5e9809812d70f 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -815,7 +815,7 @@ added: v23.8.0 * Type: {bigint} -### `sessionStats.maxBytesInFlights` +### `sessionStats.maxBytesInFlight` + +> Stability: 1 - Experimental + +A QUIC stream was reset by the peer. The error includes the reset code +provided by the peer. + ### `ERR_QUIC_TRANSPORT_ERROR` diff --git a/doc/api/quic.md b/doc/api/quic.md index b5e9809812d70f..4d7047f7d5cd77 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -1149,13 +1149,85 @@ Sets the priority of the stream. Throws `ERR_INVALID_STATE` if the session does not support priority (e.g. non-HTTP/3). Has no effect if the stream has been destroyed. -### `stream.readable` +### `stream[Symbol.asyncIterator]()` -* Type: {ReadableStream} +* Returns: {AsyncIterableIterator} yielding {Uint8Array\[]} + +The stream implements `Symbol.asyncIterator`, making it directly usable +in `for await...of` loops. Each iteration yields a batch of `Uint8Array` +chunks. + +Only one async iterator can be obtained per stream. A second call throws +`ERR_INVALID_STATE`. Non-readable streams (outbound-only unidirectional +or closed) return an immediately-finished iterator. + +```mjs +for await (const chunks of stream) { + for (const chunk of chunks) { + // Process each Uint8Array chunk + } +} +``` + +Compatible with stream/iter utilities: + +```mjs +import Stream from 'node:stream/iter'; +const body = await Stream.bytes(stream); +const text = await Stream.text(stream); +await Stream.pipeTo(stream, someWriter); +``` + +### `stream.writer` + + + +* Type: {Object} + +Returns a Writer object for pushing data to the stream incrementally. +The Writer implements the stream/iter Writer interface with the +try-sync-fallback-to-async pattern. + +Only available when no `body` source was provided at creation time or via +[`stream.setBody()`][]. Non-writable streams return an already-closed +Writer. Throws `ERR_INVALID_STATE` if the outbound is already configured. + +The Writer has the following methods: + +* `writeSync(chunk)` — Synchronous write. Returns `true` if accepted, + `false` if flow-controlled. Data is NOT accepted on `false`. +* `write(chunk[, options])` — Async write with drain wait. `options.signal` + is checked at entry but not observed during the write. +* `writevSync(chunks)` — Synchronous vectored write. All-or-nothing. +* `writev(chunks[, options])` — Async vectored write. +* `endSync()` — Synchronous close. Returns total bytes or `-1`. +* `end([options])` — Async close. +* `fail(reason)` — Errors the stream (sends RESET\_STREAM to peer). +* `desiredSize` — Available capacity in bytes, or `null` if closed/errored. + +### `stream.setBody(body)` + + + +* `body` {string|ArrayBuffer|SharedArrayBuffer|TypedArray|Blob|AsyncIterable|Iterable|Promise|null} + +Sets the outbound body source for the stream. Can only be called once. +Mutually exclusive with [`stream.writer`][]. + +If `body` is `null`, the writable side is closed immediately (FIN sent). +If `body` is a `Promise`, it is awaited and the resolved value is used. +Other types are handled per their optimization tier (see below). + +Throws `ERR_INVALID_STATE` if the outbound is already configured or if +the writer has been accessed. ### `stream.session` @@ -2235,4 +2307,6 @@ added: v23.8.0 [`stream.onwanttrailers`]: #streamonwanttrailers [`stream.pendingTrailers`]: #streampendingtrailers [`stream.sendTrailers()`]: #streamsendtrailersheaders +[`stream.setBody()`]: #streamsetbodybody [`stream.setPriority()`]: #streamsetpriorityoptions +[`stream.writer`]: #streamwriter diff --git a/lib/internal/blob.js b/lib/internal/blob.js index e1b1dceabd629d..78ac7d11339058 100644 --- a/lib/internal/blob.js +++ b/lib/internal/blob.js @@ -2,6 +2,7 @@ const { ArrayFrom, + ArrayPrototypePush, MathMax, MathMin, ObjectDefineProperties, @@ -523,13 +524,75 @@ function createBlobReaderStream(reader) { }, { highWaterMark: 0 }); } +// Maximum number of chunks to collect in a single batch to prevent +// unbounded memory growth when the DataQueue has a large burst of data. +const kMaxBatchChunks = 16; + +async function* createBlobReaderIterable(reader, options = {}) { + const { getReadError } = options; + let wakeup = PromiseWithResolvers(); + reader.setWakeup(wakeup.resolve); + + try { + while (true) { + const batch = []; + let blocked = false; + let eos = false; + let error = null; + + // Pull as many chunks as available synchronously. + // reader.pull(callback) calls the callback synchronously via + // MakeCallback, so we can collect multiple chunks per iteration + // step without any async overhead. + while (true) { + let pullResult; + reader.pull((status, buffer) => { + pullResult = { status, buffer }; + }); + + if (pullResult.status === 0) { + eos = true; + break; + } + if (pullResult.status < 0) { + error = typeof getReadError === 'function' ? + getReadError(pullResult.status) : + new ERR_INVALID_STATE('The reader is not readable'); + break; + } + if (pullResult.status === 2) { + blocked = true; + break; + } + ArrayPrototypePush(batch, new Uint8Array(pullResult.buffer)); + if (batch.length >= kMaxBatchChunks) break; + } + + if (batch.length > 0) { + yield batch; + } + + if (eos) return; + if (error) throw error; + + if (blocked) { + await wakeup.promise; + wakeup = PromiseWithResolvers(); + } + } + } finally { + reader.setWakeup(undefined); + } +} + module.exports = { Blob, createBlob, createBlobFromFilePath, + createBlobReaderIterable, + createBlobReaderStream, isBlob, kHandle, resolveObjectURL, TransferableBlob, - createBlobReaderStream, }; diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 206e2a24716022..38d1666f7c6bd6 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1686,6 +1686,8 @@ E('ERR_QUIC_APPLICATION_ERROR', 'A QUIC application error occurred. %d [%s]', Er E('ERR_QUIC_CONNECTION_FAILED', 'QUIC connection failed', Error); E('ERR_QUIC_ENDPOINT_CLOSED', 'QUIC endpoint closed: %s (%d)', Error); E('ERR_QUIC_OPEN_STREAM_FAILED', 'Failed to open QUIC stream', Error); +E('ERR_QUIC_STREAM_RESET', + 'The QUIC stream was reset by the peer with error code %d', Error); E('ERR_QUIC_TRANSPORT_ERROR', 'A QUIC transport error occurred. %d [%s]', Error); E('ERR_QUIC_VERSION_NEGOTIATION_ERROR', 'The QUIC session requires version negotiation', Error); E('ERR_REQUIRE_ASYNC_MODULE', function(filename, parentFilename) { diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 9dfb1917c55bbd..a8eb713e8f861e 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -14,11 +14,17 @@ const { DataViewPrototypeGetByteLength, DataViewPrototypeGetByteOffset, FunctionPrototypeBind, + Number, ObjectDefineProperties, ObjectKeys, + PromisePrototypeThen, + PromiseResolve, PromiseWithResolvers, SafeSet, SymbolAsyncDispose, + SymbolAsyncIterator, + SymbolDispose, + SymbolIterator, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeGetByteLength, TypedArrayPrototypeGetByteOffset, @@ -96,6 +102,7 @@ const { ERR_QUIC_CONNECTION_FAILED, ERR_QUIC_ENDPOINT_CLOSED, ERR_QUIC_OPEN_STREAM_FAILED, + ERR_QUIC_STREAM_RESET, ERR_QUIC_TRANSPORT_ERROR, ERR_QUIC_VERSION_NEGOTIATION_ERROR, }, @@ -108,11 +115,26 @@ const { } = require('internal/socketaddress'); const { - createBlobReaderStream, + createBlobReaderIterable, isBlob, kHandle: kBlobHandle, } = require('internal/blob'); +const { + drainableProtocol, + kValidatedSource, +} = require('internal/streams/iter/types'); + +const { + toUint8Array, + convertChunks, +} = require('internal/streams/iter/utils'); + +const { + from: streamFrom, + fromSync: streamFromSync, +} = require('internal/streams/iter/from'); + const { isKeyObject, } = require('internal/crypto/keys'); @@ -137,6 +159,7 @@ const { kConnect, kDatagram, kDatagramStatus, + kDrain, kFinishClose, kHandshake, kHeaders, @@ -563,6 +586,12 @@ setCallbacks({ this[kOwner][kBlocked](); }, + onStreamDrain() { + // Called when the stream's outbound buffer has capacity for more data. + debug('stream drain callback', this[kOwner]); + this[kOwner][kDrain](); + }, + onStreamClose(error) { // Called when the stream C++ handle has been closed. debug(`stream ${this[kOwner].id} closed callback with error: ${error}`); @@ -682,6 +711,150 @@ function applyCallbacks(session, cbs) { } } +/** + * Configures the outbound data source for a stream. Detects the source + * type and calls the appropriate C++ method. + * @param {object} handle The C++ stream handle + * @param {QuicStream} stream The JS stream object + * @param {*} body The body source + */ +const kMaxConfigureOutboundDepth = 3; + +function configureOutbound(handle, stream, body, depth = 0) { + if (depth > kMaxConfigureOutboundDepth) { + throw new ERR_INVALID_STATE( + 'Body source resolved to too many nested promises'); + } + + // body: null - close writable side immediately (FIN) + if (body === null) { + handle.initStreamingSource(); + handle.endWrite(); + return; + } + + // Handle Promise - await and recurse with depth limit + if (isPromise(body)) { + PromisePrototypeThen( + body, + (resolved) => configureOutbound(handle, stream, resolved, depth + 1), + () => { + if (!stream.destroyed) { + handle.resetStream(0n); + } + }, + ); + return; + } + + // Tier: One-shot - string (checked before sync iterable since + // strings are iterable but we want the one-shot path) + if (typeof body === 'string') { + handle.attachSource(Buffer.from(body, 'utf8')); + return; + } + + // Tier: One-shot - ArrayBuffer, SharedArrayBuffer, TypedArray, + // DataView, Blob. validateBody handles transfer-vs-copy logic, + // SharedArrayBuffer copying, and partial view safety. + if (isArrayBuffer(body) || isSharedArrayBuffer(body) || + isArrayBufferView(body) || isBlob(body)) { + handle.attachSource(validateBody(body)); + return; + } + + // Tier: Streaming - AsyncIterable (ReadableStream, stream.Readable, + // async generators, etc.). Checked before sync iterable because some + // objects implement both protocols and we prefer async. + if (isAsyncIterable(body)) { + consumeAsyncSource(handle, stream, body); + return; + } + + // Tier: Sync iterable - consumed synchronously + if (isSyncIterable(body)) { + consumeSyncSource(handle, stream, body); + return; + } + + throw new ERR_INVALID_ARG_TYPE( + 'body', + ['string', 'ArrayBuffer', 'SharedArrayBuffer', 'TypedArray', + 'Blob', 'Iterable', 'AsyncIterable', 'Promise', 'null'], + body, + ); +} + +// Waits for the stream's drain callback to fire, indicating the +// outbound has capacity for more data. +function waitForDrain(stream) { + const { promise, resolve } = PromiseWithResolvers(); + const prevDrain = stream[kDrain]; + stream[kDrain] = () => { + stream[kDrain] = prevDrain; + resolve(); + }; + return promise; +} + +// Writes a batch to the handle, awaiting drain if backpressured. +// Returns true if the stream was destroyed during the wait. +async function writeBatchWithDrain(handle, stream, batch) { + const result = handle.write(batch); + if (result !== undefined) return false; + // Write rejected (flow control) - wait for drain + await waitForDrain(stream); + if (stream.destroyed) return true; + handle.write(batch); + return false; +} + +async function consumeAsyncSource(handle, stream, source) { + handle.initStreamingSource(); + try { + // Normalize to AsyncIterable + const normalized = streamFrom(source); + for await (const batch of normalized) { + if (stream.destroyed) return; + if (await writeBatchWithDrain(handle, stream, batch)) return; + } + handle.endWrite(); + } catch { + if (!stream.destroyed) { + handle.resetStream(0n); + } + } +} + +async function consumeSyncSource(handle, stream, source) { + handle.initStreamingSource(); + // Normalize to Iterable. Manually iterate so we can + // pause between next() calls when backpressure hits. + const normalized = streamFromSync(source); + const iter = normalized[SymbolIterator](); + try { + while (true) { + if (stream.destroyed) return; + const { value: batch, done } = iter.next(); + if (done) break; + if (await writeBatchWithDrain(handle, stream, batch)) return; + } + handle.endWrite(); + } catch { + if (!stream.destroyed) { + handle.resetStream(0n); + } + } +} + +function isAsyncIterable(obj) { + return obj != null && typeof obj[SymbolAsyncIterator] === 'function'; +} + +function isSyncIterable(obj) { + return obj != null && typeof obj[SymbolIterator] === 'function'; +} + // Functions used specifically for internal or assertion purposes only. let getQuicStreamState; let getQuicSessionState; @@ -742,8 +915,9 @@ class QuicStream { /** @type {Promise} */ #pendingClose = PromiseWithResolvers(); #reader; - /** @type {ReadableStream} */ - #readable; + #iteratorLocked = false; + #writer = undefined; + #outboundSet = false; static { getQuicStreamState = function(stream) { @@ -791,17 +965,32 @@ class QuicStream { } } + get [kValidatedSource]() { return true; } + /** - * Returns a ReadableStream to consume incoming data on the stream. - * @type {ReadableStream} + * Returns an AsyncIterator that yields Uint8Array[] batches of + * incoming data. Only one iterator can be obtained per stream. + * Non-readable streams return an immediately-finished iterator. + * @yields {Uint8Array[]} */ - get readable() { + async *[SymbolAsyncIterator]() { QuicStream.#assertIsQuicStream(this); - if (this.#readable === undefined) { - assert(this.#reader); - this.#readable = createBlobReaderStream(this.#reader); + if (this.#iteratorLocked) { + throw new ERR_INVALID_STATE('Stream is already being read'); } - return this.#readable; + this.#iteratorLocked = true; + + // Non-readable stream (outbound-only unidirectional, or closed) + if (!this.#reader) return; + + yield* createBlobReaderIterable(this.#reader, { + getReadError: () => { + if (this.#state.reset) { + return new ERR_QUIC_STREAM_RESET(Number(this.#state.resetCode)); + } + return new ERR_INVALID_STATE('The stream is not readable'); + }, + }); } /** @@ -1080,6 +1269,174 @@ class QuicStream { kHeadersKindTrailing, headerString, kHeadersFlagsNone); } + /** + * Returns a Writer for pushing data to this stream incrementally. + * Only available when no body source was provided at creation time + * or via setBody(). Non-writable streams return an already-closed Writer. + * @type {object} + */ + get writer() { + QuicStream.#assertIsQuicStream(this); + if (this.#writer !== undefined) return this.#writer; + if (this.#outboundSet) { + throw new ERR_INVALID_STATE( + 'Stream outbound already configured with a body source'); + } + + const handle = this.#handle; + const stream = this; + let closed = false; + let errored = false; + let error = null; + let totalBytesWritten = 0; + let drainWakeup = null; + + // Drain callback - C++ fires this when send buffer has space + stream[kDrain] = () => { + if (drainWakeup) { + drainWakeup.resolve(true); + drainWakeup = null; + } + }; + + function writeSync(chunk) { + if (closed || errored) return false; + chunk = toUint8Array(chunk); + const result = handle.write([chunk]); + if (result === undefined) return false; + totalBytesWritten += chunk.byteLength; + return true; + } + + async function write(chunk, options) { + if (options?.signal?.aborted) { + throw options.signal.reason; + } + if (errored) throw error; + if (closed) throw new ERR_INVALID_STATE('Writer is closed'); + chunk = toUint8Array(chunk); + const result = handle.write([chunk]); + if (result === undefined) { + throw new ERR_INVALID_STATE('Stream write buffer is full'); + } + totalBytesWritten += chunk.byteLength; + } + + function writevSync(chunks) { + if (closed || errored) return false; + chunks = convertChunks(chunks); + const result = handle.write(chunks); + if (result === undefined) return false; + for (const c of chunks) totalBytesWritten += c.byteLength; + return true; + } + + async function writev(chunks, options) { + if (options?.signal?.aborted) { + throw options.signal.reason; + } + if (errored) throw error; + if (closed) throw new ERR_INVALID_STATE('Writer is closed'); + chunks = convertChunks(chunks); + const result = handle.write(chunks); + if (result === undefined) { + throw new ERR_INVALID_STATE('Stream write buffer is full'); + } + for (const c of chunks) totalBytesWritten += c.byteLength; + } + + function endSync() { + if (errored) return -1; + if (closed) return totalBytesWritten; + handle.endWrite(); + closed = true; + return totalBytesWritten; + } + + async function end(options) { + const n = endSync(); + if (n >= 0) return n; + if (errored) throw error; + drainWakeup = PromiseWithResolvers(); + await drainWakeup.promise; + drainWakeup = null; + return endSync(); + } + + function fail(reason) { + if (closed || errored) return; + errored = true; + error = reason; + handle.resetStream(0n); + if (drainWakeup) { + drainWakeup.reject(reason); + drainWakeup = null; + } + } + + const writer = { + __proto__: null, + get desiredSize() { + if (closed || errored) return null; + return Number(stream.#state.writeDesiredSize); + }, + writeSync, + write, + writevSync, + writev, + endSync, + end, + fail, + [drainableProtocol]() { + if (closed || errored) return null; + if (Number(stream.#state.writeDesiredSize) > 0) return null; + drainWakeup = PromiseWithResolvers(); + return drainWakeup.promise; + }, + [SymbolAsyncDispose]() { + if (!closed && !errored) fail(); + return PromiseResolve(); + }, + [SymbolDispose]() { + if (!closed && !errored) fail(); + }, + }; + + // Non-writable stream - return a pre-closed writer + if (!handle || this.destroyed || this.#state.writeEnded) { + closed = true; + this.#writer = writer; + return this.#writer; + } + + // Initialize the outbound DataQueue for streaming writes + handle.initStreamingSource(); + + this.#writer = writer; + return this.#writer; + } + + /** + * Sets the outbound body source for this stream. Accepts all body + * source types (string, TypedArray, Blob, AsyncIterable, Promise, null). + * Can only be called once. Mutually exclusive with stream.writer. + * @param {*} body + */ + setBody(body) { + QuicStream.#assertIsQuicStream(this); + if (this.destroyed) { + throw new ERR_INVALID_STATE('Stream is destroyed'); + } + if (this.#outboundSet) { + throw new ERR_INVALID_STATE('Stream outbound already configured'); + } + if (this.#writer !== undefined) { + throw new ERR_INVALID_STATE('Stream writer already accessed'); + } + this.#outboundSet = true; + configureOutbound(this.#handle, this, body); + } + /** * Tells the peer to stop sending data for this stream. The optional error * code will be sent to the peer as part of the request. If the stream is @@ -1220,6 +1577,11 @@ class QuicStream { this.#onblocked(); } + [kDrain]() { + // No-op by default. Overridden by the writer closure when + // stream.writer is accessed. + } + [kReset](error) { // The reset event should only be called if the stream was created with // an onreset callback. The callback should always exist here. diff --git a/lib/internal/quic/state.js b/lib/internal/quic/state.js index 3b12ef925bbfab..97bfb3f2efd1c7 100644 --- a/lib/internal/quic/state.js +++ b/lib/internal/quic/state.js @@ -100,6 +100,8 @@ const { IDX_STATE_STREAM_WANTS_HEADERS, IDX_STATE_STREAM_WANTS_RESET, IDX_STATE_STREAM_WANTS_TRAILERS, + IDX_STATE_STREAM_WRITE_DESIRED_SIZE, + IDX_STATE_STREAM_RESET_CODE, } = internalBinding('quic'); assert(IDX_STATE_SESSION_LISTENER_FLAGS !== undefined); @@ -135,6 +137,8 @@ assert(IDX_STATE_STREAM_WANTS_BLOCK !== undefined); assert(IDX_STATE_STREAM_WANTS_HEADERS !== undefined); assert(IDX_STATE_STREAM_WANTS_RESET !== undefined); assert(IDX_STATE_STREAM_WANTS_TRAILERS !== undefined); +assert(IDX_STATE_STREAM_WRITE_DESIRED_SIZE !== undefined); +assert(IDX_STATE_STREAM_RESET_CODE !== undefined); class QuicEndpointState { /** @type {DataView} */ @@ -612,6 +616,20 @@ class QuicStreamState { DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_TRAILERS, val ? 1 : 0); } + /** @type {bigint} */ + get resetCode() { + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; + return DataViewPrototypeGetBigUint64( + this.#handle, IDX_STATE_STREAM_RESET_CODE, kIsLittleEndian); + } + + /** @type {bigint} */ + get writeDesiredSize() { + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; + return DataViewPrototypeGetBigUint64( + this.#handle, IDX_STATE_STREAM_WRITE_DESIRED_SIZE, kIsLittleEndian); + } + toString() { return JSONStringify(this.toJSON()); } diff --git a/lib/internal/quic/symbols.js b/lib/internal/quic/symbols.js index f13abfac6274d1..6b20dd4045a259 100644 --- a/lib/internal/quic/symbols.js +++ b/lib/internal/quic/symbols.js @@ -25,6 +25,7 @@ const { const kBlocked = Symbol('kBlocked'); const kConnect = Symbol('kConnect'); +const kDrain = Symbol('kDrain'); const kDatagram = Symbol('kDatagram'); const kDatagramStatus = Symbol('kDatagramStatus'); const kFinishClose = Symbol('kFinishClose'); @@ -54,6 +55,7 @@ module.exports = { kConnect, kDatagram, kDatagramStatus, + kDrain, kFinishClose, kHandshake, kHeaders, diff --git a/src/quic/bindingdata.h b/src/quic/bindingdata.h index 9d2199b7e8fc24..a29d9ca451340d 100644 --- a/src/quic/bindingdata.h +++ b/src/quic/bindingdata.h @@ -48,6 +48,7 @@ class Packet; V(stream_blocked, StreamBlocked) \ V(stream_close, StreamClose) \ V(stream_created, StreamCreated) \ + V(stream_drain, StreamDrain) \ V(stream_headers, StreamHeaders) \ V(stream_reset, StreamReset) \ V(stream_trailers, StreamTrailers) diff --git a/src/quic/streams.cc b/src/quic/streams.cc index 17a70ccf519d7b..184be0393c6933 100644 --- a/src/quic/streams.cc +++ b/src/quic/streams.cc @@ -43,6 +43,7 @@ namespace quic { V(READ_ENDED, read_ended, uint8_t) \ V(WRITE_ENDED, write_ended, uint8_t) \ V(RESET, reset, uint8_t) \ + V(RESET_CODE, reset_code, uint64_t) \ V(HAS_OUTBOUND, has_outbound, uint8_t) \ V(HAS_READER, has_reader, uint8_t) \ /* Set when the stream has a block event handler */ \ @@ -52,7 +53,8 @@ namespace quic { /* Set when the stream has a reset event handler */ \ V(WANTS_RESET, wants_reset, uint8_t) \ /* Set when the stream has a trailers event handler */ \ - V(WANTS_TRAILERS, wants_trailers, uint8_t) + V(WANTS_TRAILERS, wants_trailers, uint8_t) \ + V(WRITE_DESIRED_SIZE, write_desired_size, uint64_t) #define STREAM_STATS(V) \ /* Marks the timestamp when the stream object was created. */ \ @@ -523,6 +525,7 @@ class Stream::Outbound final : public MemoryRetainer { bool is_streaming() const { return streaming_; } size_t total() const { return total_; } + size_t uncommitted() const { return uncommitted_; } // Appends an entry to the underlying DataQueue. Only valid when // the Outbound was created in streaming mode. @@ -1187,6 +1190,7 @@ void Stream::WriteStreamData(const v8::FunctionCallbackInfo& args) { if (!is_pending()) session_->ResumeStream(id()); + UpdateWriteDesiredSize(); args.GetReturnValue().Set(static_cast(outbound_->total())); } @@ -1282,6 +1286,7 @@ void Stream::Acknowledge(size_t datalen) { // Consumes the given number of bytes in the buffer. outbound_->Acknowledge(datalen); STAT_RECORD_TIMESTAMP(Stats, acked_at); + UpdateWriteDesiredSize(); } void Stream::Commit(size_t datalen, bool fin) { @@ -1409,6 +1414,7 @@ void Stream::ReceiveStreamReset(uint64_t final_size, QuicError error) { "Received stream reset with final size %" PRIu64 " and error %s", final_size, error); + state_->reset_code = error.code(); EndReadable(final_size); EmitReset(error); } @@ -1426,6 +1432,38 @@ void Stream::EmitBlocked() { MakeCallback(BindingData::Get(env()).stream_blocked_callback(), 0, nullptr); } +void Stream::EmitDrain() { + if (!env()->can_call_into_js()) return; + CallbackScope cb_scope(this); + MakeCallback(BindingData::Get(env()).stream_drain_callback(), 0, nullptr); +} + +void Stream::UpdateWriteDesiredSize() { + if (!outbound_ || !outbound_->is_streaming()) return; + + // Calculate available capacity based on QUIC flow control. + // The effective limit is the minimum of stream-level and + // connection-level flow control remaining. + ngtcp2_conn* conn = session(); + uint64_t stream_left = ngtcp2_conn_get_max_stream_data_left(conn, id()); + uint64_t conn_left = ngtcp2_conn_get_max_data_left(conn); + uint64_t available = std::min(stream_left, conn_left); + + // Subtract uncommitted bytes — data queued but not yet sent. + // Committed bytes are already on the wire (retained only for + // retransmission) and don't count toward backpressure. + uint64_t buffered = outbound_->uncommitted(); + uint64_t desired = (available > buffered) ? (available - buffered) : 0; + + uint64_t old_size = state_->write_desired_size; + state_->write_desired_size = desired; + + // Fire drain when transitioning from 0 to non-zero + if (old_size == 0 && desired > 0) { + EmitDrain(); + } +} + void Stream::EmitClose(const QuicError& error) { if (!env()->can_call_into_js()) return; CallbackScope cb_scope(this); diff --git a/src/quic/streams.h b/src/quic/streams.h index 795aa196d31a81..9ba8d2f44c201b 100644 --- a/src/quic/streams.h +++ b/src/quic/streams.h @@ -326,6 +326,14 @@ class Stream final : public AsyncWrap, // blocked because of flow control restriction. void EmitBlocked(); + // Notifies the JavaScript side that the outbound buffer has capacity + // for more data. Fires when write_desired_size transitions from 0 to > 0. + void EmitDrain(); + + // Updates the write_desired_size state field based on current flow control + // and outbound buffer state. Emits drain if transitioning from 0 to > 0. + void UpdateWriteDesiredSize(); + // Delivers the set of inbound headers that have been collected. void EmitHeaders(); diff --git a/test/parallel/test-quic-internal-setcallbacks.mjs b/test/parallel/test-quic-internal-setcallbacks.mjs index 6273570cca9954..c36aa4e58695df 100644 --- a/test/parallel/test-quic-internal-setcallbacks.mjs +++ b/test/parallel/test-quic-internal-setcallbacks.mjs @@ -24,6 +24,7 @@ const callbacks = { onStreamCreated() {}, onStreamBlocked() {}, onStreamClose() {}, + onStreamDrain() {}, onStreamReset() {}, onStreamHeaders() {}, onStreamTrailers() {},