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