From 419c80d75f7875e5b34d009143e24f44f54b02f7 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 4 Oct 2025 10:44:06 -0700 Subject: [PATCH 1/3] quic: continue working on quic api bits --- lib/internal/quic/quic.js | 342 ++++++++++++++---- lib/internal/quic/symbols.js | 2 - ...quic-internal-endpoint-listen-defaults.mjs | 17 +- ...est-quic-internal-endpoint-stats-state.mjs | 33 +- 4 files changed, 295 insertions(+), 99 deletions(-) diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 4fec012ba131d2..faae386b55683f 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -39,16 +39,16 @@ const { CC_ALGO_RENO_STR, CC_ALGO_CUBIC_STR, CC_ALGO_BBR_STR, - PREFERRED_ADDRESS_IGNORE, - PREFERRED_ADDRESS_USE, - DEFAULT_PREFERRED_ADDRESS_POLICY, DEFAULT_CIPHERS, DEFAULT_GROUPS, - STREAM_DIRECTION_BIDIRECTIONAL, - STREAM_DIRECTION_UNIDIRECTIONAL, // Internal constants for use by the implementation. // These are not exposed to end users. + PREFERRED_ADDRESS_IGNORE: kPreferredAddressIgnore, + PREFERRED_ADDRESS_USE: kPreferredAddressUse, + DEFAULT_PREFERRED_ADDRESS_POLICY: kPreferredAddressDefault, + STREAM_DIRECTION_BIDIRECTIONAL: kStreamDirectionBidirectional, + STREAM_DIRECTION_UNIDIRECTIONAL: kStreamDirectionUnidirectional, CLOSECONTEXT_CLOSE: kCloseContextClose, CLOSECONTEXT_BIND_FAILURE: kCloseContextBindFailure, CLOSECONTEXT_LISTEN_FAILURE: kCloseContextListenFailure, @@ -60,6 +60,7 @@ const { const { isArrayBuffer, isArrayBufferView, + isSharedArrayBuffer, } = require('util/types'); const { @@ -72,6 +73,7 @@ const { ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE, ERR_INVALID_STATE, + ERR_INVALID_THIS, ERR_MISSING_ARGS, ERR_QUIC_APPLICATION_ERROR, ERR_QUIC_CONNECTION_FAILED, @@ -104,6 +106,7 @@ const { validateFunction, validateNumber, validateObject, + validateOneOf, validateString, } = require('internal/validators'); @@ -135,7 +138,6 @@ const { kReset, kSendHeaders, kSessionTicket, - kState, kTrailers, kVersionNegotiation, kInspect, @@ -544,10 +546,30 @@ setCallbacks({ function validateBody(body) { // TODO(@jasnell): Support streaming sources if (body === undefined) return body; - if (isArrayBuffer(body)) return ArrayBufferPrototypeTransfer(body); + // Transfer ArrayBuffers... + if (isArrayBuffer(body)) { + return ArrayBufferPrototypeTransfer(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(); + } if (isArrayBufferView(body)) { const size = body.byteLength; const offset = body.byteOffset; + // 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(); + } + // 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); } if (isBlob(body)) return body[kBlobHandle]; @@ -559,6 +581,11 @@ function validateBody(body) { ], body); } +// Functions used specifically for internal testing purposes only. +let getQuicStreamState; +let getQuicSessionState; +let getQuicEndpointState; + class QuicStream { /** @type {object} */ #handle; @@ -581,8 +608,22 @@ class QuicStream { /** @type {Promise} */ #pendingClose = Promise.withResolvers(); // eslint-disable-line node-core/prefer-primordials #reader; + /** @type {ReadableStream} */ #readable; + static { + getQuicStreamState = function(stream) { + QuicStream.#assertIsQuicStream(stream); + return stream.#state; + }; + } + + static #assertIsQuicStream(val) { + if (val == null || !(#handle in val)) { + throw new ERR_INVALID_THIS('QuicStream'); + } + } + /** * @param {symbol} privateSymbol * @param {object} handle @@ -609,7 +650,12 @@ class QuicStream { } } + /** + * Returns a ReadableStream to consume incoming data on the stream. + * @type {ReadableStream} + */ get readable() { + QuicStream.#assertIsQuicStream(this); if (this.#readable === undefined) { assert(this.#reader); this.#readable = createBlobReaderStream(this.#reader); @@ -617,13 +663,24 @@ class QuicStream { return this.#readable; } - /** @type {boolean} */ - get pending() { return this.#state.pending; } + /** + * True if the stream is still pending (i.e. it has not yet been opened + * and assigned an ID). + * @type {boolean} + */ + get pending() { + QuicStream.#assertIsQuicStream(this); + return this.#state.pending; + } /** @type {OnBlockedCallback} */ - get onblocked() { return this.#onblocked; } + get onblocked() { + QuicStream.#assertIsQuicStream(this); + return this.#onblocked; + } set onblocked(fn) { + QuicStream.#assertIsQuicStream(this); if (fn === undefined) { this.#onblocked = undefined; this.#state.wantsBlock = false; @@ -635,9 +692,13 @@ class QuicStream { } /** @type {OnStreamErrorCallback} */ - get onreset() { return this.#onreset; } + get onreset() { + QuicStream.#assertIsQuicStream(this); + return this.#onreset; + } set onreset(fn) { + QuicStream.#assertIsQuicStream(this); if (fn === undefined) { this.#onreset = undefined; this.#state.wantsReset = false; @@ -649,7 +710,9 @@ class QuicStream { } /** @type {OnHeadersCallback} */ - get [kOnHeaders]() { return this.#onheaders; } + get [kOnHeaders]() { + return this.#onheaders; + } set [kOnHeaders](fn) { if (fn === undefined) { @@ -676,44 +739,76 @@ class QuicStream { } } - /** @type {QuicStreamStats} */ - get stats() { return this.#stats; } - - /** @type {QuicStreamState} */ - get state() { return this.#state; } + /** + * The statistics collected for this stream. + * @type {QuicStreamStats} + */ + get stats() { + QuicStream.#assertIsQuicStream(this); + return this.#stats; + } - /** @type {QuicSession} */ - get session() { return this.#session; } + /** + * The session this stream belongs to. If the stream is destroyed, + * `null` will be returned. + * @type {QuicSession | null} + */ + get session() { + QuicStream.#assertIsQuicStream(this); + if (this.destroyed) return null; + return this.#session; + } /** * Returns the id for this stream. If the stream is destroyed or still pending, - * `undefined` will be returned. - * @type {bigint} + * `null` will be returned. + * @type {bigint | null} */ get id() { - if (this.destroyed || this.pending) return undefined; + QuicStream.#assertIsQuicStream(this); + if (this.destroyed || this.pending) return null; return this.#state.id; } - /** @type {'bidi'|'uni'} */ + /** + * Returns the directionality of this stream. If the stream is destroyed + * or still pending, `null` will be returned. + * @type {'bidi'|'uni'|null} + */ get direction() { - return this.#direction === STREAM_DIRECTION_BIDIRECTIONAL ? 'bidi' : 'uni'; + QuicStream.#assertIsQuicStream(this); + if (this.destroyed || this.pending) return null; + return this.#direction === kStreamDirectionBidirectional ? 'bidi' : 'uni'; } - /** @returns {boolean} */ + /** + * True if the stream has been destroyed. + * @returns {boolean} + */ get destroyed() { + QuicStream.#assertIsQuicStream(this); return this.#handle === undefined; } - /** @type {Promise} */ + /** + * A promise that will be resolved when the stream is closed. + * @type {Promise} + */ get closed() { + QuicStream.#assertIsQuicStream(this); return this.#pendingClose.promise; } /** - * @param {ArrayBuffer|ArrayBufferView|Blob} outbound + * Sets the outbound data source for the stream. This can only be called + * once and must be called before any data will be sent. The body can be + * an ArrayBuffer, a TypedArray or DataView, or a Blob. If the stream + * is destroyed or already has an outbound data source, an error will + * be thrown. + * @param {ArrayBuffer|SharedArrayBuffer|ArrayBufferView|Blob} outbound */ setOutbound(outbound) { + QuicStream.#assertIsQuicStream(this); if (this.destroyed) { throw new ERR_INVALID_STATE('Stream is destroyed'); } @@ -724,44 +819,53 @@ class QuicStream { } /** - * @param {bigint} code + * 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 + * already destroyed, this is a no-op. No acknowledgement of this action + * will be provided. + * @param {number|bigint} code */ stopSending(code = 0n) { - if (this.destroyed) { - throw new ERR_INVALID_STATE('Stream is destroyed'); - } + QuicStream.#assertIsQuicStream(this); + if (this.destroyed) return; this.#handle.stopSending(BigInt(code)); } /** - * @param {bigint} code + * Tells the peer that this end will not send any more data on this stream. + * The optional error code will be sent to the peer as part of the + * request. If the stream is already destroyed, this is a no-op. No + * acknowledgement of this action will be provided. + * @param {number|bigint} code */ resetStream(code = 0n) { - if (this.destroyed) { - throw new ERR_INVALID_STATE('Stream is destroyed'); - } + QuicStream.#assertIsQuicStream(this); + if (this.destroyed) return; this.#handle.resetStream(BigInt(code)); } - /** @type {'default' | 'low' | 'high'} */ + /** + * The priority of the stream. If the stream is destroyed or if + * the session does not support priority, `null` will be + * returned. + * @type {'default' | 'low' | 'high' | null} + */ get priority() { - if (this.destroyed || !this.session.state.isPrioritySupported) return undefined; - switch (this.#handle.getPriority()) { - case 3: return 'default'; - case 7: return 'low'; - case 0: return 'high'; - default: return 'default'; - } + QuicStream.#assertIsQuicStream(this); + if (this.destroyed || !this.session.state.isPrioritySupported) return null; + const priority = this.#handle.getPriority(); + return priority < 3 ? 'high' : priority > 3 ? 'low' : 'default'; } set priority(val) { + QuicStream.#assertIsQuicStream(this); if (this.destroyed || !this.session.state.isPrioritySupported) return; + validateOneOf(val, 'priority', ['default', 'low', 'high']); switch (val) { case 'default': this.#handle.setPriority(3, 1); break; case 'low': this.#handle.setPriority(7, 1); break; case 'high': this.#handle.setPriority(0, 1); break; } - // Otherwise ignore the value as invalid. } /** @@ -868,6 +972,7 @@ class QuicStream { }; return `Stream ${inspect({ + __proto__: null, id: this.id, direction: this.direction, pending: this.pending, @@ -902,6 +1007,19 @@ class QuicSession { /** @type {{}} */ #sessionticket = undefined; + static { + getQuicSessionState = function(session) { + QuicSession.#assertIsQuicSession(session); + return session.#state; + }; + } + + static #assertIsQuicSession(val) { + if (val == null || !(#handle in val)) { + throw new ERR_INVALID_THIS('QuicSession'); + } + } + /** * @param {symbol} privateSymbol * @param {object} handle @@ -930,13 +1048,23 @@ class QuicSession { return this.#handle === undefined || this.#isPendingClose; } - /** @type {any} */ - get sessionticket() { return this.#sessionticket; } + /** + * Get the session ticket associated with this session, if any. + * @type {any} + */ + get sessionticket() { + QuicSession.#assertIsQuicSession(this); + return this.#sessionticket; + } /** @type {OnStreamCallback} */ - get onstream() { return this.#onstream; } + get onstream() { + QuicSession.#assertIsQuicSession(this); + return this.#onstream; + } set onstream(fn) { + QuicSession.#assertIsQuicSession(this); if (fn === undefined) { this.#onstream = undefined; } else { @@ -946,9 +1074,13 @@ class QuicSession { } /** @type {OnDatagramCallback} */ - get ondatagram() { return this.#ondatagram; } + get ondatagram() { + QuicSession.#assertIsQuicSession(this); + return this.#ondatagram; + } set ondatagram(fn) { + QuicSession.#assertIsQuicSession(this); if (fn === undefined) { this.#ondatagram = undefined; this.#state.hasDatagramListener = false; @@ -959,14 +1091,25 @@ class QuicSession { } } - /** @type {QuicSessionStats} */ - get stats() { return this.#stats; } - - /** @type {QuicSessionState} */ - get [kState]() { return this.#state; } + /** + * The statistics collected for this session. + * @type {QuicSessionStats} + */ + get stats() { + QuicSession.#assertIsQuicSession(this); + return this.#stats; + } - /** @type {QuicEndpoint} */ - get endpoint() { return this.#endpoint; } + /** + * The endpoint this session belongs to. If the session has been destroyed, + * `null` will be returned. + * @type {QuicEndpoint|null} + */ + get endpoint() { + QuicSession.#assertIsQuicSession(this); + if (this.destroyed) return null; + return this.#endpoint; + } /** * @param {number} direction @@ -977,7 +1120,7 @@ class QuicSession { if (this.#isClosedOrClosing) { throw new ERR_INVALID_STATE('Session is closed. New streams cannot be opened.'); } - const dir = direction === STREAM_DIRECTION_BIDIRECTIONAL ? 'bidi' : 'uni'; + const dir = direction === kStreamDirectionBidirectional ? 'bidi' : 'uni'; if (this.#state.isStreamOpenAllowed) { debug(`opening new pending ${dir} stream`); } else { @@ -1025,11 +1168,14 @@ class QuicSession { } /** + * Creates a new bidirectional stream on this session. If the session + * does not allow new streams to be opened, an error will be thrown. * @param {OpenStreamOptions} [options] * @returns {Promise} */ async createBidirectionalStream(options = kEmptyObject) { - return await this.#createStream(STREAM_DIRECTION_BIDIRECTIONAL, options); + QuicSession.#assertIsQuicSession(this); + return await this.#createStream(kStreamDirectionBidirectional, options); } /** @@ -1037,7 +1183,8 @@ class QuicSession { * @returns {Promise} */ async createUnidirectionalStream(options = kEmptyObject) { - return await this.#createStream(STREAM_DIRECTION_UNIDIRECTIONAL, options); + QuicSession.#assertIsQuicSession(this); + return await this.#createStream(kStreamDirectionUnidirectional, options); } /** @@ -1052,6 +1199,7 @@ class QuicSession { * @returns {Promise} */ async sendDatagram(datagram) { + QuicSession.#assertIsQuicSession(this); if (this.#isClosedOrClosing) { throw new ERR_INVALID_STATE('Session is closed'); } @@ -1086,6 +1234,7 @@ class QuicSession { * Initiate a key update. */ updateKey() { + QuicSession.#assertIsQuicSession(this); if (this.#isClosedOrClosing) { throw new ERR_INVALID_STATE('Session is closed'); } @@ -1111,6 +1260,7 @@ class QuicSession { * @returns {Promise} */ close() { + QuicSession.#assertIsQuicSession(this); if (!this.#isClosedOrClosing) { this.#isPendingClose = true; @@ -1127,17 +1277,26 @@ class QuicSession { } /** @type {Promise} */ - get opened() { return this.#pendingOpen.promise; } + get opened() { + QuicSession.#assertIsQuicSession(this); + return this.#pendingOpen.promise; + } /** * A promise that is resolved when the session is closed, or is rejected if * the session is closed abruptly due to an error. * @type {Promise} */ - get closed() { return this.#pendingClose.promise; } + get closed() { + QuicSession.#assertIsQuicSession(this); + return this.#pendingClose.promise; + } /** @type {boolean} */ - get destroyed() { return this.#handle === undefined; } + get destroyed() { + QuicSession.#assertIsQuicSession(this); + return this.#handle === undefined; + } /** * Forcefully closes the session abruptly without waiting for streams to be @@ -1148,6 +1307,7 @@ class QuicSession { * @param {any} error */ destroy(error) { + QuicSession.#assertIsQuicSession(this); if (this.destroyed) return; debug('destroying the session'); @@ -1502,6 +1662,19 @@ class QuicEndpoint { */ #onsession = undefined; + static { + getQuicEndpointState = function(endpoint) { + QuicEndpoint.#assertIsQuicEndpoint(endpoint); + return endpoint.#state; + }; + } + + static #assertIsQuicEndpoint(val) { + if (val == null || !(#handle in val)) { + throw new ERR_INVALID_THIS('QuicEndpoint'); + } + } + /** * @param {EndpointOptions} options * @returns {EndpointOptions} @@ -1592,10 +1765,10 @@ class QuicEndpoint { * Statistics collected while the endpoint is operational. * @type {QuicEndpointStats} */ - get stats() { return this.#stats; } - - /** @type {QuicEndpointState} */ - get [kState]() { return this.#state; } + get stats() { + QuicEndpoint.#assertIsQuicEndpoint(this); + return this.#stats; + } get #isClosedOrClosing() { return this.destroyed || this.#isPendingClose; @@ -1606,12 +1779,16 @@ class QuicEndpoint { * Existing connections will continue to work. * @type {boolean} */ - get busy() { return this.#busy; } + get busy() { + QuicEndpoint.#assertIsQuicEndpoint(this); + return this.#busy; + } /** * @type {boolean} */ set busy(val) { + QuicEndpoint.#assertIsQuicEndpoint(this); if (this.#isClosedOrClosing) { throw new ERR_INVALID_STATE('Endpoint is closed'); } @@ -1635,6 +1812,7 @@ class QuicEndpoint { * @type {SocketAddress|undefined} */ get address() { + QuicEndpoint.#assertIsQuicEndpoint(this); if (this.#isClosedOrClosing) return undefined; if (this.#address === undefined) { const addr = this.#handle.address(); @@ -1701,6 +1879,7 @@ class QuicEndpoint { * @returns {Promise} Returns this.closed */ close() { + QuicEndpoint.#assertIsQuicEndpoint(this); if (!this.#isClosedOrClosing) { if (onEndpointClosingChannel.hasSubscribers) { onEndpointClosingChannel.publish({ @@ -1723,15 +1902,25 @@ class QuicEndpoint { * is set to the same promise that is returned by the close() method. * @type {Promise} */ - get closed() { return this.#pendingClose.promise; } + get closed() { + QuicEndpoint.#assertIsQuicEndpoint(this); + return this.#pendingClose.promise; + } /** + * True if the endpoint is pending close. * @type {boolean} */ - get closing() { return this.#isPendingClose; } + get closing() { + QuicEndpoint.#assertIsQuicEndpoint(this); + return this.#isPendingClose; + } /** @type {boolean} */ - get destroyed() { return this.#handle === undefined; } + get destroyed() { + QuicEndpoint.#assertIsQuicEndpoint(this); + return this.#handle === undefined; + } /** * Forcefully terminates the endpoint by immediately destroying all sessions @@ -1742,6 +1931,7 @@ class QuicEndpoint { * @returns {Promise} Returns this.closed */ destroy(error) { + QuicEndpoint.#assertIsQuicEndpoint(this); debug('destroying the endpoint'); if (!this.#isClosedOrClosing) { this.#pendingError = error; @@ -1858,8 +2048,6 @@ class QuicEndpoint { this.#sessions.delete(session); } - async [SymbolAsyncDispose]() { await this.close(); } - [kInspect](depth, options) { if (depth < 0) return this; @@ -1881,6 +2069,8 @@ class QuicEndpoint { state: this.#state, }, opts)}`; } + + async [SymbolAsyncDispose]() { await this.close(); } }; function readOnlyConstant(value) { @@ -2037,9 +2227,9 @@ function processTlsOptions(tls, forServer) { */ function getPreferredAddressPolicy(policy = 'default') { switch (policy) { - case 'use': return PREFERRED_ADDRESS_USE; - case 'ignore': return PREFERRED_ADDRESS_IGNORE; - case 'default': return DEFAULT_PREFERRED_ADDRESS_POLICY; + case 'use': return kPreferredAddressUse; + case 'ignore': return kPreferredAddressIgnore; + case 'default': return kPreferredAddressDefault; } throw new ERR_INVALID_ARG_VALUE('options.preferredAddressPolicy', policy); } @@ -2203,6 +2393,10 @@ module.exports = { QuicSession, QuicStream, Http3, + // These are exported only for internal testing purposes. + getQuicStreamState, + getQuicSessionState, + getQuicEndpointState, }; ObjectDefineProperties(module.exports, { diff --git a/lib/internal/quic/symbols.js b/lib/internal/quic/symbols.js index 6ef07eb3bbe867..d82bbd984afab6 100644 --- a/lib/internal/quic/symbols.js +++ b/lib/internal/quic/symbols.js @@ -45,7 +45,6 @@ const kRemoveStream = Symbol('kRemoveStream'); const kReset = Symbol('kReset'); const kSendHeaders = Symbol('kSendHeaders'); const kSessionTicket = Symbol('kSessionTicket'); -const kState = Symbol('kState'); const kTrailers = Symbol('kTrailers'); const kVersionNegotiation = Symbol('kVersionNegotiation'); const kWantsHeaders = Symbol('kWantsHeaders'); @@ -76,7 +75,6 @@ module.exports = { kReset, kSendHeaders, kSessionTicket, - kState, kTrailers, kVersionNegotiation, kWantsHeaders, diff --git a/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs b/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs index d62bdda5755e64..2f665fee2ac61e 100644 --- a/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs +++ b/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs @@ -17,16 +17,16 @@ if (!hasQuic) { // Import after the hasQuic check const { listen, QuicEndpoint } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); -const { kState } = (await import('internal/quic/symbols')).default; +const { getQuicEndpointState } = (await import('internal/quic/quic')).default; const keys = createPrivateKey(readKey('agent1-key.pem')); const certs = readKey('agent1-cert.pem'); const endpoint = new QuicEndpoint(); - -ok(!endpoint[kState].isBound); -ok(!endpoint[kState].isReceiving); -ok(!endpoint[kState].isListening); +const state = getQuicEndpointState(endpoint); +ok(!state.isBound); +ok(!state.isReceiving); +ok(!state.isListening); strictEqual(endpoint.address, undefined); @@ -42,10 +42,9 @@ await listen(() => {}, { keys, certs, endpoint }); await rejects(listen(() => {}, { keys, certs, endpoint }), { code: 'ERR_INVALID_STATE', }); - -ok(endpoint[kState].isBound); -ok(endpoint[kState].isReceiving); -ok(endpoint[kState].isListening); +ok(state.isBound); +ok(state.isReceiving); +ok(state.isListening); const address = endpoint.address; ok(address instanceof SocketAddress); diff --git a/test/parallel/test-quic-internal-endpoint-stats-state.mjs b/test/parallel/test-quic-internal-endpoint-stats-state.mjs index c5a06ced49b523..b743fc1f0d524b 100644 --- a/test/parallel/test-quic-internal-endpoint-stats-state.mjs +++ b/test/parallel/test-quic-internal-endpoint-stats-state.mjs @@ -23,20 +23,23 @@ const { const { kFinishClose, kPrivateConstructor, - kState, } = (await import('internal/quic/symbols')).default; +const { + getQuicEndpointState, +} = (await import('internal/quic/quic')).default; { const endpoint = new QuicEndpoint(); + const state = getQuicEndpointState(endpoint); - strictEqual(endpoint[kState].isBound, false); - strictEqual(endpoint[kState].isReceiving, false); - strictEqual(endpoint[kState].isListening, false); - strictEqual(endpoint[kState].isClosing, false); - strictEqual(endpoint[kState].isBusy, false); - strictEqual(endpoint[kState].pendingCallbacks, 0n); + strictEqual(state.isBound, false); + strictEqual(state.isReceiving, false); + strictEqual(state.isListening, false); + strictEqual(state.isClosing, false); + strictEqual(state.isBusy, false); + strictEqual(state.pendingCallbacks, 0n); - deepStrictEqual(JSON.parse(JSON.stringify(endpoint[kState])), { + deepStrictEqual(JSON.parse(JSON.stringify(state)), { isBound: false, isReceiving: false, isListening: false, @@ -46,23 +49,25 @@ const { }); endpoint.busy = true; - strictEqual(endpoint[kState].isBusy, true); + strictEqual(state.isBusy, true); endpoint.busy = false; - strictEqual(endpoint[kState].isBusy, false); - strictEqual(typeof inspect(endpoint[kState]), 'string'); + strictEqual(state.isBusy, false); + strictEqual(typeof inspect(state), 'string'); } { // It is not bound after close. const endpoint = new QuicEndpoint(); - endpoint[kState][kFinishClose](); - strictEqual(endpoint[kState].isBound, undefined); + const state = getQuicEndpointState(endpoint); + state[kFinishClose](); + strictEqual(state.isBound, undefined); } { // State constructor argument is ArrayBuffer const endpoint = new QuicEndpoint(); - const StateCons = endpoint[kState].constructor; + const state = getQuicEndpointState(endpoint); + const StateCons = state.constructor; throws(() => new StateCons(kPrivateConstructor, 1), { code: 'ERR_INVALID_ARG_TYPE' }); From 4eb70e38619531d50f156b9dfa22efdcdb2b8d05 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 4 Oct 2025 11:47:31 -0700 Subject: [PATCH 2/3] quic: handle quic constants better --- lib/internal/quic/quic.js | 35 ++++++-------------- lib/quic.js | 50 ++++++++++++++++++++++------- test/parallel/test-quic-exports.mjs | 43 +++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 38 deletions(-) create mode 100644 test/parallel/test-quic-exports.mjs diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index faae386b55683f..642d4d5285d3b1 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -36,9 +36,9 @@ const { setCallbacks, // The constants to be exposed to end users for various options. - CC_ALGO_RENO_STR, - CC_ALGO_CUBIC_STR, - CC_ALGO_BBR_STR, + CC_ALGO_RENO_STR: CC_ALGO_RENO, + CC_ALGO_CUBIC_STR: CC_ALGO_CUBIC, + CC_ALGO_BBR_STR: CC_ALGO_BBR, DEFAULT_CIPHERS, DEFAULT_GROUPS, @@ -2073,16 +2073,6 @@ class QuicEndpoint { async [SymbolAsyncDispose]() { await this.close(); } }; -function readOnlyConstant(value) { - return { - __proto__: null, - value, - writable: false, - configurable: false, - enumerable: true, - }; -} - /** * @param {EndpointOptions} endpoint * @returns {{ endpoint: Endpoint_, created: boolean }} @@ -2263,10 +2253,7 @@ function processSessionOptions(options, forServer = false) { } if (cc !== undefined) { - validateString(cc, 'options.cc'); - if (cc !== 'reno' || cc !== 'bbr' || cc !== 'cubic') { - throw new ERR_INVALID_ARG_VALUE('options.cc', cc); - } + validateOneOf(cc, 'options.cc', [CC_ALGO_RENO, CC_ALGO_BBR, CC_ALGO_CUBIC]); } const { @@ -2393,19 +2380,15 @@ module.exports = { QuicSession, QuicStream, Http3, + CC_ALGO_RENO, + CC_ALGO_CUBIC, + CC_ALGO_BBR, + DEFAULT_CIPHERS, + DEFAULT_GROUPS, // These are exported only for internal testing purposes. getQuicStreamState, getQuicSessionState, getQuicEndpointState, }; -ObjectDefineProperties(module.exports, { - CC_ALGO_RENO: readOnlyConstant(CC_ALGO_RENO_STR), - CC_ALGO_CUBIC: readOnlyConstant(CC_ALGO_CUBIC_STR), - CC_ALGO_BBR: readOnlyConstant(CC_ALGO_BBR_STR), - DEFAULT_CIPHERS: readOnlyConstant(DEFAULT_CIPHERS), - DEFAULT_GROUPS: readOnlyConstant(DEFAULT_GROUPS), -}); - - /* c8 ignore stop */ diff --git a/lib/quic.js b/lib/quic.js index a6ca37825fbe71..d14a3a7406daf5 100644 --- a/lib/quic.js +++ b/lib/quic.js @@ -1,5 +1,10 @@ 'use strict'; +const { + ObjectCreate, + ObjectSeal, +} = primordials; + const { emitExperimentalWarning, } = require('internal/util'); @@ -18,15 +23,36 @@ const { DEFAULT_GROUPS, } = require('internal/quic/quic'); -module.exports = { - connect, - listen, - QuicEndpoint, - QuicSession, - QuicStream, - CC_ALGO_RENO, - CC_ALGO_CUBIC, - CC_ALGO_BBR, - DEFAULT_CIPHERS, - DEFAULT_GROUPS, -}; +function getEnumerableConstant(value) { + return { + __proto__: null, + value, + enumerable: true, + configurable: false, + writable: false, + }; +} + +const cc = ObjectSeal(ObjectCreate(null, { + __proto__: null, + RENO: getEnumerableConstant(CC_ALGO_RENO), + CUBIC: getEnumerableConstant(CC_ALGO_CUBIC), + BBR: getEnumerableConstant(CC_ALGO_BBR), +})); + +const constants = ObjectSeal(ObjectCreate(null, { + __proto__: null, + cc: getEnumerableConstant(cc), + DEFAULT_CIPHERS: getEnumerableConstant(DEFAULT_CIPHERS), + DEFAULT_GROUPS: getEnumerableConstant(DEFAULT_GROUPS), +})); + +module.exports = ObjectSeal(ObjectCreate(null, { + __proto__: null, + connect: getEnumerableConstant(connect), + listen: getEnumerableConstant(listen), + QuicEndpoint: getEnumerableConstant(QuicEndpoint), + QuicSession: getEnumerableConstant(QuicSession), + QuicStream: getEnumerableConstant(QuicStream), + constants: getEnumerableConstant(constants), +})); diff --git a/test/parallel/test-quic-exports.mjs b/test/parallel/test-quic-exports.mjs new file mode 100644 index 00000000000000..47de1edfee9255 --- /dev/null +++ b/test/parallel/test-quic-exports.mjs @@ -0,0 +1,43 @@ +// Flags: --experimental-quic --no-warnings +import { hasQuic, skip } from '../common/index.mjs'; +import { strictEqual, throws } from 'node:assert'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const quic = await import('node:quic'); + +// Test that the main exports exist and are of the correct type. +strictEqual(typeof quic.connect, 'function'); +strictEqual(typeof quic.listen, 'function'); +strictEqual(typeof quic.QuicEndpoint, 'function'); +strictEqual(typeof quic.QuicSession, 'function'); +strictEqual(typeof quic.QuicStream, 'function'); +strictEqual(typeof quic.QuicEndpoint.Stats, 'function'); +strictEqual(typeof quic.QuicSession.Stats, 'function'); +strictEqual(typeof quic.QuicStream.Stats, 'function'); +strictEqual(typeof quic.constants, 'object'); +strictEqual(typeof quic.constants.cc, 'object'); + +// Test that the constants exist and are of the correct type. +strictEqual(quic.constants.cc.RENO, 'reno'); +strictEqual(quic.constants.cc.CUBIC, 'cubic'); +strictEqual(quic.constants.cc.BBR, 'bbr'); +strictEqual(quic.constants.DEFAULT_CIPHERS, + 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:' + + 'TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_CCM_SHA256'); +strictEqual(quic.constants.DEFAULT_GROUPS, 'X25519:P-256:P-384:P-521'); + +// Ensure the constants are.. well, constant. +throws(() => { quic.constants.cc.RENO = 'foo'; }, TypeError); +strictEqual(quic.constants.cc.RENO, 'reno'); + +throws(() => { quic.constants.cc.NEW_CONSTANT = 'bar'; }, TypeError); +strictEqual(quic.constants.cc.NEW_CONSTANT, undefined); + +throws(() => { quic.constants.DEFAULT_CIPHERS = 123; }, TypeError); +strictEqual(typeof quic.constants.DEFAULT_CIPHERS, 'string'); + +throws(() => { quic.constants.NEW_CONSTANT = 456; }, TypeError); +strictEqual(quic.constants.NEW_CONSTANT, undefined); From bc19ae8b371fa555452acbb9b09b830b84774b7b Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 5 Oct 2025 06:49:06 -0700 Subject: [PATCH 3/3] quic: add ipv6-only handshake test and fix --- lib/internal/quic/quic.js | 34 ++++------ .../test-quic-handshake-ipv6-only.mjs | 67 +++++++++++++++++++ 2 files changed, 80 insertions(+), 21 deletions(-) create mode 100644 test/parallel/test-quic-handshake-ipv6-only.mjs diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 642d4d5285d3b1..6ad476eed5216f 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -2079,21 +2079,13 @@ class QuicEndpoint { */ function processEndpointOption(endpoint) { if (endpoint === undefined) { - return { - endpoint: new QuicEndpoint(), - created: true, - }; + // No endpoint or endpoint options were given. Create a default. + return new QuicEndpoint(); } else if (endpoint instanceof QuicEndpoint) { - return { - endpoint, - created: false, - }; + // We were given an existing endpoint. Use it as-is. + return endpoint; } - validateObject(endpoint, 'options.endpoint'); - return { - endpoint: new QuicEndpoint(endpoint), - created: true, - }; + return new QuicEndpoint(endpoint); } /** @@ -2226,10 +2218,10 @@ function getPreferredAddressPolicy(policy = 'default') { /** * @param {SessionOptions} options - * @param {boolean} [forServer] + * @param {{forServer: boolean, addressFamily: string}} [config] * @returns {SessionOptions} */ -function processSessionOptions(options, forServer = false) { +function processSessionOptions(options, config = {}) { validateObject(options, 'options'); const { endpoint, @@ -2248,6 +2240,10 @@ function processSessionOptions(options, forServer = false) { [kApplicationProvider]: provider, } = options; + const { + forServer = false, + } = config; + if (provider !== undefined) { validateObject(provider, 'options[kApplicationProvider]'); } @@ -2256,15 +2252,11 @@ function processSessionOptions(options, forServer = false) { validateOneOf(cc, 'options.cc', [CC_ALGO_RENO, CC_ALGO_BBR, CC_ALGO_CUBIC]); } - const { - endpoint: actualEndpoint, - created: endpointCreated, - } = processEndpointOption(endpoint); + const actualEndpoint = processEndpointOption(endpoint); return { __proto__: null, endpoint: actualEndpoint, - endpointCreated, version, minVersion, preferredAddressPolicy: getPreferredAddressPolicy(preferredAddressPolicy), @@ -2294,7 +2286,7 @@ async function listen(callback, options = kEmptyObject) { const { endpoint, ...sessionOptions - } = processSessionOptions(options, true /* for server */); + } = processSessionOptions(options, { forServer: true }); endpoint[kListen](callback, sessionOptions); if (onEndpointListeningChannel.hasSubscribers) { diff --git a/test/parallel/test-quic-handshake-ipv6-only.mjs b/test/parallel/test-quic-handshake-ipv6-only.mjs new file mode 100644 index 00000000000000..8163fad4c15e4d --- /dev/null +++ b/test/parallel/test-quic-handshake-ipv6-only.mjs @@ -0,0 +1,67 @@ +// Flags: --experimental-quic --no-warnings + +import { hasQuic, hasIPv6, skip } from '../common/index.mjs'; +import { ok, partialDeepStrictEqual } from 'node:assert'; +import { readKey } from '../common/fixtures.mjs'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +if (!hasIPv6) { + skip('IPv6 is not supported'); +} + +// Import after the hasQuic check +const { listen, connect } = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +const keys = createPrivateKey(readKey('agent1-key.pem')); +const certs = readKey('agent1-cert.pem'); + +const check = { + // The SNI value + servername: 'localhost', + // The selected ALPN protocol + protocol: 'h3', + // The negotiated cipher suite + cipher: 'TLS_AES_128_GCM_SHA256', + cipherVersion: 'TLSv1.3', +}; + +// The opened promise should resolve when the handshake is complete. + +const serverOpened = Promise.withResolvers(); +const clientOpened = Promise.withResolvers(); + +const serverEndpoint = await listen(async (serverSession) => { + const info = await serverSession.opened; + partialDeepStrictEqual(info, check); + serverOpened.resolve(); + serverSession.close(); +}, { keys, certs, endpoint: { + address: { + address: '::1', + family: 'ipv6', + }, + ipv6Only: true, +} }); + +// The server must have an address to connect to after listen resolves. +ok(serverEndpoint.address !== undefined); + +const clientSession = await connect(serverEndpoint.address, { + endpoint: { + address: { + address: '::', + family: 'ipv6', + }, + } +}); +clientSession.opened.then((info) => { + partialDeepStrictEqual(info, check); + clientOpened.resolve(); +}); + +await Promise.all([serverOpened.promise, clientOpened.promise]); +clientSession.close();