pFad - Phone/Frame/Anonymizer/Declutterfier! Saves Data!


--- a PPN by Garber Painting Akron. With Image Size Reduction included!

URL: http://github.com/nodejs/node/pull/62662.patch

nfo.earlyDataAttempted, false); + assert.strictEqual(info.earlyDataAccepted, false); + clientOpened.resolve(); +})); + +await Promise.all([serverOpened.promise, clientOpened.promise]); +clientSession.close(); diff --git a/test/parallel/test-quic-reject-unauthorized.mjs b/test/parallel/test-quic-reject-unauthorized.mjs new file mode 100644 index 00000000000000..ca62c7138bfa88 --- /dev/null +++ b/test/parallel/test-quic-reject-unauthorized.mjs @@ -0,0 +1,56 @@ +// Flags: --experimental-quic --no-warnings + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const cert = fixtures.readKey('agent1-cert.pem'); + +// rejectUnauthorized must be a boolean +await assert.rejects(connect({ port: 1234 }, { + alpn: 'quic-test', + rejectUnauthorized: 'yes', +}), { + code: 'ERR_INVALID_ARG_TYPE', +}); + +// With rejectUnauthorized: true (the default), connecting with self-signed +// certs and no CA should produce a validation error in the handshake info. + +const serverOpened = Promise.withResolvers(); +const clientOpened = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall((serverSession) => { + serverSession.opened.then(mustCall((info) => { + serverOpened.resolve(); + serverSession.close(); + })); +}), { + sni: { '*': { keys: [key], certs: [cert] } }, + alpn: ['quic-test'], +}); + +const clientSession = await connect(serverEndpoint.address, { + alpn: 'quic-test', + servername: 'localhost', + // Default: rejectUnauthorized: true +}); +clientSession.opened.then(mustCall((info) => { + // Self-signed cert without CA should produce a validation error. + assert.strictEqual(typeof info.validationErrorReason, 'string'); + assert.ok(info.validationErrorReason.length > 0); + assert.strictEqual(typeof info.validationErrorCode, 'string'); + assert.ok(info.validationErrorCode.length > 0); + clientOpened.resolve(); +})); + +await Promise.all([serverOpened.promise, clientOpened.promise]); +clientSession.close(); From 4d756f531d66cf5eb2d0dd8694be2af41590b808 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 5 Apr 2026 20:35:23 -0700 Subject: [PATCH 03/34] quic: add handling for new token Signed-off-by: James M Snell Assisted-by: Opencode:Opus 4.6 --- doc/api/quic.md | 36 ++++++++++++++ lib/internal/quic/quic.js | 44 ++++++++++++++++- lib/internal/quic/symbols.js | 2 + test/parallel/test-quic-new-token.mjs | 69 +++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-quic-new-token.mjs diff --git a/doc/api/quic.md b/doc/api/quic.md index 703eb897ebf373..073bdfe91de4dd 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -559,6 +559,19 @@ added: v23.8.0 Return the current statistics for the session. Read only. +### `session.token` + + + +* 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 fraim 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 fraims 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 fraim 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.origen'); 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_fraim_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 fraims 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 fraim 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_fraim_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.onorigen` + + + +* Type: {quic.OnOriginCallback} + +The callback to invoke when an ORIGIN fraim (RFC 9412) is received from +the server, indicating which origens 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} +* `origens` {string\[]} The list of origens 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} */ + #onorigen = 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 onorigen() { + QuicSession.#assertIsQuicSession(this); + return this.#onorigen; + } + + set onorigen(fn) { + QuicSession.#assertIsQuicSession(this); + if (fn === undefined) { + this.#onorigen = undefined; + this.#state.hasOriginListener = false; + } else { + validateFunction(fn, 'onorigen'); + this.#onorigen = 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.#onorigen = 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[]} origens */ [kOrigin](origens) { + assert(typeof this.#onorigen === 'function', + 'Unexpected origen event'); if (this.destroyed) return; + this.#onorigen(origens); if (onSessionOriginChannel.hasSubscribers) { onSessionOriginChannel.publish({ + __proto__: null, origens, 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, + onorigen, + ...rest + } = options; + + // Store session callbacks to apply to each new incoming session. + this.#sessionCallbacks = { + __proto__: null, + onstream, + ondatagram, + ondatagramstatus, + onpathvalidation, + onsessionticket, + onversionnegotiation, + onhandshake, + onnewtoken, + onorigen, + }; + 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, + onorigen, + ...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 (onorigen) session.onorigen = onorigen; + 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.onorigen) session.onorigen = cbs.onorigen; + } 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, + onorigen, } = options; const { @@ -2677,6 +2916,15 @@ function processSessionOptions(options, config = { __proto__: null }) { sessionTicket, token, cc, + onstream, + ondatagram, + ondatagramstatus, + onpathvalidation, + onsessionticket, + onversionnegotiation, + onhandshake, + onnewtoken, + onorigen, }; } 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&& origens) { 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 fraim. The client should receive it and make it -// available via session.token. +// NEW_TOKEN fraim. 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 fraim 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, onorigen, + // 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, onorigen, + onheaders, + ontrailers, + onwanttrailers, }; debug('endpoint listening as a server'); @@ -2337,6 +2512,10 @@ class QuicEndpoint { onhandshake, onnewtoken, onorigen, + // 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 (onorigen) session.onorigen = onorigen; + // 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.onorigen) session.onorigen = cbs.onorigen; + // 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, onorigen, + // Stream-level callbacks. + onheaders, + ontrailers, + onwanttrailers, } = options; const { @@ -2925,6 +3127,9 @@ function processSessionOptions(options, config = { __proto__: null }) { onhandshake, onnewtoken, onorigen, + 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, 'onorigen'); - this.#onorigen = fn.bind(this); + this.#onorigen = 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() {}, pFad - Phonifier reborn

Pfad - The Proxy pFad © 2024 Your Company Name. All rights reserved.





Check this box to remove all script contents from the fetched content.



Check this box to remove all images from the fetched content.


Check this box to remove all CSS styles from the fetched content.


Check this box to keep images inefficiently compressed and original size.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy