diff --git a/.gitignore b/.gitignore index 0b8f1a405bda3a..69c1dd205316fa 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ /tags /tags.* /doc/api.xml +/docs/ /node /node_g /gon-config.json diff --git a/lib/internal/fs/promises.js b/lib/internal/fs/promises.js index 720bd1319b381f..0aa01d9b39dc3e 100644 --- a/lib/internal/fs/promises.js +++ b/lib/internal/fs/promises.js @@ -178,6 +178,12 @@ function handleErrorFromBinding(error) { } class FileHandle extends EventEmitter { + #brandCheck = undefined; + + static isFileHandle(value) { + return (value != null && typeof value === 'object' && #brandCheck in value); + } + /** * @param {InternalFSBinding.FileHandle | undefined} filehandle */ diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 6ca59469faf2de..25cbc7fd29dfab 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -203,14 +203,13 @@ const { kTrailers, kVersionNegotiation, kInspect, - kWantsHeaders, - kWantsTrailers, } = require('internal/quic/symbols'); const { QuicEndpointStats, QuicStreamStats, QuicSessionStats, + kCreateDisconnected, } = require('internal/quic/stats'); const { @@ -464,6 +463,51 @@ const endpointRegistry = new SafeSet(); * @property {string} [validationErrorCode] The error code for the validation failure (if any) */ +/** + * @typedef {object} QuicStreamDestroyOptions + * @property {bigint|number} [code] An explicit application + * error code to send on the resulting `RESET_STREAM` / + * `STOP_SENDING` frames. Numbers are coerced to `BigInt`. When + * omitted, the code is derived from `error` per the precedence + * above. + * @property {string} [reason] Optional human-readable reason. + * Accepted for symmetry with `session.close()` / + * `session.destroy()`; QUIC `RESET_STREAM` and `STOP_SENDING` + * frames do not themselves carry a reason field over the wire. + */ + +/** + * @typedef {object} SendHeadersOptions + * @property {boolean} [terminal] When true, indicates that no body data will be + * sent after these headers. + */ + +/** + * @typedef {object} StreamPriority + * @property {'default' | 'low' | 'high'} level The priority level of the stream. + * @property {boolean} incremental Whether to interleave data with same-priority streams. + */ + +/** + * @typedef {object} QuicSessionPath + * @property {SocketAddress} local The local address for this path + * @property {SocketAddress} remote The remote address for this path + */ + +/** + * @typedef {object} SNIContextOptions + * @property {boolean} [replace] When `true`, the provided SNI context will replace + * the default context for the session. When `false` (default), the provided + * context will be merged with the default context, with precedence given to + * the provided context on any overlapping options. + */ + +/** + * @typedef {object} ProcessSessionOptions + * @property {boolean} forServer true if processing options for a server session + * @property {string} addressFamily the address family to use for validating + */ + /** * Called when the Endpoint receives a new server-side Session. * @callback OnSessionCallback @@ -658,7 +702,7 @@ setCallbacks({ }, /** * Called when the QuicEndpoint C++ handle receives a new server-side session - * @param {*} session The QuicSession C++ handle + * @param {object} session The QuicSession C++ handle */ onSessionNew(session) { debug('new server session callback', this[kOwner], session); @@ -792,9 +836,9 @@ setCallbacks({ /** * Called when the session receives a session version negotiation request - * @param {*} version - * @param {*} requestedVersions - * @param {*} supportedVersions + * @param {number} version + * @param {number[]} requestedVersions + * @param {number[]} supportedVersions */ onSessionVersionNegotiation(version, requestedVersions, @@ -885,6 +929,12 @@ setCallbacks({ }, }); +function assertPrivateSymbol(privateSymbol) { + if (privateSymbol !== kPrivateConstructor) { + throw new ERR_ILLEGAL_CONSTRUCTOR(); + } +} + // QUIC error codes are 62-bit varints (RFC 9000 section 16). The // maximum representable code is 2**62 - 1. const kMaxQuicErrorCode = (1n << 62n) - 1n; @@ -911,6 +961,10 @@ class QuicError extends Error { /** @type {'transport' | 'application'} */ #type; + static isQuicError(val) { + return val != null && typeof val === 'object' && #errorCode in val; + } + /** * @param {string} message * @param {object} options @@ -1089,7 +1143,7 @@ function validateBody(body) { // FileHandle -- lock it and pass the C++ handle to GetDataQueueFromSource // which creates an fd-backed DataQueue entry from the file path. - if (body instanceof FileHandle) { + if (FileHandle.isFileHandle(body)) { if (body[kFileLocked]) { throw new ERR_INVALID_STATE('FileHandle is locked'); } @@ -1166,7 +1220,7 @@ function applyCallbacks(session, cbs) { * 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 + * @param {any} body The body source */ const kDefaultHighWaterMark = 65536; const kDefaultMaxPendingDatagrams = 128; @@ -1208,7 +1262,7 @@ function configureOutbound(handle, stream, body) { // DataQueue entry from the file path. The FileHandle is locked to // prevent concurrent use and closed automatically when the stream // finishes. - if (body instanceof FileHandle) { + if (FileHandle.isFileHandle(body)) { if (body[kFileLocked]) { throw new ERR_INVALID_STATE('FileHandle is locked'); } @@ -1368,8 +1422,14 @@ let getQuicStreamState; let getQuicSessionState; let getQuicEndpointState; let assertIsQuicEndpoint; +let assertIsQuicStream; +let assertIsQuicSession; +let assertHeadersSupported; let assertEndpointNotClosedOrClosing; let assertEndpointIsNotBusy; +let isQuicStream; +let isQuicSession; +let isQuicEndpoint; function maybeGetCloseError(context, status, pendingError) { switch (context) { @@ -1396,70 +1456,54 @@ function maybeGetCloseError(context, status, pendingError) { } class QuicStream { - /** @type {object} */ #handle; - /** - * Flag set at the top of `destroy()` to make the method safely - * re-entrant. Distinct from `#handle === undefined` (which signals - * "fully destroyed" and is set inside `[kFinishClose]`) so that - * `[kFinishClose]`'s own destroyed-guard does not bail before the - * cleanup work runs. - * @type {boolean} - */ - #destroying = false; - /** @type {QuicSession} */ - #session; - /** @type {QuicStreamStats} */ - #stats; - /** @type {QuicStreamState} */ - #state; - /** @type {number} */ - #direction = undefined; - /** @type {Function|undefined} */ - #onerror = undefined; - /** @type {OnBlockedCallback|undefined} */ - #onblocked = undefined; - /** @type {OnStreamErrorCallback|undefined} */ - #onreset = undefined; - /** @type {Function|undefined} */ - #onheaders = undefined; - /** @type {Function|undefined} */ - #ontrailers = undefined; - /** @type {Function|undefined} */ - #oninfo = undefined; - /** @type {Function|undefined} */ - #onwanttrailers = undefined; - /** @type {object|undefined} */ - #headers = undefined; - /** @type {object|undefined} */ - #pendingTrailers = undefined; - /** @type {Promise} */ - #pendingClose = PromiseWithResolvers(); - #reader; - #iteratorLocked = false; - #writer = undefined; - #outboundSet = false; - /** @type {FileHandle|undefined} */ - #fileHandle = undefined; + #inner = { + __proto__: null, + session: undefined, + direction: undefined, + isLocal: false, + state: undefined, + stats: undefined, + pendingClose: undefined, + reader: undefined, + destroying: false, + iteratorLocked: false, + outboundSet: false, + writer: undefined, + fileHandle: undefined, + headers: undefined, + pendingTrailers: undefined, + onerror: undefined, + onblocked: undefined, + onreset: undefined, + onheaders: undefined, + ontrailers: undefined, + oninfo: undefined, + onwanttrailers: undefined, + }; static { - getQuicStreamState = function(stream) { - QuicStream.#assertIsQuicStream(stream); - return stream.#state; + isQuicStream = function(val) { + return val != null && typeof val === 'object' && #handle in val; }; - } - static #assertIsQuicStream(val) { - if (val == null || !(#handle in val)) { - throw new ERR_INVALID_THIS('QuicStream'); - } - } + assertIsQuicStream = function(val) { + if (!isQuicStream(val)) { + throw new ERR_INVALID_THIS('QuicStream'); + } + }; - #assertHeadersSupported() { - if (getQuicSessionState(this.#session).headersSupported === 2) { - throw new ERR_INVALID_STATE( - 'The negotiated QUIC application protocol does not support headers'); - } + assertHeadersSupported = function(session) { + if (getQuicSessionState(session).headersSupported === 2) { + throw new ERR_INVALID_STATE( + 'The negotiated QUIC application protocol does not support headers'); + } + }; + + getQuicStreamState = function(stream) { + assertIsQuicStream(stream); + return stream.#inner.state; + }; } /** @@ -1467,29 +1511,25 @@ class QuicStream { * @param {object} handle * @param {QuicSession} session * @param {number} direction + * @param {boolean} [isLocal] */ - constructor(privateSymbol, handle, session, direction) { - if (privateSymbol !== kPrivateConstructor) { - throw new ERR_ILLEGAL_CONSTRUCTOR(); - } + constructor(privateSymbol, handle, session, direction, isLocal) { + assertPrivateSymbol(privateSymbol); this.#handle = handle; - this.#handle[kOwner] = this; - this.#session = session; - this.#direction = direction; - this.#stats = new QuicStreamStats(kPrivateConstructor, this.#handle.stats); - this.#state = new QuicStreamState(kPrivateConstructor, this.#handle.state); - this.#reader = this.#handle.getReader(); + handle[kOwner] = this; + const inner = this.#inner; + inner.session = session; + inner.direction = direction; + inner.isLocal = isLocal; + inner.state = new QuicStreamState( + kPrivateConstructor, handle.state, handle.stateByteOffset); if (hasObserver('quic')) { startPerf(this, kPerfEntry, { type: 'quic', name: 'QuicStream' }); } - if (this.pending) { - debug(`pending ${this.direction} stream created`); - } else { - debug(`${this.direction} stream ${this.id} created`); - } + debug('stream created'); } get [kValidatedSource]() { return true; } @@ -1501,16 +1541,18 @@ class QuicStream { * @yields {Uint8Array[]} */ async *[SymbolAsyncIterator]() { - QuicStream.#assertIsQuicStream(this); - if (this.#iteratorLocked) { + assertIsQuicStream(this); + const inner = this.#inner; + if (inner.iteratorLocked) { throw new ERR_INVALID_STATE('Stream is already being read'); } - this.#iteratorLocked = true; + inner.iteratorLocked = true; + inner.reader ??= this.#handle?.getReader(); // Non-readable stream (outbound-only unidirectional, or closed) - if (!this.#reader) return; + if (!inner.reader) return; - yield* createBlobReaderIterable(this.#reader, { + yield* createBlobReaderIterable(inner.reader, { getReadError: () => { // The read side ends for one of three reasons: // * Clean FIN received from the peer (state.finReceived @@ -1524,8 +1566,8 @@ class QuicStream { // stream.stopSending(). Both paths run EndReadable in // C++, setting state.readEnded without setting // state.finReceived. There is no peer code to surface. - if (this.#state.readEnded && !this.#state.finReceived) { - const peerResetCode = this.#state.resetCode; + if (inner.state.readEnded && !inner.state.finReceived) { + const peerResetCode = inner.state.resetCode; if (peerResetCode !== undefined && peerResetCode > 0n) { return new ERR_QUIC_STREAM_RESET(Number(peerResetCode)); } @@ -1543,8 +1585,8 @@ class QuicStream { * @type {boolean} */ get pending() { - QuicStream.#assertIsQuicStream(this); - return this.#state.pending; + assertIsQuicStream(this); + return this.#inner.state.pending; } /** @@ -1554,8 +1596,8 @@ class QuicStream { * @type {boolean} */ get early() { - QuicStream.#assertIsQuicStream(this); - return this.#state.early; + assertIsQuicStream(this); + return this.#inner.state.early; } /** @@ -1565,143 +1607,153 @@ class QuicStream { * @type {number} */ get highWaterMark() { - QuicStream.#assertIsQuicStream(this); - return this.#state.highWaterMark; + assertIsQuicStream(this); + return this.#inner.state.highWaterMark; } set highWaterMark(val) { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); validateInteger(val, 'highWaterMark', 0, 0xFFFFFFFF); - this.#state.highWaterMark = val; + const inner = this.#inner; + inner.state.highWaterMark = val; // If writeDesiredSize hasn't been set yet (still 0 from initialization), // initialize it to the highWaterMark so the first write can proceed. - if (this.#state.writeDesiredSize === 0 && val > 0) { - this.#state.writeDesiredSize = val; + if (inner.state.writeDesiredSize === 0 && val > 0) { + inner.state.writeDesiredSize = val; } } /** @type {Function|undefined} */ get onerror() { - QuicStream.#assertIsQuicStream(this); - return this.#onerror; + assertIsQuicStream(this); + return this.#inner.onerror; } set onerror(fn) { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); + const inner = this.#inner; if (fn === undefined) { - this.#onerror = undefined; + inner.onerror = undefined; } else { validateFunction(fn, 'onerror'); - this.#onerror = FunctionPrototypeBind(fn, this); - markPromiseAsHandled(this.#pendingClose.promise); + inner.onerror = FunctionPrototypeBind(fn, this); + // Lazily create the close promise so it can be marked handled. + inner.pendingClose ??= PromiseWithResolvers(); + markPromiseAsHandled(inner.pendingClose.promise); } } /** @type {OnBlockedCallback} */ get onblocked() { - QuicStream.#assertIsQuicStream(this); - return this.#onblocked; + assertIsQuicStream(this); + return this.#inner.onblocked; } set onblocked(fn) { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); + const inner = this.#inner; if (fn === undefined) { - this.#onblocked = undefined; - this.#state.wantsBlock = false; + inner.onblocked = undefined; + inner.state.wantsBlock = false; } else { validateFunction(fn, 'onblocked'); - this.#onblocked = FunctionPrototypeBind(fn, this); - this.#state.wantsBlock = true; + inner.onblocked = FunctionPrototypeBind(fn, this); + inner.state.wantsBlock = true; } } /** @type {OnStreamErrorCallback} */ get onreset() { - QuicStream.#assertIsQuicStream(this); - return this.#onreset; + assertIsQuicStream(this); + return this.#inner.onreset; } set onreset(fn) { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); + const inner = this.#inner; if (fn === undefined) { - this.#onreset = undefined; - this.#state.wantsReset = false; + inner.onreset = undefined; + inner.state.wantsReset = false; } else { validateFunction(fn, 'onreset'); - this.#onreset = FunctionPrototypeBind(fn, this); - this.#state.wantsReset = true; + inner.onreset = FunctionPrototypeBind(fn, this); + inner.state.wantsReset = true; } } /** @type {OnHeadersCallback} */ get onheaders() { - QuicStream.#assertIsQuicStream(this); - return this.#onheaders; + assertIsQuicStream(this); + return this.#inner.onheaders; } set onheaders(fn) { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); + const inner = this.#inner; if (fn === undefined) { - this.#onheaders = undefined; - this.#state[kWantsHeaders] = false; + inner.onheaders = undefined; + inner.state.wantsHeaders = false; } else { - this.#assertHeadersSupported(); validateFunction(fn, 'onheaders'); - this.#onheaders = FunctionPrototypeBind(fn, this); - this.#state[kWantsHeaders] = true; + assertHeadersSupported(inner.session); + inner.onheaders = FunctionPrototypeBind(fn, this); + inner.state.wantsHeaders = true; } } /** @type {Function|undefined} */ get oninfo() { - QuicStream.#assertIsQuicStream(this); - return this.#oninfo; + assertIsQuicStream(this); + return this.#inner.oninfo; } set oninfo(fn) { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); + const inner = this.#inner; if (fn === undefined) { - this.#oninfo = undefined; + inner.oninfo = undefined; } else { - this.#assertHeadersSupported(); validateFunction(fn, 'oninfo'); - this.#oninfo = FunctionPrototypeBind(fn, this); + assertHeadersSupported(inner.session); + inner.oninfo = FunctionPrototypeBind(fn, this); } } /** @type {Function|undefined} */ get ontrailers() { - QuicStream.#assertIsQuicStream(this); - return this.#ontrailers; + assertIsQuicStream(this); + return this.#inner.ontrailers; } set ontrailers(fn) { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); + const inner = this.#inner; if (fn === undefined) { - this.#ontrailers = undefined; + inner.ontrailers = undefined; } else { - this.#assertHeadersSupported(); validateFunction(fn, 'ontrailers'); - this.#ontrailers = FunctionPrototypeBind(fn, this); + assertHeadersSupported(inner.session); + inner.ontrailers = FunctionPrototypeBind(fn, this); } } /** @type {Function|undefined} */ get onwanttrailers() { - QuicStream.#assertIsQuicStream(this); - return this.#onwanttrailers; + assertIsQuicStream(this); + return this.#inner.onwanttrailers; } set onwanttrailers(fn) { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); + const inner = this.#inner; if (fn === undefined) { - this.#onwanttrailers = undefined; - this.#state[kWantsTrailers] = false; + inner.onwanttrailers = undefined; + inner.state.wantsTrailers = false; } else { - this.#assertHeadersSupported(); validateFunction(fn, 'onwanttrailers'); - this.#onwanttrailers = FunctionPrototypeBind(fn, this); - this.#state[kWantsTrailers] = true; + assertHeadersSupported(inner.session); + inner.onwanttrailers = FunctionPrototypeBind(fn, this); + inner.state.wantsTrailers = true; } } @@ -1712,8 +1764,8 @@ class QuicStream { * @type {object|undefined} */ get headers() { - QuicStream.#assertIsQuicStream(this); - return this.#headers; + assertIsQuicStream(this); + return this.#inner.headers; } /** @@ -1721,22 +1773,20 @@ class QuicStream { * @type {object|undefined} */ get pendingTrailers() { - QuicStream.#assertIsQuicStream(this); - return this.#pendingTrailers; + assertIsQuicStream(this); + return this.#inner.pendingTrailers; } set pendingTrailers(headers) { - QuicStream.#assertIsQuicStream(this); + const inner = this.#inner; + assertIsQuicStream(this); + assertHeadersSupported(inner.session); if (headers === undefined) { - this.#pendingTrailers = undefined; + inner.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; + inner.pendingTrailers = headers; } /** @@ -1744,8 +1794,12 @@ class QuicStream { * @type {QuicStreamStats} */ get stats() { - QuicStream.#assertIsQuicStream(this); - return this.#stats; + assertIsQuicStream(this); + const inner = this.#inner; + const handle = this.#handle; + return inner.stats ??= (handle == null) ? + QuicStreamStats[kCreateDisconnected]() : + new QuicStreamStats(kPrivateConstructor, handle.stats, handle.statsByteOffset); } /** @@ -1754,31 +1808,28 @@ class QuicStream { * @type {QuicSession | null} */ get session() { - QuicStream.#assertIsQuicStream(this); - if (this.destroyed) return null; - return this.#session; + assertIsQuicStream(this); + return this.#inner.session; } /** - * Returns the id for this stream. If the stream is destroyed or still pending, + * Returns the id for this stream. If the stream is still pending, * `null` will be returned. * @type {bigint | null} */ get id() { - QuicStream.#assertIsQuicStream(this); - if (this.destroyed || this.pending) return null; - return this.#state.id; + assertIsQuicStream(this); + if (this.pending) return null; + return this.#inner.state.id; } /** - * Returns the directionality of this stream. If the stream is destroyed - * or still pending, `null` will be returned. - * @type {'bidi'|'uni'|null} + * Returns the directionality of this stream. + * @type {'bidi'|'uni'} */ get direction() { - QuicStream.#assertIsQuicStream(this); - if (this.destroyed || this.pending) return null; - return this.#direction === kStreamDirectionBidirectional ? 'bidi' : 'uni'; + assertIsQuicStream(this); + return this.#inner.direction === kStreamDirectionBidirectional ? 'bidi' : 'uni'; } /** @@ -1786,7 +1837,7 @@ class QuicStream { * @type {boolean} */ get destroyed() { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); return this.#handle === undefined; } @@ -1795,8 +1846,9 @@ class QuicStream { * @type {Promise} */ get closed() { - QuicStream.#assertIsQuicStream(this); - return this.#pendingClose.promise; + assertIsQuicStream(this); + this.#inner.pendingClose ??= PromiseWithResolvers(); + return this.#inner.pendingClose.promise; } /** @@ -1810,19 +1862,11 @@ class QuicStream { * `QuicError`) -> the negotiated application's "internal error" * code from `QuicSessionState.internalErrorCode`. * @param {any} error - * @param {object} [options] - * @param {bigint|number} [options.code] An explicit application - * error code to send on the resulting `RESET_STREAM` / - * `STOP_SENDING` frames. Numbers are coerced to `BigInt`. When - * omitted, the code is derived from `error` per the precedence - * above. - * @param {string} [options.reason] Optional human-readable reason. - * Accepted for symmetry with `session.close()` / - * `session.destroy()`; QUIC `RESET_STREAM` and `STOP_SENDING` - * frames do not themselves carry a reason field over the wire. + * @param {QuicStreamDestroyOptions} [options] */ destroy(error, options = kEmptyObject) { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); + const inner = this.#inner; // Two distinct guards: // * `#destroying` flips synchronously here so any re-entrant call // from inside this method's user callbacks hits the guard and @@ -1832,7 +1876,7 @@ class QuicStream { // `onStreamClose -> [kFinishClose]` path - which does NOT go // through `destroy()` and therefore never sets `#destroying`. // `[kFinishClose]` clears `#handle` at the end of its work. - if (this.#destroying || this.destroyed) return; + if (inner.destroying || this.destroyed) return; // Validate options up front so a malformed `options` argument // throws before any side effects (mutating `#destroying`, // emitting wire frames, invoking `onerror`, settling the closed @@ -1848,30 +1892,25 @@ class QuicStream { if (reason !== undefined) { validateString(reason, 'options.reason'); } - this.#destroying = true; + inner.destroying = true; // Resolve the wire error code for any RESET_STREAM / STOP_SENDING // frames emitted below. let abortCode; if (optionCode !== undefined) { abortCode = BigInt(optionCode); } else if (error !== undefined) { - abortCode = error instanceof QuicError ? + abortCode = QuicError.isQuicError(error) ? error.errorCode : - getQuicSessionState(this.#session).internalErrorCode; + getQuicSessionState(inner.session).internalErrorCode; } // When destroying with an error, ensure the peer stops sending // data we are about to discard by emitting STOP_SENDING. The // condition gates the emission to error-path destroys with a - // still-open readable side. Direction model for the readable - // side: - // * bidi: always has a readable side. - // * uni + #reader !== undefined: remote-initiated, read-only. - // * uni + #reader === undefined: locally-initiated, write-only; - // no readable side to stop. + // still-open readable side. The C++ state.readEnded flag is + // authoritative -- it is set for locally-initiated uni streams + // (which have no readable side) and when reading completes. if (abortCode !== undefined && - !this.#state.readEnded && - (this.#direction === kStreamDirectionBidirectional || - this.#reader !== undefined)) { + !inner.state.readEnded) { this.#handle.stopSending(abortCode); } // When destroying with an error, ensure the peer learns about @@ -1880,22 +1919,14 @@ class QuicStream { // streams that destroy without ever accessing stream.writer // (e.g. used setBody or never wrote at all) need an explicit // RESET_STREAM here so the write side does not dangle on the - // wire. The condition gates the emission to error-path destroys - // with a still-open writable side. - // Direction model for the writable side: - // * bidi: always has a writable side. - // * uni + #reader === undefined: locally-initiated, write-only. - // * uni + #reader !== undefined: remote-initiated, read-only; - // no writable side to reset. + // wire. The C++ state.writeEnded flag is authoritative. if (abortCode !== undefined && - this.#writer === undefined && - !this.#state.writeEnded && - (this.#direction === kStreamDirectionBidirectional || - this.#reader === undefined)) { + inner.writer === undefined && + !inner.state.writeEnded) { this.#handle.resetStream(abortCode); } - if (error !== undefined && typeof this.#onerror === 'function') { - invokeOnerror(this.#onerror, error); + if (error !== undefined && typeof inner.onerror === 'function') { + invokeOnerror(inner.onerror, error); } const handle = this.#handle; this[kFinishClose](error); @@ -1911,34 +1942,32 @@ class QuicStream { * @param {ArrayBuffer|SharedArrayBuffer|ArrayBufferView|Blob} outbound */ setOutbound(outbound) { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); if (this.destroyed) { throw new ERR_INVALID_STATE('Stream is destroyed'); } - if (this.#state.hasOutbound) { + if (this.#inner.state.hasOutbound) { throw new ERR_INVALID_STATE('Stream already has an outbound data source'); } 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] + * @param {SendHeadersOptions} [options] * @returns {boolean} */ sendHeaders(headers, options = kEmptyObject) { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); if (this.destroyed) return false; - if (getQuicSessionState(this.#session).headersSupported === 2) { + if (getQuicSessionState(this.#inner.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); + headers, assertValidPseudoHeader, true /* strictSingleValueFields */); const flags = terminal ? kHeadersFlagsTerminal : kHeadersFlagsNone; return this.#handle.sendHeaders(kHeadersKindInitial, headerString, flags); } @@ -1950,9 +1979,9 @@ class QuicStream { * @returns {boolean} */ sendInformationalHeaders(headers) { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); if (this.destroyed) return false; - if (getQuicSessionState(this.#session).headersSupported === 2) { + if (getQuicSessionState(this.#inner.session).headersSupported === 2) { throw new ERR_INVALID_STATE( 'The negotiated QUIC application protocol does not support headers'); } @@ -1971,9 +2000,9 @@ class QuicStream { * @returns {boolean} */ sendTrailers(headers) { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); if (this.destroyed) return false; - if (getQuicSessionState(this.#session).headersSupported === 2) { + if (getQuicSessionState(this.#inner.session).headersSupported === 2) { throw new ERR_INVALID_STATE( 'The negotiated QUIC application protocol does not support headers'); } @@ -1990,9 +2019,10 @@ class QuicStream { * @type {object} */ get writer() { - QuicStream.#assertIsQuicStream(this); - if (this.#writer !== undefined) return this.#writer; - if (this.#outboundSet) { + assertIsQuicStream(this); + const inner = this.#inner; + if (inner.writer !== undefined) return inner.writer; + if (inner.outboundSet) { throw new ERR_INVALID_STATE( 'Stream outbound already configured with a body source'); } @@ -2023,7 +2053,7 @@ class QuicStream { // more data. Refuse the sync write. // If a drain is already pending, another operation is waiting // for capacity. Refuse the sync write. - if (closed || errored || stream.#state.writeEnded || drainWakeup != null) { + if (closed || errored || stream.#inner.state.writeEnded || drainWakeup != null) { return false; } chunk = toUint8Array(chunk); @@ -2031,7 +2061,7 @@ class QuicStream { if (len === 0) return true; // Refuse the write if the chunk doesn't fit in the available // buffer capacity. The caller should wait for drain and retry. - if (len > stream.#state.writeDesiredSize) return false; + if (len > stream.#inner.state.writeDesiredSize) return false; const result = handle.write([chunk]); if (result === undefined) return false; totalBytesWritten += len; @@ -2046,7 +2076,7 @@ class QuicStream { signal.throwIfAborted(); } if (errored) throw error; - if (closed || stream.#state.writeEnded) { + if (closed || stream.#inner.state.writeEnded) { throw new ERR_INVALID_STATE('Writer is closed'); } // If a drain is already pending, another operation is waiting @@ -2063,14 +2093,14 @@ class QuicStream { } function writevSync(chunks) { - if (closed || errored || stream.#state.writeEnded || drainWakeup != null) { + if (closed || errored || stream.#inner.state.writeEnded || drainWakeup != null) { return false; } chunks = convertChunks(chunks); let len = 0; for (const c of chunks) len += TypedArrayPrototypeGetByteLength(c); if (len === 0) return true; - if (len > stream.#state.writeDesiredSize) return false; + if (len > stream.#inner.state.writeDesiredSize) return false; const result = handle.write(chunks); if (result === undefined) return false; totalBytesWritten += len; @@ -2086,7 +2116,7 @@ class QuicStream { } if (errored) throw error; - if (closed || stream.#state.writeEnded) { + if (closed || stream.#inner.state.writeEnded) { throw new ERR_INVALID_STATE('Writer is closed'); } @@ -2177,9 +2207,9 @@ class QuicStream { // `H3_INTERNAL_ERROR` (0x102); for raw QUIC applications // it falls back to the QUIC transport-layer // `INTERNAL_ERROR` (0x1). - const code = error instanceof QuicError ? + const code = QuicError.isQuicError(error) ? error.errorCode : - getQuicSessionState(stream.#session).internalErrorCode; + getQuicSessionState(stream.#inner.session).internalErrorCode; handle.resetStream(code); if (drainWakeup != null) { drainWakeup.reject(error); @@ -2190,8 +2220,8 @@ class QuicStream { const writer = { __proto__: null, get desiredSize() { - if (closed || errored || stream.#state.writeEnded) return null; - return stream.#state.writeDesiredSize; + if (closed || errored || stream.#inner.state.writeEnded) return null; + return stream.#inner.state.writeDesiredSize; }, writeSync, write, @@ -2204,7 +2234,7 @@ class QuicStream { if (closed || errored) return null; // If a drain is already pending, return the existing promise. if (drainWakeup != null) return drainWakeup.promise; - if (stream.#state.writeDesiredSize > 0) return null; + if (stream.#inner.state.writeDesiredSize > 0) return null; drainWakeup = PromiseWithResolvers(); return drainWakeup.promise; }, @@ -2218,45 +2248,46 @@ class QuicStream { }; // Non-writable stream - return a pre-closed writer. - // A readable unidirectional stream is a remote uni (read-only). - if (!handle || this.destroyed || this.#state.writeEnded || - (this.#direction === kStreamDirectionUnidirectional && - this.#reader !== undefined)) { + // A remote unidirectional stream is read-only and has no writable + // side. isLocal distinguishes locally-initiated (writable) from + // remotely-initiated (read-only) uni streams. + if (!handle || this.destroyed || inner.state.writeEnded || + (inner.direction === kStreamDirectionUnidirectional && + !inner.isLocal)) { closed = true; - this.#writer = writer; - return this.#writer; + return inner.writer = writer; } // Initialize the outbound DataQueue for streaming writes handle.initStreamingSource(); initStreamingBackpressure(this); - this.#writer = writer; - return this.#writer; + return inner.writer = 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 + * @param {any} body */ setBody(body) { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); if (this.destroyed) { throw new ERR_INVALID_STATE('Stream is destroyed'); } - if (this.#outboundSet) { + const inner = this.#inner; + if (inner.outboundSet) { throw new ERR_INVALID_STATE('Stream outbound already configured'); } - if (this.#writer !== undefined) { + if (inner.writer !== undefined) { throw new ERR_INVALID_STATE('Stream writer already accessed'); } - this.#outboundSet = true; + inner.outboundSet = true; // If the body is a FileHandle, store it so it is closed // automatically when the stream finishes. - if (body instanceof FileHandle) { - this.#fileHandle = body; + if (FileHandle.isFileHandle(body)) { + inner.fileHandle = body; } configureOutbound(this.#handle, this, body); } @@ -2268,7 +2299,7 @@ class QuicStream { * @param {FileHandle} fh */ [kAttachFileHandle](fh) { - this.#fileHandle = fh; + this.#inner.fileHandle = fh; } /** @@ -2279,7 +2310,7 @@ class QuicStream { * @param {number|bigint} code */ stopSending(code = 0n) { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); if (this.destroyed) return; this.#handle.stopSending(BigInt(code)); } @@ -2292,7 +2323,7 @@ class QuicStream { * @param {number|bigint} code */ resetStream(code = 0n) { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); if (this.destroyed) return; this.#handle.resetStream(BigInt(code)); } @@ -2301,12 +2332,12 @@ class QuicStream { * The priority of the stream. If the stream is destroyed or if * the session does not support priority, `null` will be * returned. - * @type {{ level: 'default' | 'low' | 'high', incremental: boolean } | null} + * @type {StreamPriority | null} */ get priority() { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); if (this.destroyed || - !getQuicSessionState(this.#session).isPrioritySupported) return null; + !getQuicSessionState(this.#inner.session).isPrioritySupported) return null; const packed = this.#handle.getPriority(); const urgency = packed >> 1; const incremental = !!(packed & 1); @@ -2316,15 +2347,12 @@ class QuicStream { /** * Sets the priority of the stream. - * @param {{ - * level?: 'default' | 'low' | 'high', - * incremental?: boolean, - * }} options + * @param {StreamPriority} [options] */ setPriority(options = kEmptyObject) { - QuicStream.#assertIsQuicStream(this); + assertIsQuicStream(this); if (this.destroyed) return; - if (!getQuicSessionState(this.#session).isPrioritySupported) { + if (!getQuicSessionState(this.#inner.session).isPrioritySupported) { throw new ERR_INVALID_STATE( 'The session does not support stream priority'); } @@ -2354,7 +2382,7 @@ class QuicStream { [kSendHeaders](headers, kind = kHeadersKindInitial, flags = kHeadersFlagsTerminal) { validateObject(headers, 'headers'); - if (getQuicSessionState(this.#session).headersSupported === 2) { + if (getQuicSessionState(this.#inner.session).headersSupported === 2) { throw new ERR_INVALID_STATE( 'The negotiated QUIC application protocol does not support headers'); } @@ -2372,27 +2400,22 @@ class QuicStream { } [kFinishClose](error) { - if (this.destroyed) return this.#pendingClose.promise; + const inner = this.#inner; + inner.pendingClose ??= PromiseWithResolvers(); + if (this.destroyed) { + return inner.pendingClose.promise; + } if (error !== undefined) { - if (this.pending) { - debug(`destroying pending stream with error: ${error}`); - } else { - debug(`destroying stream ${this.id} with error: ${error}`); - } - this.#pendingClose.reject(error); + inner.pendingClose.reject(error); } else { - if (this.pending) { - debug('destroying pending stream with no error'); - } else { - debug(`destroying stream ${this.id} with no error`); - } - this.#pendingClose.resolve(); + inner.pendingClose.resolve(); } + debug('stream closed'); if (onStreamClosedChannel.hasSubscribers) { onStreamClosedChannel.publish({ __proto__: null, stream: this, - session: this.#session, + session: inner.session, error, stats: this.stats, }); @@ -2405,46 +2428,45 @@ class QuicStream { }, }); } - this.#stats[kFinishClose](); - this.#state[kFinishClose](); - this.#session[kRemoveStream](this); - if (this.#writer !== undefined) { - this.#writer.fail(error); - } - this.#session = undefined; - this.#pendingClose.reject = undefined; - this.#pendingClose.resolve = undefined; - this.#onblocked = undefined; - this.#onreset = undefined; - this.#onheaders = undefined; - this.#onerror = undefined; - this.#ontrailers = undefined; - this.#oninfo = undefined; - this.#onwanttrailers = undefined; - this.#headers = undefined; - this.#pendingTrailers = undefined; + inner.stats?.[kFinishClose](); + inner.state?.[kFinishClose](); + inner.session[kRemoveStream](this); + inner.writer?.fail(error); + inner.session = undefined; + inner.pendingClose.reject = undefined; + inner.pendingClose.resolve = undefined; + inner.onblocked = undefined; + inner.onreset = undefined; + inner.onheaders = undefined; + inner.onerror = undefined; + inner.ontrailers = undefined; + inner.oninfo = undefined; + inner.onwanttrailers = undefined; + inner.headers = undefined; + inner.pendingTrailers = undefined; this.#handle = undefined; - if (this.#fileHandle !== undefined) { + if (inner.fileHandle !== undefined) { // Close the FileHandle that was used as a body source. The close // may fail if the user already closed it -- that's expected and // harmless, so mark the promise as handled. - markPromiseAsHandled(this.#fileHandle.close()); - this.#fileHandle = undefined; + markPromiseAsHandled(this.#inner.fileHandle.close()); + inner.fileHandle = undefined; } } [kBlocked]() { + const inner = this.#inner; // The blocked event should only be called if the stream was created with // an onblocked callback. The callback should always exist here. - assert(this.#onblocked, 'Unexpected stream blocked event'); + assert(inner.onblocked, 'Unexpected stream blocked event'); if (onStreamBlockedChannel.hasSubscribers) { onStreamBlockedChannel.publish({ __proto__: null, stream: this, - session: this.#session, + session: inner.session, }); } - safeCallbackInvoke(this.#onblocked, this); + safeCallbackInvoke(inner.onblocked, this); } [kDrain]() { @@ -2453,81 +2475,85 @@ class QuicStream { } [kReset](error) { + const inner = this.#inner; // The reset event should only be called if the stream was created with // an onreset callback. The callback should always exist here. - assert(this.#onreset, 'Unexpected stream reset event'); + assert(inner.onreset, 'Unexpected stream reset event'); if (onStreamResetChannel.hasSubscribers) { onStreamResetChannel.publish({ __proto__: null, stream: this, - session: this.#session, + session: inner.session, error, }); } - safeCallbackInvoke(this.#onreset, this, error); + safeCallbackInvoke(inner.onreset, this, error); } [kHeaders](headers, kind) { const block = parseHeaderPairs(headers); const kindName = kHeadersKindName[kind] ?? kind; + const inner = this.#inner; switch (kindName) { case 'initial': - assert(this.#onheaders, 'Unexpected stream headers event'); - if (this.#headers === undefined) this.#headers = block; + assert(inner.onheaders, 'Unexpected stream headers event'); + inner.headers ??= block; if (onStreamHeadersChannel.hasSubscribers) { onStreamHeadersChannel.publish({ __proto__: null, stream: this, - session: this.#session, + session: inner.session, headers: block, }); } - safeCallbackInvoke(this.#onheaders, this, block); + safeCallbackInvoke(inner.onheaders, this, block); break; case 'trailing': if (onStreamTrailersChannel.hasSubscribers) { onStreamTrailersChannel.publish({ __proto__: null, stream: this, - session: this.#session, + session: inner.session, trailers: block, }); } - if (this.#ontrailers) - safeCallbackInvoke(this.#ontrailers, this, block); + if (inner.ontrailers) + safeCallbackInvoke(inner.ontrailers, this, block); break; case 'hints': if (onStreamInfoChannel.hasSubscribers) { onStreamInfoChannel.publish({ __proto__: null, stream: this, - session: this.#session, + session: inner.session, headers: block, }); } - if (this.#oninfo) - safeCallbackInvoke(this.#oninfo, this, block); + if (typeof inner.oninfo === 'function') + safeCallbackInvoke(inner.oninfo, this, block); break; } } [kTrailers]() { if (this.destroyed) return; + const inner = this.#inner; // 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) { - safeCallbackInvoke(this.#onwanttrailers, this); + if (inner.pendingTrailers) { + this.sendTrailers(inner.pendingTrailers); + inner.pendingTrailers = undefined; + } else if (typeof inner.onwanttrailers === 'function') { + safeCallbackInvoke(inner.onwanttrailers, this); } } [kInspect](depth, options) { - if (depth < 0) - return this; + if (depth < 0) { + return 'QuicStream { }'; + } const opts = { __proto__: null, @@ -2535,105 +2561,83 @@ class QuicStream { depth: options.depth == null ? null : options.depth - 1, }; + const { + id, + direction, + pending, + stats, + session, + } = this; + return `QuicStream ${inspect({ __proto__: null, - id: this.id, - direction: this.direction, - pending: this.pending, - stats: this.stats, - state: this.#state, - session: this.session, + id, + direction, + pending, + stats, + state: this.#inner.state, + session, }, opts)}`; } } class QuicSession { - /** @type {QuicEndpoint} */ - #endpoint = undefined; - /** @type {boolean} */ - #isPendingClose = false; - /** @type {boolean} */ - #selfInitiatedClose = false; - /** - * Flag set at the top of `destroy()` to make the method safely - * re-entrant. Distinct from `#handle === undefined` so callbacks - * that fire from C++ during teardown (e.g. `onSessionClose` -> - * `[kFinishClose]`) still see a live `#handle` and can complete - * their work. - * @type {boolean} - */ - #destroying = false; - /** - * Set to `true` once the TLS handshake has completed successfully - * (i.e. `[kHandshake]` has fired). Used to gate operations that only - * make sense for a fully-opened session - notably, attempting to - * send a `CONNECTION_CLOSE` from `endpoint.destroy(error)` cascade. - * The C++ side cannot create a valid `CONNECTION_CLOSE` packet - * before handshake completion and falls back to a path that - * re-enters JS `destroy()` and trips our `#destroying` guard, - * leaving the C++ side asserting an inconsistent state. - * @type {boolean} - */ - #handshakeCompleted = false; /** @type {object|undefined} */ #handle; - /** @type {PromiseWithResolvers} */ - #pendingClose = PromiseWithResolvers(); - /** @type {PromiseWithResolvers} */ - #pendingOpen = PromiseWithResolvers(); - /** @type {QuicSessionState} */ - #state; - /** @type {QuicSessionStats} */ - #stats; - /** @type {Set} */ - #streams = new SafeSet(); - /** @type {Function|undefined} */ - #onerror = undefined; - /** @type {OnStreamCallback} */ - #onstream = undefined; - /** @type {OnDatagramCallback|undefined} */ - #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} */ - #onearlyrejected = undefined; - /** @type {Function|undefined} */ - #onorigin = undefined; - /** @type {Function|undefined} */ - #ongoaway = undefined; - /** @type {Function|undefined} */ - #onkeylog = undefined; - /** @type {Function|undefined} */ - #onqlog = undefined; - #pendingQlog = undefined; - #handshakeInfo = undefined; - /** @type {{ local: SocketAddress, remote: SocketAddress }|undefined} */ - #path = undefined; - #certificate = undefined; - #peerCertificate = undefined; - #ephemeralKeyInfo = undefined; + + #inner = { + __proto__: null, + /** @type {QuicEndpoint} */ + endpoint: undefined, + isPendingClose: false, + selfInitiatedClose: false, + destroying: false, + handshakeCompleted: false, + pendingClose: PromiseWithResolvers(), + pendingOpen: PromiseWithResolvers(), + /** @type {QuicSessionState} */ + state: undefined, + /** @type {QuicSessionStats} */ + stats: undefined, + streams: new SafeSet(), + onerror: undefined, + onstream: undefined, + ondatagram: undefined, + ondatagramstatus: undefined, + onpathvalidation: undefined, + onsessionticket: undefined, + onversionnegotiation: undefined, + onhandshake: undefined, + onnewtoken: undefined, + onearlyrejected: undefined, + onorigin: undefined, + ongoaway: undefined, + onkeylog: undefined, + onqlog: undefined, + pendingQlog: undefined, + handshakeInfo: undefined, + /** @type {QuicSessionPath|undefined} */ + path: undefined, + certificate: undefined, + peerCertificate: undefined, + ephemeralKeyInfo: undefined, + }; static { - getQuicSessionState = function(session) { - QuicSession.#assertIsQuicSession(session); - return session.#state; + isQuicSession = function(val) { + return val != null && typeof val === 'object' && #handle in val; }; - } - static #assertIsQuicSession(val) { - if (val == null || !(#handle in val)) { - throw new ERR_INVALID_THIS('QuicSession'); - } + assertIsQuicSession = function(val) { + if (!isQuicSession(val)) { + throw new ERR_INVALID_THIS('QuicSession'); + } + }; + + getQuicSessionState = function(session) { + assertIsQuicSession(session); + return session.#inner.state; + }; } /** @@ -2643,20 +2647,22 @@ class QuicSession { */ constructor(privateSymbol, handle, endpoint) { // Instances of QuicSession can only be created internally. - if (privateSymbol !== kPrivateConstructor) { - throw new ERR_ILLEGAL_CONSTRUCTOR(); - } + assertPrivateSymbol(privateSymbol); - this.#endpoint = endpoint; this.#handle = handle; this.#handle[kOwner] = this; + + const inner = this.#inner; + inner.endpoint = endpoint; // Move any qlog entries that arrived before the wrapper existed. if (handle._pendingQlog !== undefined) { - this.#pendingQlog = handle._pendingQlog; + inner.pendingQlog = handle._pendingQlog; handle._pendingQlog = undefined; } - this.#stats = new QuicSessionStats(kPrivateConstructor, handle.stats); - this.#state = new QuicSessionState(kPrivateConstructor, handle.state); + inner.stats = new QuicSessionStats( + kPrivateConstructor, handle.stats, handle.statsByteOffset); + inner.state = new QuicSessionState( + kPrivateConstructor, handle.state, handle.stateByteOffset); if (hasObserver('quic')) { startPerf(this, kPerfEntry, { type: 'quic', name: 'QuicSession' }); @@ -2667,32 +2673,33 @@ class QuicSession { /** @type {boolean} */ get #isClosedOrClosing() { - return this.#handle === undefined || this.#isPendingClose; + return this.#handle === undefined || this.#inner.isPendingClose; } /** @type {Function|undefined} */ get onerror() { - QuicSession.#assertIsQuicSession(this); - return this.#onerror; + assertIsQuicSession(this); + return this.#inner.onerror; } set onerror(fn) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); + const inner = this.#inner; if (fn === undefined) { - this.#onerror = undefined; + inner.onerror = undefined; } else { validateFunction(fn, 'onerror'); - this.#onerror = FunctionPrototypeBind(fn, this); + inner.onerror = FunctionPrototypeBind(fn, this); // When an onerror handler is provided, mark the pending promises // as handled so that rejections from destroy(error) don't surface // as unhandled rejections. The onerror callback is the // application's error handler for this session. - markPromiseAsHandled(this.#pendingClose.promise); - markPromiseAsHandled(this.#pendingOpen.promise); + markPromiseAsHandled(inner.pendingClose.promise); + markPromiseAsHandled(inner.pendingOpen.promise); // Also mark existing streams' closed promises. Stream rejections // during session destruction are expected collateral when the // session has an error handler. - for (const stream of this.#streams) { + for (const stream of inner.streams) { markPromiseAsHandled(stream.closed); } } @@ -2700,35 +2707,37 @@ class QuicSession { /** @type {OnStreamCallback} */ get onstream() { - QuicSession.#assertIsQuicSession(this); - return this.#onstream; + assertIsQuicSession(this); + return this.#inner.onstream; } set onstream(fn) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); + const inner = this.#inner; if (fn === undefined) { - this.#onstream = undefined; + inner.onstream = undefined; } else { validateFunction(fn, 'onstream'); - this.#onstream = FunctionPrototypeBind(fn, this); + inner.onstream = FunctionPrototypeBind(fn, this); } } /** @type {OnDatagramCallback} */ get ondatagram() { - QuicSession.#assertIsQuicSession(this); - return this.#ondatagram; + assertIsQuicSession(this); + return this.#inner.ondatagram; } set ondatagram(fn) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); + const inner = this.#inner; if (fn === undefined) { - this.#ondatagram = undefined; - this.#state.hasDatagramListener = false; + inner.ondatagram = undefined; + inner.state.hasDatagramListener = false; } else { validateFunction(fn, 'ondatagram'); - this.#ondatagram = FunctionPrototypeBind(fn, this); - this.#state.hasDatagramListener = true; + inner.ondatagram = FunctionPrototypeBind(fn, this); + inner.state.hasDatagramListener = true; } } @@ -2738,71 +2747,75 @@ class QuicSession { * @type {OnDatagramStatusCallback} */ get ondatagramstatus() { - QuicSession.#assertIsQuicSession(this); - return this.#ondatagramstatus; + assertIsQuicSession(this); + return this.#inner.ondatagramstatus; } set ondatagramstatus(fn) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); + const inner = this.#inner; if (fn === undefined) { - this.#ondatagramstatus = undefined; - this.#state.hasDatagramStatusListener = false; + inner.ondatagramstatus = undefined; + inner.state.hasDatagramStatusListener = false; } else { validateFunction(fn, 'ondatagramstatus'); - this.#ondatagramstatus = FunctionPrototypeBind(fn, this); - this.#state.hasDatagramStatusListener = true; + inner.ondatagramstatus = FunctionPrototypeBind(fn, this); + inner.state.hasDatagramStatusListener = true; } } /** @type {Function|undefined} */ get onpathvalidation() { - QuicSession.#assertIsQuicSession(this); - return this.#onpathvalidation; + assertIsQuicSession(this); + return this.#inner.onpathvalidation; } set onpathvalidation(fn) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); + const inner = this.#inner; if (fn === undefined) { - this.#onpathvalidation = undefined; - this.#state.hasPathValidationListener = false; + inner.onpathvalidation = undefined; + inner.state.hasPathValidationListener = false; } else { validateFunction(fn, 'onpathvalidation'); - this.#onpathvalidation = FunctionPrototypeBind(fn, this); - this.#state.hasPathValidationListener = true; + inner.onpathvalidation = FunctionPrototypeBind(fn, this); + inner.state.hasPathValidationListener = true; } } get onkeylog() { - QuicSession.#assertIsQuicSession(this); - return this.#onkeylog; + assertIsQuicSession(this); + return this.#inner.onkeylog; } set onkeylog(fn) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); + const inner = this.#inner; if (fn === undefined) { - this.#onkeylog = undefined; + inner.onkeylog = undefined; } else { validateFunction(fn, 'onkeylog'); - this.#onkeylog = FunctionPrototypeBind(fn, this); + inner.onkeylog = FunctionPrototypeBind(fn, this); } } get onqlog() { - QuicSession.#assertIsQuicSession(this); - return this.#onqlog; + assertIsQuicSession(this); + return this.#inner.onqlog; } set onqlog(fn) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); + const inner = this.#inner; if (fn === undefined) { - this.#onqlog = undefined; + inner.onqlog = undefined; } else { validateFunction(fn, 'onqlog'); - this.#onqlog = FunctionPrototypeBind(fn, this); + inner.onqlog = FunctionPrototypeBind(fn, this); // Flush any qlog entries that were cached before the callback was set. - if (this.#pendingQlog !== undefined) { - const pending = this.#pendingQlog; - this.#pendingQlog = undefined; + if (inner.pendingQlog !== undefined) { + const pending = inner.pendingQlog; + inner.pendingQlog = undefined; for (let i = 0; i < pending.length; i += 2) { this[kQlog](pending[i], pending[i + 1]); } @@ -2812,119 +2825,126 @@ class QuicSession { /** @type {Function|undefined} */ get onsessionticket() { - QuicSession.#assertIsQuicSession(this); - return this.#onsessionticket; + assertIsQuicSession(this); + return this.#inner.onsessionticket; } set onsessionticket(fn) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); + const inner = this.#inner; if (fn === undefined) { - this.#onsessionticket = undefined; - this.#state.hasSessionTicketListener = false; + inner.onsessionticket = undefined; + inner.state.hasSessionTicketListener = false; } else { validateFunction(fn, 'onsessionticket'); - this.#onsessionticket = FunctionPrototypeBind(fn, this); - this.#state.hasSessionTicketListener = true; + inner.onsessionticket = FunctionPrototypeBind(fn, this); + inner.state.hasSessionTicketListener = true; } } /** @type {Function|undefined} */ get onversionnegotiation() { - QuicSession.#assertIsQuicSession(this); - return this.#onversionnegotiation; + assertIsQuicSession(this); + return this.#inner.onversionnegotiation; } set onversionnegotiation(fn) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); + const inner = this.#inner; if (fn === undefined) { - this.#onversionnegotiation = undefined; + inner.onversionnegotiation = undefined; } else { validateFunction(fn, 'onversionnegotiation'); - this.#onversionnegotiation = FunctionPrototypeBind(fn, this); + inner.onversionnegotiation = FunctionPrototypeBind(fn, this); } } /** @type {Function|undefined} */ get onhandshake() { - QuicSession.#assertIsQuicSession(this); - return this.#onhandshake; + assertIsQuicSession(this); + return this.#inner.onhandshake; } set onhandshake(fn) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); + const inner = this.#inner; if (fn === undefined) { - this.#onhandshake = undefined; + inner.onhandshake = undefined; } else { validateFunction(fn, 'onhandshake'); - this.#onhandshake = FunctionPrototypeBind(fn, this); + inner.onhandshake = FunctionPrototypeBind(fn, this); } } /** @type {Function|undefined} */ get onnewtoken() { - QuicSession.#assertIsQuicSession(this); - return this.#onnewtoken; + assertIsQuicSession(this); + return this.#inner.onnewtoken; } set onnewtoken(fn) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); + const inner = this.#inner; if (fn === undefined) { - this.#onnewtoken = undefined; - this.#state.hasNewTokenListener = false; + inner.onnewtoken = undefined; + inner.state.hasNewTokenListener = false; } else { validateFunction(fn, 'onnewtoken'); - this.#onnewtoken = FunctionPrototypeBind(fn, this); - this.#state.hasNewTokenListener = true; + inner.onnewtoken = FunctionPrototypeBind(fn, this); + inner.state.hasNewTokenListener = true; } } /** @type {Function|undefined} */ get onearlyrejected() { - QuicSession.#assertIsQuicSession(this); - return this.#onearlyrejected; + assertIsQuicSession(this); + return this.#inner.onearlyrejected; } set onearlyrejected(fn) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); + const inner = this.#inner; if (fn === undefined) { - this.#onearlyrejected = undefined; + inner.onearlyrejected = undefined; } else { validateFunction(fn, 'onearlyrejected'); - this.#onearlyrejected = FunctionPrototypeBind(fn, this); + inner.onearlyrejected = FunctionPrototypeBind(fn, this); } } /** @type {Function|undefined} */ get onorigin() { - QuicSession.#assertIsQuicSession(this); - return this.#onorigin; + assertIsQuicSession(this); + return this.#inner.onorigin; } set onorigin(fn) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); + const inner = this.#inner; if (fn === undefined) { - this.#onorigin = undefined; - this.#state.hasOriginListener = false; + inner.onorigin = undefined; + inner.state.hasOriginListener = false; } else { validateFunction(fn, 'onorigin'); - this.#onorigin = FunctionPrototypeBind(fn, this); - this.#state.hasOriginListener = true; + inner.onorigin = FunctionPrototypeBind(fn, this); + inner.state.hasOriginListener = true; } } /** @type {Function|undefined} */ get ongoaway() { - QuicSession.#assertIsQuicSession(this); - return this.#ongoaway; + assertIsQuicSession(this); + return this.#inner.ongoaway; } set ongoaway(fn) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); + const inner = this.#inner; if (fn === undefined) { - this.#ongoaway = undefined; + inner.ongoaway = undefined; } else { validateFunction(fn, 'ongoaway'); - this.#ongoaway = FunctionPrototypeBind(fn, this); + inner.ongoaway = FunctionPrototypeBind(fn, this); } } @@ -2934,8 +2954,8 @@ class QuicSession { * @type {bigint} */ get maxDatagramSize() { - QuicSession.#assertIsQuicSession(this); - return this.#state.maxDatagramSize; + assertIsQuicSession(this); + return this.#inner.state.maxDatagramSize; } /** @@ -2945,14 +2965,14 @@ class QuicSession { * @type {number} */ get maxPendingDatagrams() { - QuicSession.#assertIsQuicSession(this); - return this.#state.maxPendingDatagrams; + assertIsQuicSession(this); + return this.#inner.state.maxPendingDatagrams; } set maxPendingDatagrams(val) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); validateInteger(val, 'maxPendingDatagrams', 0, 0xFFFF); - this.#state.maxPendingDatagrams = val; + this.#inner.state.maxPendingDatagrams = val; } /** @@ -2960,8 +2980,8 @@ class QuicSession { * @type {QuicSessionStats} */ get stats() { - QuicSession.#assertIsQuicSession(this); - return this.#stats; + assertIsQuicSession(this); + return this.#inner.stats; } /** @@ -2970,19 +2990,19 @@ class QuicSession { * @type {QuicEndpoint|null} */ get endpoint() { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); if (this.destroyed) return null; - return this.#endpoint; + return this.#inner.endpoint; } /** * The local and remote socket addresses associated with the session. - * @type {{ local: SocketAddress, remote: SocketAddress } | undefined} + * @type {QuicSessionPath | undefined} */ get path() { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); if (this.destroyed) return undefined; - return this.#path ??= { + return this.#inner.path ??= { __proto__: null, local: new InternalSocketAddress(this.#handle.getLocalAddress()), remote: new InternalSocketAddress(this.#handle.getRemoteAddress()), @@ -2994,9 +3014,9 @@ class QuicSession { * @type {object|undefined} */ get certificate() { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); if (this.destroyed) return undefined; - return this.#certificate ??= this.#handle.getCertificate(); + return this.#inner.certificate ??= this.#handle.getCertificate(); } /** @@ -3005,9 +3025,9 @@ class QuicSession { * @type {object|undefined} */ get peerCertificate() { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); if (this.destroyed) return undefined; - return this.#peerCertificate ??= this.#handle.getPeerCertificate(); + return this.#inner.peerCertificate ??= this.#handle.getPeerCertificate(); } /** @@ -3017,9 +3037,9 @@ class QuicSession { * @type {object|undefined} */ get ephemeralKeyInfo() { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); if (this.destroyed) return undefined; - return this.#ephemeralKeyInfo ??= this.#handle.getEphemeralKey(); + return this.#inner.ephemeralKeyInfo ??= this.#handle.getEphemeralKey(); } /** @@ -3028,11 +3048,12 @@ class QuicSession { * @returns {QuicStream} */ async #createStream(direction, options = kEmptyObject) { + const inner = this.#inner; if (this.#isClosedOrClosing) { throw new ERR_INVALID_STATE('Session is closed. New streams cannot be opened.'); } const dir = direction === kStreamDirectionBidirectional ? 'bidi' : 'uni'; - if (this.#state.isStreamOpenAllowed) { + if (inner.state.isStreamOpenAllowed) { debug(`opening new pending ${dir} stream`); } else { debug(`opening new ${dir} stream`); @@ -3061,20 +3082,21 @@ class QuicSession { throw new ERR_QUIC_OPEN_STREAM_FAILED(); } - if (this.#state.isPrioritySupported) { + if (inner.state.isPrioritySupported) { const urgency = priority === 'high' ? 0 : priority === 'low' ? 7 : 3; handle.setPriority((urgency << 1) | (incremental ? 1 : 0)); } - const stream = new QuicStream(kPrivateConstructor, handle, this, direction); - this.#streams.add(stream); - if (typeof this.#onerror === 'function') { + const stream = new QuicStream( + kPrivateConstructor, handle, this, direction, true /* isLocal */); + inner.streams.add(stream); + if (typeof this.#inner.onerror === 'function') { markPromiseAsHandled(stream.closed); } // If the body was a FileHandle, store it on the stream so it is // closed automatically when the stream finishes. - if (body instanceof FileHandle) { + if (FileHandle.isFileHandle(body)) { stream[kAttachFileHandle](body); } @@ -3109,7 +3131,7 @@ class QuicSession { * @returns {Promise} */ async createBidirectionalStream(options = kEmptyObject) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); return await this.#createStream(kStreamDirectionBidirectional, options); } @@ -3120,7 +3142,7 @@ class QuicSession { * @returns {Promise} */ async createUnidirectionalStream(options = kEmptyObject) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); return await this.#createStream(kStreamDirectionUnidirectional, options); } @@ -3145,12 +3167,12 @@ class QuicSession { * @returns {Promise} The datagram ID */ async sendDatagram(datagram, encoding = 'utf8') { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); if (this.#isClosedOrClosing) { throw new ERR_INVALID_STATE('Session is closed'); } - const maxDatagramSize = this.#state.maxDatagramSize; + const maxDatagramSize = this.#inner.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 @@ -3203,7 +3225,7 @@ class QuicSession { * Initiate a key update. */ updateKey() { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); if (this.#isClosedOrClosing) { throw new ERR_INVALID_STATE('Session is closed'); } @@ -3237,12 +3259,13 @@ class QuicSession { * @returns {Promise} */ close(options = kEmptyObject) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); options = validateCloseOptions(options); + const inner = this.#inner; if (!this.#isClosedOrClosing) { - this.#isPendingClose = true; + inner.isPendingClose = true; if (options?.code !== undefined) { - this.#selfInitiatedClose = true; + inner.selfInitiatedClose = true; } debug('gracefully closing the session'); @@ -3260,13 +3283,13 @@ class QuicSession { /** @type {boolean} */ get closing() { - return this.#isPendingClose; + return this.#inner.isPendingClose; } /** @type {Promise} */ get opened() { - QuicSession.#assertIsQuicSession(this); - return this.#pendingOpen.promise; + assertIsQuicSession(this); + return this.#inner.pendingOpen.promise; } /** @@ -3275,13 +3298,13 @@ class QuicSession { * @type {Promise} */ get closed() { - QuicSession.#assertIsQuicSession(this); - return this.#pendingClose.promise; + assertIsQuicSession(this); + return this.#inner.pendingClose.promise; } /** @type {boolean} */ get destroyed() { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); return this.#handle === undefined; } @@ -3301,7 +3324,8 @@ class QuicSession { * string included in the CONNECTION_CLOSE frame (diagnostic only). */ destroy(error, options) { - QuicSession.#assertIsQuicSession(this); + assertIsQuicSession(this); + const inner = this.#inner; // Two distinct guards (see also `QuicStream.destroy`): // * `#destroying` flips synchronously here so any re-entrant call // (e.g. from a user `onerror` callback or from a cascading @@ -3312,10 +3336,10 @@ class QuicSession { // "fully torn down". Defense-in-depth for paths that may have // finished teardown without setting `#destroying` and for // repeat invocations after this method has fully run. - if (this.#destroying || this.destroyed) return; + if (inner.destroying || this.destroyed) return; if (options !== undefined) options = validateCloseOptions(options); - this.#destroying = true; + inner.destroying = true; debug('destroying the session'); @@ -3327,31 +3351,31 @@ class QuicSession { error, }); } - if (typeof this.#onerror === 'function') { - invokeOnerror(this.#onerror, error); + if (typeof inner.onerror === 'function') { + invokeOnerror(inner.onerror, error); } } // First, forcefully and immediately destroy all open streams, if any. - for (const stream of this.#streams) { + for (const stream of inner.streams) { stream.destroy(error); } // The streams should remove themselves when they are destroyed but let's // be doubly sure. - if (this.#streams.size) { + if (inner.streams.size) { process.emitWarning( - `The session is destroyed with ${this.#streams.size} active streams. ` + + `The session is destroyed with ${inner.streams.size} active streams. ` + 'This should not happen and indicates a bug in Node.js. Please open an ' + 'issue in the Node.js GitHub repository at https://github.com/nodejs/node ' + 'to report the problem.', ); } - this.#streams.clear(); + inner.streams.clear(); // Remove this session immediately from the endpoint - this.#endpoint[kRemoveSession](this); - this.#endpoint = undefined; - this.#isPendingClose = false; + inner.endpoint[kRemoveSession](this); + inner.endpoint = undefined; + inner.isPendingClose = false; // If the handshake never completed, reject the opened promise. The // session is being destroyed, so the handshake will never complete @@ -3367,54 +3391,54 @@ class QuicSession { // rejection warning - common for server-side sessions delivered via // `onsession`, which often do not await opened. The rejection is // still observable via `await session.opened`. - if (this.#pendingOpen.reject) { - markPromiseAsHandled(this.#pendingOpen.promise); - this.#pendingOpen.reject(error ?? new ERR_INVALID_STATE( + if (inner.pendingOpen.reject) { + markPromiseAsHandled(inner.pendingOpen.promise); + inner.pendingOpen.reject(error ?? new ERR_INVALID_STATE( 'Session was destroyed before it opened')); } if (error) { // If the session is still waiting to be closed, and error // is specified, reject the closed promise. - this.#pendingClose.reject?.(error); + inner.pendingClose.reject?.(error); } else { - this.#pendingClose.resolve?.(); + inner.pendingClose.resolve?.(); } - this.#pendingClose.reject = undefined; - this.#pendingClose.resolve = undefined; - this.#pendingOpen.reject = undefined; - this.#pendingOpen.resolve = undefined; + inner.pendingClose.reject = undefined; + inner.pendingClose.resolve = undefined; + inner.pendingOpen.reject = undefined; + inner.pendingOpen.resolve = undefined; - this.#state[kFinishClose](); - this.#stats[kFinishClose](); + inner.state[kFinishClose](); + inner.stats[kFinishClose](); if (this[kPerfEntry] && hasObserver('quic')) { stopPerf(this, kPerfEntry, { detail: { - stats: this.stats, - handshake: this.#handshakeInfo, - path: this.#path, + stats: inner.stats, + handshake: inner.handshakeInfo, + path: inner.path, }, }); } - this.#onerror = undefined; - this.#onstream = undefined; - this.#ondatagram = undefined; - this.#ondatagramstatus = undefined; - this.#onpathvalidation = undefined; - this.#onsessionticket = undefined; - this.#onkeylog = undefined; - this.#onversionnegotiation = undefined; - this.#onhandshake = undefined; - this.#onnewtoken = undefined; - this.#onorigin = undefined; - this.#ongoaway = undefined; - this.#path = undefined; - this.#certificate = undefined; - this.#peerCertificate = undefined; - this.#ephemeralKeyInfo = undefined; + inner.onerror = undefined; + inner.onstream = undefined; + inner.ondatagram = undefined; + inner.ondatagramstatus = undefined; + inner.onpathvalidation = undefined; + inner.onsessionticket = undefined; + inner.onkeylog = undefined; + inner.onversionnegotiation = undefined; + inner.onhandshake = undefined; + inner.onnewtoken = undefined; + inner.onorigin = undefined; + inner.ongoaway = undefined; + inner.path = undefined; + inner.certificate = undefined; + inner.peerCertificate = undefined; + inner.ephemeralKeyInfo = undefined; // Destroy the underlying C++ handle. Pass close error options if // provided so the CONNECTION_CLOSE frame carries the correct code. @@ -3430,7 +3454,7 @@ class QuicSession { __proto__: null, session: this, error, - stats: this.stats, + stats: inner.stats, }); } } @@ -3442,7 +3466,8 @@ class QuicSession { * @param {bigint} lastStreamId */ [kGoaway](lastStreamId) { - this.#isPendingClose = true; + const inner = this.#inner; + inner.isPendingClose = true; if (onSessionClosingChannel.hasSubscribers) { onSessionClosingChannel.publish({ __proto__: null, session: this }); } @@ -3453,8 +3478,8 @@ class QuicSession { lastStreamId, }); } - if (this.#ongoaway) { - safeCallbackInvoke(this.#ongoaway, this, lastStreamId); + if (typeof inner.ongoaway === 'function') { + safeCallbackInvoke(inner.ongoaway, this, lastStreamId); } } @@ -3477,7 +3502,7 @@ class QuicSession { // If the local side initiated this close with an error code (via // close({ code })), this is an intentional shutdown; not an error. // The closed promise should resolve, not reject. - if (this.#selfInitiatedClose) { + if (this.#inner.selfInitiatedClose) { this.destroy(); return; } @@ -3517,13 +3542,15 @@ class QuicSession { } [kKeylog](line) { - if (this.destroyed || this.onkeylog === undefined) return; - safeCallbackInvoke(this.#onkeylog, this, line); + const inner = this.#inner; + if (this.destroyed || inner.onkeylog === undefined) return; + safeCallbackInvoke(inner.onkeylog, this, line); } [kQlog](data, fin) { - if (this.onqlog === undefined) return; - safeCallbackInvoke(this.#onqlog, this, data, fin); + const inner = this.#inner; + if (inner.onqlog === undefined) return; + safeCallbackInvoke(inner.onqlog, this, data, fin); } /** @@ -3533,7 +3560,8 @@ class QuicSession { [kDatagram](u8, early) { // The datagram event should only be called if the session has // an ondatagram callback. The callback should always exist here. - assert(typeof this.#ondatagram === 'function', 'Unexpected datagram event'); + const inner = this.#inner; + assert(typeof inner.ondatagram === 'function', 'Unexpected datagram event'); if (this.destroyed) return; const length = TypedArrayPrototypeGetByteLength(u8); if (onSessionReceiveDatagramChannel.hasSubscribers) { @@ -3544,7 +3572,7 @@ class QuicSession { session: this, }); } - safeCallbackInvoke(this.#ondatagram, this, u8, early); + safeCallbackInvoke(inner.ondatagram, this, u8, early); } /** @@ -3552,9 +3580,10 @@ class QuicSession { * @param {'lost'|'acknowledged'} status */ [kDatagramStatus](id, status) { + const inner = this.#inner; // 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'); + assert(typeof inner.ondatagramstatus === 'function', 'Unexpected datagram status event'); if (this.destroyed) return; if (onSessionReceiveDatagramStatusChannel.hasSubscribers) { onSessionReceiveDatagramStatusChannel.publish({ @@ -3564,7 +3593,7 @@ class QuicSession { session: this, }); } - safeCallbackInvoke(this.#ondatagramstatus, this, id, status); + safeCallbackInvoke(inner.ondatagramstatus, this, id, status); } /** @@ -3577,7 +3606,8 @@ class QuicSession { */ [kPathValidation](result, newLocalAddress, newRemoteAddress, oldLocalAddress, oldRemoteAddress, preferredAddress) { - assert(typeof this.#onpathvalidation === 'function', + const inner = this.#inner; + assert(typeof inner.onpathvalidation === 'function', 'Unexpected path validation event'); if (this.destroyed) return; const newLocal = new InternalSocketAddress(newLocalAddress); @@ -3598,7 +3628,7 @@ class QuicSession { session: this, }); } - safeCallbackInvoke(this.#onpathvalidation, this, result, newLocal, newRemote, + safeCallbackInvoke(inner.onpathvalidation, this, result, newLocal, newRemote, oldLocal, oldRemote, preferredAddress); } @@ -3606,7 +3636,8 @@ class QuicSession { * @param {object} ticket */ [kSessionTicket](ticket) { - assert(typeof this.#onsessionticket === 'function', + const inner = this.#inner; + assert(typeof inner.onsessionticket === 'function', 'Unexpected session ticket event'); if (this.destroyed) return; if (onSessionTicketChannel.hasSubscribers) { @@ -3616,7 +3647,7 @@ class QuicSession { session: this, }); } - safeCallbackInvoke(this.#onsessionticket, this, ticket); + safeCallbackInvoke(inner.onsessionticket, this, ticket); } /** @@ -3624,7 +3655,8 @@ class QuicSession { * @param {SocketAddress} address */ [kNewToken](token, address) { - assert(typeof this.#onnewtoken === 'function', + const inner = this.#inner; + assert(typeof inner.onnewtoken === 'function', 'Unexpected new token event'); if (this.destroyed) return; const addr = new InternalSocketAddress(address); @@ -3636,7 +3668,7 @@ class QuicSession { session: this, }); } - safeCallbackInvoke(this.#onnewtoken, this, token, addr); + safeCallbackInvoke(inner.onnewtoken, this, token, addr); } [kEarlyDataRejected]() { @@ -3647,8 +3679,9 @@ class QuicSession { session: this, }); } - if (typeof this.#onearlyrejected === 'function') { - safeCallbackInvoke(this.#onearlyrejected, this); + const inner = this.#inner; + if (typeof inner.onearlyrejected === 'function') { + safeCallbackInvoke(inner.onearlyrejected, this); } } @@ -3668,8 +3701,9 @@ class QuicSession { session: this, }); } - if (this.#onversionnegotiation) { - safeCallbackInvoke(this.#onversionnegotiation, this, + const inner = this.#inner; + if (typeof inner.onversionnegotiation === 'function') { + safeCallbackInvoke(inner.onversionnegotiation, this, version, requestedVersions, supportedVersions); } // Version negotiation is always a fatal event - the session must be @@ -3682,9 +3716,9 @@ class QuicSession { * @param {string[]} origins */ [kOrigin](origins) { - assert(typeof this.#onorigin === 'function', - 'Unexpected origin event'); if (this.destroyed) return; + const inner = this.#inner; + assert(typeof inner.onorigin === 'function', 'Unexpected origin event'); if (onSessionOriginChannel.hasSubscribers) { onSessionOriginChannel.publish({ __proto__: null, @@ -3692,7 +3726,7 @@ class QuicSession { session: this, }); } - safeCallbackInvoke(this.#onorigin, this, origins); + safeCallbackInvoke(inner.onorigin, this, origins); } /** @@ -3705,13 +3739,15 @@ class QuicSession { */ [kHandshake](servername, protocol, cipher, cipherVersion, validationErrorReason, validationErrorCode, earlyDataAttempted, earlyDataAccepted) { - if (this.destroyed || !this.#pendingOpen.resolve) return; + const inner = this.#inner; + if (this.destroyed || !inner.pendingOpen.resolve) return; + const addr = this.#handle.getRemoteAddress(); const info = { __proto__: null, - local: this.#endpoint.address, + local: inner.endpoint.address, remote: addr !== undefined ? new InternalSocketAddress(addr) : undefined, @@ -3726,7 +3762,7 @@ class QuicSession { }; // Stash timing-relevant handshake info for the perf entry detail. - this.#handshakeInfo = { + inner.handshakeInfo = { __proto__: null, servername, protocol, @@ -3742,19 +3778,19 @@ class QuicSession { }); } - if (this.#onhandshake) { - safeCallbackInvoke(this.#onhandshake, this, info); + if (typeof inner.onhandshake === 'function') { + safeCallbackInvoke(inner.onhandshake, this, info); } - this.#pendingOpen.resolve?.(info); - this.#pendingOpen.resolve = undefined; - this.#pendingOpen.reject = undefined; - this.#handshakeCompleted = true; + inner.pendingOpen.resolve?.(info); + inner.pendingOpen.resolve = undefined; + inner.pendingOpen.reject = undefined; + inner.handshakeCompleted = true; } /** @type {boolean} */ get [kHandshakeCompleted]() { - return this.#handshakeCompleted; + return this.#inner.handshakeCompleted; } /** @@ -3762,22 +3798,25 @@ class QuicSession { * @param {number} direction */ [kNewStream](handle, direction) { - const stream = new QuicStream(kPrivateConstructor, handle, this, direction); + const inner = this.#inner; + const stream = new QuicStream(kPrivateConstructor, handle, this, direction, + false /* isLocal */); // Set the default high water mark for received streams. stream.highWaterMark = kDefaultHighWaterMark; // A new stream was received. If we don't have an onstream callback, then // there's nothing we can do about it. Destroy the stream in this case. - if (typeof this.#onstream !== 'function') { + if (typeof inner.onstream !== 'function') { process.emitWarning('A new stream was received but no onstream callback was provided'); stream.destroy(); return; } - this.#streams.add(stream); + + inner.streams.add(stream); // If the session has an onerror handler, mark the stream's closed // promise as handled. See the onerror setter for explanation. - if (typeof this.#onerror === 'function') { + if (typeof inner.onerror === 'function') { markPromiseAsHandled(stream.closed); } @@ -3800,16 +3839,17 @@ class QuicSession { }); } - safeCallbackInvoke(this.#onstream, this, stream); + safeCallbackInvoke(inner.onstream, this, stream); } [kRemoveStream](stream) { - this.#streams.delete(stream); + this.#inner.streams.delete(stream); } [kInspect](depth, options) { - if (depth < 0) - return this; + if (depth < 0) { + return 'QuicSession { }'; + } const opts = { __proto__: null, @@ -3817,100 +3857,54 @@ class QuicSession { depth: options.depth == null ? null : options.depth - 1, }; + const { + isPendingClose: closing, + endpoint, + path, + state, + stats, + streams, + } = this.#inner; + return `QuicSession ${inspect({ closed: this.closed, - closing: this.#isPendingClose, + closing, destroyed: this.destroyed, - endpoint: this.endpoint, - path: this.path, - state: this.#state, - stats: this.stats, - streams: this.#streams, + endpoint, + path, + state, + stats, + streams, }, opts)}`; } async [SymbolAsyncDispose]() { await this.close(); } } -let isQuicEndpoint; - // The QuicEndpoint represents a local UDP port binding. It can act as both a // server for receiving peer sessions, or a client for initiating them. The // local UDP port will be lazily bound only when connect() or listen() are // called. class QuicEndpoint { - /** - * The local socket address on which the endpoint is listening (lazily created) - * @type {SocketAddress|undefined} - */ - #address = undefined; - /** - * When true, the endpoint has been marked busy and is temporarily not accepting - * new sessions (only used when the Endpoint is acting as a server) - * @type {boolean} - */ - #busy = false; - /** - * The underlying C++ handle for the endpoint. When undefined the endpoint is - * considered to be closed. - * @type {object} - */ #handle; - /** - * True if endpoint.close() has been called and the [kFinishClose] method has - * not yet been called. - * @type {boolean} - */ - #isPendingClose = false; - /** - * True if the endpoint is acting as a server and actively listening for connections. - * @type {boolean} - */ - #listening = false; - /** - * A promise that is resolved when the endpoint has been closed (or rejected if - * the endpoint closes abruptly due to an error). - * @type {PromiseWithResolvers} - */ - #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. - * @type {any} - */ - #pendingError = undefined; - /** - * The collection of active sessions. - * @type {Set} - */ - #sessions = new SafeSet(); - /** - * The internal state of the endpoint. Used to efficiently track and update the - * state of the underlying c++ endpoint handle. - * @type {QuicEndpointState} - */ - #state; - /** - * The collected statistics for the endpoint. - * @type {QuicEndpointStats} - */ - #stats; - /** - * The user provided callback that is invoked when a new session is received. - * (used only when the endpoint is acting as a server) - * @type {OnSessionCallback} - */ - #onsession = undefined; - #sessionCallbacks = undefined; + #inner = { + __proto__: null, + address: undefined, + busy: false, + isPendingClose: false, + listening: false, + pendingClose: PromiseWithResolvers(), + pendingError: undefined, + sessions: new SafeSet(), + stat: undefined, + stats: undefined, + onsession: undefined, + sessionCallbacks: undefined, + }; static { - getQuicEndpointState = function(endpoint) { - assertIsQuicEndpoint(endpoint); - return endpoint.#state; - }; - isQuicEndpoint = function(val) { - return val != null && #handle in val; + return val != null && typeof val === 'object' && #handle in val; }; assertIsQuicEndpoint = function(val) { @@ -3919,6 +3913,11 @@ class QuicEndpoint { } }; + getQuicEndpointState = function(endpoint) { + assertIsQuicEndpoint(endpoint); + return endpoint.#inner.state; + }; + assertEndpointNotClosedOrClosing = function(endpoint) { if (endpoint.#isClosedOrClosing) { throw new ERR_INVALID_STATE('Endpoint is closed'); @@ -3926,7 +3925,7 @@ class QuicEndpoint { }; assertEndpointIsNotBusy = function(endpoint) { - if (endpoint.#state.isBusy) { + if (endpoint.#inner.state.isBusy) { throw new ERR_INVALID_STATE('Endpoint is busy'); } }; @@ -3942,8 +3941,8 @@ class QuicEndpoint { const { retryTokenExpiration, tokenExpiration, - maxConnectionsPerHost = 0, - maxConnectionsTotal = 0, + maxConnectionsPerHost = 100, + maxConnectionsTotal = 10_000, maxStatelessResetsPerHost, disableStatelessReset, addressLRUSize, @@ -4000,7 +3999,7 @@ class QuicEndpoint { #newSession(handle) { const session = new QuicSession(kPrivateConstructor, handle, this); - this.#sessions.add(session); + this.#inner.sessions.add(session); // Set default pending datagram queue size. session.maxPendingDatagrams = kDefaultMaxPendingDatagrams; return session; @@ -4013,8 +4012,9 @@ class QuicEndpoint { const options = this.#processEndpointOptions(config); this.#handle = new Endpoint_(options); this.#handle[kOwner] = this; - this.#stats = new QuicEndpointStats(kPrivateConstructor, this.#handle.stats); - this.#state = new QuicEndpointState(kPrivateConstructor, this.#handle.state); + const inner = this.#inner; + inner.stats = new QuicEndpointStats(kPrivateConstructor, this.#handle.stats); + inner.state = new QuicEndpointState(kPrivateConstructor, this.#handle.state); // Connection limits are stored in the shared state buffer so they // can be read by C++ and mutated from JS after construction. @@ -4049,11 +4049,11 @@ class QuicEndpoint { */ get stats() { assertIsQuicEndpoint(this); - return this.#stats; + return this.#inner.stats; } get #isClosedOrClosing() { - return this.destroyed || this.#isPendingClose; + return this.destroyed || this.#inner.isPendingClose; } /** @@ -4063,7 +4063,7 @@ class QuicEndpoint { */ get busy() { assertIsQuicEndpoint(this); - return this.#busy; + return this.#inner.busy; } /** @@ -4074,15 +4074,16 @@ class QuicEndpoint { assertEndpointNotClosedOrClosing(this); // The val is allowed to be any truthy value // Non-op if there is no change - if (!!val !== this.#busy) { - debug('toggling endpoint busy status to ', !this.#busy); - this.#busy = !this.#busy; - this.#handle.markBusy(this.#busy); + const inner = this.#inner; + if (!!val !== inner.busy) { + debug('toggling endpoint busy status to ', !inner.busy); + inner.busy = !inner.busy; + this.#handle.markBusy(inner.busy); if (onEndpointBusyChangeChannel.hasSubscribers) { onEndpointBusyChangeChannel.publish({ __proto__: null, endpoint: this, - busy: this.#busy, + busy: inner.busy, }); } } @@ -4095,13 +4096,13 @@ class QuicEndpoint { */ get maxConnectionsPerHost() { assertIsQuicEndpoint(this); - return this.#state.maxConnectionsPerHost; + return this.#inner.state.maxConnectionsPerHost; } set maxConnectionsPerHost(val) { assertIsQuicEndpoint(this); validateInteger(val, 'maxConnectionsPerHost', 0, 0xFFFF); - this.#state.maxConnectionsPerHost = val; + this.#inner.state.maxConnectionsPerHost = val; } /** @@ -4111,13 +4112,13 @@ class QuicEndpoint { */ get maxConnectionsTotal() { assertIsQuicEndpoint(this); - return this.#state.maxConnectionsTotal; + return this.#inner.state.maxConnectionsTotal; } set maxConnectionsTotal(val) { assertIsQuicEndpoint(this); validateInteger(val, 'maxConnectionsTotal', 0, 0xFFFF); - this.#state.maxConnectionsTotal = val; + this.#inner.state.maxConnectionsTotal = val; } /** @@ -4127,11 +4128,11 @@ class QuicEndpoint { get address() { assertIsQuicEndpoint(this); if (this.#isClosedOrClosing) return undefined; - if (this.#address === undefined) { + if (this.#inner.address === undefined) { const addr = this.#handle.address(); - if (addr !== undefined) this.#address = new InternalSocketAddress(addr); + if (addr !== undefined) this.#inner.address = new InternalSocketAddress(addr); } - return this.#address; + return this.#inner.address; } /** @@ -4142,11 +4143,13 @@ class QuicEndpoint { [kListen](onsession, options) { assertEndpointNotClosedOrClosing(this); assertEndpointIsNotBusy(this); - if (this.#listening) { + const inner = this.#inner; + if (inner.listening) { throw new ERR_INVALID_STATE('Endpoint is already listening'); } validateObject(options, 'options'); - this.#onsession = FunctionPrototypeBind(onsession, this); + validateFunction(onsession, 'onsession'); + this.#inner.onsession = FunctionPrototypeBind(onsession, this); const { onerror, @@ -4172,7 +4175,7 @@ class QuicEndpoint { } = options; // Store session and stream callbacks to apply to each new incoming session. - this.#sessionCallbacks = { + inner.sessionCallbacks = { __proto__: null, onerror, onstream, @@ -4194,9 +4197,9 @@ class QuicEndpoint { onwanttrailers, }; - debug('endpoint listening as a server'); this.#handle.listen(rest); - this.#listening = true; + inner.listening = true; + debug('endpoint listening as a server'); } /** @@ -4238,15 +4241,16 @@ class QuicEndpoint { assertIsQuicEndpoint(this); if (!this.#isClosedOrClosing) { debug('gracefully closing the endpoint'); + const inner = this.#inner; + inner.isPendingClose = true; + this.#handle.closeGracefully(); if (onEndpointClosingChannel.hasSubscribers) { onEndpointClosingChannel.publish({ __proto__: null, endpoint: this, - hasPendingError: this.#pendingError !== undefined, + hasPendingError: inner.pendingError !== undefined, }); } - this.#isPendingClose = true; - this.#handle.closeGracefully(); } return this.closed; } @@ -4259,7 +4263,7 @@ class QuicEndpoint { */ get closed() { assertIsQuicEndpoint(this); - return this.#pendingClose.promise; + return this.#inner.pendingClose.promise; } /** @@ -4268,13 +4272,13 @@ class QuicEndpoint { */ get closing() { assertIsQuicEndpoint(this); - return this.#isPendingClose; + return this.#inner.isPendingClose; } /** @type {boolean} */ get listening() { assertIsQuicEndpoint(this); - return this.#listening; + return this.#inner.listening; } /** @type {boolean} */ @@ -4294,6 +4298,7 @@ class QuicEndpoint { destroy(error) { assertIsQuicEndpoint(this); debug('destroying the endpoint'); + const inner = this.#inner; // Record the error before deciding whether to initiate a close. If // `close()` was already called (e.g. the user kicked off a graceful // shutdown and then a fatal error was reported afterwards via @@ -4302,7 +4307,7 @@ class QuicEndpoint { // last in-flight session finishes draining. Only the *first* error // is recorded, matching how other Node subsystems handle a // double-error race. - if (error !== undefined) this.#pendingError ??= error; + if (error !== undefined) inner.pendingError ??= error; // Force all sessions to be abruptly closed *before* signalling the // endpoint to close gracefully. The order matters: each session's // `destroy(error, options)` asks the C++ side to emit a @@ -4319,7 +4324,7 @@ class QuicEndpoint { // `destroy()`, which trips the `#destroying` guard and leaves the // C++ side asserting an inconsistent destroyed state. const closeOptions = errorToCloseOptions(error); - for (const session of this.#sessions) { + for (const session of inner.sessions) { // Mark each cascaded session's `closed` as handled before // destroying it. This prevents unhandled-rejection warnings when // the session is collateral damage from an endpoint-level destroy @@ -4347,7 +4352,7 @@ class QuicEndpoint { * replace is true, the entire SNI map is replaced. Otherwise, the * provided entries are merged into the existing map. * @param {object} entries - * @param {{replace?: boolean}} [options] + * @param {SNIContextOptions} [options] */ setSNIContexts(entries, options = kEmptyObject) { assertIsQuicEndpoint(this); @@ -4382,38 +4387,39 @@ class QuicEndpoint { debug('endpoint is finishing close', context, status); endpointRegistry.delete(this); this.#handle = undefined; - this.#stats[kFinishClose](); - this.#state[kFinishClose](); + const inner = this.#inner; + inner.stats[kFinishClose](); + inner.state[kFinishClose](); if (this[kPerfEntry] && hasObserver('quic')) { stopPerf(this, kPerfEntry, { detail: { stats: this.stats }, }); } - this.#address = undefined; - this.#busy = false; - this.#listening = false; - this.#isPendingClose = false; + inner.address = undefined; + inner.busy = false; + inner.listening = false; + inner.isPendingClose = false; // As QuicSessions are closed they are expected to remove themselves // from the sessions collection. Just in case they don't, let's force // it by resetting the set so we don't leak memory. Let's emit a warning, // tho, if the set is not empty at this point as that would indicate a // bug in Node.js that should be fixed. - if (this.#sessions.size > 0) { + if (inner.sessions.size > 0) { process.emitWarning( - `The endpoint is closed with ${this.#sessions.size} active sessions. ` + + `The endpoint is closed with ${inner.sessions.size} active sessions. ` + 'This should not happen and indicates a bug in Node.js. Please open an ' + 'issue in the Node.js GitHub repository at https://github.com/nodejs/node ' + 'to report the problem.', ); } - this.#sessions.clear(); + inner.sessions.clear(); // If destroy was called with an error, then the this.#pendingError will be // 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 = maybeGetCloseError(context, status, this.#pendingError); + const maybeCloseError = maybeGetCloseError(context, status, inner.pendingError); if (maybeCloseError !== undefined) { if (onEndpointErrorChannel.hasSubscribers) { onEndpointErrorChannel.publish({ @@ -4422,33 +4428,36 @@ class QuicEndpoint { error: maybeCloseError, }); } - this.#pendingClose.reject(maybeCloseError); + inner.pendingClose.reject(maybeCloseError); } else { // Otherwise we are good to resolve the pending close promise! - this.#pendingClose.resolve(); + inner.pendingClose.resolve(); } if (onEndpointClosedChannel.hasSubscribers) { onEndpointClosedChannel.publish({ __proto__: null, endpoint: this, - stats: this.stats, + stats: inner.stats, }); } // Note that we are intentionally not clearing the // this.#pendingClose.promise here. - this.#pendingClose.resolve = undefined; - this.#pendingClose.reject = undefined; - this.#pendingError = undefined; + inner.pendingClose.resolve = undefined; + inner.pendingClose.reject = undefined; + inner.pendingError = undefined; } [kNewSession](handle) { + const inner = this.#inner; + assert(typeof inner.onsession === 'function', + 'onsession callback not specified'); 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) { - applyCallbacks(session, this.#sessionCallbacks); + if (inner.sessionCallbacks) { + applyCallbacks(session, inner.sessionCallbacks); } if (onEndpointServerSessionChannel.hasSubscribers) { onEndpointServerSessionChannel.publish({ @@ -4458,25 +4467,24 @@ class QuicEndpoint { address: session.path?.remote, }); } - assert(typeof this.#onsession === 'function', - 'onsession callback not specified'); // Route through safeCallbackInvoke so that a synchronous throw or a // rejected promise from the user's onsession callback destroys this // endpoint with the error rather than surfacing as an unhandled // exception or unhandled rejection coming out of the C++ -> JS // boundary. - safeCallbackInvoke(this.#onsession, this, session); + safeCallbackInvoke(inner.onsession, this, session); } // Called by the QuicSession when it closes to remove itself from // the active sessions tracked by the QuicEndpoint. [kRemoveSession](session) { - this.#sessions.delete(session); + this.#inner.sessions.delete(session); } [kInspect](depth, options) { - if (depth < 0) - return this; + if (depth < 0) { + return 'QuicEndpoint { }'; + } const opts = { __proto__: null, @@ -4484,16 +4492,26 @@ class QuicEndpoint { depth: options.depth == null ? null : options.depth - 1, }; + const { + address, + busy, + isPendingClose: closing, + listening, + sessions, + stats, + state, + } = this.#inner; + return `QuicEndpoint ${inspect({ - address: this.address, - busy: this.busy, + address, + busy, closed: this.closed, - closing: this.#isPendingClose, + closing, destroyed: this.destroyed, - listening: this.#listening, - sessions: this.#sessions, - stats: this.stats, - state: this.#state, + listening, + sessions, + stats, + state, }, opts)}`; } @@ -4835,10 +4853,10 @@ function getPreferredAddressPolicy(policy = 'default') { /** * @param {SessionOptions} options - * @param {{forServer: boolean, addressFamily: string}} [config] + * @param {ProcessSessionOptions} [config] * @returns {SessionOptions} */ -function processSessionOptions(options, config = { __proto__: null }) { +function processSessionOptions(options, config = kEmptyObject) { validateObject(options, 'options'); const { endpoint, diff --git a/lib/internal/quic/state.js b/lib/internal/quic/state.js index efaccb4aa00527..82277b115d6e9a 100644 --- a/lib/internal/quic/state.js +++ b/lib/internal/quic/state.js @@ -5,7 +5,6 @@ /* c8 ignore start */ const { - ArrayBuffer, DataView, DataViewPrototypeGetBigInt64, DataViewPrototypeGetBigUint64, @@ -16,21 +15,15 @@ const { DataViewPrototypeSetUint16, DataViewPrototypeSetUint32, DataViewPrototypeSetUint8, - Float32Array, JSONStringify, - Uint8Array, + Number, } = 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 { + isBigEndian, +} = internalBinding('os'); + +const kIsLittleEndian = !isBigEndian; const { getOptionValue, @@ -58,8 +51,6 @@ const { kFinishClose, kInspect, kPrivateConstructor, - kWantsHeaders, - kWantsTrailers, } = require('internal/quic/symbols'); // This file defines the helper objects for accessing state for @@ -155,6 +146,11 @@ assert(IDX_STATE_STREAM_WANTS_TRAILERS !== undefined); assert(IDX_STATE_STREAM_WRITE_DESIRED_SIZE !== undefined); assert(IDX_STATE_STREAM_RESET_CODE !== undefined); +const kEmptyObject = { __proto__: null }; + +// The internal state objects for endpoints, sessions, and streams. +// These are not exposed directly to users. + class QuicEndpointState { /** @type {DataView} */ #handle; @@ -175,58 +171,67 @@ class QuicEndpointState { /** @type {boolean} */ get isBound() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_BOUND); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, IDX_STATE_ENDPOINT_BOUND) !== 0; } /** @type {boolean} */ get isReceiving() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_RECEIVING); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, IDX_STATE_ENDPOINT_RECEIVING) !== 0; } /** @type {boolean} */ get isListening() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_LISTENING); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, IDX_STATE_ENDPOINT_LISTENING) !== 0; } /** @type {boolean} */ get isClosing() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_CLOSING); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, IDX_STATE_ENDPOINT_CLOSING) !== 0; } /** @type {boolean} */ get isBusy() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_BUSY); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, IDX_STATE_ENDPOINT_BUSY) !== 0; } /** @type {number} */ get maxConnectionsPerHost() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; + const handle = this.#handle; + if (handle === undefined) return undefined; return DataViewPrototypeGetUint16( - this.#handle, IDX_STATE_ENDPOINT_MAX_CONNECTIONS_PER_HOST, kIsLittleEndian); + handle, IDX_STATE_ENDPOINT_MAX_CONNECTIONS_PER_HOST, kIsLittleEndian); } set maxConnectionsPerHost(val) { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; + const handle = this.#handle; + if (handle === undefined) return; DataViewPrototypeSetUint16( - this.#handle, IDX_STATE_ENDPOINT_MAX_CONNECTIONS_PER_HOST, val, kIsLittleEndian); + handle, IDX_STATE_ENDPOINT_MAX_CONNECTIONS_PER_HOST, val, kIsLittleEndian); } /** @type {number} */ get maxConnectionsTotal() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; + const handle = this.#handle; + if (handle === undefined) return undefined; return DataViewPrototypeGetUint16( - this.#handle, IDX_STATE_ENDPOINT_MAX_CONNECTIONS_TOTAL, kIsLittleEndian); + handle, IDX_STATE_ENDPOINT_MAX_CONNECTIONS_TOTAL, kIsLittleEndian); } set maxConnectionsTotal(val) { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; + const handle = this.#handle; + if (handle === undefined) return; DataViewPrototypeSetUint16( - this.#handle, IDX_STATE_ENDPOINT_MAX_CONNECTIONS_TOTAL, val, kIsLittleEndian); + handle, IDX_STATE_ENDPOINT_MAX_CONNECTIONS_TOTAL, val, kIsLittleEndian); } /** @@ -236,8 +241,9 @@ class QuicEndpointState { * @type {bigint} */ get pendingCallbacks() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_ENDPOINT_PENDING_CALLBACKS, kIsLittleEndian); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetBigUint64(handle, IDX_STATE_ENDPOINT_PENDING_CALLBACKS, kIsLittleEndian); } toString() { @@ -245,68 +251,94 @@ class QuicEndpointState { } toJSON() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return {}; + if (this.#handle === undefined) return kEmptyObject; + const { + isBound, + isReceiving, + isListening, + isClosing, + isBusy, + maxConnectionsPerHost, + maxConnectionsTotal, + pendingCallbacks, + } = this; return { __proto__: null, - isBound: this.isBound, - isReceiving: this.isReceiving, - isListening: this.isListening, - isClosing: this.isClosing, - isBusy: this.isBusy, - maxConnectionsPerHost: this.maxConnectionsPerHost, - maxConnectionsTotal: this.maxConnectionsTotal, - pendingCallbacks: `${this.pendingCallbacks}`, + isBound, + isReceiving, + isListening, + isClosing, + isBusy, + maxConnectionsPerHost, + maxConnectionsTotal, + pendingCallbacks: Number(pendingCallbacks), }; } [kInspect](depth, options) { - if (depth < 0) - return this; - - if (DataViewPrototypeGetByteLength(this.#handle) === 0) { + if (this.#handle === undefined) { return 'QuicEndpointState { }'; } + if (depth < 0) { + return 'QuicEndpointState { }'; + } + const opts = { __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; + const { + isBound, + isReceiving, + isListening, + isClosing, + isBusy, + maxConnectionsPerHost, + maxConnectionsTotal, + pendingCallbacks, + } = this; + return `QuicEndpointState ${inspect({ - isBound: this.isBound, - isReceiving: this.isReceiving, - isListening: this.isListening, - isClosing: this.isClosing, - isBusy: this.isBusy, - pendingCallbacks: this.pendingCallbacks, + isBound, + isReceiving, + isListening, + isClosing, + isBusy, + maxConnectionsPerHost, + maxConnectionsTotal, + pendingCallbacks, }, opts)}`; } [kFinishClose]() { - // Snapshot the state into a new DataView since the underlying - // buffer will be destroyed. - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; - this.#handle = new DataView(new ArrayBuffer(0)); + this.#handle = undefined; } } class QuicSessionState { /** @type {DataView} */ #handle; + /** @type {number} */ + #offset = 0; /** * @param {symbol} privateSymbol - * @param {ArrayBuffer} buffer + * @param {DataView|ArrayBuffer} view + * @param {number} [byteOffset] */ - constructor(privateSymbol, buffer) { + constructor(privateSymbol, view, byteOffset = 0) { if (privateSymbol !== kPrivateConstructor) { throw new ERR_ILLEGAL_CONSTRUCTOR(); } - if (!isArrayBuffer(buffer)) { - throw new ERR_INVALID_ARG_TYPE('buffer', ['ArrayBuffer'], buffer); + if (isArrayBuffer(view)) { + this.#handle = new DataView(view); + } else { + this.#handle = view; } - this.#handle = new DataView(buffer); + this.#offset = byteOffset; } // Listener flags are packed into a single uint32_t bitfield. The bit @@ -319,17 +351,19 @@ class QuicSessionState { static #LISTENER_ORIGIN = 1 << 5; #getListenerFlag(flag) { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!(DataViewPrototypeGetUint32( - this.#handle, IDX_STATE_SESSION_LISTENER_FLAGS, kIsLittleEndian) & flag); + const handle = this.#handle; + if (handle === undefined) return undefined; + return (DataViewPrototypeGetUint32( + handle, this.#offset + IDX_STATE_SESSION_LISTENER_FLAGS, kIsLittleEndian) & flag) !== 0; } #setListenerFlag(flag, val) { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; + const handle = this.#handle; + if (handle === undefined) return; const current = DataViewPrototypeGetUint32( - this.#handle, IDX_STATE_SESSION_LISTENER_FLAGS, kIsLittleEndian); + handle, this.#offset + IDX_STATE_SESSION_LISTENER_FLAGS, kIsLittleEndian); DataViewPrototypeSetUint32( - this.#handle, IDX_STATE_SESSION_LISTENER_FLAGS, + handle, this.#offset + IDX_STATE_SESSION_LISTENER_FLAGS, val ? (current | flag) : (current & ~flag), kIsLittleEndian); } @@ -383,50 +417,58 @@ class QuicSessionState { /** @type {boolean} */ get isClosing() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_CLOSING); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_CLOSING) !== 0; } /** @type {boolean} */ get isGracefulClose() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_GRACEFUL_CLOSE); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_GRACEFUL_CLOSE) !== 0; } /** @type {boolean} */ get isSilentClose() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_SILENT_CLOSE); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_SILENT_CLOSE) !== 0; } /** @type {boolean} */ get isStatelessReset() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_STATELESS_RESET); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_STATELESS_RESET) !== 0; } /** @type {boolean} */ get isHandshakeCompleted() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_HANDSHAKE_COMPLETED); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_HANDSHAKE_COMPLETED) !== 0; } /** @type {boolean} */ get isHandshakeConfirmed() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_HANDSHAKE_CONFIRMED); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_HANDSHAKE_CONFIRMED) !== 0; } /** @type {boolean} */ get isStreamOpenAllowed() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_STREAM_OPEN_ALLOWED); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_STREAM_OPEN_ALLOWED) !== 0; } /** @type {boolean} */ get isPrioritySupported() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_PRIORITY_SUPPORTED); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_PRIORITY_SUPPORTED) !== 0; } /** @@ -435,20 +477,23 @@ class QuicSessionState { * @type {number} */ get headersSupported() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_HEADERS_SUPPORTED); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_HEADERS_SUPPORTED); } /** @type {boolean} */ get isWrapped() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_WRAPPED); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_WRAPPED) !== 0; } /** @type {number} */ get applicationType() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_APPLICATION_TYPE); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_APPLICATION_TYPE); } /** @@ -459,9 +504,10 @@ class QuicSessionState { * @type {bigint} */ get noErrorCode() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; + const handle = this.#handle; + if (handle === undefined) return undefined; return DataViewPrototypeGetBigUint64( - this.#handle, IDX_STATE_SESSION_NO_ERROR_CODE, kIsLittleEndian); + handle, this.#offset + IDX_STATE_SESSION_NO_ERROR_CODE, kIsLittleEndian); } /** @@ -474,34 +520,43 @@ class QuicSessionState { * @type {bigint} */ get internalErrorCode() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; + const handle = this.#handle; + if (handle === undefined) return undefined; return DataViewPrototypeGetBigUint64( - this.#handle, IDX_STATE_SESSION_INTERNAL_ERROR_CODE, kIsLittleEndian); + handle, this.#offset + IDX_STATE_SESSION_INTERNAL_ERROR_CODE, kIsLittleEndian); } /** @type {number} */ get maxDatagramSize() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return DataViewPrototypeGetUint16(this.#handle, IDX_STATE_SESSION_MAX_DATAGRAM_SIZE, kIsLittleEndian); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint16( + handle, this.#offset + IDX_STATE_SESSION_MAX_DATAGRAM_SIZE, + kIsLittleEndian); } /** @type {bigint} */ get lastDatagramId() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_SESSION_LAST_DATAGRAM_ID, kIsLittleEndian); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetBigUint64( + handle, this.#offset + IDX_STATE_SESSION_LAST_DATAGRAM_ID, + kIsLittleEndian); } /** @type {number} */ get maxPendingDatagrams() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; + const handle = this.#handle; + if (handle === undefined) return undefined; return DataViewPrototypeGetUint16( - this.#handle, IDX_STATE_SESSION_MAX_PENDING_DATAGRAMS, kIsLittleEndian); + handle, this.#offset + IDX_STATE_SESSION_MAX_PENDING_DATAGRAMS, kIsLittleEndian); } set maxPendingDatagrams(val) { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; + const handle = this.#handle; + if (handle === undefined) return; DataViewPrototypeSetUint16( - this.#handle, IDX_STATE_SESSION_MAX_PENDING_DATAGRAMS, val, kIsLittleEndian); + handle, this.#offset + IDX_STATE_SESSION_MAX_PENDING_DATAGRAMS, val, kIsLittleEndian); } toString() { @@ -509,239 +564,316 @@ class QuicSessionState { } toJSON() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return {}; + if (this.#handle === undefined) return kEmptyObject; + const { + hasPathValidationListener, + hasDatagramListener, + hasDatagramStatusListener, + hasSessionTicketListener, + hasNewTokenListener, + hasOriginListener, + isClosing, + isGracefulClose, + isSilentClose, + isStatelessReset, + isHandshakeCompleted, + isHandshakeConfirmed, + isStreamOpenAllowed, + isPrioritySupported, + headersSupported, + isWrapped, + applicationType, + noErrorCode, + internalErrorCode, + maxDatagramSize, + lastDatagramId, + maxPendingDatagrams, + } = this; return { __proto__: null, - hasPathValidationListener: this.hasPathValidationListener, - hasDatagramListener: this.hasDatagramListener, - hasDatagramStatusListener: this.hasDatagramStatusListener, - hasSessionTicketListener: this.hasSessionTicketListener, - hasNewTokenListener: this.hasNewTokenListener, - hasOriginListener: this.hasOriginListener, - isClosing: this.isClosing, - isGracefulClose: this.isGracefulClose, - isSilentClose: this.isSilentClose, - isStatelessReset: this.isStatelessReset, - isHandshakeCompleted: this.isHandshakeCompleted, - isHandshakeConfirmed: this.isHandshakeConfirmed, - isStreamOpenAllowed: this.isStreamOpenAllowed, - isPrioritySupported: this.isPrioritySupported, - headersSupported: this.headersSupported, - isWrapped: this.isWrapped, - applicationType: this.applicationType, - noErrorCode: `${this.noErrorCode}`, - internalErrorCode: `${this.internalErrorCode}`, - maxDatagramSize: `${this.maxDatagramSize}`, - lastDatagramId: `${this.lastDatagramId}`, - maxPendingDatagrams: this.maxPendingDatagrams, + hasPathValidationListener, + hasDatagramListener, + hasDatagramStatusListener, + hasSessionTicketListener, + hasNewTokenListener, + hasOriginListener, + isClosing, + isGracefulClose, + isSilentClose, + isStatelessReset, + isHandshakeCompleted, + isHandshakeConfirmed, + isStreamOpenAllowed, + isPrioritySupported, + headersSupported, + isWrapped, + applicationType, + noErrorCode: `${noErrorCode}`, + internalErrorCode: `${internalErrorCode}`, + maxDatagramSize: `${maxDatagramSize}`, + lastDatagramId: `${lastDatagramId}`, + maxPendingDatagrams, }; } [kInspect](depth, options) { - if (depth < 0) - return this; - - if (DataViewPrototypeGetByteLength(this.#handle) === 0) { + if (this.#handle === undefined) { return 'QuicSessionState { }'; } + if (depth < 0) { + return 'QuicSessionState { }'; + } + const opts = { __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; + const { + hasPathValidationListener, + hasDatagramListener, + hasDatagramStatusListener, + hasSessionTicketListener, + hasNewTokenListener, + hasOriginListener, + isClosing, + isGracefulClose, + isSilentClose, + isStatelessReset, + isHandshakeCompleted, + isHandshakeConfirmed, + isStreamOpenAllowed, + isPrioritySupported, + headersSupported, + isWrapped, + applicationType, + noErrorCode, + internalErrorCode, + maxDatagramSize, + lastDatagramId, + maxPendingDatagrams, + } = this; + return `QuicSessionState ${inspect({ - hasPathValidationListener: this.hasPathValidationListener, - hasDatagramListener: this.hasDatagramListener, - hasDatagramStatusListener: this.hasDatagramStatusListener, - hasSessionTicketListener: this.hasSessionTicketListener, - hasNewTokenListener: this.hasNewTokenListener, - hasOriginListener: this.hasOriginListener, - isClosing: this.isClosing, - isGracefulClose: this.isGracefulClose, - isSilentClose: this.isSilentClose, - isStatelessReset: this.isStatelessReset, - isHandshakeCompleted: this.isHandshakeCompleted, - isHandshakeConfirmed: this.isHandshakeConfirmed, - isStreamOpenAllowed: this.isStreamOpenAllowed, - isPrioritySupported: this.isPrioritySupported, - headersSupported: this.headersSupported, - isWrapped: this.isWrapped, - applicationType: this.applicationType, - noErrorCode: this.noErrorCode, - internalErrorCode: this.internalErrorCode, - maxDatagramSize: this.maxDatagramSize, - lastDatagramId: this.lastDatagramId, + hasPathValidationListener, + hasDatagramListener, + hasDatagramStatusListener, + hasSessionTicketListener, + hasNewTokenListener, + hasOriginListener, + isClosing, + isGracefulClose, + isSilentClose, + isStatelessReset, + isHandshakeCompleted, + isHandshakeConfirmed, + isStreamOpenAllowed, + isPrioritySupported, + headersSupported, + isWrapped, + applicationType, + noErrorCode, + internalErrorCode, + maxDatagramSize, + lastDatagramId, + maxPendingDatagrams, }, opts)}`; } [kFinishClose]() { - // Snapshot the state into a new DataView since the underlying - // buffer will be destroyed. - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; - this.#handle = new DataView(new ArrayBuffer(0)); + this.#handle = undefined; } } class QuicStreamState { /** @type {DataView} */ #handle; + /** @type {number} */ + #offset = 0; + /** @type {bigint|undefined} */ + #id = undefined; /** * @param {symbol} privateSymbol - * @param {ArrayBuffer} buffer + * @param {DataView|ArrayBuffer} view + * @param {number} [byteOffset] */ - constructor(privateSymbol, buffer) { + constructor(privateSymbol, view, byteOffset = 0) { if (privateSymbol !== kPrivateConstructor) { throw new ERR_ILLEGAL_CONSTRUCTOR(); } - if (!isArrayBuffer(buffer)) { - throw new ERR_INVALID_ARG_TYPE('buffer', ['ArrayBuffer'], buffer); + if (isArrayBuffer(view)) { + this.#handle = new DataView(view); + } else { + this.#handle = view; } - this.#handle = new DataView(buffer); + this.#offset = byteOffset; } /** @type {bigint} */ get id() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return DataViewPrototypeGetBigInt64(this.#handle, IDX_STATE_STREAM_ID, kIsLittleEndian); + const handle = this.#handle; + if (handle === undefined) return this.#id; + return DataViewPrototypeGetBigInt64(handle, this.#offset + IDX_STATE_STREAM_ID, kIsLittleEndian); } /** @type {boolean} */ get pending() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_PENDING); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_STREAM_PENDING) !== 0; } /** @type {boolean} */ get finSent() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_FIN_SENT); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_STREAM_FIN_SENT) !== 0; } /** @type {boolean} */ get finReceived() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_FIN_RECEIVED); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_STREAM_FIN_RECEIVED) !== 0; } /** @type {boolean} */ get readEnded() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_READ_ENDED); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_STREAM_READ_ENDED) !== 0; } /** @type {boolean} */ get writeEnded() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WRITE_ENDED); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_STREAM_WRITE_ENDED) !== 0; } - /** @type {boolean} */ get reset() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_RESET); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_STREAM_RESET) !== 0; } /** @type {boolean} */ get hasOutbound() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_HAS_OUTBOUND); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_STREAM_HAS_OUTBOUND) !== 0; } /** @type {boolean} */ get hasReader() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_HAS_READER); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_STREAM_HAS_READER) !== 0; } /** @type {boolean} */ get wantsBlock() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_BLOCK); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_STREAM_WANTS_BLOCK) !== 0; } /** @type {boolean} */ set wantsBlock(val) { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; - DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_BLOCK, val ? 1 : 0); + const handle = this.#handle; + if (handle === undefined) return; + DataViewPrototypeSetUint8(handle, this.#offset + IDX_STATE_STREAM_WANTS_BLOCK, val ? 1 : 0); } /** @type {boolean} */ - get [kWantsHeaders]() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_HEADERS); + get wantsHeaders() { + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_STREAM_WANTS_HEADERS) !== 0; } /** @type {boolean} */ - set [kWantsHeaders](val) { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; - DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_HEADERS, val ? 1 : 0); + set wantsHeaders(val) { + const handle = this.#handle; + if (handle === undefined) return; + DataViewPrototypeSetUint8(handle, this.#offset + IDX_STATE_STREAM_WANTS_HEADERS, val ? 1 : 0); } /** @type {boolean} */ get wantsReset() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_RESET); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_STREAM_WANTS_RESET) !== 0; } /** @type {boolean} */ set wantsReset(val) { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; - DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_RESET, val ? 1 : 0); + const handle = this.#handle; + if (handle === undefined) return; + DataViewPrototypeSetUint8(handle, this.#offset + IDX_STATE_STREAM_WANTS_RESET, val ? 1 : 0); } /** @type {boolean} */ - get [kWantsTrailers]() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_TRAILERS); + get wantsTrailers() { + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_STREAM_WANTS_TRAILERS) !== 0; } /** @type {boolean} */ - set [kWantsTrailers](val) { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; - DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_TRAILERS, val ? 1 : 0); + set wantsTrailers(val) { + const handle = this.#handle; + if (handle === undefined) return; + DataViewPrototypeSetUint8(handle, this.#offset + IDX_STATE_STREAM_WANTS_TRAILERS, val ? 1 : 0); } /** @type {boolean} */ get early() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_RECEIVED_EARLY_DATA); + const handle = this.#handle; + if (handle === undefined) return undefined; + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_STREAM_RECEIVED_EARLY_DATA) !== 0; } /** @type {bigint} */ get resetCode() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; + const handle = this.#handle; + if (handle === undefined) return undefined; return DataViewPrototypeGetBigUint64( - this.#handle, IDX_STATE_STREAM_RESET_CODE, kIsLittleEndian); + handle, this.#offset + IDX_STATE_STREAM_RESET_CODE, kIsLittleEndian); } /** @type {bigint} */ get writeDesiredSize() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; + const handle = this.#handle; + if (handle === undefined) return undefined; return DataViewPrototypeGetUint32( - this.#handle, IDX_STATE_STREAM_WRITE_DESIRED_SIZE, kIsLittleEndian); + handle, this.#offset + IDX_STATE_STREAM_WRITE_DESIRED_SIZE, kIsLittleEndian); } set writeDesiredSize(val) { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; + const handle = this.#handle; + if (handle === undefined) return; DataViewPrototypeSetUint32( - this.#handle, IDX_STATE_STREAM_WRITE_DESIRED_SIZE, val, kIsLittleEndian); + handle, this.#offset + IDX_STATE_STREAM_WRITE_DESIRED_SIZE, val, kIsLittleEndian); } /** @type {number} */ get highWaterMark() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; + const handle = this.#handle; + if (handle === undefined) return undefined; return DataViewPrototypeGetUint32( - this.#handle, IDX_STATE_STREAM_HIGH_WATER_MARK, kIsLittleEndian); + handle, this.#offset + IDX_STATE_STREAM_HIGH_WATER_MARK, kIsLittleEndian); } set highWaterMark(val) { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; + const handle = this.#handle; + if (handle === undefined) return; DataViewPrototypeSetUint32( - this.#handle, IDX_STATE_STREAM_HIGH_WATER_MARK, val, kIsLittleEndian); + handle, this.#offset + IDX_STATE_STREAM_HIGH_WATER_MARK, val, kIsLittleEndian); } toString() { @@ -749,59 +881,108 @@ class QuicStreamState { } toJSON() { - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return {}; + if (this.#handle === undefined) return kEmptyObject; + const { + id, + pending, + finSent, + finReceived, + readEnded, + writeEnded, + reset, + hasOutbound, + hasReader, + wantsBlock, + wantsReset, + wantsHeaders, + wantsTrailers, + early, + resetCode, + writeDesiredSize, + highWaterMark, + } = this; return { __proto__: null, - id: `${this.id}`, - pending: this.pending, - finSent: this.finSent, - finReceived: this.finReceived, - readEnded: this.readEnded, - writeEnded: this.writeEnded, - reset: this.reset, - hasOutbound: this.hasOutbound, - hasReader: this.hasReader, - wantsBlock: this.wantsBlock, - wantsReset: this.wantsReset, - early: this.early, + id: `${id}`, + pending, + finSent, + finReceived, + readEnded, + writeEnded, + reset, + hasOutbound, + hasReader, + wantsBlock, + wantsReset, + wantsHeaders, + wantsTrailers, + early, + resetCode, + writeDesiredSize, + highWaterMark, }; } [kInspect](depth, options) { - if (depth < 0) - return this; - if (DataViewPrototypeGetByteLength(this.#handle) === 0) { return 'QuicStreamState { }'; } + if (depth < 0) { + return 'QuicStreamState { }'; + } + const opts = { __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; + const { + id, + pending, + finSent, + finReceived, + readEnded, + writeEnded, + reset, + hasOutbound, + hasReader, + wantsBlock, + wantsReset, + wantsHeaders, + wantsTrailers, + early, + resetCode, + writeDesiredSize, + highWaterMark, + } = this; + return `QuicStreamState ${inspect({ - id: this.id, - pending: this.pending, - finSent: this.finSent, - finReceived: this.finReceived, - readEnded: this.readEnded, - writeEnded: this.writeEnded, - reset: this.reset, - hasOutbound: this.hasOutbound, - hasReader: this.hasReader, - wantsBlock: this.wantsBlock, - wantsReset: this.wantsReset, - early: this.early, + id, + pending, + finSent, + finReceived, + readEnded, + writeEnded, + reset, + hasOutbound, + hasReader, + wantsBlock, + wantsReset, + wantsHeaders, + wantsTrailers, + early, + resetCode, + writeDesiredSize, + highWaterMark, }, opts)}`; } [kFinishClose]() { - // Snapshot the state into a new DataView since the underlying - // buffer will be destroyed. - if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; - this.#handle = new DataView(new ArrayBuffer(0)); + // Cache the stream ID since the buffer will be zeroed out and the ID will be lost. + this.#id = this.id; + this.#handle = undefined; } } diff --git a/lib/internal/quic/stats.js b/lib/internal/quic/stats.js index 280cf5a26f419b..9ffbff8df201cb 100644 --- a/lib/internal/quic/stats.js +++ b/lib/internal/quic/stats.js @@ -7,6 +7,8 @@ const { BigUint64Array, JSONStringify, + Symbol, + TypedArrayPrototypeSubarray, } = primordials; const { @@ -25,6 +27,7 @@ const { codes: { ERR_ILLEGAL_CONSTRUCTOR, ERR_INVALID_ARG_TYPE, + ERR_INVALID_THIS, }, } = require('internal/errors'); @@ -88,11 +91,11 @@ const { IDX_STATS_SESSION_BYTES_LOST, IDX_STATS_SESSION_PING_RECV, IDX_STATS_SESSION_PKT_DISCARDED, - IDX_STATS_SESSION_DATAGRAMS_RECEIVED, IDX_STATS_SESSION_DATAGRAMS_SENT, IDX_STATS_SESSION_DATAGRAMS_ACKNOWLEDGED, IDX_STATS_SESSION_DATAGRAMS_LOST, + IDX_STATS_SESSION_COUNT, IDX_STATS_STREAM_CREATED_AT, IDX_STATS_STREAM_OPENED_AT, @@ -105,6 +108,7 @@ const { IDX_STATS_STREAM_MAX_OFFSET_ACK, IDX_STATS_STREAM_MAX_OFFSET_RECV, IDX_STATS_STREAM_FINAL_SIZE, + IDX_STATS_STREAM_COUNT, } = internalBinding('quic'); assert(IDX_STATS_ENDPOINT_CREATED_AT !== undefined); @@ -162,6 +166,23 @@ assert(IDX_STATS_STREAM_MAX_OFFSET !== undefined); assert(IDX_STATS_STREAM_MAX_OFFSET_ACK !== undefined); assert(IDX_STATS_STREAM_MAX_OFFSET_RECV !== undefined); assert(IDX_STATS_STREAM_FINAL_SIZE !== undefined); +assert(IDX_STATS_STREAM_COUNT !== undefined); +assert(IDX_STATS_SESSION_COUNT !== undefined); + +const kCreateDisconnected = Symbol('kCreateDisconnected'); + +let assertIsQuicEndpointStats; +let assertIsQuicSessionStats; +let assertIsQuicStreamStats; +let isQuicEndpointStats; +let isQuicSessionStats; +let isQuicStreamStats; + +function assertIsPrivateConstructor(privateSymbol) { + if (privateSymbol !== kPrivateConstructor) { + throw new ERR_ILLEGAL_CONSTRUCTOR(); + } +} class QuicEndpointStats { /** @type {BigUint64Array} */ @@ -169,6 +190,18 @@ class QuicEndpointStats { /** @type {boolean} */ #disconnected = false; + static { + isQuicEndpointStats = function(val) { + return val != null && typeof val === 'object' && #handle in val; + }; + + assertIsQuicEndpointStats = function(val) { + if (!isQuicEndpointStats(val)) { + throw new ERR_INVALID_THIS('QuicEndpointStats'); + } + }; + } + /** * @param {symbol} privateSymbol * @param {ArrayBuffer} buffer @@ -176,9 +209,7 @@ class QuicEndpointStats { constructor(privateSymbol, buffer) { // We use the kPrivateConstructor symbol to restrict the ability to // create new instances of QuicEndpointStats to internal code. - if (privateSymbol !== kPrivateConstructor) { - throw new ERR_ILLEGAL_CONSTRUCTOR(); - } + assertIsPrivateConstructor(privateSymbol); if (!isArrayBuffer(buffer)) { throw new ERR_INVALID_ARG_TYPE('buffer', ['ArrayBuffer'], buffer); } @@ -187,66 +218,79 @@ class QuicEndpointStats { /** @type {bigint} */ get createdAt() { + assertIsQuicEndpointStats(this); return this.#handle[IDX_STATS_ENDPOINT_CREATED_AT]; } /** @type {bigint} */ get destroyedAt() { + assertIsQuicEndpointStats(this); return this.#handle[IDX_STATS_ENDPOINT_DESTROYED_AT]; } /** @type {bigint} */ get bytesReceived() { + assertIsQuicEndpointStats(this); return this.#handle[IDX_STATS_ENDPOINT_BYTES_RECEIVED]; } /** @type {bigint} */ get bytesSent() { + assertIsQuicEndpointStats(this); return this.#handle[IDX_STATS_ENDPOINT_BYTES_SENT]; } /** @type {bigint} */ get packetsReceived() { + assertIsQuicEndpointStats(this); return this.#handle[IDX_STATS_ENDPOINT_PACKETS_RECEIVED]; } /** @type {bigint} */ get packetsSent() { + assertIsQuicEndpointStats(this); return this.#handle[IDX_STATS_ENDPOINT_PACKETS_SENT]; } /** @type {bigint} */ get serverSessions() { + assertIsQuicEndpointStats(this); return this.#handle[IDX_STATS_ENDPOINT_SERVER_SESSIONS]; } /** @type {bigint} */ get clientSessions() { + assertIsQuicEndpointStats(this); return this.#handle[IDX_STATS_ENDPOINT_CLIENT_SESSIONS]; } /** @type {bigint} */ get serverBusyCount() { + assertIsQuicEndpointStats(this); return this.#handle[IDX_STATS_ENDPOINT_SERVER_BUSY_COUNT]; } /** @type {bigint} */ get retryCount() { + assertIsQuicEndpointStats(this); return this.#handle[IDX_STATS_ENDPOINT_RETRY_COUNT]; } /** @type {bigint} */ get versionNegotiationCount() { + assertIsQuicEndpointStats(this); return this.#handle[IDX_STATS_ENDPOINT_VERSION_NEGOTIATION_COUNT]; } /** @type {bigint} */ get statelessResetCount() { + assertIsQuicEndpointStats(this); return this.#handle[IDX_STATS_ENDPOINT_STATELESS_RESET_COUNT]; } /** @type {bigint} */ get immediateCloseCount() { + assertIsQuicEndpointStats(this); return this.#handle[IDX_STATS_ENDPOINT_IMMEDIATE_CLOSE_COUNT]; } @@ -255,30 +299,48 @@ class QuicEndpointStats { } toJSON() { + assertIsQuicEndpointStats(this); + const { + createdAt, + destroyedAt, + bytesReceived, + bytesSent, + packetsReceived, + packetsSent, + serverSessions, + clientSessions, + serverBusyCount, + retryCount, + versionNegotiationCount, + statelessResetCount, + immediateCloseCount, + } = this; return { __proto__: null, connected: this.isConnected, // We need to convert the values to strings because JSON does not // support BigInts. - createdAt: `${this.createdAt}`, - destroyedAt: `${this.destroyedAt}`, - bytesReceived: `${this.bytesReceived}`, - bytesSent: `${this.bytesSent}`, - packetsReceived: `${this.packetsReceived}`, - packetsSent: `${this.packetsSent}`, - serverSessions: `${this.serverSessions}`, - clientSessions: `${this.clientSessions}`, - serverBusyCount: `${this.serverBusyCount}`, - retryCount: `${this.retryCount}`, - versionNegotiationCount: `${this.versionNegotiationCount}`, - statelessResetCount: `${this.statelessResetCount}`, - immediateCloseCount: `${this.immediateCloseCount}`, + createdAt: `${createdAt}`, + destroyedAt: `${destroyedAt}`, + bytesReceived: `${bytesReceived}`, + bytesSent: `${bytesSent}`, + packetsReceived: `${packetsReceived}`, + packetsSent: `${packetsSent}`, + serverSessions: `${serverSessions}`, + clientSessions: `${clientSessions}`, + serverBusyCount: `${serverBusyCount}`, + retryCount: `${retryCount}`, + versionNegotiationCount: `${versionNegotiationCount}`, + statelessResetCount: `${statelessResetCount}`, + immediateCloseCount: `${immediateCloseCount}`, }; } [kInspect](depth, options) { - if (depth < 0) - return this; + assertIsQuicEndpointStats(this); + if (depth < 0) { + return 'QuicEndpointStats { }'; + } const opts = { __proto__: null, @@ -286,21 +348,37 @@ class QuicEndpointStats { depth: options.depth == null ? null : options.depth - 1, }; + const { + createdAt, + destroyedAt, + bytesReceived, + bytesSent, + packetsReceived, + packetsSent, + serverSessions, + clientSessions, + serverBusyCount, + retryCount, + versionNegotiationCount, + statelessResetCount, + immediateCloseCount, + } = this; + return `QuicEndpointStats ${inspect({ connected: this.isConnected, - createdAt: this.createdAt, - destroyedAt: this.destroyedAt, - bytesReceived: this.bytesReceived, - bytesSent: this.bytesSent, - packetsReceived: this.packetsReceived, - packetsSent: this.packetsSent, - serverSessions: this.serverSessions, - clientSessions: this.clientSessions, - serverBusyCount: this.serverBusyCount, - retryCount: this.retryCount, - versionNegotiationCount: this.versionNegotiationCount, - statelessResetCount: this.statelessResetCount, - immediateCloseCount: this.immediateCloseCount, + createdAt, + destroyedAt, + bytesReceived, + bytesSent, + packetsReceived, + packetsSent, + serverSessions, + clientSessions, + serverBusyCount, + retryCount, + versionNegotiationCount, + statelessResetCount, + immediateCloseCount, }, opts)}`; } @@ -311,12 +389,11 @@ class QuicEndpointStats { * @type {boolean} */ get isConnected() { + assertIsQuicEndpointStats(this); return !this.#disconnected; } [kFinishClose]() { - // Snapshot the stats into a new BigUint64Array since the underlying - // buffer will be destroyed. this.#handle = new BigUint64Array(this.#handle); this.#disconnected = true; } @@ -325,170 +402,215 @@ class QuicEndpointStats { class QuicSessionStats { /** @type {BigUint64Array} */ #handle; - /** @type {boolean} */ #disconnected = false; + #offset = 0; + + static { + isQuicSessionStats = function(val) { + return val != null && typeof val === 'object' && #handle in val; + }; + + assertIsQuicSessionStats = function(val) { + if (!isQuicSessionStats(val)) { + throw new ERR_INVALID_THIS('QuicSessionStats'); + } + }; + } + /** * @param {symbol} privateSymbol - * @param {ArrayBuffer} buffer + * @param {ArrayBuffer} view + * @param {number} [byteOffset] */ - constructor(privateSymbol, buffer) { + constructor(privateSymbol, view, byteOffset = 0) { // We use the kPrivateConstructor symbol to restrict the ability to // create new instances of QuicSessionStats to internal code. - if (privateSymbol !== kPrivateConstructor) { - throw new ERR_ILLEGAL_CONSTRUCTOR(); + assertIsPrivateConstructor(privateSymbol); + if (isArrayBuffer(view)) { + this.#handle = new BigUint64Array(view); + } else { + this.#handle = view; } - if (!isArrayBuffer(buffer)) { - throw new ERR_INVALID_ARG_TYPE('buffer', ['ArrayBuffer'], buffer); - } - this.#handle = new BigUint64Array(buffer); + this.#offset = byteOffset / 8; } /** @type {bigint} */ get createdAt() { - return this.#handle[IDX_STATS_SESSION_CREATED_AT]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_CREATED_AT]; } /** @type {bigint} */ get destroyedAt() { - return this.#handle[IDX_STATS_SESSION_DESTROYED_AT]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_DESTROYED_AT]; } /** @type {bigint} */ get closingAt() { - return this.#handle[IDX_STATS_SESSION_CLOSING_AT]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_CLOSING_AT]; } /** @type {bigint} */ get handshakeCompletedAt() { - return this.#handle[IDX_STATS_SESSION_HANDSHAKE_COMPLETED_AT]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_HANDSHAKE_COMPLETED_AT]; } /** @type {bigint} */ get handshakeConfirmedAt() { - return this.#handle[IDX_STATS_SESSION_HANDSHAKE_CONFIRMED_AT]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_HANDSHAKE_CONFIRMED_AT]; } /** @type {bigint} */ get bytesReceived() { - return this.#handle[IDX_STATS_SESSION_BYTES_RECEIVED]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_BYTES_RECEIVED]; } /** @type {bigint} */ get bidiInStreamCount() { - return this.#handle[IDX_STATS_SESSION_BIDI_IN_STREAM_COUNT]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_BIDI_IN_STREAM_COUNT]; } /** @type {bigint} */ get bidiOutStreamCount() { - return this.#handle[IDX_STATS_SESSION_BIDI_OUT_STREAM_COUNT]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_BIDI_OUT_STREAM_COUNT]; } /** @type {bigint} */ get uniInStreamCount() { - return this.#handle[IDX_STATS_SESSION_UNI_IN_STREAM_COUNT]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_UNI_IN_STREAM_COUNT]; } /** @type {bigint} */ get uniOutStreamCount() { - return this.#handle[IDX_STATS_SESSION_UNI_OUT_STREAM_COUNT]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_UNI_OUT_STREAM_COUNT]; } /** @type {bigint} */ get maxBytesInFlight() { - return this.#handle[IDX_STATS_SESSION_MAX_BYTES_IN_FLIGHT]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_MAX_BYTES_IN_FLIGHT]; } /** @type {bigint} */ get bytesInFlight() { - return this.#handle[IDX_STATS_SESSION_BYTES_IN_FLIGHT]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_BYTES_IN_FLIGHT]; } /** @type {bigint} */ get blockCount() { - return this.#handle[IDX_STATS_SESSION_BLOCK_COUNT]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_BLOCK_COUNT]; } /** @type {bigint} */ get cwnd() { - return this.#handle[IDX_STATS_SESSION_CWND]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_CWND]; } /** @type {bigint} */ get latestRtt() { - return this.#handle[IDX_STATS_SESSION_LATEST_RTT]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_LATEST_RTT]; } /** @type {bigint} */ get minRtt() { - return this.#handle[IDX_STATS_SESSION_MIN_RTT]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_MIN_RTT]; } /** @type {bigint} */ get rttVar() { - return this.#handle[IDX_STATS_SESSION_RTTVAR]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_RTTVAR]; } /** @type {bigint} */ get smoothedRtt() { - return this.#handle[IDX_STATS_SESSION_SMOOTHED_RTT]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_SMOOTHED_RTT]; } /** @type {bigint} */ get ssthresh() { - return this.#handle[IDX_STATS_SESSION_SSTHRESH]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_SSTHRESH]; } get pktSent() { - return this.#handle[IDX_STATS_SESSION_PKT_SENT]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_PKT_SENT]; } get bytesSent() { - return this.#handle[IDX_STATS_SESSION_BYTES_SENT]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_BYTES_SENT]; } get pktRecv() { - return this.#handle[IDX_STATS_SESSION_PKT_RECV]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_PKT_RECV]; } get bytesRecv() { - return this.#handle[IDX_STATS_SESSION_BYTES_RECV]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_BYTES_RECV]; } get pktLost() { - return this.#handle[IDX_STATS_SESSION_PKT_LOST]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_PKT_LOST]; } get bytesLost() { - return this.#handle[IDX_STATS_SESSION_BYTES_LOST]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_BYTES_LOST]; } get pingRecv() { - return this.#handle[IDX_STATS_SESSION_PING_RECV]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_PING_RECV]; } get pktDiscarded() { - return this.#handle[IDX_STATS_SESSION_PKT_DISCARDED]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_PKT_DISCARDED]; } /** @type {bigint} */ get datagramsReceived() { - return this.#handle[IDX_STATS_SESSION_DATAGRAMS_RECEIVED]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_DATAGRAMS_RECEIVED]; } /** @type {bigint} */ get datagramsSent() { - return this.#handle[IDX_STATS_SESSION_DATAGRAMS_SENT]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_DATAGRAMS_SENT]; } /** @type {bigint} */ get datagramsAcknowledged() { - return this.#handle[IDX_STATS_SESSION_DATAGRAMS_ACKNOWLEDGED]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_DATAGRAMS_ACKNOWLEDGED]; } /** @type {bigint} */ get datagramsLost() { - return this.#handle[IDX_STATS_SESSION_DATAGRAMS_LOST]; + assertIsQuicSessionStats(this); + return this.#handle[this.#offset + IDX_STATS_SESSION_DATAGRAMS_LOST]; } toString() { @@ -496,47 +618,81 @@ class QuicSessionStats { } toJSON() { + assertIsQuicSessionStats(this); + const { + createdAt, + closingAt, + handshakeCompletedAt, + handshakeConfirmedAt, + bytesReceived, + bidiInStreamCount, + bidiOutStreamCount, + uniInStreamCount, + uniOutStreamCount, + maxBytesInFlight, + bytesInFlight, + blockCount, + cwnd, + latestRtt, + minRtt, + rttVar, + smoothedRtt, + ssthresh, + pktSent, + bytesSent, + pktRecv, + bytesRecv, + pktLost, + bytesLost, + pingRecv, + pktDiscarded, + datagramsReceived, + datagramsSent, + datagramsAcknowledged, + datagramsLost, + } = this; return { __proto__: null, connected: this.isConnected, // We need to convert the values to strings because JSON does not // support BigInts. - createdAt: `${this.createdAt}`, - closingAt: `${this.closingAt}`, - handshakeCompletedAt: `${this.handshakeCompletedAt}`, - handshakeConfirmedAt: `${this.handshakeConfirmedAt}`, - bytesReceived: `${this.bytesReceived}`, - bidiInStreamCount: `${this.bidiInStreamCount}`, - bidiOutStreamCount: `${this.bidiOutStreamCount}`, - uniInStreamCount: `${this.uniInStreamCount}`, - uniOutStreamCount: `${this.uniOutStreamCount}`, - maxBytesInFlight: `${this.maxBytesInFlight}`, - bytesInFlight: `${this.bytesInFlight}`, - blockCount: `${this.blockCount}`, - cwnd: `${this.cwnd}`, - latestRtt: `${this.latestRtt}`, - minRtt: `${this.minRtt}`, - rttVar: `${this.rttVar}`, - smoothedRtt: `${this.smoothedRtt}`, - ssthresh: `${this.ssthresh}`, - pktSent: `${this.pktSent}`, - bytesSent: `${this.bytesSent}`, - pktRecv: `${this.pktRecv}`, - bytesRecv: `${this.bytesRecv}`, - pktLost: `${this.pktLost}`, - bytesLost: `${this.bytesLost}`, - pingRecv: `${this.pingRecv}`, - pktDiscarded: `${this.pktDiscarded}`, - datagramsReceived: `${this.datagramsReceived}`, - datagramsSent: `${this.datagramsSent}`, - datagramsAcknowledged: `${this.datagramsAcknowledged}`, - datagramsLost: `${this.datagramsLost}`, + createdAt: `${createdAt}`, + closingAt: `${closingAt}`, + handshakeCompletedAt: `${handshakeCompletedAt}`, + handshakeConfirmedAt: `${handshakeConfirmedAt}`, + bytesReceived: `${bytesReceived}`, + bidiInStreamCount: `${bidiInStreamCount}`, + bidiOutStreamCount: `${bidiOutStreamCount}`, + uniInStreamCount: `${uniInStreamCount}`, + uniOutStreamCount: `${uniOutStreamCount}`, + maxBytesInFlight: `${maxBytesInFlight}`, + bytesInFlight: `${bytesInFlight}`, + blockCount: `${blockCount}`, + cwnd: `${cwnd}`, + latestRtt: `${latestRtt}`, + minRtt: `${minRtt}`, + rttVar: `${rttVar}`, + smoothedRtt: `${smoothedRtt}`, + ssthresh: `${ssthresh}`, + pktSent: `${pktSent}`, + bytesSent: `${bytesSent}`, + pktRecv: `${pktRecv}`, + bytesRecv: `${bytesRecv}`, + pktLost: `${pktLost}`, + bytesLost: `${bytesLost}`, + pingRecv: `${pingRecv}`, + pktDiscarded: `${pktDiscarded}`, + datagramsReceived: `${datagramsReceived}`, + datagramsSent: `${datagramsSent}`, + datagramsAcknowledged: `${datagramsAcknowledged}`, + datagramsLost: `${datagramsLost}`, }; } [kInspect](depth, options) { - if (depth < 0) - return this; + if (depth < 0) { + return 'QuicSessionStats { }'; + } const opts = { __proto__: null, @@ -544,38 +700,71 @@ class QuicSessionStats { depth: options.depth == null ? null : options.depth - 1, }; + const { + createdAt, + closingAt, + handshakeCompletedAt, + handshakeConfirmedAt, + bytesReceived, + bidiInStreamCount, + bidiOutStreamCount, + uniInStreamCount, + uniOutStreamCount, + maxBytesInFlight, + bytesInFlight, + blockCount, + cwnd, + latestRtt, + minRtt, + rttVar, + smoothedRtt, + ssthresh, + pktSent, + bytesSent, + pktRecv, + bytesRecv, + pktLost, + bytesLost, + pingRecv, + pktDiscarded, + datagramsReceived, + datagramsSent, + datagramsAcknowledged, + datagramsLost, + } = this; + return `QuicSessionStats ${inspect({ connected: this.isConnected, - createdAt: this.createdAt, - closingAt: this.closingAt, - handshakeCompletedAt: this.handshakeCompletedAt, - handshakeConfirmedAt: this.handshakeConfirmedAt, - bytesReceived: this.bytesReceived, - bidiInStreamCount: this.bidiInStreamCount, - bidiOutStreamCount: this.bidiOutStreamCount, - uniInStreamCount: this.uniInStreamCount, - uniOutStreamCount: this.uniOutStreamCount, - maxBytesInFlight: this.maxBytesInFlight, - bytesInFlight: this.bytesInFlight, - blockCount: this.blockCount, - cwnd: this.cwnd, - latestRtt: this.latestRtt, - minRtt: this.minRtt, - rttVar: this.rttVar, - smoothedRtt: this.smoothedRtt, - ssthresh: this.ssthresh, - pktSent: this.pktSent, - bytesSent: this.bytesSent, - pktRecv: this.pktRecv, - bytesRecv: this.bytesRecv, - pktLost: this.pktLost, - bytesLost: this.bytesLost, - pingRecv: this.pingRecv, - pktDiscarded: this.pktDiscarded, - datagramsReceived: this.datagramsReceived, - datagramsSent: this.datagramsSent, - datagramsAcknowledged: this.datagramsAcknowledged, - datagramsLost: this.datagramsLost, + createdAt, + closingAt, + handshakeCompletedAt, + handshakeConfirmedAt, + bytesReceived, + bidiInStreamCount, + bidiOutStreamCount, + uniInStreamCount, + uniOutStreamCount, + maxBytesInFlight, + bytesInFlight, + blockCount, + cwnd, + latestRtt, + minRtt, + rttVar, + smoothedRtt, + ssthresh, + pktSent, + bytesSent, + pktRecv, + bytesRecv, + pktLost, + bytesLost, + pingRecv, + pktDiscarded, + datagramsReceived, + datagramsSent, + datagramsAcknowledged, + datagramsLost, }, opts)}`; } @@ -590,9 +779,11 @@ class QuicSessionStats { } [kFinishClose]() { - // Snapshot the stats into a new BigUint64Array since the underlying - // buffer will be destroyed. - this.#handle = new BigUint64Array(this.#handle); + const view = TypedArrayPrototypeSubarray(this.#handle, + this.#offset, + this.#offset + IDX_STATS_STREAM_COUNT); + this.#handle = new BigUint64Array(view); + this.#offset = 0; this.#disconnected = true; } } @@ -600,78 +791,102 @@ class QuicSessionStats { class QuicStreamStats { /** @type {BigUint64Array} */ #handle; - /** type {boolean} */ + #offset = 0; #disconnected = false; + static { + isQuicStreamStats = function(val) { + return val != null && typeof val === 'object' && #handle in val; + }; + + assertIsQuicStreamStats = function(val) { + if (!isQuicStreamStats(val)) { + throw new ERR_INVALID_THIS('QuicStreamStats'); + } + }; + } + /** * @param {symbol} privateSymbol - * @param {ArrayBuffer} buffer + * @param {BigUint64Array|ArrayBuffer} view + * @param {number} [byteOffset] - byte offset into the shared page view */ - constructor(privateSymbol, buffer) { + constructor(privateSymbol, view, byteOffset = 0) { // We use the kPrivateConstructor symbol to restrict the ability to // create new instances of QuicStreamStats to internal code. - if (privateSymbol !== kPrivateConstructor) { - throw new ERR_ILLEGAL_CONSTRUCTOR(); - } - if (!isArrayBuffer(buffer)) { - throw new ERR_INVALID_ARG_TYPE('buffer', ['ArrayBuffer'], buffer); + assertIsPrivateConstructor(privateSymbol); + if (isArrayBuffer(view)) { + this.#handle = new BigUint64Array(view); + } else { + this.#handle = view; } - this.#handle = new BigUint64Array(buffer); + this.#offset = byteOffset / 8; } /** @type {bigint} */ get createdAt() { - return this.#handle[IDX_STATS_STREAM_CREATED_AT]; + assertIsQuicStreamStats(this); + return this.#handle[this.#offset + IDX_STATS_STREAM_CREATED_AT]; } /** @type {bigint} */ get openedAt() { - return this.#handle[IDX_STATS_STREAM_OPENED_AT]; + assertIsQuicStreamStats(this); + return this.#handle[this.#offset + IDX_STATS_STREAM_OPENED_AT]; } /** @type {bigint} */ get receivedAt() { - return this.#handle[IDX_STATS_STREAM_RECEIVED_AT]; + assertIsQuicStreamStats(this); + return this.#handle[this.#offset + IDX_STATS_STREAM_RECEIVED_AT]; } /** @type {bigint} */ get ackedAt() { - return this.#handle[IDX_STATS_STREAM_ACKED_AT]; + assertIsQuicStreamStats(this); + return this.#handle[this.#offset + IDX_STATS_STREAM_ACKED_AT]; } /** @type {bigint} */ get destroyedAt() { - return this.#handle[IDX_STATS_STREAM_DESTROYED_AT]; + assertIsQuicStreamStats(this); + return this.#handle[this.#offset + IDX_STATS_STREAM_DESTROYED_AT]; } /** @type {bigint} */ get bytesReceived() { - return this.#handle[IDX_STATS_STREAM_BYTES_RECEIVED]; + assertIsQuicStreamStats(this); + return this.#handle[this.#offset + IDX_STATS_STREAM_BYTES_RECEIVED]; } /** @type {bigint} */ get bytesSent() { - return this.#handle[IDX_STATS_STREAM_BYTES_SENT]; + assertIsQuicStreamStats(this); + return this.#handle[this.#offset + IDX_STATS_STREAM_BYTES_SENT]; } /** @type {bigint} */ get maxOffset() { - return this.#handle[IDX_STATS_STREAM_MAX_OFFSET]; + assertIsQuicStreamStats(this); + return this.#handle[this.#offset + IDX_STATS_STREAM_MAX_OFFSET]; } /** @type {bigint} */ get maxOffsetAcknowledged() { - return this.#handle[IDX_STATS_STREAM_MAX_OFFSET_ACK]; + assertIsQuicStreamStats(this); + return this.#handle[this.#offset + IDX_STATS_STREAM_MAX_OFFSET_ACK]; } /** @type {bigint} */ get maxOffsetReceived() { - return this.#handle[IDX_STATS_STREAM_MAX_OFFSET_RECV]; + assertIsQuicStreamStats(this); + return this.#handle[this.#offset + IDX_STATS_STREAM_MAX_OFFSET_RECV]; } /** @type {bigint} */ get finalSize() { - return this.#handle[IDX_STATS_STREAM_FINAL_SIZE]; + assertIsQuicStreamStats(this); + return this.#handle[this.#offset + IDX_STATS_STREAM_FINAL_SIZE]; } toString() { @@ -679,28 +894,43 @@ class QuicStreamStats { } toJSON() { + assertIsQuicStreamStats(this); + const { + createdAt, + openedAt, + receivedAt, + ackedAt, + destroyedAt, + bytesReceived, + bytesSent, + maxOffset, + maxOffsetAcknowledged, + maxOffsetReceived, + finalSize, + } = this; return { __proto__: null, connected: this.isConnected, // We need to convert the values to strings because JSON does not // support BigInts. - createdAt: `${this.createdAt}`, - openedAt: `${this.openedAt}`, - receivedAt: `${this.receivedAt}`, - ackedAt: `${this.ackedAt}`, - destroyedAt: `${this.destroyedAt}`, - bytesReceived: `${this.bytesReceived}`, - bytesSent: `${this.bytesSent}`, - maxOffset: `${this.maxOffset}`, - maxOffsetAcknowledged: `${this.maxOffsetAcknowledged}`, - maxOffsetReceived: `${this.maxOffsetReceived}`, - finalSize: `${this.finalSize}`, + createdAt: `${createdAt}`, + openedAt: `${openedAt}`, + receivedAt: `${receivedAt}`, + ackedAt: `${ackedAt}`, + destroyedAt: `${destroyedAt}`, + bytesReceived: `${bytesReceived}`, + bytesSent: `${bytesSent}`, + maxOffset: `${maxOffset}`, + maxOffsetAcknowledged: `${maxOffsetAcknowledged}`, + maxOffsetReceived: `${maxOffsetReceived}`, + finalSize: `${finalSize}`, }; } [kInspect](depth, options) { - if (depth < 0) - return this; + if (depth < 0) { + return 'QuicStreamStats { }'; + } const opts = { __proto__: null, @@ -708,19 +938,33 @@ class QuicStreamStats { depth: options.depth == null ? null : options.depth - 1, }; + const { + createdAt, + openedAt, + receivedAt, + ackedAt, + destroyedAt, + bytesReceived, + bytesSent, + maxOffset, + maxOffsetAcknowledged, + maxOffsetReceived, + finalSize, + } = this; + return `QuicStreamStats ${inspect({ connected: this.isConnected, - createdAt: this.createdAt, - openedAt: this.openedAt, - receivedAt: this.receivedAt, - ackedAt: this.ackedAt, - destroyedAt: this.destroyedAt, - bytesReceived: this.bytesReceived, - bytesSent: this.bytesSent, - maxOffset: this.maxOffset, - maxOffsetAcknowledged: this.maxOffsetAcknowledged, - maxOffsetReceived: this.maxOffsetReceived, - finalSize: this.finalSize, + createdAt, + openedAt, + receivedAt, + ackedAt, + destroyedAt, + bytesReceived, + bytesSent, + maxOffset, + maxOffsetAcknowledged, + maxOffsetReceived, + finalSize, }, opts)}`; } @@ -735,17 +979,29 @@ class QuicStreamStats { } [kFinishClose]() { - // Snapshot the stats into a new BigUint64Array since the underlying - // buffer will be destroyed. - this.#handle = new BigUint64Array(this.#handle); + const view = TypedArrayPrototypeSubarray(this.#handle, + this.#offset, + this.#offset + IDX_STATS_STREAM_COUNT); + this.#handle = new BigUint64Array(view); + this.#offset = 0; this.#disconnected = true; } + + // Creates an immediately disconnected QuicStreamStats object. Used when + // lazily creating stats for a stream that has already been destroyed. + static [kCreateDisconnected]() { + const count = IDX_STATS_STREAM_FINAL_SIZE + 1; + const stats = new QuicStreamStats(kPrivateConstructor, new BigUint64Array(count), 0); + stats.#disconnected = true; + return stats; + } } module.exports = { QuicEndpointStats, QuicSessionStats, QuicStreamStats, + kCreateDisconnected, }; /* c8 ignore stop */ diff --git a/lib/internal/quic/symbols.js b/lib/internal/quic/symbols.js index 9a8e9155f0b636..75f5b72ae22669 100644 --- a/lib/internal/quic/symbols.js +++ b/lib/internal/quic/symbols.js @@ -57,8 +57,6 @@ const kSendHeaders = Symbol('kSendHeaders'); const kSessionTicket = Symbol('kSessionTicket'); const kTrailers = Symbol('kTrailers'); const kVersionNegotiation = Symbol('kVersionNegotiation'); -const kWantsHeaders = Symbol('kWantsHeaders'); -const kWantsTrailers = Symbol('kWantsTrailers'); module.exports = { kAttachFileHandle, @@ -93,8 +91,6 @@ module.exports = { kSessionTicket, kTrailers, kVersionNegotiation, - kWantsHeaders, - kWantsTrailers, }; /* c8 ignore stop */ diff --git a/src/aliased_struct-inl.h b/src/aliased_struct-inl.h index 17d5ff58097e22..ff70f423eb1bd1 100644 --- a/src/aliased_struct-inl.h +++ b/src/aliased_struct-inl.h @@ -47,6 +47,95 @@ AliasedStruct::~AliasedStruct() { if (ptr_ != nullptr) ptr_->~T(); } +// --------------------------------------------------------------------------- +// AliasedStructArena implementation +// --------------------------------------------------------------------------- + +template +typename AliasedStructArena::Page* +AliasedStructArena::FindOrCreatePage(v8::Isolate* isolate) { + for (auto& p : pages_) { + if (p->HasFreeSlots()) return p.get(); + } + auto p = std::make_unique(); + p->Init(isolate); + Page* raw = p.get(); + pages_.push_back(std::move(p)); + return raw; +} + +template +template +typename AliasedStructArena::Slot +AliasedStructArena::Allocate(v8::Isolate* isolate, + Args&&... args) { + Page* page = FindOrCreatePage(isolate); + DCHECK(page->HasFreeSlots()); + + uint32_t idx = page->free_head; + T* raw = &page->base[idx]; + + // Advance freelist before placement new overwrites the linkage. + page->free_head = *reinterpret_cast(raw); + page->used_count++; + + // Placement-construct T in the slot. + T* ptr = new (raw) T(std::forward(args)...); + + Slot slot; + slot.page = static_cast(page); + slot.ptr = static_cast(ptr); + slot.index = idx; + slot.byte_offset = reinterpret_cast(ptr) - + static_cast(page->store->Data()); + return slot; +} + +template +void AliasedStructArena::Release( + typename AliasedStructArena::Slot&& slot) { + if (!slot) return; + auto* page = static_cast(slot.page); + auto* ptr = static_cast(slot.ptr); + uint32_t idx = slot.index; + + // Destruct and zero so JS views see clean data. + ptr->~T(); + memset(ptr, 0, sizeof(T)); + + // Push onto page freelist. + *reinterpret_cast(ptr) = page->free_head; + page->free_head = idx; + page->used_count--; + + slot.page = nullptr; + slot.ptr = nullptr; + + // Drop empty pages. The shared_ptr ensures the + // underlying memory stays alive until V8 GCs any remaining JS + // references to the page's ArrayBuffer/views. + if (page->used_count == 0) { + for (auto it = pages_.begin(); it != pages_.end(); ++it) { + if (it->get() == page) { + pages_.erase(it); + break; + } + } + } +} + +template +void AliasedStructArena::ReleaseSlot(ArenaSlotBase& base) { + Slot slot; + slot.page = base.page; + slot.ptr = base.ptr; + slot.index = base.index; + slot.byte_offset = base.byte_offset; + Release(std::move(slot)); + base.page = nullptr; + base.ptr = nullptr; +} + } // namespace node #endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS diff --git a/src/aliased_struct.h b/src/aliased_struct.h index e4df393f4985a3..97753192723feb 100644 --- a/src/aliased_struct.h +++ b/src/aliased_struct.h @@ -3,9 +3,10 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS +#include +#include #include "node_internals.h" #include "v8.h" -#include namespace node { @@ -56,6 +57,190 @@ class AliasedStruct final { v8::Global buffer_; }; +// --------------------------------------------------------------------------- +// ArenaSlot — type-erased handle to a slot in an AliasedStructArena page. +// This can be stored in headers where T is incomplete. The typed accessors +// are provided via a thin typed wrapper (AliasedStructArena::Slot). +struct ArenaSlotBase { + // Opaque page pointer — only the arena knows the concrete type. + void* page = nullptr; + void* ptr = nullptr; + uint32_t index = 0; + size_t byte_offset = 0; + + explicit operator bool() const { return ptr != nullptr; } + + // Returns the page's ArrayBuffer. Implemented below after ArenaPageHeader. + v8::Local GetArrayBuffer(v8::Isolate* isolate) const; + + size_t GetByteOffset() const { return byte_offset; } + + // Returns the page's cached DataView over the full page. + // Callers use byte_offset to index into the correct slot region. + v8::Local GetPageDataView(v8::Isolate* isolate) const; + + // Returns the page's cached BigUint64Array over the full page. + // Callers use byte_offset / sizeof(uint64_t) to index into the + // correct slot region. + v8::Local GetPageBigUint64Array( + v8::Isolate* isolate) const; +}; + +// --------------------------------------------------------------------------- +// AliasedStructArena — pool allocator for AliasedStruct-style shared +// memory. Instead of creating a separate ArrayBuffer + BackingStore per +// instance, the arena pre-allocates pages of N slots backed by a single +// ArrayBuffer each. Callers receive a Slot handle that provides the same +// T*/operator-> interface as AliasedStruct, plus the ability to create a +// JS typed-array view over just that slot's region of the page buffer. +// +// Pages target kPageBytes (default 16 KB) for L1 cache residency during +// sequential access patterns. Slots are recycled via an intrusive +// freelist, and empty pages are dropped when their last slot is released. +// +// Usage: +// AliasedStructArena arena; +// auto slot = arena.Allocate(isolate); +// slot->some_field = 42; +// auto view = slot.GetArrayBuffer(isolate); // JS-visible view +// ... +// arena.Release(std::move(slot)); // return to freelist +// +template +class AliasedStructArena final { + public: + static constexpr size_t kSlotsPerPage = kPageBytes / sizeof(T); + static_assert(kSlotsPerPage >= 4, "Page too small for type T"); + static_assert(sizeof(T) >= sizeof(uint32_t), + "T must be at least 4 bytes for freelist linkage"); + + AliasedStructArena() = default; + ~AliasedStructArena() = default; + + AliasedStructArena(const AliasedStructArena&) = delete; + AliasedStructArena& operator=(const AliasedStructArena&) = delete; + + struct Page { + std::shared_ptr store; + v8::Global buffer; + // Lazily created full-page views shared by all slots in + // this page. Typically only one is used per arena. + v8::Global data_view; + v8::Global big_uint64_array; + size_t page_byte_length = 0; + T* base = nullptr; + uint32_t free_head = 0; + uint32_t used_count = 0; + static constexpr uint32_t kNoFreeSlot = UINT32_MAX; + + void Init(v8::Isolate* isolate) { + const v8::HandleScope handle_scope(isolate); + const size_t total_bytes = kSlotsPerPage * sizeof(T); + store = v8::ArrayBuffer::NewBackingStore(isolate, total_bytes); + memset(store->Data(), 0, total_bytes); + base = static_cast(store->Data()); + page_byte_length = total_bytes; + v8::Local ab = v8::ArrayBuffer::New(isolate, store); + buffer = v8::Global(isolate, ab); + + // Build freelist: each slot points to the next. + for (uint32_t i = 0; i < kSlotsPerPage - 1; i++) { + *reinterpret_cast(&base[i]) = i + 1; + } + *reinterpret_cast(&base[kSlotsPerPage - 1]) = kNoFreeSlot; + free_head = 0; + used_count = 0; + } + + bool HasFreeSlots() const { return free_head != kNoFreeSlot; } + }; + + // Typed slot handle — wraps ArenaSlotBase with T* accessors. + class Slot : public ArenaSlotBase { + public: + Slot() = default; + + const T& operator*() const { return *static_cast(ptr); } + T& operator*() { return *static_cast(ptr); } + const T* operator->() const { return static_cast(ptr); } + T* operator->() { return static_cast(ptr); } + T* Data() { return static_cast(ptr); } + const T* Data() const { return static_cast(ptr); } + }; + + // Allocate a slot, placement-constructing T with the given args. + // Creates a new page if all existing pages are full. + template + Slot Allocate(v8::Isolate* isolate, Args&&... args); + + // Release a slot back to the arena freelist. Calls ~T() and zeros + // the memory so that any JS views see clean data. + void Release(Slot&& slot); + + // Release a slot given a type-erased ArenaSlotBase reference. + // Convenience for callers that store ArenaSlotBase in headers where + // T is incomplete. + void ReleaseSlot(ArenaSlotBase& base); + + private: + Page* FindOrCreatePage(v8::Isolate* isolate); + + std::vector> pages_; +}; + +// ArenaSlotBase accessors need to reach the v8::Globals inside a Page. +// All AliasedStructArena::Page types share the same leading layout. +// The page_byte_length field allows lazy view creation without knowing T. +namespace detail { +struct ArenaPageHeader { + std::shared_ptr store; + v8::Global buffer; + v8::Global data_view; + v8::Global big_uint64_array; + size_t page_byte_length = 0; + + v8::Local GetDataView(v8::Isolate* isolate) { + if (data_view.IsEmpty()) { + const v8::HandleScope handle_scope(isolate); + auto dv = v8::DataView::New(buffer.Get(isolate), 0, page_byte_length); + data_view = v8::Global(isolate, dv); + } + return data_view.Get(isolate); + } + + v8::Local GetBigUint64Array(v8::Isolate* isolate) { + if (big_uint64_array.IsEmpty()) { + const v8::HandleScope handle_scope(isolate); + auto bu = v8::BigUint64Array::New( + buffer.Get(isolate), 0, page_byte_length / sizeof(uint64_t)); + big_uint64_array = v8::Global(isolate, bu); + } + return big_uint64_array.Get(isolate); + } +}; +} // namespace detail + +inline v8::Local ArenaSlotBase::GetArrayBuffer( + v8::Isolate* isolate) const { + DCHECK_NOT_NULL(page); + auto* header = static_cast(page); + return header->buffer.Get(isolate); +} + +inline v8::Local ArenaSlotBase::GetPageDataView( + v8::Isolate* isolate) const { + DCHECK_NOT_NULL(page); + auto* header = static_cast(page); + return header->GetDataView(isolate); +} + +inline v8::Local ArenaSlotBase::GetPageBigUint64Array( + v8::Isolate* isolate) const { + DCHECK_NOT_NULL(page); + auto* header = static_cast(page); + return header->GetBigUint64Array(isolate); +} + } // namespace node #endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS diff --git a/src/quic/application.cc b/src/quic/application.cc index b5d8c8609fa3dc..96ddf6c84cbf0e 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -239,7 +239,8 @@ void Session::Application::ReceiveStreamReset(Stream* stream, // < 0 (other): fatal error, session already closed ssize_t Session::Application::TryWritePendingDatagram(PathStorage* path, uint8_t* dest, - size_t destlen) { + size_t destlen, + uint64_t ts) { CHECK(session_->HasPendingDatagrams()); auto max_attempts = session_->config().options.max_datagram_send_attempts; @@ -262,9 +263,12 @@ ssize_t Session::Application::TryWritePendingDatagram(PathStorage* path, int accepted = 0; int dg_flags = NGTCP2_WRITE_DATAGRAM_FLAG_MORE; + // PacketInfo for the datagram path. When libuv gains per-socket ECN + // marking, the value from ngtcp2 should be forwarded to the send path. + PacketInfo dg_pi; ssize_t dg_nwrite = ngtcp2_conn_writev_datagram(*session_, &path->path, - nullptr, + dg_pi, dest, destlen, &accepted, @@ -272,7 +276,7 @@ ssize_t Session::Application::TryWritePendingDatagram(PathStorage* path, dg.id, &dgvec, 1, - uv_hrtime()); + ts); if (accepted) { // Nice, the datagram was accepted! @@ -329,20 +333,51 @@ void Session::Application::SendPendingData() { if (!session().can_send_packets()) [[unlikely]] { return; } - static constexpr size_t kMaxPackets = 32; + // Upper bound on packets per SendPendingData call. ngtcp2's send quantum + // is typically 64 KB, which at 1200-byte minimum packet size is ~53 + // packets. 64 covers the worst case with headroom. The actual count per + // call is dynamically capped by ngtcp2_conn_get_send_quantum(). + static constexpr size_t kMaxPackets = 64; Debug(session_, "Application sending pending data"); + // Cache the timestamp once for the entire send loop. ngtcp2 does not + // require nanosecond-accurate monotonicity within a single burst — + // a single timestamp per SendPendingData call is what other QUIC + // implementations use (e.g., quiche, msquic). When kernel-level + // packet pacing becomes available via libuv, this timestamp becomes + // the base for computing per-packet transmit timestamps. + const uint64_t ts = uv_hrtime(); PathStorage path; StreamData stream_data; bool closed = false; + + // Batch accumulation: packets are collected here and flushed via + // Session::SendBatch when the loop exits, the batch is full, or + // on early return. This enables synchronous batched delivery via + // uv_udp_try_send2 (sendmmsg) from the deferred flush path. + Packet::Ptr batch[kMaxPackets]; + PathStorage batch_paths[kMaxPackets]; + size_t batch_count = 0; + + auto flush_batch = [&] { + if (batch_count == 0) return; + session_->SendBatch(batch, batch_paths, batch_count); + batch_count = 0; + }; + auto update_stats = OnScopeLeave([&] { if (closed) return; - auto& s = session(); - if (!s.is_destroyed()) [[likely]] { - s.UpdatePacketTxTime(); - s.UpdateTimer(); - s.UpdateDataStats(); - } + // Flush any remaining accumulated packets before updating stats. + flush_batch(); + if (session().is_destroyed()) [[unlikely]] + return; + + // Get a strong pointer to protect against potential destruction during + // updating the time and data stats. + BaseObjectPtr s(session_); + s->UpdatePacketTxTime(); + s->UpdateTimer(); + s->UpdateDataStats(); }); // The maximum size of packet to create. @@ -353,7 +388,7 @@ void Session::Application::SendPendingData() { kMaxPackets, ngtcp2_conn_get_send_quantum(*session_) / max_packet_size); if (max_packet_count == 0) return; - // The number of packets that have been sent in this call to SendPendingData. + // The number of packets that have been prepared in this call. size_t packet_send_count = 0; Packet::Ptr packet; @@ -368,6 +403,16 @@ void Session::Application::SendPendingData() { return true; }; + // Accumulate a completed packet into the batch. + auto enqueue_packet = + [&](Packet::Ptr& pkt, size_t len, const PacketInfo& pi) { + Debug(session_, "Enqueuing packet with %zu bytes into batch", len); + pkt->Truncate(len); + pkt->set_pkt_info(pi); + path.CopyTo(&batch_paths[batch_count]); + batch[batch_count++] = std::move(pkt); + }; + // We're going to enter a loop here to prepare and send no more than // max_packet_count packets. for (;;) { @@ -405,8 +450,14 @@ void Session::Application::SendPendingData() { } // Awesome, let's write our packet! - ssize_t nwrite = WriteVStream( - &path, packet->data(), &ndatalen, packet->length(), stream_data); + PacketInfo pi; + ssize_t nwrite = WriteVStream(&path, + &pi, + packet->data(), + &ndatalen, + packet->length(), + stream_data, + ts); // When ndatalen is > 0, that's our indication that stream data was accepted // in to the packet. Yay! @@ -493,7 +544,7 @@ void Session::Application::SendPendingData() { // if there is one. Otherwise just loop around and keep going. if (session_->HasPendingDatagrams()) { auto result = TryWritePendingDatagram( - &path, packet->data(), packet->length()); + &path, packet->data(), packet->length(), ts); // When result is 0, either the datagram was congestion controlled, // didn't fit in the packet, or was abandoned. Skip and continue. @@ -502,8 +553,7 @@ void Session::Application::SendPendingData() { if (result > 0) { size_t len = result; Debug(session_, "Sending packet with %zu bytes", len); - packet->Truncate(len); - session_->Send(std::move(packet), path); + enqueue_packet(packet, len, pi); if (++packet_send_count == max_packet_count) return; } else if (result < 0) { // Any negative result other than NGTCP2_ERR_WRITE_MORE @@ -540,8 +590,7 @@ void Session::Application::SendPendingData() { // is the size of the packet we are sending. size_t len = nwrite; Debug(session_, "Sending packet with %zu bytes", len); - packet->Truncate(len); - session_->Send(std::move(packet), path); + enqueue_packet(packet, len, pi); if (++packet_send_count == max_packet_count) return; // If there are pending datagrams, try sending them in a fresh packet. @@ -557,11 +606,10 @@ void Session::Application::SendPendingData() { return session_->Close(CloseMethod::SILENT); } auto result = - TryWritePendingDatagram(&path, packet->data(), packet->length()); + TryWritePendingDatagram(&path, packet->data(), packet->length(), ts); if (result > 0) { Debug(session_, "Sending datagram packet with %zd bytes", result); - packet->Truncate(static_cast(result)); - session_->Send(std::move(packet), path); + enqueue_packet(packet, static_cast(result), PacketInfo()); if (++packet_send_count == max_packet_count) return; } else if (result < 0 && result != NGTCP2_ERR_WRITE_MORE) { // Fatal error — session already closed by TryWritePendingDatagram. @@ -574,17 +622,21 @@ void Session::Application::SendPendingData() { } ssize_t Session::Application::WriteVStream(PathStorage* path, + PacketInfo* pi, uint8_t* dest, ssize_t* ndatalen, size_t max_packet_size, - const StreamData& stream_data) { + const StreamData& stream_data, + uint64_t ts) { DCHECK_LE(stream_data.count, kMaxVectorCount); uint32_t flags = NGTCP2_WRITE_STREAM_FLAG_MORE; if (stream_data.fin) flags |= NGTCP2_WRITE_STREAM_FLAG_FIN; + // The PacketInfo out-param is populated by ngtcp2 with the ECN codepoint + // to apply when sending this packet. When libuv gains per-socket ECN + // marking, the value should be forwarded to the send path. return ngtcp2_conn_writev_stream(*session_, &path->path, - // TODO(@jasnell): ECN blocked on libuv - nullptr, + *pi, dest, max_packet_size, ndatalen, @@ -592,7 +644,7 @@ ssize_t Session::Application::WriteVStream(PathStorage* path, stream_data.id, stream_data, stream_data.count, - uv_hrtime()); + ts); } // ============================================================================ diff --git a/src/quic/application.h b/src/quic/application.h index 673a4000e4ba2d..59583b941b95b4 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -267,14 +267,19 @@ class Session::Application : public MemoryRetainer { // the datagram is either congestion limited or was abandoned ssize_t TryWritePendingDatagram(PathStorage* path, uint8_t* dest, - size_t destlen); + size_t destlen, + uint64_t ts); - // Write the given stream_data into the buffer. + // Write the given stream_data into the buffer. The PacketInfo out-param + // is populated by ngtcp2 with per-packet metadata (e.g., ECN codepoint) + // that should be applied when sending the packet. ssize_t WriteVStream(PathStorage* path, + PacketInfo* pi, uint8_t* buf, ssize_t* ndatalen, size_t max_packet_size, - const StreamData& stream_data); + const StreamData& stream_data, + uint64_t ts); Session* session_ = nullptr; }; diff --git a/src/quic/bindingdata.cc b/src/quic/bindingdata.cc index 4a3b3dba11f196..d6ceb2d2c6d2b8 100644 --- a/src/quic/bindingdata.cc +++ b/src/quic/bindingdata.cc @@ -22,6 +22,8 @@ namespace node { using mem::kReserveSizeAndAlign; using v8::Function; using v8::FunctionTemplate; +using v8::HandleScope; +using v8::Isolate; using v8::Local; using v8::Object; using v8::String; @@ -148,12 +150,88 @@ void* Nghttp3Realloc(void* ptr, size_t size, void* ud) { } } // namespace +// ============================================================================ +// CheckWrap / CheckWrapHandle + +void CheckWrap::Start() { + if (check_.data == nullptr) return; + uv_check_start(&check_, OnCheck); +} + +void CheckWrap::Stop() { + if (check_.data == nullptr) return; + uv_check_stop(&check_); +} + +void CheckWrap::Close() { + check_.data = nullptr; + env_->CloseHandle(reinterpret_cast(&check_), CheckClosedCb); +} + +void CheckWrap::Ref() { + if (check_.data == nullptr) return; + uv_ref(reinterpret_cast(&check_)); +} + +void CheckWrap::Unref() { + if (check_.data == nullptr) return; + uv_unref(reinterpret_cast(&check_)); +} + +void CheckWrap::OnCheck(uv_check_t* check) { + CheckWrap* wrap = ContainerOf(&CheckWrap::check_, check); + wrap->fn_(); +} + +void CheckWrap::CheckClosedCb(uv_handle_t* handle) { + std::unique_ptr ptr( + ContainerOf(&CheckWrap::check_, reinterpret_cast(handle))); +} + +void CheckWrapHandle::Start() { + if (check_ != nullptr) check_->Start(); +} + +void CheckWrapHandle::Stop() { + if (check_ != nullptr) check_->Stop(); +} + +void CheckWrapHandle::Close() { + if (check_ != nullptr) { + check_->env()->RemoveCleanupHook(CleanupHook, this); + check_->Close(); + } + check_ = nullptr; +} + +void CheckWrapHandle::Ref() { + if (check_ != nullptr) check_->Ref(); +} + +void CheckWrapHandle::Unref() { + if (check_ != nullptr) check_->Unref(); +} + +void CheckWrapHandle::MemoryInfo(MemoryTracker* tracker) const { + if (check_ != nullptr) tracker->TrackField("check", *check_); +} + +void CheckWrapHandle::CleanupHook(void* data) { + static_cast(data)->Close(); +} + +// ============================================================================ + BindingData& BindingData::Get(Environment* env) { return *(env->principal_realm()->GetBindingData()); } BindingData::~BindingData() { quic_alloc_state.binding = nullptr; + // flush_check_ is cleaned up by ~CheckWrapHandle() after the destructor + // body completes. The inner CheckWrap (and its uv_check_t) will be freed + // later by the uv_close callback, after CleanupHandles() runs uv_run(). + pending_flush_sessions_.clear(); } ngtcp2_mem* BindingData::ngtcp2_allocator() { @@ -197,7 +275,7 @@ void BindingData::DecreaseAllocatedSize(size_t size) { // Forwards detailed(verbose) debugging information from nghttp3. Enabled using // the NODE_DEBUG_NATIVE=NGHTTP3 category. void nghttp3_debug_log(const char* fmt, va_list args) { - auto isolate = v8::Isolate::GetCurrent(); + auto isolate = Isolate::GetCurrent(); if (isolate == nullptr) return; auto env = Environment::GetCurrent(isolate); if (env->enabled_debug_list()->enabled(DebugCategory::NGHTTP3)) { @@ -219,8 +297,11 @@ void BindingData::RegisterExternalReferences( } BindingData::BindingData(Realm* realm, Local object) - : BaseObject(realm, object) { + : BaseObject(realm, object), + flush_check_(env(), [this]() { OnFlushCheck(); }) { MakeWeak(); + // Unref so the check handle doesn't keep the event loop alive on its own. + flush_check_.Unref(); } SessionManager& BindingData::session_manager() { @@ -230,6 +311,44 @@ SessionManager& BindingData::session_manager() { return *session_manager_; } +void BindingData::ScheduleSessionFlush(const BaseObjectPtr& session) { + pending_flush_sessions_.push_back(session); + if (!flush_check_started_) { + flush_check_.Start(); + flush_check_started_ = true; + } +} + +void BindingData::OnFlushCheck() { + if (pending_flush_sessions_.empty()) { + flush_check_.Stop(); + flush_check_started_ = false; + return; + } + + HandleScope scope(env()->isolate()); + + // Swap to a local vector before iterating. SendPendingData may trigger + // MakeCallback which runs JS that could cause more packet receives via + // re-entry (e.g., a stream data callback that synchronously writes to + // another session). Any sessions added during the flush remain in + // pending_flush_sessions_ and are picked up on the next check tick. + auto sessions = std::move(pending_flush_sessions_); + for (auto& session : sessions) { + session->pending_flush_ = false; + if (!session->is_destroyed()) { + session->FlushPendingData(); + } + } + + // If no new sessions were added during the flush, stop the check + // to avoid per-tick callback overhead when idle. + if (pending_flush_sessions_.empty()) { + flush_check_.Stop(); + flush_check_started_ = false; + } +} + void BindingData::MemoryInfo(MemoryTracker* tracker) const { #define V(name, _) tracker->TrackField(#name, name##_callback()); diff --git a/src/quic/bindingdata.h b/src/quic/bindingdata.h index cc3c3a49f5647a..ed78d154e8f9c5 100644 --- a/src/quic/bindingdata.h +++ b/src/quic/bindingdata.h @@ -10,9 +10,12 @@ #include #include #include +#include #include +#include #include #include +#include #include "defs.h" namespace node::quic { @@ -154,6 +157,81 @@ class SessionManager; V(verify_private_key, "verifyPrivateKey") \ V(version, "version") +// ============================================================================= +// Lightweight wrappers around uv_check_t that ensure safe handle closure. +// The check handle is embedded in a heap-allocated CheckWrap whose destruction +// is deferred until the uv_close callback fires, preventing use-after-free +// when the owning object is destroyed before libuv finishes closing the handle. +// Follows the same two-layer pattern as TimerWrap / TimerWrapHandle +// (see timer_wrap.h). +// TODO(@jasnell): Consider moving it out to a separate file like timer_wrap.h. +class CheckWrap final : public MemoryRetainer { + public: + using CheckCb = std::function; + + template + explicit CheckWrap(Environment* env, Args&&... args) + : env_(env), fn_(std::forward(args)...) { + uv_check_init(env->event_loop(), &check_); + check_.data = this; + } + + DISALLOW_COPY_AND_MOVE(CheckWrap) + + inline Environment* env() const { return env_; } + + void Start(); + void Stop(); + void Close(); + void Ref(); + void Unref(); + + SET_NO_MEMORY_INFO() + SET_MEMORY_INFO_NAME(CheckWrap) + SET_SELF_SIZE(CheckWrap) + + private: + static void OnCheck(uv_check_t* check); + static void CheckClosedCb(uv_handle_t* handle); + ~CheckWrap() = default; + + Environment* env_; + CheckCb fn_; + uv_check_t check_; + + friend std::unique_ptr::deleter_type; +}; + +class CheckWrapHandle : public MemoryRetainer { + public: + template + explicit CheckWrapHandle(Environment* env, Args&&... args) + : check_(new CheckWrap(env, std::forward(args)...)) { + env->AddCleanupHook(CleanupHook, this); + } + + DISALLOW_COPY_AND_MOVE(CheckWrapHandle) + + ~CheckWrapHandle() { Close(); } + + inline operator bool() const { return check_ != nullptr; } + + void Start(); + void Stop(); + void Close(); + void Ref(); + void Unref(); + + void MemoryInfo(node::MemoryTracker* tracker) const override; + + SET_MEMORY_INFO_NAME(CheckWrapHandle) + SET_SELF_SIZE(CheckWrapHandle) + + private: + static void CleanupHook(void* data); + CheckWrap* check_; +}; + // ============================================================================= // The BindingState object holds state for the internalBinding('quic') binding // instance. It is mostly used to hold the persistent constructors, strings, and @@ -201,6 +279,13 @@ class BindingData final // routing so that any endpoint can route packets to any session. SessionManager& session_manager(); + // Schedule a session for deferred SendPendingData. Sessions are accumulated + // during the I/O poll phase (via Endpoint::Receive -> Session::ReadPacket) + // and flushed in a uv_check callback immediately after poll completes. + // This batches multiple received packets before generating responses, + // allowing ngtcp2 to make better ACK coalescing decisions. + void ScheduleSessionFlush(const BaseObjectPtr& session); + std::unordered_map> listening_endpoints; size_t current_ngtcp2_memory_ = 0; @@ -247,6 +332,29 @@ class BindingData final #undef V std::unique_ptr session_manager_; + + // Type-erased arena storage. The concrete AliasedStructArena types + // are only complete in the .cc files where Stream::State etc. are defined. + // Each .cc file provides typed accessor methods. The deleters are set + // when the arenas are created so that ~BindingData destroys them correctly. + using ArenaDeleter = void (*)(void*); + using ArenaPtr = std::unique_ptr; + ArenaPtr stream_state_arena_{nullptr, +[](void*) {}}; + ArenaPtr stream_stats_arena_{nullptr, +[](void*) {}}; + ArenaPtr session_state_arena_{nullptr, +[](void*) {}}; + ArenaPtr session_stats_arena_{nullptr, +[](void*) {}}; + ArenaPtr endpoint_state_arena_{nullptr, +[](void*) {}}; + ArenaPtr endpoint_stats_arena_{nullptr, +[](void*) {}}; + + // Deferred send flush state. The CheckWrapHandle fires immediately after + // the I/O poll phase in the same event loop tick, allowing batched + // receive processing: all packets are read during poll, then + // SendPendingData is called once per dirty session in the check callback. + CheckWrapHandle flush_check_; + std::vector> pending_flush_sessions_; + bool flush_check_started_ = false; + + void OnFlushCheck(); }; JS_METHOD_IMPL(IllegalConstructor); diff --git a/src/quic/data.cc b/src/quic/data.cc index be2bf458d28352..d56420feae1e1b 100644 --- a/src/quic/data.cc +++ b/src/quic/data.cc @@ -17,7 +17,10 @@ using v8::Array; using v8::ArrayBuffer; using v8::ArrayBufferView; using v8::BackingStore; +using v8::BackingStoreInitializationMode; +using v8::BackingStoreOnFailureMode; using v8::BigInt; +using v8::Isolate; using v8::Just; using v8::Local; using v8::Maybe; @@ -89,14 +92,14 @@ Store::Store(std::unique_ptr store, size_t length, size_t offset) } Maybe Store::From(Local buffer) { - v8::Isolate* isolate = v8::Isolate::GetCurrent(); + Isolate* isolate = Isolate::GetCurrent(); Environment* env = Environment::GetCurrent(isolate->GetCurrentContext()); auto length = buffer->ByteLength(); auto dest = ArrayBuffer::NewBackingStore( isolate, length, - v8::BackingStoreInitializationMode::kUninitialized, - v8::BackingStoreOnFailureMode::kReturnNull); + BackingStoreInitializationMode::kUninitialized, + BackingStoreOnFailureMode::kReturnNull); if (!dest) { THROW_ERR_MEMORY_ALLOCATION_FAILED(env); return Nothing(); @@ -108,15 +111,15 @@ Maybe Store::From(Local buffer) { } Maybe Store::From(Local view) { - v8::Isolate* isolate = v8::Isolate::GetCurrent(); + Isolate* isolate = Isolate::GetCurrent(); Environment* env = Environment::GetCurrent(isolate->GetCurrentContext()); auto length = view->ByteLength(); auto offset = view->ByteOffset(); auto dest = ArrayBuffer::NewBackingStore( isolate, length, - v8::BackingStoreInitializationMode::kUninitialized, - v8::BackingStoreOnFailureMode::kReturnNull); + BackingStoreInitializationMode::kUninitialized, + BackingStoreOnFailureMode::kReturnNull); if (!dest) { THROW_ERR_MEMORY_ALLOCATION_FAILED(env); return Nothing(); @@ -130,24 +133,34 @@ Maybe Store::From(Local view) { } Store Store::CopyFrom(Local buffer) { - v8::Isolate* isolate = v8::Isolate::GetCurrent(); + Isolate* isolate = Isolate::GetCurrent(); auto backing = buffer->GetBackingStore(); auto length = buffer->ByteLength(); auto dest = ArrayBuffer::NewBackingStore( - isolate, length, v8::BackingStoreInitializationMode::kUninitialized); + isolate, length, BackingStoreInitializationMode::kUninitialized, + BackingStoreOnFailureMode::kReturnNull); + if (!dest) { + THROW_ERR_MEMORY_ALLOCATION_FAILED(Environment::GetCurrent(isolate)); + return Store(); + } // copy content memcpy(dest->Data(), backing->Data(), length); return Store(std::move(dest), length, 0); } Store Store::CopyFrom(Local view) { - v8::Isolate* isolate = v8::Isolate::GetCurrent(); + Isolate* isolate = Isolate::GetCurrent(); auto backing = view->Buffer()->GetBackingStore(); auto length = view->ByteLength(); auto offset = view->ByteOffset(); auto dest = ArrayBuffer::NewBackingStore( - isolate, length, v8::BackingStoreInitializationMode::kUninitialized); + isolate, length, BackingStoreInitializationMode::kUninitialized, + BackingStoreOnFailureMode::kReturnNull); // copy content + if (!dest) { + THROW_ERR_MEMORY_ALLOCATION_FAILED(Environment::GetCurrent(isolate)); + return Store(); + } memcpy(dest->Data(), static_cast(backing->Data()) + offset, length); return Store(std::move(dest), length, 0); } diff --git a/src/quic/data.h b/src/quic/data.h index 2b6d777caf7b81..ec8d40cbc4c7a0 100644 --- a/src/quic/data.h +++ b/src/quic/data.h @@ -19,6 +19,40 @@ namespace node::quic { template concept OneByteType = sizeof(T) == 1; +// Lightweight wrapper around ngtcp2_pkt_info. Insulates the Node.js QUIC +// code from the ngtcp2 struct layout and provides a clean API boundary +// for per-packet metadata (currently ECN codepoint; may grow as ngtcp2 +// and libuv evolve). +// +// Default-constructed PacketInfo is zero-initialized, which ngtcp2 treats +// as ECN Not-ECT — identical to passing nullptr for the pkt_info parameter. +class PacketInfo final { + public: + // ECN codepoints as defined by RFC 3168. + enum class Ecn : uint32_t { + NOT_ECT = 0, // Not ECN-Capable Transport + ECT_1 = 1, // ECN-Capable Transport(1) + ECT_0 = 2, // ECN-Capable Transport(0) + CE = 3, // Congestion Experienced + }; + + PacketInfo() : info_{} {} + explicit PacketInfo(const ngtcp2_pkt_info& info) : info_(info) {} + + // ECN codepoint for this packet. When libuv gains per-packet ECN + // reporting, populate via set_ecn() from the receive metadata + // before passing to ReadPacket(). + Ecn ecn() const { return static_cast(info_.ecn); } + void set_ecn(Ecn ecn) { info_.ecn = static_cast(ecn); } + + // Conversion operators for ngtcp2 API calls. + operator const ngtcp2_pkt_info*() const { return &info_; } + operator ngtcp2_pkt_info*() { return &info_; } + + private: + ngtcp2_pkt_info info_; +}; + struct Path final : public ngtcp2_path { explicit Path(const SocketAddress& local, const SocketAddress& remote); Path(Path&& other) noexcept = default; diff --git a/src/quic/endpoint.cc b/src/quic/endpoint.cc index 66413f66cafee2..46dd0231c4ff7e 100644 --- a/src/quic/endpoint.cc +++ b/src/quic/endpoint.cc @@ -29,7 +29,6 @@ namespace node { using v8::Array; using v8::ArrayBufferView; -using v8::BackingStore; using v8::HandleScope; using v8::Integer; using v8::Just; @@ -312,10 +311,18 @@ class Endpoint::UDP::Impl final : public HandleWrap { SET_SELF_SIZE(Impl) private: + // Pre-allocated receive buffer. Reused across all datagrams because + // ngtcp2_conn_read_pkt is synchronous — it copies what it needs and + // does not retain a reference to the buffer after returning. This + // eliminates a malloc(64KB)/free(64KB) cycle per received datagram. + static constexpr size_t kRecvBufferSize = 65536; // UV__UDP_DGRAM_MAXSIZE + char recv_buf_[kRecvBufferSize]; + static void OnAlloc(uv_handle_t* handle, size_t suggested_size, uv_buf_t* buf) { - *buf = From(handle)->env()->allocate_managed_buffer(suggested_size); + auto* impl = From(handle); + *buf = uv_buf_init(impl->recv_buf_, kRecvBufferSize); } static void OnReceive(uv_udp_t* handle, @@ -327,26 +334,22 @@ class Endpoint::UDP::Impl final : public HandleWrap { DCHECK_NOT_NULL(impl); DCHECK_NOT_NULL(impl->endpoint_); - auto release_buf = [&]() { - if (buf->base != nullptr) impl->env()->release_managed_buffer(*buf); - }; - // Nothing to do in these cases. Specifically, if the nread // is zero or we have received a partial packet, we are just - // going to ignore it. + // going to ignore it. No buffer release needed — recv_buf_ + // is pre-allocated and reused. if (nread == 0 || flags & UV_UDP_PARTIAL) { - release_buf(); return; } if (nread < 0) { - release_buf(); impl->endpoint_->Destroy(CloseContext::RECEIVE_FAILURE, static_cast(nread)); return; } - impl->endpoint_->Receive(uv_buf_init(buf->base, static_cast(nread)), + impl->endpoint_->Receive(reinterpret_cast(buf->base), + static_cast(nread), SocketAddress(addr)); } @@ -492,6 +495,24 @@ int Endpoint::UDP::Send(Packet::Ptr packet) { return err; } +int Endpoint::UDP::TrySend(const Packet::Ptr& packet) { + DCHECK(packet); + if (is_closed_or_closing()) return UV_EBADF; + uv_buf_t buf = *packet; + return uv_udp_try_send( + &impl_->handle_, &buf, 1, packet->destination().data()); +} + +int Endpoint::UDP::TrySendBatch(uv_buf_t* bufs[], + unsigned int nbufs[], + struct sockaddr* addrs[], + size_t count) { + DCHECK_GT(count, 0); + if (is_closed_or_closing()) return UV_EBADF; + return uv_udp_try_send2( + &impl_->handle_, static_cast(count), bufs, nbufs, addrs, 0); +} + void Endpoint::UDP::MemoryInfo(MemoryTracker* tracker) const { if (impl_) tracker->TrackField("impl", impl_); } @@ -714,7 +735,9 @@ void Endpoint::RemoveSession(const CID& cid, } } if (primary_session_count_ > 0 && --primary_session_count_ == 0) { - udp_.Unref(); + if (!is_listening()) { + udp_.Unref(); + } session_manager().RemoveSession(cid); // The endpoint may be idle (no sessions, not listening). MaybeDestroy // handles both closing (immediate destroy) and idle timeout (start @@ -812,6 +835,111 @@ void Endpoint::Send(Packet::Ptr packet) { STAT_INCREMENT(Stats, packets_sent); } +void Endpoint::SendOrTrySend(Packet::Ptr packet) { +#ifdef DEBUG + if (is_diagnostic_packet_loss(options_.tx_loss)) [[unlikely]] { + return; + } +#endif + + if (is_closed() || is_closing() || !packet || packet->length() == 0) { + return; + } + + Debug(this, "TrySend %s", packet->ToString()); + + // Attempt synchronous send. On success (returns number of bytes sent), + // the packet is delivered immediately — no callback overhead, no + // waiting for the next poll cycle. + int err = udp_.TrySend(packet); + if (err >= 0) { + // Synchronous send succeeded. + STAT_INCREMENT_N(Stats, bytes_sent, packet->length()); + STAT_INCREMENT(Stats, packets_sent); + // Ptr destructor releases back to arena pool. + return; + } + + if (err == UV_EAGAIN) { + // Socket not writable or async sends are queued. Fall back to the + // async path — the packet will be queued and flushed on the next + // POLLOUT cycle. + Debug(this, "TrySend got EAGAIN, falling back to async Send"); + return Send(std::move(packet)); + } + + // Other errors are fatal. + Debug(this, "TrySend failed with error %d", err); + Destroy(CloseContext::SEND_FAILURE, err); +} + +void Endpoint::SendBatch(Packet::Ptr* packets, size_t count) { + if (count == 0) return; + +#ifdef DEBUG + if (is_diagnostic_packet_loss(options_.tx_loss)) [[unlikely]] { + for (size_t i = 0; i < count; i++) packets[i].reset(); + return; + } +#endif + + if (is_closed() || is_closing()) { + for (size_t i = 0; i < count; i++) packets[i].reset(); + return; + } + + static constexpr size_t kMaxBatch = 64; + DCHECK_LE(count, kMaxBatch); + + // Build libuv argument arrays directly from the Ptr array. + // Packets with zero length are released and skipped. + uv_buf_t bufs[kMaxBatch]; + uv_buf_t* buf_ptrs[kMaxBatch]; + unsigned int nbufs[kMaxBatch]; + struct sockaddr* addrs[kMaxBatch]; + // Map from valid-index back to the original packets[] index. + size_t index_map[kMaxBatch]; + size_t valid_count = 0; + + for (size_t i = 0; i < count; i++) { + if (!packets[i] || packets[i]->length() == 0) { + packets[i].reset(); + continue; + } + bufs[valid_count] = *packets[i]; + buf_ptrs[valid_count] = &bufs[valid_count]; + nbufs[valid_count] = 1; + addrs[valid_count] = + const_cast(packets[i]->destination().data()); + index_map[valid_count] = i; + valid_count++; + } + + if (valid_count == 0) return; + + // Attempt synchronous batched send via sendmmsg. + int sent = udp_.TrySendBatch(buf_ptrs, nbufs, addrs, valid_count); + + if (sent > 0) { + // Packets [0, sent) were delivered synchronously. + // Release them immediately — no async callback needed. + for (size_t i = 0; i < static_cast(sent); i++) { + size_t idx = index_map[i]; + STAT_INCREMENT_N(Stats, bytes_sent, packets[idx]->length()); + STAT_INCREMENT(Stats, packets_sent); + packets[idx].reset(); + } + } + + // Any unsent packets (EAGAIN, partial send, or total failure) fall + // back to async uv_udp_send. + size_t start = (sent > 0) ? static_cast(sent) : 0; + for (size_t i = start; i < valid_count; i++) { + size_t idx = index_map[i]; + Send(std::move(packets[idx])); + } +} + void Endpoint::SendRetry(const PathDescriptor& options) { // Generating and sending retry packets does consume some system resources, // and it is possible for a malicious peer to trigger sending a large number @@ -840,22 +968,31 @@ void Endpoint::SendRetry(const PathDescriptor& options) { void Endpoint::SendVersionNegotiation(const PathDescriptor& options) { Debug(this, "Sending version negotiation on path %s", options); - // While creating and sending a version negotiation packet does consume a - // small amount of system resources, and while it is fairly trivial for a - // malicious peer to force a version negotiation to be sent, these are more - // trivial to create than the cryptographically generated retry and stateless - // reset packets. If the packet is sent, then we'll at least increment the - // version_negotiation_count statistic so that application code can keep an - // eye on it. + // A malicious peer can trivially force version negotiation packets by + // sending packets with unsupported QUIC versions, potentially from + // spoofed source addresses. Rate-limit per remote host to prevent + // amplification attacks. + const auto exceeds_limits = [&] { + SocketAddressInfoTraits::Type* counts = + addr_validation_lru_.Peek(options.remote_address); + auto count = counts != nullptr ? counts->version_negotiation_count : 0; + return count >= kMaxVersionNegotiations; + }; + + if (exceeds_limits()) { + Debug(this, + "Version negotiation rate limit exceeded for %s", + options.remote_address); + return; + } + auto packet = Packet::CreateVersionNegotiationPacket(*this, options); if (packet) { + addr_validation_lru_.Upsert(options.remote_address) + ->version_negotiation_count++; STAT_INCREMENT(Stats, version_negotiation_count); Send(std::move(packet)); } - - // If creating the packet is unsuccessful, we just drop things on the floor. - // It's not worth committing any further resources to this one packet. We - // might want to log the failure at some point tho. } bool Endpoint::SendStatelessReset(const PathDescriptor& options, @@ -902,11 +1039,28 @@ void Endpoint::SendImmediateConnectionClose(const PathDescriptor& options, "Sending immediate connection close on path %s with reason %s", options, reason); - // While it is possible for a malicious peer to cause us to create a large - // number of these, generating them is fairly trivial. + // A malicious peer can trigger immediate connection close packets by + // sending Initial packets with invalid tokens or when the server is + // busy. Rate-limit per remote host to prevent amplification attacks. + const auto exceeds_limits = [&] { + SocketAddressInfoTraits::Type* counts = + addr_validation_lru_.Peek(options.remote_address); + auto count = counts != nullptr ? counts->immediate_close_count : 0; + return count >= kMaxImmediateCloses; + }; + + if (exceeds_limits()) { + Debug(this, + "Immediate connection close rate limit exceeded for %s", + options.remote_address); + return; + } + auto packet = Packet::CreateImmediateConnectionClosePacket(*this, options, reason); if (packet) { + addr_validation_lru_.Upsert(options.remote_address) + ->immediate_close_count++; STAT_INCREMENT(Stats, immediate_close_count); Send(std::move(packet)); } @@ -1117,24 +1271,39 @@ void Endpoint::CloseGracefully() { MaybeDestroy(); } -void Endpoint::Receive(const uv_buf_t& buf, +void Endpoint::Receive(const uint8_t* data, + size_t len, const SocketAddress& remote_address) { const auto receive = [&](Session* session, - Store&& store, + const uint8_t* pkt_data, + size_t pkt_len, const SocketAddress& local_address, const SocketAddress& remote_address, const CID& dcid, const CID& scid) { DCHECK_NOT_NULL(session); if (session->is_destroyed()) return; - size_t len = store.length(); - if (session->Receive(std::move(store), local_address, remote_address)) { - STAT_INCREMENT_N(Stats, bytes_received, len); + // Use ReadPacket (no SendPendingDataScope) so that multiple packets + // received in the same I/O burst are processed before any responses + // are generated. The deferred flush via BindingData's uv_check + // callback calls SendPendingData once per dirty session after all + // packets in the burst have been read. + if (session->ReadPacket(pkt_data, pkt_len, local_address, remote_address)) { + STAT_INCREMENT_N(Stats, bytes_received, pkt_len); STAT_INCREMENT(Stats, packets_received); } + // Schedule the session for deferred SendPendingData if it hasn't + // been scheduled already in this burst. + if (!session->is_destroyed() && !session->pending_flush_) { + session->pending_flush_ = true; + BindingData::Get(env()).ScheduleSessionFlush( + BaseObjectPtr(session)); + } }; - const auto accept = [&](const Session::Config& config, Store&& store) { + const auto accept = [&](const Session::Config& config, + const uint8_t* pkt_data, + size_t pkt_len) { // One final check. If the endpoint is closed, closing, or is not listening // as a server, then we cannot accept the initial packet. if (is_closed() || is_closing() || !is_listening()) return; @@ -1164,7 +1333,8 @@ void Endpoint::Receive(const uv_buf_t& buf, return; receive(session.get(), - std::move(store), + pkt_data, + pkt_len, config.local_address, config.remote_address, config.dcid, @@ -1174,7 +1344,8 @@ void Endpoint::Receive(const uv_buf_t& buf, const auto acceptInitialPacket = [&](const uint32_t version, const CID& dcid, const CID& scid, - Store&& store, + const uint8_t* pkt_data, + size_t pkt_len, const SocketAddress& local_address, const SocketAddress& remote_address) { // If we're not listening as a server, do not accept an initial packet. @@ -1184,8 +1355,7 @@ void Endpoint::Receive(const uv_buf_t& buf, // This is our first condition check... A minimal check to see if ngtcp2 can // even recognize this packet as a quic packet. - ngtcp2_vec vec = store; - if (ngtcp2_accept(&hd, vec.base, vec.len) != NGTCP2_SUCCESS) { + if (ngtcp2_accept(&hd, pkt_data, pkt_len) != NGTCP2_SUCCESS) { // Per the ngtcp2 docs, ngtcp2_accept returns 0 if the check was // successful, or an error code if it was not. Currently there's only one // documented error code (NGTCP2_ERR_INVALID_ARGUMENT) but we'll handle @@ -1423,7 +1593,7 @@ void Endpoint::Receive(const uv_buf_t& buf, } } - accept(config, std::move(store)); + accept(config, pkt_data, pkt_len); }; // When a received packet contains a QUIC short header but cannot be matched @@ -1439,14 +1609,15 @@ void Endpoint::Receive(const uv_buf_t& buf, // possible to avoid a DOS vector. const auto maybeStatelessReset = [&](const CID& dcid, const CID& scid, - Store& store, + const uint8_t* pkt_data, + size_t pkt_len, const SocketAddress& local_address, const SocketAddress& remote_address) { // Support for stateless resets can be disabled by the application. If that // case, or if the packet is too short to contain a reset token, then we // skip the remaining checks. if (options_.disable_stateless_reset || - store.length() < NGTCP2_STATELESS_RESET_TOKENLEN) { + pkt_len < NGTCP2_STATELESS_RESET_TOKENLEN) { return false; } @@ -1454,20 +1625,21 @@ void Endpoint::Receive(const uv_buf_t& buf, // NGTCP2_STATELESS_RESET_TOKENLEN bytes in the received packet. If it is a // stateless reset then then rest of the bytes in the packet are garbage // that we'll ignore. - ngtcp2_vec vec = store; - vec.base += (vec.len - NGTCP2_STATELESS_RESET_TOKENLEN); + const uint8_t* token_pos = + pkt_data + (pkt_len - NGTCP2_STATELESS_RESET_TOKENLEN); // If a Session has been associated with the token, then it is a valid // stateless reset token. We need to dispatch it to the session to be // processed. auto* session = session_manager().FindSessionByStatelessResetToken( - StatelessResetToken(vec.base)); + StatelessResetToken(token_pos)); if (session != nullptr) { // If the session happens to have been destroyed already, we'll // just ignore the packet. if (!session->is_destroyed()) [[likely]] { receive(session, - std::move(store), + pkt_data, + pkt_len, local_address, remote_address, dcid, @@ -1495,22 +1667,8 @@ void Endpoint::Receive(const uv_buf_t& buf, // return; // } - Debug(this, "Received %zu-byte packet from %s", buf.len, remote_address); - - // The managed buffer here contains the received packet. We do not yet know - // at this point if it is a valid QUIC packet. We need to do some basic - // checks. It is critical at this point that we do as little work as possible - // to avoid a DOS vector. - std::shared_ptr backing = env()->release_managed_buffer(buf); - if (!backing) [[unlikely]] { - // At this point something bad happened and we need to treat this as a fatal - // case. There's likely no way to test this specific condition reliably. - return Destroy(CloseContext::RECEIVE_FAILURE, UV_ENOMEM); - } - - Store store(std::move(backing), buf.len, 0); + Debug(this, "Received %zu-byte packet from %s", len, remote_address); - ngtcp2_vec vec = store; ngtcp2_version_cid pversion_cid; // This is our first check to see if the received data can be processed as a @@ -1519,7 +1677,7 @@ void Endpoint::Receive(const uv_buf_t& buf, // valid QUIC header but there is still no guarantee that the packet can be // successfully processed. switch (ngtcp2_pkt_decode_version_cid( - &pversion_cid, vec.base, vec.len, NGTCP2_MAX_CIDLEN)) { + &pversion_cid, data, len, NGTCP2_MAX_CIDLEN)) { case 0: break; // Supported version, continue processing. case NGTCP2_ERR_VERSION_NEGOTIATION: { @@ -1597,7 +1755,7 @@ void Endpoint::Receive(const uv_buf_t& buf, // necessary here. We want to return immediately without committing any // further resources. if (pversion_cid.version == 0 && - maybeStatelessReset(dcid, scid, store, addr, remote_address)) { + maybeStatelessReset(dcid, scid, data, len, addr, remote_address)) { Debug(this, "Packet was a stateless reset"); return; // Stateless reset! Don't do any further processing. } @@ -1612,17 +1770,13 @@ void Endpoint::Receive(const uv_buf_t& buf, SendStatelessReset( PathDescriptor{ pversion_cid.version, dcid, scid, addr, remote_address}, - store.length()); + len); return; } // Process the packet as an initial packet... - return acceptInitialPacket(pversion_cid.version, - dcid, - scid, - std::move(store), - addr, - remote_address); + return acceptInitialPacket( + pversion_cid.version, dcid, scid, data, len, addr, remote_address); } if (session->is_destroyed()) [[unlikely]] { @@ -1634,7 +1788,7 @@ void Endpoint::Receive(const uv_buf_t& buf, // If we got here, the dcid matched the scid of a known local session. Yay! // The session will take over any further processing of the packet. Debug(this, "Dispatching packet to known session"); - receive(session.get(), std::move(store), addr, remote_address, dcid, scid); + receive(session.get(), data, len, addr, remote_address, dcid, scid); // It is important to note that the session may have been destroyed during // the call to receive(...). If that's the case, the session object still diff --git a/src/quic/endpoint.h b/src/quic/endpoint.h index b9f20f8659dfa6..a9f020e0328eff 100644 --- a/src/quic/endpoint.h +++ b/src/quic/endpoint.h @@ -47,6 +47,20 @@ class Endpoint final : public AsyncWrap, public Packet::Listener { // intentionally triggering generation of a large number of retries. static constexpr uint64_t DEFAULT_MAX_RETRY_LIMIT = 10; + // Maximum number of version negotiation packets that will be sent to a + // given remote host within the LRU tracking window. Version negotiation + // packets are cheap to generate but can be used as an amplification + // vector with spoofed source addresses. + // TODO(@jasnell): Consider making this configurable via Endpoint::Options. + static constexpr uint64_t kMaxVersionNegotiations = 10; + + // Maximum number of immediate connection close packets that will be sent + // to a given remote host within the LRU tracking window. These are sent + // when the server is busy or a token is invalid — a malicious peer could + // trigger a large number of them. + // TODO(@jasnell): Consider making this configurable via Endpoint::Options. + static constexpr uint64_t kMaxImmediateCloses = 10; + // Endpoint configuration options struct Options final : public MemoryRetainer { // The local socket address to which the UDP port will be bound. The port @@ -208,6 +222,20 @@ class Endpoint final : public AsyncWrap, public Packet::Listener { void Send(Packet::Ptr packet); + // Attempt synchronous send via uv_udp_try_send. If the socket is + // writable, the packet is sent immediately and the Ptr is released. + // If the socket is not writable (UV_EAGAIN), falls back to the + // async Send path. Used by the deferred flush callback to avoid + // the one-tick latency of async uv_udp_send. + void SendOrTrySend(Packet::Ptr packet); + + // Send a batch of packets using uv_udp_try_send2 (sendmmsg) for + // synchronous batched delivery. Packets successfully sent are released + // immediately. On EAGAIN or partial send, remaining packets fall back + // to async uv_udp_send. The Packet::Ptr array is consumed: all entries + // will be empty (released or moved) on return. + void SendBatch(Packet::Ptr* packets, size_t count); + // Acquire a Packet from the pool. length sets the initial working // size (must be <= pool capacity). The slot is always allocated at // full capacity to avoid fragmentation. @@ -281,6 +309,20 @@ class Endpoint final : public AsyncWrap, public Packet::Listener { void Close(); int Send(Packet::Ptr packet); + // Synchronous send using uv_udp_try_send. Returns the number of + // bytes sent on success, UV_EAGAIN if the socket is not writable + // or the send queue is non-empty, or another negative error code. + // The Ptr is not consumed — the caller manages the lifecycle. + int TrySend(const Packet::Ptr& packet); + + // Synchronous batched send using uv_udp_try_send2 (sendmmsg). + // Takes pre-built libuv argument arrays. Returns the number of + // messages successfully sent (>= 0), or a negative error code. + int TrySendBatch(uv_buf_t* bufs[], + unsigned int nbufs[], + struct sockaddr* addrs[], + size_t count); + // Returns the local UDP socket address to which we are bound, // or fail with an assert if we are not bound. SocketAddress local_address() const; @@ -381,7 +423,7 @@ class Endpoint final : public AsyncWrap, public Packet::Listener { // Ref() causes a listening Endpoint to keep the event loop active. JS_METHOD(Ref); - void Receive(const uv_buf_t& buf, const SocketAddress& from); + void Receive(const uint8_t* data, size_t len, const SocketAddress& from); AliasedStruct stats_; AliasedStruct state_; @@ -426,6 +468,8 @@ class Endpoint final : public AsyncWrap, public Packet::Listener { struct Type final { size_t reset_count; size_t retry_count; + size_t version_negotiation_count; + size_t immediate_close_count; uint64_t timestamp; bool validated; }; diff --git a/src/quic/http3.cc b/src/quic/http3.cc index ea07c0a5a596fb..6717ac064801cb 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -262,11 +262,17 @@ class Http3ApplicationImpl final : public Session::Application { } void BeginShutdown() override { - if (conn_) nghttp3_conn_submit_shutdown_notice(*this); + // Only submit a shutdown notice if the H3 connection was fully + // started (control streams bound). If the TLS handshake failed + // before Start() was called, conn_ exists but its control streams + // are unbound, and nghttp3_conn_submit_shutdown_notice would crash. + if (conn_ && started_) nghttp3_conn_submit_shutdown_notice(*this); } void CompleteShutdown() override { - if (conn_) nghttp3_conn_shutdown(*this); + // Same guard as BeginShutdown — nghttp3_conn_shutdown asserts + // that the control stream is bound (conn->tx.ctrl != NULL). + if (conn_ && started_) nghttp3_conn_shutdown(*this); } bool ReceiveStreamData(stream_id id, diff --git a/src/quic/packet.h b/src/quic/packet.h index ffeb582471333f..a94ee1264c2a6a 100644 --- a/src/quic/packet.h +++ b/src/quic/packet.h @@ -68,6 +68,8 @@ class Packet final { size_t length() const { return length_; } size_t capacity() const { return capacity_; } const SocketAddress& destination() const { return destination_; } + const PacketInfo& pkt_info() const { return pkt_info_; } + void set_pkt_info(const PacketInfo& pi) { pkt_info_ = pi; } Listener* listener() const { return listener_; } // Redirect the packet to a different endpoint for cross-endpoint sends @@ -148,6 +150,7 @@ class Packet final { Listener* listener_; // Touched at send time. + PacketInfo pkt_info_; SocketAddress destination_; // Only touched by libuv during uv_udp_send and in the send callback. diff --git a/src/quic/preferredaddress.cc b/src/quic/preferredaddress.cc index 3d584be2d74811..ca7908dda35c59 100644 --- a/src/quic/preferredaddress.cc +++ b/src/quic/preferredaddress.cc @@ -17,6 +17,7 @@ namespace node { using v8::Just; using v8::Local; using v8::Maybe; +using v8::Object; using v8::Value; namespace quic { @@ -131,7 +132,7 @@ Maybe PreferredAddress::tryGetPolicy( : Just(FromV8Value(value)); } -void PreferredAddress::Initialize(Environment* env, Local target) { +void PreferredAddress::Initialize(Environment* env, Local target) { // The QUIC_* constants are expected to be exported out to be used on // the JavaScript side of the API. static constexpr auto PREFERRED_ADDRESS_USE = diff --git a/src/quic/session.cc b/src/quic/session.cc index 4af903e0c2a0af..7737f97e98af27 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -40,7 +40,9 @@ using v8::Array; using v8::ArrayBufferView; using v8::BigInt; using v8::Boolean; +using v8::Function; using v8::FunctionCallbackInfo; +using v8::Global; using v8::HandleScope; using v8::Int32; using v8::Integer; @@ -54,6 +56,7 @@ using v8::Number; using v8::Object; using v8::ObjectTemplate; using v8::String; +using v8::Uint32; using v8::Undefined; using v8::Value; @@ -197,6 +200,46 @@ struct Session::State final { STAT_STRUCT(Session, SESSION) +using SessionStateArena = AliasedStructArena; +using SessionStatsArena = AliasedStructArena; + +// Session uses arena-allocated stats, not AliasedStruct, so override the +// STAT_* macros to use impl_->stats() instead of stats_.Data(). +#undef STAT_INCREMENT +#undef STAT_INCREMENT_N +#undef STAT_RECORD_TIMESTAMP +#undef STAT_SET +#undef STAT_GET +#define STAT_INCREMENT(Type, name) \ + IncrementStat(impl_->stats()); +#define STAT_INCREMENT_N(Type, name, amt) \ + IncrementStat(impl_->stats(), amt); +#define STAT_RECORD_TIMESTAMP(Type, name) \ + RecordTimestampStat(impl_->stats()); +#define STAT_SET(Type, name, val) \ + SetStat(impl_->stats(), val) +#define STAT_GET(Type, name) GetStat(impl_->stats()) + +namespace { +SessionStateArena& GetSessionStateArena(BindingData& binding) { + if (!binding.session_state_arena_) { + auto* arena = new SessionStateArena(); + binding.session_state_arena_ = BindingData::ArenaPtr( + arena, +[](void* p) { delete static_cast(p); }); + } + return *static_cast(binding.session_state_arena_.get()); +} + +SessionStatsArena& GetSessionStatsArena(BindingData& binding) { + if (!binding.session_stats_arena_) { + auto* arena = new SessionStatsArena(); + binding.session_stats_arena_ = BindingData::ArenaPtr( + arena, +[](void* p) { delete static_cast(p); }); + } + return *static_cast(binding.session_stats_arena_.get()); +} +} // namespace + // ============================================================================ class Http3Application; @@ -380,9 +423,9 @@ bool SetOption(Environment* env, template bool SetOption(Environment* env, Opt* options, - const v8::Local& object, - const v8::Local& name) { - v8::Local value; + const Local& object, + const Local& name) { + Local value; if (!object->Get(env->context(), name).ToLocal(&value)) return false; if (!value->IsUndefined()) { if (!value->IsUint32()) { @@ -391,7 +434,7 @@ bool SetOption(Environment* env, env, "The %s option must be an uint8", *nameStr); return false; } - uint32_t val = value.As()->Value(); + uint32_t val = value.As()->Value(); if (val > 255) { Utf8Value nameStr(env->isolate(), name); THROW_ERR_INVALID_ARG_VALUE( @@ -464,7 +507,12 @@ Session::Config::Config(Environment* env, settings.log_printf = ngtcp2_debug_log; } - settings.handshake_timeout = options.handshake_timeout; + // The handshake_timeout option is in milliseconds; ngtcp2 expects + // nanoseconds (ngtcp2_duration). UINT64_MAX means no timeout. + settings.handshake_timeout = + options.handshake_timeout == UINT64_MAX + ? UINT64_MAX + : options.handshake_timeout * NGTCP2_MILLISECONDS; settings.max_stream_window = options.max_stream_window; settings.max_window = options.max_window; settings.ack_thresh = options.unacknowledged_packet_threshold; @@ -707,8 +755,8 @@ std::string Session::Options::ToString() const { // Session::Impl maintains most of the internal state of an active Session. struct Session::Impl final : public MemoryRetainer { Session* session_; - AliasedStruct stats_; - AliasedStruct state_; + ArenaSlotBase stats_slot_; + ArenaSlotBase state_slot_; BaseObjectWeakPtr endpoint_; Config config_; SocketAddress local_address_; @@ -736,20 +784,30 @@ struct Session::Impl final : public MemoryRetainer { // and the stream/datagram data is included in the 0-RTT flight. bool handshake_deferred_ = false; + Stats* stats() { return static_cast(stats_slot_.ptr); } + const Stats* stats() const { + return static_cast(stats_slot_.ptr); + } + State* state() { return static_cast(state_slot_.ptr); } + const State* state() const { + return static_cast(state_slot_.ptr); + } + Impl(Session* session, Endpoint* endpoint, const Config& config) : session_(session), - stats_(env()->isolate()), - state_(env()->isolate()), endpoint_(endpoint), config_(config), local_address_(config.local_address), remote_address_(config.remote_address), timer_(session_->env(), [this] { session_->OnTimeout(); }) { + auto& binding = BindingData::Get(env()); + stats_slot_ = GetSessionStatsArena(binding).Allocate(env()->isolate()); + state_slot_ = GetSessionStateArena(binding).Allocate(env()->isolate()); timer_.Unref(); } DISALLOW_COPY_AND_MOVE(Impl) - inline bool is_closing() const { return state_->closing; } + inline bool is_closing() const { return state()->closing; } ~Impl() { // Ensure that Close() was called before dropping @@ -785,6 +843,10 @@ struct Session::Impl final : public MemoryRetainer { } endpoint->RemoveSession(config_.scid, remote_address_); + + auto& binding = BindingData::Get(env()); + if (stats_slot_) GetSessionStatsArena(binding).ReleaseSlot(stats_slot_); + if (state_slot_) GetSessionStateArena(binding).ReleaseSlot(state_slot_); } void MemoryInfo(MemoryTracker* tracker) const override { @@ -1304,7 +1366,7 @@ struct Session::Impl final : public MemoryRetainer { // NGTCP2_ERR_DRAINING. The actual close handling happens in // Session::Receive when it processes that return value and // checks this flag. - session->impl_->state_->stateless_reset = 1; + session->impl_->state()->stateless_reset = 1; return NGTCP2_SUCCESS; } @@ -1629,10 +1691,7 @@ Session::Session(Endpoint* endpoint, connection_(InitConnection()), tls_session_(tls_context->NewSession(this, session_ticket)) { DCHECK(impl_); - { - auto& stats_ = impl_->stats_; - STAT_RECORD_TIMESTAMP(Stats, created_at); - } + STAT_RECORD_TIMESTAMP(Stats, created_at); // For clients, select the Application immediately — the ALPN is // known upfront from the options. For servers, application_ stays @@ -1661,10 +1720,33 @@ Session::Session(Endpoint* endpoint, MakeWeak(); Debug(this, "Session created."); - JS_DEFINE_READONLY_PROPERTY( - env(), object, env()->stats_string(), impl_->stats_.GetArrayBuffer()); - JS_DEFINE_READONLY_PROPERTY( - env(), object, env()->state_string(), impl_->state_.GetArrayBuffer()); + { + const HandleScope handle_scope(env()->isolate()); + JS_DEFINE_READONLY_PROPERTY( + env(), + object, + env()->state_string(), + impl_->state_slot_.GetPageDataView(env()->isolate())); + JS_DEFINE_READONLY_PROPERTY( + env(), + object, + FIXED_ONE_BYTE_STRING(env()->isolate(), "stateByteOffset"), + Integer::NewFromUnsigned( + env()->isolate(), + static_cast(impl_->state_slot_.GetByteOffset()))); + JS_DEFINE_READONLY_PROPERTY( + env(), + object, + env()->stats_string(), + impl_->stats_slot_.GetPageBigUint64Array(env()->isolate())); + JS_DEFINE_READONLY_PROPERTY( + env(), + object, + FIXED_ONE_BYTE_STRING(env()->isolate(), "statsByteOffset"), + Integer::NewFromUnsigned( + env()->isolate(), + static_cast(impl_->stats_slot_.GetByteOffset()))); + } UpdateDataStats(); } @@ -1732,12 +1814,11 @@ bool Session::is_destroyed() const { } bool Session::is_destroyed_or_closing() const { - return !impl_ || impl_->state_->closing; + return !impl_ || impl_->state()->closing; } void Session::Close(CloseMethod method) { if (is_destroyed()) return; - auto& stats_ = impl_->stats_; // If the handshake was deferred (0-RTT client that never sent), // no packets were ever transmitted. Close silently since there is @@ -1752,7 +1833,7 @@ void Session::Close(CloseMethod method) { } STAT_RECORD_TIMESTAMP(Stats, closing_at); - impl_->state_->closing = 1; + impl_->state()->closing = 1; // With both the DEFAULT and SILENT options, we will proceed to closing // the session immediately. All open streams will be immediately destroyed @@ -1768,27 +1849,27 @@ void Session::Close(CloseMethod method) { switch (method) { case CloseMethod::DEFAULT: { Debug(this, "Immediately closing session"); - impl_->state_->silent_close = 0; + impl_->state()->silent_close = 0; return FinishClose(); } case CloseMethod::SILENT: { Debug(this, "Immediately closing session silently"); - impl_->state_->silent_close = 1; + impl_->state()->silent_close = 1; return FinishClose(); } case CloseMethod::GRACEFUL: { // If we are already closing gracefully, do nothing. - if (impl_->state_->graceful_close) [[unlikely]] { + if (impl_->state()->graceful_close) [[unlikely]] { return; } - impl_->state_->graceful_close = 1; + impl_->state()->graceful_close = 1; // application_ may be null for server sessions if close() is called // before the TLS handshake selects the ALPN. Without an application // we cannot do a graceful shutdown (GOAWAY, CONNECTION_CLOSE etc.), // so fall through to a silent close. if (!impl_->application_) { - impl_->state_->silent_close = 1; + impl_->state()->silent_close = 1; return FinishClose(); } @@ -1806,7 +1887,7 @@ void Session::Close(CloseMethod method) { // 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()->silent_close = 0; return FinishClose(); } @@ -1850,11 +1931,11 @@ void Session::FinishClose() { // trigger MakeCallback (stream destruction, pending queue rejection, // SendConnectionClose, EmitClose). if (is_destroyed()) return; - DCHECK(impl_->state_->closing); + DCHECK(impl_->state()->closing); // Clear the graceful_close flag to prevent RemoveStream() from // re-entering FinishClose() when we destroy streams below. - impl_->state_->graceful_close = 0; + impl_->state()->graceful_close = 0; // Destroy all open streams immediately. We copy the map because // streams remove themselves during destruction. Each Destroy() call @@ -1876,7 +1957,7 @@ void Session::FinishClose() { // Send final application-level shutdown and CONNECTION_CLOSE // unless this is a silent close. - if (!impl_->state_->silent_close) { + if (!impl_->state()->silent_close) { if (impl_->application_) { application().CompleteShutdown(); } @@ -1889,7 +1970,7 @@ void Session::FinishClose() { // 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) { + if (impl_->state()->wrapped) { EmitClose(impl_->last_error_); } else { Destroy(); @@ -1901,7 +1982,7 @@ void Session::Destroy() { // 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; + impl_->state()->closing = 1; // If we're inside a ngtcp2 or nghttp3 callback scope, we cannot // destroy impl_ now because the callback is executing methods on @@ -1914,10 +1995,7 @@ void Session::Destroy() { } Debug(this, "Session destroyed"); - { - auto& stats_ = impl_->stats_; - STAT_RECORD_TIMESTAMP(Stats, destroyed_at); - } + STAT_RECORD_TIMESTAMP(Stats, destroyed_at); impl_.reset(); } @@ -1993,16 +2071,16 @@ void Session::SetApplication(std::unique_ptr app) { return; } } - impl_->state_->application_type = static_cast(app->type()); - impl_->state_->headers_supported = static_cast( + impl_->state()->application_type = static_cast(app->type()); + impl_->state()->headers_supported = static_cast( app->SupportsHeaders() ? HeadersSupportState::SUPPORTED : HeadersSupportState::UNSUPPORTED); // Surface the application's "no error" and "internal error" codes via // session state so that JS-side code (e.g. the stream writer's fail() // path) can resolve the right wire code for the negotiated ALPN // without duplicating the per-application table. - impl_->state_->no_error_code = app->GetNoErrorCode(); - impl_->state_->internal_error_code = app->GetInternalErrorCode(); + impl_->state()->no_error_code = app->GetNoErrorCode(); + impl_->state()->internal_error_code = app->GetInternalErrorCode(); impl_->application_ = std::move(app); } @@ -2052,8 +2130,8 @@ void Session::EmitQlog(uint32_t flags, std::string_view data) { // ngtcp2_conn is mid-destruction. Defer the final chunk via SetImmediate. if (is_destroyed()) { auto isolate = env()->isolate(); - v8::Global recv(isolate, object()); - v8::Global cb( + Global recv(isolate, object()); + Global cb( isolate, BindingData::Get(env()).session_qlog_callback()); std::string buf(data); env()->SetImmediate([recv = std::move(recv), @@ -2101,22 +2179,35 @@ void Session::SetLastError(QuicError&& error) { impl_->last_error_ = std::move(error); } -bool Session::Receive(Store&& store, +bool Session::Receive(const uint8_t* data, + size_t len, const SocketAddress& local_address, - const SocketAddress& remote_address) { + const SocketAddress& remote_address, + const PacketInfo& pkt_info, + uint64_t ts) { + // Convenience wrapper: reads the packet and immediately triggers + // SendPendingData. Used by paths that need an immediate response + // (e.g., Endpoint::Connect for client Initial packets). + // The hot receive path uses ReadPacket() directly with deferred + // flush via BindingData's uv_check callback. + SendPendingDataScope send_scope(this); + return ReadPacket(data, len, local_address, remote_address, pkt_info, ts); +} + +bool Session::ReadPacket(const uint8_t* data, + size_t len, + const SocketAddress& local_address, + const SocketAddress& remote_address, + const PacketInfo& pkt_info, + uint64_t ts) { DCHECK(!is_destroyed()); impl_->remote_address_ = remote_address; - // When we are done processing this packet, we arrange to send any - // pending data for this session. - SendPendingDataScope send_scope(this); - - ngtcp2_vec vec = store; Path path(local_address, remote_address); Debug(this, "Session is receiving %zu-byte packet received along path %s", - vec.len, + len, path); // It is important to understand that reading the packet will cause @@ -2125,29 +2216,29 @@ bool Session::Receive(Store&& store, // ensures that any deferred destroy waits until all callbacks for this // packet have completed. After calling ngtcp2_conn_read_pkt here, we // will need to double check that the session is not destroyed before - // we try doing anything with it (like updating stats, sending pending - // data, etc). + // we try doing anything with it (like updating stats, etc). int err; { NgTcp2CallbackScope callback_scope(this); - err = ngtcp2_conn_read_pkt(*this, - &path, - // TODO(@jasnell): ECN pkt_info blocked on libuv - nullptr, - vec.base, - vec.len, - uv_hrtime()); + // The PacketInfo carries per-packet metadata (currently ECN codepoint). + // When libuv gains per-packet ECN reporting, the caller should + // populate pkt_info from the receive metadata before calling + // ReadPacket(). + // When ts is 0 (the default), call uv_hrtime() here. The batched + // receive path caches a timestamp and passes it to all ReadPacket() + // calls in the same I/O burst. + if (ts == 0) ts = uv_hrtime(); + err = ngtcp2_conn_read_pkt(*this, &path, pkt_info, data, len, ts); } if (is_destroyed()) return false; - Debug(this, "Session receiving %zu-byte packet with result %d", vec.len, err); + Debug(this, "Session receiving %zu-byte packet with result %d", len, err); switch (err) { case 0: { - Debug(this, "Session successfully received %zu-byte packet", vec.len); + Debug(this, "Session successfully received %zu-byte packet", len); if (!is_destroyed()) [[likely]] { - auto& stats_ = impl_->stats_; - STAT_INCREMENT_N(Stats, bytes_received, vec.len); + STAT_INCREMENT_N(Stats, bytes_received, len); // Process deferred operations that couldn't run inside callback // scopes (e.g., HTTP/3 GOAWAY handling that calls into JS). application().PostReceive(); @@ -2178,7 +2269,7 @@ bool Session::Receive(Store&& store, // There is no point in waiting for a draining period — the // peer has no state. Close immediately with an error. if (!is_destroyed()) [[likely]] { - if (impl_->state_->stateless_reset) { + if (impl_->state()->stateless_reset) { Debug(this, "Session received stateless reset, closing"); SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_DRAINING)); Close(CloseMethod::SILENT); @@ -2245,6 +2336,72 @@ bool Session::Receive(Store&& store, return false; } +void Session::SendBatch(Packet::Ptr* packets, + PathStorage* paths, + size_t count) { + DCHECK(!is_destroyed()); + if (count == 0) return; + + // Separate packets into those going to the primary endpoint and those + // redirected to other endpoints (rare: path validation, preferred address). + // Redirected packets are sent individually via the target endpoint. + static constexpr size_t kMaxBatch = 64; + DCHECK_LE(count, kMaxBatch); + Packet::Ptr primary_packets[kMaxBatch]; + size_t primary_count = 0; + + for (size_t i = 0; i < count; i++) { + if (!packets[i] || !can_send_packets()) { + packets[i].reset(); + continue; + } + + UpdatePath(paths[i]); + + // Check for cross-endpoint redirect. + bool redirected = false; + if (paths[i].path.local.addrlen > 0) { + SocketAddress local_addr(paths[i].path.local.addr); + auto& mgr = BindingData::Get(env()).session_manager(); + Endpoint* target = mgr.FindEndpointForAddress(local_addr); + if (target != nullptr && target != &endpoint()) { + SocketAddress remote_addr(paths[i].path.remote.addr); + packets[i]->Redirect(static_cast(target), + remote_addr); + target->Send(std::move(packets[i])); + redirected = true; + } + } + + if (!redirected) { + primary_packets[primary_count++] = std::move(packets[i]); + } + } + + if (primary_count == 0) return; + + // Use batched send for the primary endpoint. + if (prefer_try_send_) { + endpoint().SendBatch(primary_packets, primary_count); + } else { + // Non-flush path: send individually via async uv_udp_send. + for (size_t i = 0; i < primary_count; i++) { + Send(std::move(primary_packets[i])); + } + } +} + +void Session::FlushPendingData() { + DCHECK(!is_destroyed()); + if (impl_->application_) { + // Prefer synchronous sends during the deferred flush to avoid the + // one-tick latency of async uv_udp_send from the uv_check callback. + prefer_try_send_ = true; + application().SendPendingData(); + prefer_try_send_ = false; + } +} + void Session::Send(Packet::Ptr packet) { // Sending a Packet is generally best effort. If we're not in a state // where we can send a packet, it's ok to drop it on the floor. The @@ -2261,6 +2418,16 @@ void Session::Send(Packet::Ptr packet) { return; } + // When called from the deferred flush path (uv_check callback), + // prefer synchronous send to avoid the one-tick latency of async + // uv_udp_send. SendOrTrySend uses uv_udp_try_send first, falling + // back to uv_udp_send on EAGAIN. + if (prefer_try_send_) { + Debug(this, "Session is sending (try_send) %s", packet->ToString()); + endpoint().SendOrTrySend(std::move(packet)); + return; + } + Debug(this, "Session is sending %s", packet->ToString()); endpoint().Send(std::move(packet)); } @@ -2333,10 +2500,10 @@ datagram_id Session::SendDatagram(Store&& data) { } // Assign the datagram ID. - datagram_id did = ++impl_->state_->last_datagram_id; + datagram_id did = ++impl_->state()->last_datagram_id; // Check queue capacity. Apply the drop policy when full. - auto max_pending = impl_->state_->max_pending_datagrams; + auto max_pending = impl_->state()->max_pending_datagrams; if (max_pending > 0 && impl_->pending_datagrams_.size() >= max_pending) { auto drop_policy = impl_->config_.options.datagram_drop_policy; if (drop_policy == DatagramDropPolicy::DROP_OLDEST) { @@ -2507,7 +2674,6 @@ void Session::AddStream(BaseObjectPtr stream, // Update tracking statistics for the number of streams associated with this // session. - auto& stats_ = impl_->stats_; if (ngtcp2_conn_is_local_stream(*this, id)) { switch (direction) { case Direction::BIDIRECTIONAL: { @@ -2559,7 +2725,7 @@ void Session::RemoveStream(stream_id id) { // then we can proceed to finishing the close now. Note that the // expectation is that the session will be destroyed once FinishClose // returns. - if (impl_->state_->closing && impl_->state_->graceful_close) { + if (impl_->state()->closing && impl_->state()->graceful_close) { FinishClose(); CHECK(is_destroyed()); } @@ -2597,7 +2763,6 @@ void Session::ShutdownStreamWrite(stream_id id, QuicError code) { void Session::StreamDataBlocked(stream_id id) { DCHECK(!is_destroyed()); - auto& stats_ = impl_->stats_; STAT_INCREMENT(Stats, block_count); application().BlockStream(id); } @@ -2668,20 +2833,20 @@ bool Session::is_in_draining_period() const { bool Session::wants_session_ticket() const { return !is_destroyed() && - HasListenerFlag(impl_->state_->listener_flags, + HasListenerFlag(impl_->state()->listener_flags, SessionListenerFlags::SESSION_TICKET); } void Session::SetStreamOpenAllowed() { DCHECK(!is_destroyed()); - impl_->state_->stream_open_allowed = 1; + impl_->state()->stream_open_allowed = 1; } void Session::PopulateEarlyTransportParamsState() { DCHECK(!is_destroyed()); const ngtcp2_transport_params* tp = remote_transport_params(); if (tp != nullptr) { - impl_->state_->max_datagram_size = + impl_->state()->max_datagram_size = MaxDatagramPayload(tp->max_datagram_frame_size); } } @@ -2702,7 +2867,7 @@ bool Session::can_create_streams() const { } bool Session::can_open_streams() const { - return !is_destroyed() && impl_->state_->stream_open_allowed; + return !is_destroyed() && impl_->state()->stream_open_allowed; } uint64_t Session::max_data_left() const { @@ -2723,12 +2888,12 @@ uint64_t Session::max_local_streams_bidi() const { void Session::set_wrapped() { DCHECK(!is_destroyed()); - impl_->state_->wrapped = 1; + impl_->state()->wrapped = 1; } void Session::set_priority_supported(bool on) { DCHECK(!is_destroyed()); - impl_->state_->priority_supported = on ? 1 : 0; + impl_->state()->priority_supported = on ? 1 : 0; } void Session::ExtendStreamOffset(stream_id id, size_t amount) { @@ -2761,14 +2926,13 @@ size_t Session::PendingDatagramCount() const { void Session::DatagramSent(datagram_id id) { Debug(this, "Datagram %" PRIu64 " sent", id); - auto& stats_ = impl_->stats_; STAT_INCREMENT(Stats, datagrams_sent); } void Session::UpdateDataStats() { if (is_destroyed()) return; Debug(this, "Updating data stats"); - auto& stats_ = impl_->stats_; + ngtcp2_conn_info info; ngtcp2_conn_get_conn_info(*this, &info); STAT_SET(Stats, bytes_in_flight, info.bytes_in_flight); @@ -2857,6 +3021,15 @@ void Session::SendConnectionClose() { void Session::OnTimeout() { if (is_destroyed()) return; if (!impl_->application_) return; + // Hold a strong reference to prevent the Session from being freed during + // re-entrant calls. SendPendingData's scope guard calls UpdateTimer(), + // which can synchronously re-enter OnTimeout() when the timer has already + // expired. That re-entrant path can reach FinishClose → EmitClose → + // Destroy → impl_.reset() → ~Impl → RemoveSession(), dropping the last + // BaseObjectPtr from the endpoint map and freeing the Session. Without + // this guard, the outer OnTimeout / SendPendingData frames would operate + // on a freed object. + BaseObjectPtr ref(this); HandleScope scope(env()->isolate()); int ret; { @@ -2922,7 +3095,7 @@ void Session::UpdateTimer() { void Session::DatagramStatus(datagram_id datagramId, quic::DatagramStatus status) { DCHECK(!is_destroyed()); - auto& stats_ = impl_->stats_; + switch (status) { case DatagramStatus::ACKNOWLEDGED: { Debug(this, "Datagram %" PRIu64 " was acknowledged", datagramId); @@ -2940,7 +3113,7 @@ void Session::DatagramStatus(datagram_id datagramId, break; } } - if (HasListenerFlag(impl_->state_->listener_flags, + if (HasListenerFlag(impl_->state()->listener_flags, SessionListenerFlags::DATAGRAM_STATUS)) { EmitDatagramStatus(datagramId, status); } @@ -2952,13 +3125,13 @@ 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 (!HasListenerFlag(impl_->state_->listener_flags, + 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_; + STAT_INCREMENT(Stats, datagrams_received); JS_TRY_ALLOCATE_BACKING(env(), backing, datalen) memcpy(backing->Data(), data, datalen); @@ -2979,18 +3152,18 @@ void Session::GenerateNewConnectionId(ngtcp2_cid* cid, bool Session::HandshakeCompleted() { DCHECK(!is_destroyed()); - DCHECK(!impl_->state_->handshake_completed); + DCHECK(!impl_->state()->handshake_completed); Debug(this, "Session handshake completed"); - impl_->state_->handshake_completed = 1; - auto& stats_ = impl_->stats_; + impl_->state()->handshake_completed = 1; + STAT_RECORD_TIMESTAMP(Stats, handshake_completed_at); SetStreamOpenAllowed(); // Capture the peer's max datagram frame size from the remote transport // parameters so JavaScript can check it without a C++ round-trip. const ngtcp2_transport_params* tp = remote_transport_params(); - impl_->state_->max_datagram_size = + impl_->state()->max_datagram_size = MaxDatagramPayload(tp->max_datagram_frame_size); // If early data was attempted but rejected by the server, @@ -3025,10 +3198,10 @@ bool Session::HandshakeCompleted() { void Session::HandshakeConfirmed() { DCHECK(!is_destroyed()); - DCHECK(!impl_->state_->handshake_confirmed); + DCHECK(!impl_->state()->handshake_confirmed); Debug(this, "Session handshake confirmed"); - impl_->state_->handshake_confirmed = 1; - auto& stats_ = impl_->stats_; + impl_->state()->handshake_confirmed = 1; + STAT_RECORD_TIMESTAMP(Stats, handshake_confirmed_at); } @@ -3166,7 +3339,7 @@ void Session::EmitClose(const QuicError& error) { void Session::set_max_datagram_size(uint16_t size) { if (!is_destroyed()) { - impl_->state_->max_datagram_size = size; + impl_->state()->max_datagram_size = size; } } @@ -3281,7 +3454,7 @@ void Session::EmitPathValidation(PathValidationResult result, if (!env()->can_call_into_js()) return; - if (!HasListenerFlag(impl_->state_->listener_flags, + if (!HasListenerFlag(impl_->state()->listener_flags, SessionListenerFlags::PATH_VALIDATION)) [[likely]] { return; } @@ -3327,7 +3500,7 @@ void Session::EmitSessionTicket(Store&& ticket) { // If there is nothing listening for the session ticket, don't bother // emitting. - if (!HasListenerFlag(impl_->state_->listener_flags, + if (!HasListenerFlag(impl_->state()->listener_flags, SessionListenerFlags::SESSION_TICKET)) [[likely]] { Debug(this, "Session ticket was discarded"); return; @@ -3384,7 +3557,7 @@ void Session::EmitEarlyDataRejected() { void Session::EmitNewToken(const uint8_t* token, size_t len) { DCHECK(!is_destroyed()); - if (!HasListenerFlag(impl_->state_->listener_flags, + if (!HasListenerFlag(impl_->state()->listener_flags, SessionListenerFlags::NEW_TOKEN)) return; if (!env()->can_call_into_js()) return; @@ -3460,7 +3633,7 @@ void Session::EmitVersionNegotiation(const ngtcp2_pkt_hd& hd, void Session::EmitOrigins(std::vector&& origins) { DCHECK(!is_destroyed()); - if (!HasListenerFlag(impl_->state_->listener_flags, + if (!HasListenerFlag(impl_->state()->listener_flags, SessionListenerFlags::ORIGIN)) return; if (!env()->can_call_into_js()) return; @@ -3550,6 +3723,10 @@ void Session::InitPerContext(Realm* realm, Local target) { NODE_DEFINE_CONSTANT(target, QUIC_PROTO_MAX); NODE_DEFINE_CONSTANT(target, QUIC_PROTO_MIN); + static constexpr auto DEFAULT_HANDSHAKE_TIMEOUT = + Session::Options::DEFAULT_HANDSHAKE_TIMEOUT; + NODE_DEFINE_CONSTANT(target, DEFAULT_HANDSHAKE_TIMEOUT); + NODE_DEFINE_STRING_CONSTANT( target, "DEFAULT_CIPHERS", TLSContext::DEFAULT_CIPHERS); NODE_DEFINE_STRING_CONSTANT( @@ -3564,6 +3741,8 @@ void Session::InitPerContext(Realm* realm, Local target) { SESSION_STATE(V) #undef V + NODE_DEFINE_CONSTANT(target, IDX_STATS_SESSION_COUNT); + #define V(name, _) NODE_DEFINE_CONSTANT(target, IDX_STATS_SESSION_##name); SESSION_STATS(V) NODE_DEFINE_CONSTANT(target, IDX_STATS_SESSION_COUNT); diff --git a/src/quic/session.h b/src/quic/session.h index 650e8f79ba1428..472079984f313a 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -153,8 +153,15 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { bool qlog = false; // The amount of time (in milliseconds) that the endpoint will wait for the - // completion of the tls handshake. - uint64_t handshake_timeout = UINT64_MAX; + // completion of the TLS handshake. If the handshake does not complete + // within this time, the session is closed. This prevents a peer from + // holding a session open indefinitely in the handshake state, consuming + // server resources (ngtcp2 connection, TLS state, JS objects) without + // ever completing the connection. The default of 10 seconds is generous + // enough to accommodate slow networks with retransmissions while still + // bounding resource exposure. Set to UINT64_MAX to disable. + static constexpr uint64_t DEFAULT_HANDSHAKE_TIMEOUT = 10'000; + uint64_t handshake_timeout = DEFAULT_HANDSHAKE_TIMEOUT; // The keep-alive timeout in milliseconds. When set to a non-zero value, // ngtcp2 will automatically send PING frames to keep the connection alive @@ -353,9 +360,45 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { bool early = false; }; - bool Receive(Store&& store, + bool Receive(const uint8_t* data, + size_t len, const SocketAddress& local_address, - const SocketAddress& remote_address); + const SocketAddress& remote_address, + const PacketInfo& pkt_info = PacketInfo(), + uint64_t ts = 0); + + // ReadPacket processes a single inbound packet through ngtcp2 without + // triggering SendPendingData. This is the building block for batched + // receive processing: the caller (Endpoint::Receive) accumulates + // dirty sessions and a uv_check callback flushes them after all + // packets in the I/O burst have been read. + // Receive() is kept as a convenience wrapper that calls ReadPacket() + // then triggers SendPendingData (for paths like Connect that need + // immediate response). + // The data pointer is used synchronously — ngtcp2_conn_read_pkt does + // not retain a reference after returning, so the caller's buffer can + // be reused immediately. + // When ts is 0 (the default), uv_hrtime() is called internally. + // The batched receive path caches a timestamp and passes it to all + // ReadPacket() calls in the same I/O burst. + bool ReadPacket(const uint8_t* data, + size_t len, + const SocketAddress& local_address, + const SocketAddress& remote_address, + const PacketInfo& pkt_info = PacketInfo(), + uint64_t ts = 0); + + // Called by BindingData's flush callback to trigger SendPendingData + // on this session. Encapsulates the application() access so that + // bindingdata.cc doesn't need the full Application type definition. + void FlushPendingData(); + + // Send a batch of packets accumulated by SendPendingData. Uses + // Endpoint::SendBatch (uv_udp_try_send2 / sendmmsg) for synchronous + // batched delivery when called from the deferred flush path. + // Handles per-packet path updates and cross-endpoint redirects. + // All Ptr entries are consumed (released or moved) on return. + void SendBatch(Packet::Ptr* packets, PathStorage* paths, size_t count); void Send(Packet::Ptr packet); void Send(Packet::Ptr packet, const PathStorage& path); @@ -572,11 +615,22 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { bool in_ngtcp2_callback_scope_ = false; bool in_nghttp3_callback_scope_ = false; bool destroy_deferred_ = false; + // Set when this session is in BindingData's pending_flush_sessions_ vector. + // Cleared by the flush callback before calling SendPendingData. + // Provides O(1) dedup so a session receiving multiple packets in one I/O + // burst is only scheduled for flush once. + bool pending_flush_ = false; + // When true, Session::Send prefers synchronous delivery via + // Endpoint::SendOrTrySend (uv_udp_try_send with async fallback). + // Set during FlushPendingData to avoid the one-tick latency of + // async-only sends from the uv_check callback. + bool prefer_try_send_ = false; QuicConnectionPointer connection_; std::unique_ptr tls_session_; friend struct NgTcp2CallbackScope; friend struct NgHttp3CallbackScope; friend class Application; + friend class BindingData; friend class DefaultApplication; friend class Http3ApplicationImpl; friend class Endpoint; diff --git a/src/quic/streams.cc b/src/quic/streams.cc index dd7f7ecbb3880e..f5debd842c0303 100644 --- a/src/quic/streams.cc +++ b/src/quic/streams.cc @@ -26,6 +26,7 @@ using v8::BackingStore; using v8::BigInt; using v8::FunctionCallbackInfo; using v8::Global; +using v8::HandleScope; using v8::Integer; using v8::Just; using v8::Local; @@ -34,6 +35,7 @@ using v8::Nothing; using v8::Object; using v8::ObjectTemplate; using v8::SharedArrayBuffer; +using v8::String; using v8::Uint32; using v8::Uint8Array; using v8::Value; @@ -151,6 +153,44 @@ struct Stream::State { STAT_STRUCT(Stream, STREAM) +// Stream uses arena-allocated stats, not AliasedStruct, so override the +// STAT_* macros to use the stats() accessor instead of stats_.Data(). +#undef STAT_INCREMENT +#undef STAT_INCREMENT_N +#undef STAT_RECORD_TIMESTAMP +#undef STAT_SET +#undef STAT_GET +#define STAT_INCREMENT(Type, name) IncrementStat(stats()); +#define STAT_INCREMENT_N(Type, name, amt) \ + IncrementStat(stats(), amt); +#define STAT_RECORD_TIMESTAMP(Type, name) \ + RecordTimestampStat(stats()); +#define STAT_SET(Type, name, val) SetStat(stats(), val) +#define STAT_GET(Type, name) GetStat(stats()) + +using StreamStateArena = AliasedStructArena; +using StreamStatsArena = AliasedStructArena; + +namespace { +StreamStateArena& GetStreamStateArena(BindingData& binding) { + if (!binding.stream_state_arena_) { + auto* arena = new StreamStateArena(); + binding.stream_state_arena_ = BindingData::ArenaPtr( + arena, +[](void* p) { delete static_cast(p); }); + } + return *static_cast(binding.stream_state_arena_.get()); +} + +StreamStatsArena& GetStreamStatsArena(BindingData& binding) { + if (!binding.stream_stats_arena_) { + auto* arena = new StreamStatsArena(); + binding.stream_stats_arena_ = BindingData::ArenaPtr( + arena, +[](void* p) { delete static_cast(p); }); + } + return *static_cast(binding.stream_stats_arena_.get()); +} +} // namespace + // ============================================================================ namespace { @@ -239,7 +279,7 @@ Maybe> Stream::GetDataQueueFromSource( // object's constructor name is "FileHandle". if (value->IsObject()) { auto obj = value.As(); - Local ctor_name; + Local ctor_name; auto maybe_name = obj->GetConstructorName(); if (!maybe_name.IsEmpty()) { ctor_name = maybe_name; @@ -249,9 +289,8 @@ Maybe> Stream::GetDataQueueFromSource( ASSIGN_OR_RETURN_UNWRAP( &file_handle, value, Nothing>()); Local path; - if (!v8::String::NewFromUtf8(env->isolate(), - file_handle->original_name().c_str()) - .ToLocal(&path)) { + if (!ToV8Value(env->context(), file_handle->original_name()) + .ToLocal(&path)) { return Nothing>(); } auto entry = DataQueue::CreateFdEntry(env, path); @@ -382,14 +421,14 @@ struct Stream::Impl { code = args[0].As()->Uint64Value(&lossless); } - if (stream->state_->reset == 1) return; + if (stream->state()->reset == 1) return; stream->EndWritable(); // We can release our outbound here now. Since the stream is being reset // on the ngtcp2 side, we do not need to keep any of the data around // waiting for acknowledgement that will never come. stream->outbound_.reset(); - stream->state_->reset = 1; + stream->state()->reset = 1; if (!stream->is_pending()) { if (stream->is_remote_unidirectional()) return; @@ -945,6 +984,8 @@ void Stream::InitPerContext(Realm* realm, Local target) { STREAM_STATE(V) #undef V + NODE_DEFINE_CONSTANT(target, IDX_STATS_STREAM_COUNT); + constexpr int QUIC_STREAM_HEADERS_KIND_HINTS = static_cast(HeadersKind::HINTS); constexpr int QUIC_STREAM_HEADERS_KIND_INITIAL = @@ -993,30 +1034,56 @@ Stream::Stream(BaseObjectWeakPtr session, stream_id id, std::shared_ptr source) : AsyncWrap(session->env(), object, PROVIDER_QUIC_STREAM), - stats_(env()->isolate()), - state_(env()->isolate()), session_(std::move(session)), inbound_(DataQueue::Create()), headers_(env()->isolate()) { + auto& binding = BindingData::Get(env()); + stats_slot_ = GetStreamStatsArena(binding).Allocate(env()->isolate()); + state_slot_ = GetStreamStateArena(binding).Allocate(env()->isolate()); MakeWeak(); DCHECK(id < kMaxStreamId); - state_->id = id; - state_->pending = 0; + state()->id = id; + state()->pending = 0; // Allows us to be notified when data is actually read from the // inbound queue so that we can update the stream flow control. inbound_->addBackpressureListener(this); - JS_DEFINE_READONLY_PROPERTY( - env(), object, env()->state_string(), state_.GetArrayBuffer()); - JS_DEFINE_READONLY_PROPERTY( - env(), object, env()->stats_string(), stats_.GetArrayBuffer()); + { + const HandleScope handle_scope(env()->isolate()); + // Pass the page's shared views and this slot's byte offset. JS uses + // the offset to index into the shared view — no per-stream V8 object + // creation. + JS_DEFINE_READONLY_PROPERTY(env(), + object, + env()->state_string(), + state_slot_.GetPageDataView(env()->isolate())); + JS_DEFINE_READONLY_PROPERTY( + env(), + object, + FIXED_ONE_BYTE_STRING(env()->isolate(), "stateByteOffset"), + Integer::NewFromUnsigned( + env()->isolate(), + static_cast(state_slot_.GetByteOffset()))); + JS_DEFINE_READONLY_PROPERTY( + env(), + object, + env()->stats_string(), + stats_slot_.GetPageBigUint64Array(env()->isolate())); + JS_DEFINE_READONLY_PROPERTY( + env(), + object, + FIXED_ONE_BYTE_STRING(env()->isolate(), "statsByteOffset"), + Integer::NewFromUnsigned( + env()->isolate(), + static_cast(stats_slot_.GetByteOffset()))); + } set_outbound(std::move(source)); STAT_RECORD_TIMESTAMP(Stats, created_at); auto params = ngtcp2_conn_get_local_transport_params(this->session()); STAT_SET(Stats, max_offset, params->initial_max_data); - STAT_SET(Stats, opened_at, stats_->created_at); + STAT_SET(Stats, opened_at, stats()->created_at); } Stream::Stream(BaseObjectWeakPtr session, @@ -1024,25 +1091,48 @@ Stream::Stream(BaseObjectWeakPtr session, Direction direction, std::shared_ptr source) : AsyncWrap(session->env(), object, PROVIDER_QUIC_STREAM), - stats_(env()->isolate()), - state_(env()->isolate()), session_(std::move(session)), inbound_(DataQueue::Create()), maybe_pending_stream_( std::make_unique(direction, this, session_)), headers_(env()->isolate()) { + auto& binding = BindingData::Get(env()); + stats_slot_ = GetStreamStatsArena(binding).Allocate(env()->isolate()); + state_slot_ = GetStreamStateArena(binding).Allocate(env()->isolate()); MakeWeak(); - state_->id = kMaxStreamId; - state_->pending = 1; + state()->id = kMaxStreamId; + state()->pending = 1; // Allows us to be notified when data is actually read from the // inbound queue so that we can update the stream flow control. inbound_->addBackpressureListener(this); - JS_DEFINE_READONLY_PROPERTY( - env(), object, env()->state_string(), state_.GetArrayBuffer()); - JS_DEFINE_READONLY_PROPERTY( - env(), object, env()->stats_string(), stats_.GetArrayBuffer()); + { + const HandleScope handle_scope(env()->isolate()); + JS_DEFINE_READONLY_PROPERTY(env(), + object, + env()->state_string(), + state_slot_.GetPageDataView(env()->isolate())); + JS_DEFINE_READONLY_PROPERTY( + env(), + object, + FIXED_ONE_BYTE_STRING(env()->isolate(), "stateByteOffset"), + Integer::NewFromUnsigned( + env()->isolate(), + static_cast(state_slot_.GetByteOffset()))); + JS_DEFINE_READONLY_PROPERTY( + env(), + object, + env()->stats_string(), + stats_slot_.GetPageBigUint64Array(env()->isolate())); + JS_DEFINE_READONLY_PROPERTY( + env(), + object, + FIXED_ONE_BYTE_STRING(env()->isolate(), "statsByteOffset"), + Integer::NewFromUnsigned( + env()->isolate(), + static_cast(stats_slot_.GetByteOffset()))); + } set_outbound(std::move(source)); @@ -1053,15 +1143,24 @@ Stream::Stream(BaseObjectWeakPtr session, Stream::~Stream() { // Make sure that Destroy() was called before Stream is actually destructed. - DCHECK_NE(stats_->destroyed_at, 0); + DCHECK_NE(stats()->destroyed_at, 0); + + // Release arena slots back to the freelist. + auto& binding = BindingData::Get(env()); + if (stats_slot_) { + GetStreamStatsArena(binding).ReleaseSlot(stats_slot_); + } + if (state_slot_) { + GetStreamStateArena(binding).ReleaseSlot(state_slot_); + } } void Stream::NotifyStreamOpened(stream_id id) { CHECK(is_pending()); DCHECK(id < kMaxStreamId); Debug(this, "Pending stream opened with id %" PRIi64, id); - state_->pending = 0; - state_->id = id; + state()->pending = 0; + state()->id = id; STAT_RECORD_TIMESTAMP(Stats, opened_at); // Now that the stream is actually opened, add it to the sessions // list of known open streams. @@ -1132,26 +1231,26 @@ void Stream::EnqueuePendingHeaders(HeadersKind kind, } bool Stream::is_pending() const { - return state_->pending; + return state()->pending; } stream_id Stream::id() const { - return state_->id; + return state()->id; } Side Stream::origin() const { CHECK(!is_pending()); - return (state_->id & 0b01) ? Side::SERVER : Side::CLIENT; + return (state()->id & 0b01) ? Side::SERVER : Side::CLIENT; } Direction Stream::direction() const { - if (state_->pending) { + if (state()->pending) { CHECK(maybe_pending_stream_.has_value()); auto& val = maybe_pending_stream_.value(); return val->direction(); } - return (state_->id & 0b10) ? Direction::UNIDIRECTIONAL - : Direction::BIDIRECTIONAL; + return (state()->id & 0b10) ? Direction::UNIDIRECTIONAL + : Direction::BIDIRECTIONAL; } Session& Stream::session() const { @@ -1169,15 +1268,15 @@ bool Stream::is_remote_unidirectional() const { } bool Stream::is_eos() const { - return state_->fin_sent; + return state()->fin_sent; } bool Stream::wants_trailers() const { - return state_->wants_trailers; + return state()->wants_trailers; } void Stream::set_early() { - state_->received_early_data = 1; + state()->received_early_data = 1; } bool Stream::is_writable() const { @@ -1187,7 +1286,7 @@ bool Stream::is_writable() const { !ngtcp2_conn_is_local_stream(session(), id())) { return false; } - return state_->write_ended == 0; + return state()->write_ended == 0; } bool Stream::has_outbound() const { @@ -1209,21 +1308,21 @@ bool Stream::is_readable() const { ngtcp2_conn_is_local_stream(session(), id())) { return false; } - return state_->read_ended == 0; + return state()->read_ended == 0; } BaseObjectPtr Stream::get_reader() { - if (!is_readable() || state_->has_reader) return {}; - state_->has_reader = 1; + if (!is_readable() || state()->has_reader) return {}; + state()->has_reader = 1; auto reader = Blob::Reader::Create(env(), Blob::Create(env(), inbound_)); reader_ = reader; return reader; } void Stream::set_final_size(uint64_t final_size) { - DCHECK_IMPLIES(state_->fin_received == 1, + DCHECK_IMPLIES(state()->fin_received == 1, final_size <= STAT_GET(Stats, final_size)); - state_->fin_received = 1; + state()->fin_received = 1; STAT_SET(Stats, final_size, final_size); } @@ -1232,7 +1331,7 @@ void Stream::set_outbound(std::shared_ptr source) { Debug(this, "Setting the outbound data source"); DCHECK_NULL(outbound_); outbound_ = std::make_unique(this, std::move(source)); - state_->has_outbound = 1; + state()->has_outbound = 1; // Note: We intentionally do NOT call ResumeStream here. During // construction, the stream has not yet been added to the session's // streams map, so FindStream would fail. The caller (CreateStream / @@ -1251,7 +1350,7 @@ void Stream::InitStreaming() { } Debug(this, "Initializing streaming outbound source"); outbound_ = std::make_unique(this); - state_->has_outbound = 1; + state()->has_outbound = 1; if (!is_pending()) session_->ResumeStream(id()); } @@ -1397,7 +1496,7 @@ void Stream::Commit(size_t datalen, bool fin) { Debug(this, "Committing %zu bytes", datalen); STAT_INCREMENT_N(Stats, bytes_sent, datalen); if (outbound_) outbound_->Commit(datalen); - if (fin) state_->fin_sent = 1; + if (fin) state()->fin_sent = 1; } void Stream::EndWritable() { @@ -1407,12 +1506,12 @@ void Stream::EndWritable() { // will be a non-op since we're not going to be writing any more data // into it anyway. if (outbound_) outbound_->Cap(); - state_->write_ended = 1; + state()->write_ended = 1; } void Stream::EndReadable(std::optional maybe_final_size) { if (!is_readable()) return; - state_->read_ended = 1; + state()->read_ended = 1; set_final_size(maybe_final_size.value_or(STAT_GET(Stats, bytes_received))); inbound_->cap(STAT_GET(Stats, final_size)); // Notify the JS reader so it can see EOS. Pass fin=true so the @@ -1422,20 +1521,20 @@ void Stream::EndReadable(std::optional maybe_final_size) { } void Stream::Destroy(QuicError error) { - if (stats_->destroyed_at != 0) return; + if (stats()->destroyed_at != 0) return; // Record the destroyed at timestamp before notifying the JavaScript side // that the stream is being destroyed. STAT_RECORD_TIMESTAMP(Stats, destroyed_at); DCHECK_NOT_NULL(session_.get()); - if (!state_->pending) { + if (!state()->pending) { Debug( this, "Stream %" PRIi64 " being destroyed with error %s", id(), error); } else { Debug(this, "Pending stream being destroyed with error %s", error); } - state_->pending = 0; + state()->pending = 0; maybe_pending_stream_.reset(); @@ -1465,8 +1564,11 @@ void Stream::Destroy(QuicError error) { auto session = session_; session_.reset(); // EmitClose above triggers MakeCallback which can destroy the session - // via JS re-entrancy. The weak pointer may now be null. - if (session) session->RemoveStream(id()); + // via JS re-entrancy. The weak pointer may still be non-null (the + // Session BaseObject can be kept alive by a BaseObjectPtr elsewhere, + // e.g. OnTimeout's ref) even though impl_ has been reset. We must + // check is_destroyed() to avoid dereferencing the null impl_. + if (session && !session->is_destroyed()) session->RemoveStream(id()); // Critically, make sure that the RemoveStream call is the last thing // trying to use this stream object. Once that call is made, the stream @@ -1482,12 +1584,12 @@ void Stream::ReceiveData(const uint8_t* data, // If reading has ended, or there is no data, there's nothing to do but maybe // end the readable side if this is the last bit of data we've received. Debug(this, "Receiving %zu bytes of data", len); - if (state_->read_ended == 1 || len == 0) { + if (state()->read_ended == 1 || len == 0) { if (flags.fin) EndReadable(); return; } - if (flags.early) state_->received_early_data = 1; + if (flags.early) state()->received_early_data = 1; STAT_INCREMENT_N(Stats, bytes_received, len); STAT_SET(Stats, max_offset_received, STAT_GET(Stats, bytes_received)); STAT_RECORD_TIMESTAMP(Stats, received_at); @@ -1509,10 +1611,10 @@ void Stream::ReceiveStopSending(QuicError error) { // writable side has already been shut down (e.g. we already sent // RESET_STREAM ourselves or finished sending with FIN) there is // nothing more to do here. The previous guard checked - // `state_->read_ended` which is unrelated to the writable side and + // `state()->read_ended` which is unrelated to the writable side and // suppressed STOP_SENDING handling whenever a sibling RESET_STREAM // frame had been processed first within the same packet. - if (state_->write_ended) return; + if (state()->write_ended) return; Debug(this, "Received stop sending with error %s", error); ngtcp2_conn_shutdown_stream_write(session(), 0, id(), error.code()); EndWritable(); @@ -1528,7 +1630,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(); + state()->reset_code = error.code(); EndReadable(final_size); EmitReset(error); } @@ -1536,10 +1638,10 @@ void Stream::ReceiveStreamReset(uint64_t final_size, QuicError error) { // ============================================================================ void Stream::EmitBlocked() { - // state_->wants_block will be set from the javascript side if the + // state()->wants_block will be set from the javascript side if the // stream object has a handler for the blocked event. Debug(this, "Blocked"); - if (!env()->can_call_into_js() || !state_->wants_block) { + if (!env()->can_call_into_js() || !state()->wants_block) { return; } CallbackScope cb_scope(this); @@ -1556,7 +1658,7 @@ void Stream::UpdateWriteDesiredSize() { if (!outbound_ || !outbound_->is_streaming()) return; uint64_t available; - uint64_t hwm = state_->high_water_mark; + uint64_t hwm = state()->high_water_mark; if (is_pending()) { // Pending streams don't have a stream ID yet, so ngtcp2 can't @@ -1589,8 +1691,8 @@ void Stream::UpdateWriteDesiredSize() { uint32_t clamped = static_cast( std::min(desired, std::numeric_limits::max())); - uint32_t old_size = state_->write_desired_size; - state_->write_desired_size = clamped; + uint32_t old_size = state()->write_desired_size; + state()->write_desired_size = clamped; // Fire drain when transitioning from 0 to non-zero if (old_size == 0 && desired > 0) { @@ -1607,9 +1709,9 @@ void Stream::EmitClose(const QuicError& error) { } void Stream::EmitHeaders() { - // state_->wants_headers will be set from the javascript side if the + // state()->wants_headers will be set from the javascript side if the // stream object has a handler for the headers event. - if (!env()->can_call_into_js() || !state_->wants_headers) { + if (!env()->can_call_into_js() || !state()->wants_headers) { return; } CallbackScope cb_scope(this); @@ -1626,9 +1728,9 @@ void Stream::EmitHeaders() { } void Stream::EmitReset(const QuicError& error) { - // state_->wants_reset will be set from the javascript side if the + // state()->wants_reset will be set from the javascript side if the // stream object has a handler for the reset event. - if (!env()->can_call_into_js() || !state_->wants_reset) { + if (!env()->can_call_into_js() || !state()->wants_reset) { return; } CallbackScope cb_scope(this); @@ -1639,9 +1741,9 @@ void Stream::EmitReset(const QuicError& error) { } void Stream::EmitWantTrailers() { - // state_->wants_trailers will be set from the javascript side if the + // state()->wants_trailers will be set from the javascript side if the // stream object has a handler for the trailers event. - if (!env()->can_call_into_js() || !state_->wants_trailers) { + if (!env()->can_call_into_js() || !state()->wants_trailers) { return; } CallbackScope cb_scope(this); diff --git a/src/quic/streams.h b/src/quic/streams.h index 0edeeed7a9209e..b72298f16636ae 100644 --- a/src/quic/streams.h +++ b/src/quic/streams.h @@ -304,6 +304,17 @@ class Stream final : public AsyncWrap, struct State; struct Stats; + // Typed accessors for arena-allocated state/stats. These are defined + // in streams.cc where State and Stats are complete types. + inline State* state() { return static_cast(state_slot_.ptr); } + inline const State* state() const { + return static_cast(state_slot_.ptr); + } + inline Stats* stats() { return static_cast(stats_slot_.ptr); } + inline const Stats* stats() const { + return static_cast(stats_slot_.ptr); + } + private: struct Impl; struct PendingHeaders; @@ -362,8 +373,8 @@ class Stream final : public AsyncWrap, v8::Local headers, HeadersFlags flags); - AliasedStruct stats_; - AliasedStruct state_; + ArenaSlotBase stats_slot_; + ArenaSlotBase state_slot_; BaseObjectWeakPtr session_; std::unique_ptr outbound_; std::shared_ptr inbound_; diff --git a/src/quic/tlscontext.cc b/src/quic/tlscontext.cc index b563bae5071e0f..90640b2a15ad51 100644 --- a/src/quic/tlscontext.cc +++ b/src/quic/tlscontext.cc @@ -30,13 +30,16 @@ using ncrypto::SSLCtxPointer; using ncrypto::SSLPointer; using ncrypto::SSLSessionPointer; using ncrypto::X509Pointer; +using v8::Array; using v8::ArrayBuffer; +using v8::ArrayBufferView; using v8::Just; using v8::Local; using v8::Maybe; using v8::MaybeLocal; using v8::Nothing; using v8::Object; +using v8::String; using v8::Undefined; using v8::Value; @@ -95,7 +98,7 @@ template Opt::*member> bool SetOption(Environment* env, Opt* options, const Local& object, - const Local& name) { + const Local& name) { Local value; if (!object->Get(env->context(), name).ToLocal(&value)) return false; @@ -105,7 +108,7 @@ bool SetOption(Environment* env, if (value->IsArray()) { auto context = env->context(); - auto values = value.As(); + auto values = value.As(); uint32_t count = values->Length(); for (uint32_t n = 0; n < count; n++) { Local item; @@ -125,7 +128,7 @@ bool SetOption(Environment* env, } } else if constexpr (std::is_same::value) { if (item->IsArrayBufferView()) { - Store store = Store::CopyFrom(item.As()); + Store store = Store::CopyFrom(item.As()); (options->*member).push_back(std::move(store)); } else if (item->IsArrayBuffer()) { Store store = Store::CopyFrom(item.As()); @@ -154,7 +157,7 @@ bool SetOption(Environment* env, } } else if constexpr (std::is_same::value) { if (value->IsArrayBufferView()) { - Store store = Store::CopyFrom(value.As()); + Store store = Store::CopyFrom(value.As()); (options->*member).push_back(std::move(store)); } else if (value->IsArrayBuffer()) { Store store = Store::CopyFrom(value.As()); diff --git a/test/parallel/test-quic-alpn-h3.mjs b/test/parallel/test-quic-alpn-h3.mjs index ba76d138b99673..431175a743d502 100644 --- a/test/parallel/test-quic-alpn-h3.mjs +++ b/test/parallel/test-quic-alpn-h3.mjs @@ -43,4 +43,5 @@ async function checkClient() { } await Promise.all([serverOpened.promise, checkClient()]); -clientSession.close(); +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-alpn-mismatch.mjs b/test/parallel/test-quic-alpn-mismatch.mjs index 5dfff57219e0aa..648c08d4402710 100644 --- a/test/parallel/test-quic-alpn-mismatch.mjs +++ b/test/parallel/test-quic-alpn-mismatch.mjs @@ -48,3 +48,5 @@ await rejects(clientSession.opened, { await rejects(clientSession.closed, { code: 'ERR_QUIC_TRANSPORT_ERROR', }); + +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-alpn.mjs b/test/parallel/test-quic-alpn.mjs index a077d1cfb610d2..babb945d9d0c33 100644 --- a/test/parallel/test-quic-alpn.mjs +++ b/test/parallel/test-quic-alpn.mjs @@ -45,3 +45,4 @@ const clientSession = await connect(serverEndpoint.address, { await Promise.all([serverOpened.promise, checkSession(clientSession)]); await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-callback-error-onblocked.mjs b/test/parallel/test-quic-callback-error-onblocked.mjs index 11aad4017a699f..db61c378f46ad0 100644 --- a/test/parallel/test-quic-callback-error-onblocked.mjs +++ b/test/parallel/test-quic-callback-error-onblocked.mjs @@ -43,3 +43,4 @@ stream.setBody(new Uint8Array(4096)); // The stream's closed promise should reject with the error from the throw. await rejects(stream.closed, testError); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-callback-error-ondatagram-async.mjs b/test/parallel/test-quic-callback-error-ondatagram-async.mjs index 4e6f814906fb40..23eade07161c2c 100644 --- a/test/parallel/test-quic-callback-error-ondatagram-async.mjs +++ b/test/parallel/test-quic-callback-error-ondatagram-async.mjs @@ -38,9 +38,5 @@ await clientSession.opened; await clientSession.sendDatagram(new Uint8Array([1, 2, 3])); await serverDone.promise; -// The server session was destroyed abruptly (no CONNECTION_CLOSE sent). -// The client may receive a stateless reset if it sends any packet -// before its idle timeout fires, so closed may reject. -await assert.rejects(clientSession.closed, { code: 'ERR_QUIC_TRANSPORT_ERROR' }); -serverEndpoint.close(); -await serverEndpoint.closed; +await clientSession.closed; +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-callback-error-ondatagram.mjs b/test/parallel/test-quic-callback-error-ondatagram.mjs index f0253f22768380..69d1440ed49da6 100644 --- a/test/parallel/test-quic-callback-error-ondatagram.mjs +++ b/test/parallel/test-quic-callback-error-ondatagram.mjs @@ -41,8 +41,5 @@ await clientSession.opened; await clientSession.sendDatagram(new Uint8Array([1, 2, 3])); await serverDone.promise; -// The server session was destroyed abruptly (no CONNECTION_CLOSE sent). -// The client may receive a stateless reset if it sends any packet -// before its idle timeout fires, so closed may reject. -await rejects(clientSession.closed, { code: 'ERR_QUIC_TRANSPORT_ERROR' }); +await clientSession.closed; await serverEndpoint.close(); diff --git a/test/parallel/test-quic-callback-error-ondatagramstatus.mjs b/test/parallel/test-quic-callback-error-ondatagramstatus.mjs index 17e2b500720cc3..c03cacdefb03b4 100644 --- a/test/parallel/test-quic-callback-error-ondatagramstatus.mjs +++ b/test/parallel/test-quic-callback-error-ondatagramstatus.mjs @@ -38,3 +38,4 @@ await clientSession.sendDatagram(new Uint8Array([1, 2, 3])); // The session's closed should reject with the error from the throw. await rejects(clientSession.closed, testError); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-callback-error-onerror-option.mjs b/test/parallel/test-quic-callback-error-onerror-option.mjs index 29b9e707d52845..ecee9c3f81ed07 100644 --- a/test/parallel/test-quic-callback-error-onerror-option.mjs +++ b/test/parallel/test-quic-callback-error-onerror-option.mjs @@ -34,3 +34,4 @@ await clientSession.opened; clientSession.destroy(testError); await rejects(clientSession.closed, testError); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-callback-error-onerror-validation.mjs b/test/parallel/test-quic-callback-error-onerror-validation.mjs index e695c1d761ac78..f44d8c4830ad55 100644 --- a/test/parallel/test-quic-callback-error-onerror-validation.mjs +++ b/test/parallel/test-quic-callback-error-onerror-validation.mjs @@ -60,3 +60,4 @@ stream.onerror = undefined; strictEqual(stream.onerror, undefined); await clientSession.closed; +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-callback-error-onerror.mjs b/test/parallel/test-quic-callback-error-onerror.mjs index 536efdc92c7cd4..6306d36acf5f12 100644 --- a/test/parallel/test-quic-callback-error-onerror.mjs +++ b/test/parallel/test-quic-callback-error-onerror.mjs @@ -72,5 +72,4 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { await clientSession.closed; } -serverEndpoint.close(); -await serverEndpoint.closed; +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-callback-error-suppressed.mjs b/test/parallel/test-quic-callback-error-suppressed.mjs index fcc4d0fcc7f304..38e932f281fcdc 100644 --- a/test/parallel/test-quic-callback-error-suppressed.mjs +++ b/test/parallel/test-quic-callback-error-suppressed.mjs @@ -50,3 +50,4 @@ clientSession.destroy(originalError); // Closed rejects with the original error (not the SuppressedError). await rejects(clientSession.closed, originalError); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-connection-limits.mjs b/test/parallel/test-quic-connection-limits.mjs index acb0f8065d4c78..2f41c388805dc4 100644 --- a/test/parallel/test-quic-connection-limits.mjs +++ b/test/parallel/test-quic-connection-limits.mjs @@ -27,9 +27,11 @@ const endpoint = new QuicEndpoint({ maxConnectionsTotal: 1 }); // Verify the limits are readable and mutable. strictEqual(endpoint.maxConnectionsTotal, 1); -strictEqual(endpoint.maxConnectionsPerHost, 0); -endpoint.maxConnectionsPerHost = 100; +// The default maxConnectionsPerHost is 100 — a non-zero default that +// prevents a single host from exhausting server resources. strictEqual(endpoint.maxConnectionsPerHost, 100); +endpoint.maxConnectionsPerHost = 50; +strictEqual(endpoint.maxConnectionsPerHost, 50); endpoint.maxConnectionsPerHost = 0; let sessionCount = 0; diff --git a/test/parallel/test-quic-enable-early-data.mjs b/test/parallel/test-quic-enable-early-data.mjs index d2b140c20d6cbd..90524aaf19d1da 100644 --- a/test/parallel/test-quic-enable-early-data.mjs +++ b/test/parallel/test-quic-enable-early-data.mjs @@ -56,3 +56,4 @@ clientSession.opened.then(mustCall((info) => { await Promise.all([serverOpened.promise, clientOpened.promise]); clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-endpoint-onsession-throws.mjs b/test/parallel/test-quic-endpoint-onsession-throws.mjs index aa6e5722534642..7f9a5483820f09 100644 --- a/test/parallel/test-quic-endpoint-onsession-throws.mjs +++ b/test/parallel/test-quic-endpoint-onsession-throws.mjs @@ -49,6 +49,7 @@ const transportParams = { maxIdleTimeout: 1 }; // robust to network-dropped close packets and stops the event loop // from waiting on the client's idle timer to expire. clientSession.destroy(); + await rejects(serverEndpoint.close(), sessionError); } // ------------------------------------------------------------------- @@ -71,4 +72,5 @@ const transportParams = { maxIdleTimeout: 1 }; await closedAssertion; clientSession.destroy(); + await rejects(serverEndpoint.close(), sessionError); } diff --git a/test/parallel/test-quic-h3-concurrent-requests.mjs b/test/parallel/test-quic-h3-concurrent-requests.mjs index a52137e5cb9362..69ab450cb7e445 100644 --- a/test/parallel/test-quic-h3-concurrent-requests.mjs +++ b/test/parallel/test-quic-h3-concurrent-requests.mjs @@ -87,4 +87,5 @@ const requests = paths.map(mustCall(async (path) => { }, REQUEST_COUNT)); await Promise.all([...requests, serverDone.promise]); -clientSession.close(); +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-datagram.mjs b/test/parallel/test-quic-h3-datagram.mjs index cdc4ac65529610..2c3ea0aad77138 100644 --- a/test/parallel/test-quic-h3-datagram.mjs +++ b/test/parallel/test-quic-h3-datagram.mjs @@ -105,7 +105,8 @@ const decoder = new TextDecoder(); await Promise.all([serverGotDatagram.promise, clientGotDatagram.promise]); await serverDone.promise; - clientSession.close(); + await clientSession.close(); + await serverEndpoint.close(); } // Test 2: Server has enableDatagrams: false. The peer's H3 SETTINGS @@ -168,4 +169,5 @@ const decoder = new TextDecoder(); await Promise.all([stream.closed, serverDone.promise]); clientSession.close(); + await serverEndpoint.close(); } diff --git a/test/parallel/test-quic-h3-error-codes.mjs b/test/parallel/test-quic-h3-error-codes.mjs index aaea8f93e880a9..f8d520d3fdbffe 100644 --- a/test/parallel/test-quic-h3-error-codes.mjs +++ b/test/parallel/test-quic-h3-error-codes.mjs @@ -70,7 +70,7 @@ const decoder = new TextDecoder(); code: 'ERR_QUIC_APPLICATION_ERROR', }); - serverEndpoint.close(); + await serverEndpoint.close(); } // Graceful close with no explicit error code. @@ -118,5 +118,6 @@ const decoder = new TextDecoder(); // Graceful close - session close promise resolves // because H3_NO_ERROR is a clean close. await serverDone.promise; - clientSession.close(); + await clientSession.close(); + await serverEndpoint.close(); } diff --git a/test/parallel/test-quic-h3-goaway-non-h3.mjs b/test/parallel/test-quic-h3-goaway-non-h3.mjs index d0956625cf995f..86931ec46fb51a 100644 --- a/test/parallel/test-quic-h3-goaway-non-h3.mjs +++ b/test/parallel/test-quic-h3-goaway-non-h3.mjs @@ -61,5 +61,5 @@ await Promise.all([stream.closed, serverDone.promise]); // Wait a tick for any deferred callbacks to fire. await setImmediate(); - -clientSession.close(); +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-goaway.mjs b/test/parallel/test-quic-h3-goaway.mjs index ef9564fc084754..c8a597b6f2e115 100644 --- a/test/parallel/test-quic-h3-goaway.mjs +++ b/test/parallel/test-quic-h3-goaway.mjs @@ -144,5 +144,6 @@ dc.subscribe('quic.session.goaway', mustCall((msg) => { // Both streams close cleanly. await Promise.all([stream1.closed, stream2.closed]); - clientSession.close(); + await clientSession.close(); + await serverEndpoint.close(); } diff --git a/test/parallel/test-quic-h3-handshake-failure.mjs b/test/parallel/test-quic-h3-handshake-failure.mjs new file mode 100644 index 00000000000000..128acab8fffe3d --- /dev/null +++ b/test/parallel/test-quic-h3-handshake-failure.mjs @@ -0,0 +1,56 @@ +// Flags: --experimental-quic --no-warnings + +// Regression test: HTTP/3 server must not crash when a session is closed +// before the H3 application is fully started (control streams bound). +// Previously, closing such a session would call nghttp3_conn_shutdown on +// an H3 connection whose control streams were never bound, causing an +// assertion failure in nghttp3 (conn->tx.ctrl != NULL). +// +// The test creates an H3 server and a client that immediately closes the +// session before the handshake completes. The server creates the H3 +// application during ALPN negotiation, but Start() (which binds control +// streams) hasn't been called yet when the session is torn down. +// The server must handle this gracefully without crashing. + +import { hasQuic, skip, mustNotCall } from '../common/index.mjs'; +import { setTimeout } from 'node:timers/promises'; +import * as fixtures from '../common/fixtures.mjs'; + +const { readKey } = fixtures; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(readKey('agent1-key.pem')); +const cert = readKey('agent1-cert.pem'); + +const serverEndpoint = await listen(async (serverSession) => { + await serverSession.closed; +}, { + sni: { '*': { keys: [key], certs: [cert] } }, + onheaders: mustNotCall(), +}); + +// Connect then immediately close the session before the handshake completes. +// This exercises the H3 shutdown path on the server while the H3 application +// exists but hasn't started (control streams not yet bound). +const clientSession = await connect(serverEndpoint.address, { + servername: 'localhost', + // h3 ALPN — must match the server so the H3 application is selected + // on the server side before we tear it down. +}); + +// Close immediately — don't wait for handshake. +await clientSession.close(); + +// Give the server time to process the close and tear down the session. +await setTimeout(500); + +// The critical assertion: reaching this point without a crash means the +// server correctly handled the H3 shutdown before control streams were +// bound. Verify the endpoint is still alive by closing it gracefully. +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-header-validation.mjs b/test/parallel/test-quic-h3-header-validation.mjs index 57e6981f35fea7..0388137148b0a1 100644 --- a/test/parallel/test-quic-h3-header-validation.mjs +++ b/test/parallel/test-quic-h3-header-validation.mjs @@ -108,7 +108,8 @@ const decoder = new TextDecoder(); const body = await bytes(stream); strictEqual(decoder.decode(body), 'ok'); await Promise.all([stream.closed, serverDone.promise]); - clientSession.close(); + await clientSession.close(); + await serverEndpoint.close(); } // Verify multiple pseudo-header combinations work correctly. @@ -153,5 +154,6 @@ const decoder = new TextDecoder(); }); await Promise.all([bytes(stream), stream.closed, serverDone.promise]); - clientSession.close(); + await clientSession.close(); + await serverEndpoint.close(); } diff --git a/test/parallel/test-quic-h3-headers-support.mjs b/test/parallel/test-quic-h3-headers-support.mjs index 159f5ba03faccf..d513fb15978c93 100644 --- a/test/parallel/test-quic-h3-headers-support.mjs +++ b/test/parallel/test-quic-h3-headers-support.mjs @@ -27,43 +27,30 @@ const serverDone = Promise.withResolvers(); const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { // Sending headers on non-H3 stream throws. - throws(() => { - stream.sendHeaders({ ':status': '200' }); - }, { code: 'ERR_INVALID_STATE' }); + throws(() => stream.sendHeaders({ ':status': '200' }), { code: 'ERR_INVALID_STATE' }); // Setting onheaders on non-H3 stream throws. - throws(() => { - stream.onheaders = () => {}; - }, { code: 'ERR_INVALID_STATE' }); + throws(() => stream.onheaders = () => {}, { code: 'ERR_INVALID_STATE' }); // Setting ontrailers on non-H3 stream throws. - throws(() => { - stream.ontrailers = () => {}; - }, { code: 'ERR_INVALID_STATE' }); + throws(() => stream.ontrailers = () => {}, { code: 'ERR_INVALID_STATE' }); // Setting oninfo on non-H3 stream throws. - throws(() => { - stream.oninfo = () => {}; - }, { code: 'ERR_INVALID_STATE' }); + throws(() => stream.oninfo = () => {}, { code: 'ERR_INVALID_STATE' }); // Setting onwanttrailers on non-H3 stream throws. - throws(() => { - stream.onwanttrailers = () => {}; - }, { code: 'ERR_INVALID_STATE' }); + throws(() => stream.onwanttrailers = () => {}, { code: 'ERR_INVALID_STATE' }); // sendInformationalHeaders throws on non-H3. - throws(() => { - stream.sendInformationalHeaders({ ':status': '103' }); - }, { code: 'ERR_INVALID_STATE' }); + throws(() => stream.sendInformationalHeaders({ ':status': '103' }), { + code: 'ERR_INVALID_STATE', + }); // sendTrailers throws on non-H3. - throws(() => { - stream.sendTrailers({ 'x-trailer': 'value' }); - }, { code: 'ERR_INVALID_STATE' }); + throws(() => stream.sendTrailers({ 'x-trailer': 'value' }), { code: 'ERR_INVALID_STATE' }); + + stream.writer.endSync(); - try { await stream.closed; } catch { - // Stream may close with error. - } serverSession.close(); serverDone.resolve(); }); @@ -81,15 +68,10 @@ await clientSession.opened; const stream = await clientSession.createBidirectionalStream({ body: encoder.encode('ping'), }); -stream.closed.catch(() => {}); // Client side — sending headers on non-H3 stream throws. -throws(() => { - stream.sendHeaders({ ':method': 'GET' }); -}, { code: 'ERR_INVALID_STATE' }); +throws(() => stream.sendHeaders({ ':method': 'GET' }), { code: 'ERR_INVALID_STATE' }); -try { await stream.closed; } catch { - // Stream may close with error. -} await serverDone.promise; -clientSession.close(); +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-informational-headers.mjs b/test/parallel/test-quic-h3-informational-headers.mjs index 8fbbd73d12ccd4..0f05d08c9bf0ae 100644 --- a/test/parallel/test-quic-h3-informational-headers.mjs +++ b/test/parallel/test-quic-h3-informational-headers.mjs @@ -112,4 +112,5 @@ strictEqual(decoder.decode(body), responseBody); strictEqual(stream.headers[':status'], '200'); await Promise.all([stream.closed, serverDone.promise]); -clientSession.close(); +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-origin.mjs b/test/parallel/test-quic-h3-origin.mjs index 39fcdc2d49b1a7..0e801b8e54408b 100644 --- a/test/parallel/test-quic-h3-origin.mjs +++ b/test/parallel/test-quic-h3-origin.mjs @@ -87,7 +87,8 @@ const decoder = new TextDecoder(); strictEqual(decoder.decode(body), 'ok'); await Promise.all([originReceived.promise, stream.closed, serverDone.promise]); - clientSession.close(); + await clientSession.close(); + await serverEndpoint.close(); } // port: 8443 produces origin "https://hostname:8443" @@ -181,5 +182,6 @@ const decoder = new TextDecoder(); strictEqual(decoder.decode(body), 'ok'); await Promise.all([originReceived.promise, stream.closed, serverDone.promise]); - clientSession.close(); + await clientSession.close(); + await serverEndpoint.close(); } diff --git a/test/parallel/test-quic-h3-pending-stream.mjs b/test/parallel/test-quic-h3-pending-stream.mjs index ab414a559182e3..39f5a4d833d89e 100644 --- a/test/parallel/test-quic-h3-pending-stream.mjs +++ b/test/parallel/test-quic-h3-pending-stream.mjs @@ -83,5 +83,6 @@ const decoder = new TextDecoder(); const body = await bytes(stream); strictEqual(decoder.decode(body), 'ok'); await Promise.all([stream.closed, serverDone.promise]); - clientSession.close(); + await clientSession.close(); + await serverEndpoint.close(); } diff --git a/test/parallel/test-quic-h3-post-request.mjs b/test/parallel/test-quic-h3-post-request.mjs index 6cd9e047481d4d..1bd9100810aa58 100644 --- a/test/parallel/test-quic-h3-post-request.mjs +++ b/test/parallel/test-quic-h3-post-request.mjs @@ -98,4 +98,5 @@ const responseBody = await bytes(stream); strictEqual(decoder.decode(responseBody), 'echo:' + requestBody); await Promise.all([stream.closed, serverDone.promise]); -clientSession.close(); +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-priority.mjs b/test/parallel/test-quic-h3-priority.mjs index 8ddcab23a69d54..9aac69bd6d2f22 100644 --- a/test/parallel/test-quic-h3-priority.mjs +++ b/test/parallel/test-quic-h3-priority.mjs @@ -151,7 +151,8 @@ const decoder = new TextDecoder(); stream3.closed, stream4.closed, serverDone.promise]); - clientSession.close(); + await clientSession.close(); + await serverEndpoint.close(); } // Server priority getter reflects peer's PRIORITY_UPDATE. @@ -235,5 +236,6 @@ const decoder = new TextDecoder(); await Promise.all([serverSawHighPriority.promise, stream.closed, serverDone.promise]); - clientSession.close(); + await clientSession.close(); + await serverEndpoint.close(); } diff --git a/test/parallel/test-quic-h3-qpack-settings.mjs b/test/parallel/test-quic-h3-qpack-settings.mjs index 547e70f8c2d8a5..6d059b4cbee175 100644 --- a/test/parallel/test-quic-h3-qpack-settings.mjs +++ b/test/parallel/test-quic-h3-qpack-settings.mjs @@ -81,7 +81,8 @@ async function makeRequest(clientSession, path) { await makeRequest(clientSession, '/second'); await serverDone.promise; - clientSession.close(); + await clientSession.close(); + await serverEndpoint.close(); } // Custom qpackMaxDTableCapacity = 8192 (larger than default). @@ -115,5 +116,6 @@ async function makeRequest(clientSession, path) { await makeRequest(clientSession, '/beta'); await serverDone.promise; - clientSession.close(); + await clientSession.close(); + await serverEndpoint.close(); } diff --git a/test/parallel/test-quic-h3-request-response.mjs b/test/parallel/test-quic-h3-request-response.mjs index 309489f2f16634..817f620c89f8ee 100644 --- a/test/parallel/test-quic-h3-request-response.mjs +++ b/test/parallel/test-quic-h3-request-response.mjs @@ -111,4 +111,5 @@ strictEqual(decoder.decode(body), responseBody); strictEqual(stream.headers[':status'], '200'); await Promise.all([stream.closed, serverDone.promise]); -clientSession.close(); +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-settings.mjs b/test/parallel/test-quic-h3-settings.mjs index d132f628a404e7..2b3b9a628f1c15 100644 --- a/test/parallel/test-quic-h3-settings.mjs +++ b/test/parallel/test-quic-h3-settings.mjs @@ -81,7 +81,8 @@ const decoder = new TextDecoder(); strictEqual(decoder.decode(body), 'ok'); await stream.closed; await serverDone.promise; - clientSession.close(); + await clientSession.close(); + await serverEndpoint.close(); } // maxHeaderLength enforcement. @@ -136,7 +137,8 @@ const decoder = new TextDecoder(); const body = await bytes(stream); strictEqual(decoder.decode(body), 'ok'); await Promise.all([stream.closed, serverDone.promise]); - clientSession.close(); + await clientSession.close(); + await serverEndpoint.close(); } // enableConnectProtocol and enableDatagrams settings. @@ -181,5 +183,6 @@ const decoder = new TextDecoder(); const body = await bytes(stream); strictEqual(decoder.decode(body), 'settings-ok'); await Promise.all([stream.closed, serverDone.promise]); - clientSession.close(); + await clientSession.close(); + await serverEndpoint.close(); } diff --git a/test/parallel/test-quic-h3-stream-destroy-with-headers.mjs b/test/parallel/test-quic-h3-stream-destroy-with-headers.mjs index a15668beae7dea..135d0301b00386 100644 --- a/test/parallel/test-quic-h3-stream-destroy-with-headers.mjs +++ b/test/parallel/test-quic-h3-stream-destroy-with-headers.mjs @@ -54,5 +54,6 @@ stream.destroy(); strictEqual(stream.destroyed, true); // Close everything cleanly. -clientSession.close(); +await clientSession.close(); await serverDone.promise; +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-trailing-headers.mjs b/test/parallel/test-quic-h3-trailing-headers.mjs index 99e23e01545b59..c620488a920798 100644 --- a/test/parallel/test-quic-h3-trailing-headers.mjs +++ b/test/parallel/test-quic-h3-trailing-headers.mjs @@ -119,4 +119,5 @@ await clientTrailersReceived.promise; strictEqual(stream.headers[':status'], '200'); await Promise.all([stream.closed, serverDone.promise]); -clientSession.close(); +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-handshake-ipv6-only.mjs b/test/parallel/test-quic-handshake-ipv6-only.mjs index 2101b769f4bbf0..03531b6ce42ca0 100644 --- a/test/parallel/test-quic-handshake-ipv6-only.mjs +++ b/test/parallel/test-quic-handshake-ipv6-only.mjs @@ -72,4 +72,5 @@ const info = await clientSession.opened; partialDeepStrictEqual(info, check); await serverOpened.promise; -clientSession.close(); +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-handshake.mjs b/test/parallel/test-quic-handshake.mjs index 3ff6af08b868be..1779ceaa23fbf8 100644 --- a/test/parallel/test-quic-handshake.mjs +++ b/test/parallel/test-quic-handshake.mjs @@ -60,4 +60,5 @@ const info = await clientSession.opened; partialDeepStrictEqual(info, check); await serverOpened.promise; -clientSession.close(); +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-internal-endpoint-stats-state.mjs b/test/parallel/test-quic-internal-endpoint-stats-state.mjs index 015155344fde42..c8e6cb89beaaef 100644 --- a/test/parallel/test-quic-internal-endpoint-stats-state.mjs +++ b/test/parallel/test-quic-internal-endpoint-stats-state.mjs @@ -43,9 +43,9 @@ const { isListening: false, isClosing: false, isBusy: false, - maxConnectionsPerHost: 0, - maxConnectionsTotal: 0, - pendingCallbacks: '0', + maxConnectionsPerHost: 100, + maxConnectionsTotal: 10_000, + pendingCallbacks: 0, }); endpoint.busy = true; diff --git a/test/parallel/test-quic-new-token.mjs b/test/parallel/test-quic-new-token.mjs index 6351b154b39d00..cafff1146e0da0 100644 --- a/test/parallel/test-quic-new-token.mjs +++ b/test/parallel/test-quic-new-token.mjs @@ -52,4 +52,5 @@ const clientSession = await connect(serverEndpoint.address, { await Promise.all([clientSession.opened, clientToken.promise]); -clientSession.close(); +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-session-stream-lifecycle.mjs b/test/parallel/test-quic-session-stream-lifecycle.mjs index f18f82994f26dd..2eb35145dfe9fe 100644 --- a/test/parallel/test-quic-session-stream-lifecycle.mjs +++ b/test/parallel/test-quic-session-stream-lifecycle.mjs @@ -90,8 +90,9 @@ strictEqual(clientSession.endpoint, null); strictEqual(clientSession.stats.isConnected, false); strictEqual(stream.destroyed, true); -strictEqual(stream.session, null); -strictEqual(stream.id, null); -strictEqual(stream.direction, null); + +// The stream id and direction should still be available after destruction +strictEqual(stream.id, 0n); +strictEqual(stream.direction, 'bidi'); await serverEndpoint.close(); diff --git a/test/parallel/test-quic-stream-iteration-double.mjs b/test/parallel/test-quic-stream-iteration-double.mjs index 76b88a529a444e..e667d5910bf701 100644 --- a/test/parallel/test-quic-stream-iteration-double.mjs +++ b/test/parallel/test-quic-stream-iteration-double.mjs @@ -57,3 +57,4 @@ const stream = await clientSession.createBidirectionalStream({ for await (const _ of stream) { /* drain */ } // eslint-disable-line no-unused-vars await Promise.all([stream.closed, serverDone.promise]); await clientSession.close(); +await serverEndpoint.close();