diff --git a/deps/ngtcp2/ngtcp2.gyp b/deps/ngtcp2/ngtcp2.gyp index 74c8ce60456347..98d05df4541a32 100644 --- a/deps/ngtcp2/ngtcp2.gyp +++ b/deps/ngtcp2/ngtcp2.gyp @@ -247,7 +247,10 @@ }, { 'target_name': 'ngtcp2_test_server', - 'type': 'executable', + # Disabled: ngtcp2 examples now require C++23 (, , + # std::println, std::expected) which is not yet supported on all + # Node.js platforms. Re-enable when C++23 is available. + 'type': 'none', 'cflags': [ '-Wno-everything' ], 'include_dirs': [ '', @@ -305,7 +308,10 @@ }, { 'target_name': 'ngtcp2_test_client', - 'type': 'executable', + # Disabled: ngtcp2 examples now require C++23 (, , + # std::println, std::expected) which is not yet supported on all + # Node.js platforms. Re-enable when C++23 is available. + 'type': 'none', 'cflags': [ '-Wno-everything' ], 'include_dirs': [ '', diff --git a/doc/api/errors.md b/doc/api/errors.md index 98073d49d62098..6021032d2d2a39 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -2633,6 +2633,19 @@ added: Opening a QUIC stream failed. + + +### `ERR_QUIC_STREAM_RESET` + + + +> Stability: 1 - Experimental + +A QUIC stream was reset by the peer. The error includes the reset code +provided by the peer. + ### `ERR_QUIC_TRANSPORT_ERROR` diff --git a/doc/api/quic.md b/doc/api/quic.md index 45aa2409fd1cc9..4d7047f7d5cd77 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -385,6 +385,36 @@ to complete but no new streams will be opened. Once all streams have closed, the session will be destroyed. The returned promise will be fulfilled once the session has been destroyed. +### `session.opened` + + + +* Type: {Promise} for an {Object} + * `local` {net.SocketAddress} The local socket address. + * `remote` {net.SocketAddress} The remote socket address. + * `servername` {string} The SNI server name negotiated during the handshake. + * `protocol` {string} The ALPN protocol negotiated during the handshake. + * `cipher` {string} The name of the negotiated TLS cipher suite. + * `cipherVersion` {string} The TLS protocol version of the cipher suite + (e.g., `'TLSv1.3'`). + * `validationErrorReason` {string} If certificate validation failed, the + reason string. Empty string if validation succeeded. + * `validationErrorCode` {number} If certificate validation failed, the + error code. `0` if validation succeeded. + * `earlyDataAttempted` {boolean} Whether 0-RTT early data was attempted. + * `earlyDataAccepted` {boolean} Whether 0-RTT early data was accepted by + the server. + +A promise that is fulfilled once the TLS handshake completes successfully. +The resolved value contains information about the established session +including the negotiated protocol, cipher suite, certificate validation +status, and 0-RTT early data status. + +If the handshake fails or the session is destroyed before the handshake +completes, the promise will be rejected. + ### `session.closed` + +* Type: {quic.OnNewTokenCallback} + +The callback to invoke when a NEW\_TOKEN token is received from the server. +The token can be passed as the `token` option on a future connection to +the same server to skip address validation. Read/write. + +### `session.onorigin` + + + +* Type: {quic.OnOriginCallback} + +The callback to invoke when an ORIGIN frame (RFC 9412) is received from +the server, indicating which origins the server is authoritative for. +Read/write. + ### `session.createBidirectionalStream([options])` -* `datagram` {string|ArrayBufferView} -* Returns: {bigint} +* `datagram` {string|ArrayBufferView|Promise} +* `encoding` {string} The encoding to use if `datagram` is a string. + **Default:** `'utf8'`. +* Returns: {Promise} for a {bigint} datagram ID. + +Sends an unreliable datagram to the remote peer, returning a promise for +the datagram ID. + +If `datagram` is a string, it will be encoded using the specified `encoding`. + +If `datagram` is an `ArrayBufferView`, the underlying `ArrayBuffer` will be +transferred if possible (taking ownership to prevent mutation after send). +If the buffer is not transferable (e.g., a `SharedArrayBuffer` or a view +over a subset of a larger buffer such as a pooled `Buffer`), the data will +be copied instead. + +If `datagram` is a `Promise`, it will be awaited before sending. If the +session closes while awaiting, `0n` is returned silently (datagrams are +inherently unreliable). + +If the datagram payload is zero-length (empty string after encoding, detached +buffer, or zero-length view), `0n` is returned and no datagram is sent. + +Datagrams cannot be fragmented — each must fit within a single QUIC packet. +The maximum datagram size is determined by the peer's +`maxDatagramFrameSize` transport parameter (which the peer advertises during +the handshake). If the peer sets this to `0`, datagrams are not supported +and `0n` will be returned. If the datagram exceeds the peer's limit, it +will be silently dropped and `0n` returned. The local +`maxDatagramFrameSize` transport parameter (default: `1200` bytes) controls +what this endpoint advertises to the peer as its own maximum. + +### `session.certificate` + + + +* Type: {Object|undefined} + +The local certificate as an object with properties such as `subject`, +`issuer`, `valid_from`, `valid_to`, `fingerprint`, etc. Returns `undefined` +if the session is destroyed or no certificate is available. -Sends an unreliable datagram to the remote peer, returning the datagram ID. -If the datagram payload is specified as an `ArrayBufferView`, then ownership of -that view will be transferred to the underlying stream. +### `session.peerCertificate` + + + +* Type: {Object|undefined} + +The peer's certificate as an object with properties such as `subject`, +`issuer`, `valid_from`, `valid_to`, `fingerprint`, etc. Returns `undefined` +if the session is destroyed or the peer did not present a certificate. + +### `session.ephemeralKeyInfo` + + + +* Type: {Object|undefined} + +The ephemeral key information for the session, with properties such as +`type`, `name`, and `size`. Only available on client sessions. Returns +`undefined` for server sessions or if the session is destroyed. + +### `session.maxDatagramSize` + + + +* Type: {bigint} + +The maximum datagram payload size in bytes that the peer will accept, +as advertised in the peer's `maxDatagramFrameSize` transport parameter. +Returns `0n` if the peer does not support datagrams or if the handshake +has not yet completed. Datagrams larger than this value will not be sent. ### `session.stats` @@ -662,7 +815,7 @@ added: v23.8.0 * Type: {bigint} -### `sessionStats.maxBytesInFlights` +### `sessionStats.maxBytesInFlight` -* Type: {ReadableStream} +* Type: {Object|undefined} + +The buffered initial headers received on this stream, or `undefined` if the +application does not support headers or no headers have been received yet. +For server-side streams, this contains the request headers (e.g., `:method`, +`:path`, `:scheme`). For client-side streams, this contains the response +headers (e.g., `:status`). + +Header names are lowercase strings. Multi-value headers are represented as +arrays. The object has `__proto__: null`. + +### `stream.onheaders` + + + +* Type: {quic.OnHeadersCallback} + +The callback to invoke when headers are received on the stream. The callback +receives `(headers, kind)` where `headers` is an object (same format as +`stream.headers`) and `kind` is one of `'initial'` or `'informational'` +(for 1xx responses). Throws `ERR_INVALID_STATE` if set on a session that +does not support headers. Read/write. + +### `stream.ontrailers` + + + +* Type: {quic.OnTrailersCallback} + +The callback to invoke when trailing headers are received from the peer. +The callback receives `(trailers)` where `trailers` is an object in the +same format as `stream.headers`. Throws `ERR_INVALID_STATE` if set on a +session that does not support headers. Read/write. + +### `stream.onwanttrailers` + + + +* Type: {Function} + +The callback to invoke when the application is ready for trailing headers +to be sent. This is called synchronously — the user must call +[`stream.sendTrailers()`][] within this callback. Throws +`ERR_INVALID_STATE` if set on a session that does not support headers. +Read/write. + +### `stream.pendingTrailers` + + + +* Type: {Object|undefined} + +Set trailing headers to be sent automatically when the application requests +them. This is an alternative to the [`stream.onwanttrailers`][] callback +for cases where the trailers are known before the body completes. Throws +`ERR_INVALID_STATE` if set on a session that does not support headers. +Read/write. + +### `stream.sendHeaders(headers[, options])` + + + +* `headers` {Object} Header object with string keys and string or + string-array values. Pseudo-headers (`:method`, `:path`, etc.) must + appear before regular headers. +* `options` {Object} + * `terminal` {boolean} If `true`, the stream is closed for sending + after the headers (no body will follow). **Default:** `false`. +* Returns: {boolean} + +Sends initial or response headers on the stream. For client-side streams, +this sends request headers. For server-side streams, this sends response +headers. Throws `ERR_INVALID_STATE` if the session does not support headers. + +### `stream.sendInformationalHeaders(headers)` + + + +* `headers` {Object} Header object. Must include `:status` with a 1xx + value (e.g., `{ ':status': '103', 'link': '; rel=preload' }`). +* Returns: {boolean} + +Sends informational (1xx) response headers. Server only. Throws +`ERR_INVALID_STATE` if the session does not support headers. + +### `stream.sendTrailers(headers)` + + + +* `headers` {Object} Trailing header object. Pseudo-headers must not be + included in trailers. +* Returns: {boolean} + +Sends trailing headers on the stream. Must be called synchronously during +the [`stream.onwanttrailers`][] callback, or set ahead of time via +[`stream.pendingTrailers`][]. Throws `ERR_INVALID_STATE` if the session +does not support headers. + +### `stream.priority` + + + +* Type: {Object|null} + * `level` {string} One of `'high'`, `'default'`, or `'low'`. + * `incremental` {boolean} Whether the stream data should be interleaved + with other streams of the same priority level. + +The current priority of the stream. Returns `null` if the session does not +support priority (e.g. non-HTTP/3) or if the stream has been destroyed. +Read only. Use [`stream.setPriority()`][] to change the priority. + +On client-side HTTP/3 sessions, the value reflects what was set via +[`stream.setPriority()`][]. On server-side HTTP/3 sessions, the value +reflects the peer's requested priority (e.g., from `PRIORITY_UPDATE` frames). + +### `stream.setPriority([options])` + + + +* `options` {Object} + * `level` {string} The priority level. One of `'high'`, `'default'`, or + `'low'`. **Default:** `'default'`. + * `incremental` {boolean} When `true`, data from this stream may be + interleaved with data from other streams of the same priority level. + **Default:** `false`. + +Sets the priority of the stream. Throws `ERR_INVALID_STATE` if the session +does not support priority (e.g. non-HTTP/3). Has no effect if the stream +has been destroyed. + +### `stream[Symbol.asyncIterator]()` + + + +* Returns: {AsyncIterableIterator} yielding {Uint8Array\[]} + +The stream implements `Symbol.asyncIterator`, making it directly usable +in `for await...of` loops. Each iteration yields a batch of `Uint8Array` +chunks. + +Only one async iterator can be obtained per stream. A second call throws +`ERR_INVALID_STATE`. Non-readable streams (outbound-only unidirectional +or closed) return an immediately-finished iterator. + +```mjs +for await (const chunks of stream) { + for (const chunk of chunks) { + // Process each Uint8Array chunk + } +} +``` + +Compatible with stream/iter utilities: + +```mjs +import Stream from 'node:stream/iter'; +const body = await Stream.bytes(stream); +const text = await Stream.text(stream); +await Stream.pipeTo(stream, someWriter); +``` + +### `stream.writer` + + + +* Type: {Object} + +Returns a Writer object for pushing data to the stream incrementally. +The Writer implements the stream/iter Writer interface with the +try-sync-fallback-to-async pattern. + +Only available when no `body` source was provided at creation time or via +[`stream.setBody()`][]. Non-writable streams return an already-closed +Writer. Throws `ERR_INVALID_STATE` if the outbound is already configured. + +The Writer has the following methods: + +* `writeSync(chunk)` — Synchronous write. Returns `true` if accepted, + `false` if flow-controlled. Data is NOT accepted on `false`. +* `write(chunk[, options])` — Async write with drain wait. `options.signal` + is checked at entry but not observed during the write. +* `writevSync(chunks)` — Synchronous vectored write. All-or-nothing. +* `writev(chunks[, options])` — Async vectored write. +* `endSync()` — Synchronous close. Returns total bytes or `-1`. +* `end([options])` — Async close. +* `fail(reason)` — Errors the stream (sends RESET\_STREAM to peer). +* `desiredSize` — Available capacity in bytes, or `null` if closed/errored. + +### `stream.setBody(body)` + + + +* `body` {string|ArrayBuffer|SharedArrayBuffer|TypedArray|Blob|AsyncIterable|Iterable|Promise|null} + +Sets the outbound body source for the stream. Can only be called once. +Mutually exclusive with [`stream.writer`][]. + +If `body` is `null`, the writable side is closed immediately (FIN sent). +If `body` is a `Promise`, it is awaited and the resolved value is used. +Other types are handled per their optimization tier (see below). + +Throws `ERR_INVALID_STATE` if the outbound is already configured or if +the writer has been accessed. ### `stream.session` @@ -1218,6 +1597,19 @@ added: v23.8.0 The CRL to use for client sessions. For server sessions, CRLs are specified per-identity in the [`sessionOptions.sni`][] map. +#### `sessionOptions.enableEarlyData` + + + +* Type: {boolean} **Default:** `true` + +When `true`, enables TLS 0-RTT early data for this session. Early data +allows the client to send application data before the TLS handshake +completes, reducing latency on reconnection when a valid session ticket +is available. Set to `false` to disable early data support. + #### `sessionOptions.groups` + +* Type: {bigint|number} +* **Default:** `0` (disabled) + +Specifies the keep-alive timeout in milliseconds. When set to a non-zero +value, PING frames will be sent automatically to keep the connection alive +before the idle timeout fires. The value should be less than the effective +idle timeout (`maxIdleTimeout` transport parameter) to be useful. + #### `sessionOptions.servername` (client only) + +* Type: {ArrayBufferView} + +An opaque address validation token previously received from the server +via the [`session.onnewtoken`][] callback. Providing a valid token on +reconnection allows the client to skip the server's address validation, +reducing handshake latency. + #### `sessionOptions.transportParams` + +* Type: {boolean} **Default:** `true` + +If `true`, the peer certificate is verified against the list of supplied CAs. +An error is emitted if verification fails; the error can be inspected via +the `validationErrorReason` and `validationErrorCode` fields in the +handshake callback. If `false`, peer certificate verification errors are +ignored. + #### `sessionOptions.verifyClient` * Type: {bigint|number} +* **Default:** `1200` + +The maximum size in bytes of a DATAGRAM frame payload that this endpoint +is willing to receive. Set to `0` to disable datagram support. The peer +will not send datagrams larger than this value. The actual maximum size of +a datagram that can be _sent_ is determined by the peer's +`maxDatagramFrameSize`, not this endpoint's value. ## Callbacks @@ -1647,8 +2094,28 @@ added: v23.8.0 * `cipherVersion` {string} * `validationErrorReason` {string} * `validationErrorCode` {number} +* `earlyDataAttempted` {boolean} * `earlyDataAccepted` {boolean} +### Callback: `OnNewTokenCallback` + + + +* `this` {quic.QuicSession} +* `token` {Buffer} The NEW\_TOKEN token data. +* `address` {SocketAddress} The remote address the token is associated with. + +### Callback: `OnOriginCallback` + + + +* `this` {quic.QuicSession} +* `origins` {string\[]} The list of origins the server is authoritative for. + ### Callback: `OnBlockedCallback` + +* `this` {quic.QuicStream} +* `headers` {Object} Header object with lowercase string keys and + string or string-array values. +* `kind` {string} One of `'initial'` or `'informational'`. + +### Callback: `OnTrailersCallback` + + + +* `this` {quic.QuicStream} +* `trailers` {Object} Trailing header object. + ## Diagnostic Channels ### Channel: `quic.endpoint.created` @@ -1787,6 +2274,16 @@ added: v23.8.0 added: v23.8.0 --> +### Channel: `quic.session.new.token` + + + +Published when a client session receives a NEW\_TOKEN frame from the +server. The message contains `token` {Buffer}, `address` {SocketAddress}, +and `session` {quic.QuicSession}. + ### Channel: `quic.session.ticket` +[`session.onnewtoken`]: #sessiononnewtoken [`sessionOptions.sni`]: #sessionoptionssni-server-only +[`stream.onwanttrailers`]: #streamonwanttrailers +[`stream.pendingTrailers`]: #streampendingtrailers +[`stream.sendTrailers()`]: #streamsendtrailersheaders +[`stream.setBody()`]: #streamsetbodybody +[`stream.setPriority()`]: #streamsetpriorityoptions +[`stream.writer`]: #streamwriter diff --git a/lib/internal/blob.js b/lib/internal/blob.js index e1b1dceabd629d..78ac7d11339058 100644 --- a/lib/internal/blob.js +++ b/lib/internal/blob.js @@ -2,6 +2,7 @@ const { ArrayFrom, + ArrayPrototypePush, MathMax, MathMin, ObjectDefineProperties, @@ -523,13 +524,75 @@ function createBlobReaderStream(reader) { }, { highWaterMark: 0 }); } +// Maximum number of chunks to collect in a single batch to prevent +// unbounded memory growth when the DataQueue has a large burst of data. +const kMaxBatchChunks = 16; + +async function* createBlobReaderIterable(reader, options = {}) { + const { getReadError } = options; + let wakeup = PromiseWithResolvers(); + reader.setWakeup(wakeup.resolve); + + try { + while (true) { + const batch = []; + let blocked = false; + let eos = false; + let error = null; + + // Pull as many chunks as available synchronously. + // reader.pull(callback) calls the callback synchronously via + // MakeCallback, so we can collect multiple chunks per iteration + // step without any async overhead. + while (true) { + let pullResult; + reader.pull((status, buffer) => { + pullResult = { status, buffer }; + }); + + if (pullResult.status === 0) { + eos = true; + break; + } + if (pullResult.status < 0) { + error = typeof getReadError === 'function' ? + getReadError(pullResult.status) : + new ERR_INVALID_STATE('The reader is not readable'); + break; + } + if (pullResult.status === 2) { + blocked = true; + break; + } + ArrayPrototypePush(batch, new Uint8Array(pullResult.buffer)); + if (batch.length >= kMaxBatchChunks) break; + } + + if (batch.length > 0) { + yield batch; + } + + if (eos) return; + if (error) throw error; + + if (blocked) { + await wakeup.promise; + wakeup = PromiseWithResolvers(); + } + } + } finally { + reader.setWakeup(undefined); + } +} + module.exports = { Blob, createBlob, createBlobFromFilePath, + createBlobReaderIterable, + createBlobReaderStream, isBlob, kHandle, resolveObjectURL, TransferableBlob, - createBlobReaderStream, }; diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 206e2a24716022..38d1666f7c6bd6 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1686,6 +1686,8 @@ E('ERR_QUIC_APPLICATION_ERROR', 'A QUIC application error occurred. %d [%s]', Er E('ERR_QUIC_CONNECTION_FAILED', 'QUIC connection failed', Error); E('ERR_QUIC_ENDPOINT_CLOSED', 'QUIC endpoint closed: %s (%d)', Error); E('ERR_QUIC_OPEN_STREAM_FAILED', 'Failed to open QUIC stream', Error); +E('ERR_QUIC_STREAM_RESET', + 'The QUIC stream was reset by the peer with error code %d', Error); E('ERR_QUIC_TRANSPORT_ERROR', 'A QUIC transport error occurred. %d [%s]', Error); E('ERR_QUIC_VERSION_NEGOTIATION_ERROR', 'The QUIC session requires version negotiation', Error); E('ERR_REQUIRE_ASYNC_MODULE', function(filename, parentFilename) { diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index fa0cfcf278538e..a8eb713e8f861e 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -5,14 +5,30 @@ /* c8 ignore start */ const { + ArrayBufferPrototypeGetByteLength, ArrayBufferPrototypeTransfer, ArrayIsArray, ArrayPrototypePush, BigInt, + DataViewPrototypeGetBuffer, + DataViewPrototypeGetByteLength, + DataViewPrototypeGetByteOffset, + FunctionPrototypeBind, + Number, ObjectDefineProperties, ObjectKeys, + PromisePrototypeThen, + PromiseResolve, + PromiseWithResolvers, SafeSet, SymbolAsyncDispose, + SymbolAsyncIterator, + SymbolDispose, + SymbolIterator, + TypedArrayPrototypeGetBuffer, + TypedArrayPrototypeGetByteLength, + TypedArrayPrototypeGetByteOffset, + TypedArrayPrototypeSlice, Uint8Array, } = primordials; @@ -55,11 +71,18 @@ const { CLOSECONTEXT_RECEIVE_FAILURE: kCloseContextReceiveFailure, CLOSECONTEXT_SEND_FAILURE: kCloseContextSendFailure, CLOSECONTEXT_START_FAILURE: kCloseContextStartFailure, + QUIC_STREAM_HEADERS_KIND_INITIAL: kHeadersKindInitial, + QUIC_STREAM_HEADERS_KIND_HINTS: kHeadersKindHints, + QUIC_STREAM_HEADERS_KIND_TRAILING: kHeadersKindTrailing, + QUIC_STREAM_HEADERS_FLAGS_NONE: kHeadersFlagsNone, + QUIC_STREAM_HEADERS_FLAGS_TERMINAL: kHeadersFlagsTerminal, } = internalBinding('quic'); const { isArrayBuffer, isArrayBufferView, + isDataView, + isPromise, isSharedArrayBuffer, } = require('util/types'); @@ -79,6 +102,7 @@ const { ERR_QUIC_CONNECTION_FAILED, ERR_QUIC_ENDPOINT_CLOSED, ERR_QUIC_OPEN_STREAM_FAILED, + ERR_QUIC_STREAM_RESET, ERR_QUIC_TRANSPORT_ERROR, ERR_QUIC_VERSION_NEGOTIATION_ERROR, }, @@ -91,11 +115,26 @@ const { } = require('internal/socketaddress'); const { - createBlobReaderStream, + createBlobReaderIterable, isBlob, kHandle: kBlobHandle, } = require('internal/blob'); +const { + drainableProtocol, + kValidatedSource, +} = require('internal/streams/iter/types'); + +const { + toUint8Array, + convertChunks, +} = require('internal/streams/iter/utils'); + +const { + from: streamFrom, + fromSync: streamFromSync, +} = require('internal/streams/iter/from'); + const { isKeyObject, } = require('internal/crypto/keys'); @@ -103,7 +142,6 @@ const { const { validateBoolean, validateFunction, - validateNumber, validateObject, validateOneOf, validateString, @@ -121,6 +159,7 @@ const { kConnect, kDatagram, kDatagramStatus, + kDrain, kFinishClose, kHandshake, kHeaders, @@ -130,8 +169,9 @@ const { kNewSession, kRemoveStream, kNewStream, - kOnHeaders, - kOnTrailers, + kNewToken, + kOrigin, + kStreamCallbacks, kPathValidation, kPrivateConstructor, kReset, @@ -177,10 +217,14 @@ const onSessionClosedChannel = dc.channel('quic.session.closed'); const onSessionReceiveDatagramChannel = dc.channel('quic.session.receive.datagram'); const onSessionReceiveDatagramStatusChannel = dc.channel('quic.session.receive.datagram.status'); const onSessionPathValidationChannel = dc.channel('quic.session.path.validation'); +const onSessionNewTokenChannel = dc.channel('quic.session.new.token'); const onSessionTicketChannel = dc.channel('quic.session.ticket'); const onSessionVersionNegotiationChannel = dc.channel('quic.session.version.negotiation'); +const onSessionOriginChannel = dc.channel('quic.session.receive.origin'); const onSessionHandshakeChannel = dc.channel('quic.session.handshake'); +const kNilDatagramId = 0n; + /** * @typedef {import('../socketaddress.js').SocketAddress} SocketAddress * @typedef {import('../crypto/keys.js').KeyObject} KeyObject @@ -189,7 +233,8 @@ const onSessionHandshakeChannel = dc.channel('quic.session.handshake'); /** * @typedef {object} OpenStreamOptions * @property {ArrayBuffer|ArrayBufferView|Blob} [body] The outbound payload - * @property {number} [sendOrder] The ordering of this stream relative to others in the same session. + * @property {'high'|'default'|'low'} [priority] The priority level of the stream. + * @property {boolean} [incremental] Whether to interleave data with same-priority streams. */ /** @@ -263,6 +308,8 @@ const onSessionHandshakeChannel = dc.channel('quic.session.handshake'); * @property {boolean} [qlog] Enable qlog * @property {ArrayBufferView} [sessionTicket] The session ticket * @property {bigint|number} [handshakeTimeout] The handshake timeout + * @property {bigint|number} [keepAlive] The keep-alive timeout in milliseconds. When set, + * PING frames will be sent automatically to prevent idle timeout. * @property {bigint|number} [maxStreamWindow] The maximum stream window * @property {bigint|number} [maxWindow] The maximum window * @property {bigint|number} [maxPayloadSize] The maximum payload size @@ -415,7 +462,7 @@ setCallbacks({ * @param {boolean} early */ onSessionDatagram(uint8Array, early) { - debug('session datagram callback', uint8Array.byteLength, early); + debug('session datagram callback', TypedArrayPrototypeGetByteLength(uint8Array), early); this[kOwner][kDatagram](uint8Array, early); }, @@ -437,14 +484,20 @@ setCallbacks({ * @param {string} cipherVersion * @param {string} validationErrorReason * @param {number} validationErrorCode + * @param {boolean} earlyDataAttempted + * @param {boolean} earlyDataAccepted */ onSessionHandshake(servername, protocol, cipher, cipherVersion, validationErrorReason, - validationErrorCode) { + validationErrorCode, + earlyDataAttempted, + earlyDataAccepted) { debug('session handshake callback', servername, protocol, cipher, cipherVersion, - validationErrorReason, validationErrorCode); + validationErrorReason, validationErrorCode, + earlyDataAttempted, earlyDataAccepted); this[kOwner][kHandshake](servername, protocol, cipher, cipherVersion, - validationErrorReason, validationErrorCode); + validationErrorReason, validationErrorCode, + earlyDataAttempted, earlyDataAccepted); }, /** @@ -459,11 +512,8 @@ setCallbacks({ onSessionPathValidation(result, newLocalAddress, newRemoteAddress, oldLocalAddress, oldRemoteAddress, preferredAddress) { debug('session path validation callback', this[kOwner]); - this[kOwner][kPathValidation](result, - new InternalSocketAddress(newLocalAddress), - new InternalSocketAddress(newRemoteAddress), - new InternalSocketAddress(oldLocalAddress), - new InternalSocketAddress(oldRemoteAddress), + this[kOwner][kPathValidation](result, newLocalAddress, newRemoteAddress, + oldLocalAddress, oldRemoteAddress, preferredAddress); }, @@ -485,7 +535,16 @@ setCallbacks({ */ onSessionNewToken(token, address) { debug('session new token callback', this[kOwner]); - // TODO(@jasnell): Emit to JS for storage and future reconnection use + this[kOwner][kNewToken](token, address); + }, + + /** + * Called when the session receives an ORIGIN frame from the peer (RFC 9412). + * @param {string[]} origins The list of origins the peer claims authority for + */ + onSessionOrigin(origins) { + debug('session origin callback', this[kOwner]); + this[kOwner][kOrigin](origins); }, /** @@ -527,6 +586,12 @@ setCallbacks({ this[kOwner][kBlocked](); }, + onStreamDrain() { + // Called when the stream's outbound buffer has capacity for more data. + debug('stream drain callback', this[kOwner]); + this[kOwner][kDrain](); + }, + onStreamClose(error) { // Called when the stream C++ handle has been closed. debug(`stream ${this[kOwner].id} closed callback with error: ${error}`); @@ -562,24 +627,35 @@ function validateBody(body) { // With a SharedArrayBuffer, we always copy. We cannot transfer // and it's likely unsafe to use the underlying buffer directly. if (isSharedArrayBuffer(body)) { - return new Uint8Array(body).slice(); + return TypedArrayPrototypeSlice(new Uint8Array(body)); } if (isArrayBufferView(body)) { - const size = body.byteLength; - const offset = body.byteOffset; + let size, offset, buffer; + if (isDataView(body)) { + size = DataViewPrototypeGetByteLength(body); + offset = DataViewPrototypeGetByteOffset(body); + buffer = DataViewPrototypeGetBuffer(body); + } else { + size = TypedArrayPrototypeGetByteLength(body); + offset = TypedArrayPrototypeGetByteOffset(body); + buffer = TypedArrayPrototypeGetBuffer(body); + } // We have to be careful in this case. If the ArrayBufferView is a // subset of the underlying ArrayBuffer, transferring the entire // ArrayBuffer could be incorrect if other views are also using it. // So if offset > 0 or size != buffer.byteLength, we'll copy the // subset into a new ArrayBuffer instead of transferring. - if (isSharedArrayBuffer(body.buffer) || - offset !== 0 || size !== body.buffer.byteLength) { - return new Uint8Array(body, offset, size).slice(); + if (isSharedArrayBuffer(buffer) || + offset !== 0 || + size !== ArrayBufferPrototypeGetByteLength(buffer)) { + return TypedArrayPrototypeSlice( + new Uint8Array(buffer, offset, size)); } // It's still possible that the ArrayBuffer is being used elsewhere, // but we really have no way of knowing. We'll just have to trust // the caller in this case. - return new Uint8Array(ArrayBufferPrototypeTransfer(body.buffer), offset, size); + return new Uint8Array( + ArrayBufferPrototypeTransfer(buffer), offset, size); } if (isBlob(body)) return body[kBlobHandle]; @@ -590,10 +666,226 @@ function validateBody(body) { ], body); } -// Functions used specifically for internal testing purposes only. +/** + * Parses an alternating [name, value, name, value, ...] array from C++ + * into a plain header object. Multi-value headers become arrays. + * @param {string[]} pairs + * @returns {object} + */ +function parseHeaderPairs(pairs) { + assert(ArrayIsArray(pairs)); + assert(pairs.length % 2 === 0); + const block = { __proto__: null }; + for (let n = 0; n + 1 < pairs.length; n += 2) { + if (block[pairs[n]] !== undefined) { + block[pairs[n]] = [block[pairs[n]], pairs[n + 1]]; + } else { + block[pairs[n]] = pairs[n + 1]; + } + } + return block; +} + +/** + * Applies session and stream callbacks from an options object to a session. + * @param {QuicSession} session + * @param {object} cbs + */ +function applyCallbacks(session, cbs) { + if (cbs.onstream) session.onstream = cbs.onstream; + if (cbs.ondatagram) session.ondatagram = cbs.ondatagram; + if (cbs.ondatagramstatus) session.ondatagramstatus = cbs.ondatagramstatus; + if (cbs.onpathvalidation) session.onpathvalidation = cbs.onpathvalidation; + if (cbs.onsessionticket) session.onsessionticket = cbs.onsessionticket; + if (cbs.onversionnegotiation) session.onversionnegotiation = cbs.onversionnegotiation; + if (cbs.onhandshake) session.onhandshake = cbs.onhandshake; + if (cbs.onnewtoken) session.onnewtoken = cbs.onnewtoken; + if (cbs.onorigin) session.onorigin = cbs.onorigin; + if (cbs.onheaders || cbs.ontrailers || cbs.onwanttrailers) { + session[kStreamCallbacks] = { + __proto__: null, + onheaders: cbs.onheaders, + ontrailers: cbs.ontrailers, + onwanttrailers: cbs.onwanttrailers, + }; + } +} + +/** + * Configures the outbound data source for a stream. Detects the source + * type and calls the appropriate C++ method. + * @param {object} handle The C++ stream handle + * @param {QuicStream} stream The JS stream object + * @param {*} body The body source + */ +const kMaxConfigureOutboundDepth = 3; + +function configureOutbound(handle, stream, body, depth = 0) { + if (depth > kMaxConfigureOutboundDepth) { + throw new ERR_INVALID_STATE( + 'Body source resolved to too many nested promises'); + } + + // body: null - close writable side immediately (FIN) + if (body === null) { + handle.initStreamingSource(); + handle.endWrite(); + return; + } + + // Handle Promise - await and recurse with depth limit + if (isPromise(body)) { + PromisePrototypeThen( + body, + (resolved) => configureOutbound(handle, stream, resolved, depth + 1), + () => { + if (!stream.destroyed) { + handle.resetStream(0n); + } + }, + ); + return; + } + + // Tier: One-shot - string (checked before sync iterable since + // strings are iterable but we want the one-shot path) + if (typeof body === 'string') { + handle.attachSource(Buffer.from(body, 'utf8')); + return; + } + + // Tier: One-shot - ArrayBuffer, SharedArrayBuffer, TypedArray, + // DataView, Blob. validateBody handles transfer-vs-copy logic, + // SharedArrayBuffer copying, and partial view safety. + if (isArrayBuffer(body) || isSharedArrayBuffer(body) || + isArrayBufferView(body) || isBlob(body)) { + handle.attachSource(validateBody(body)); + return; + } + + // Tier: Streaming - AsyncIterable (ReadableStream, stream.Readable, + // async generators, etc.). Checked before sync iterable because some + // objects implement both protocols and we prefer async. + if (isAsyncIterable(body)) { + consumeAsyncSource(handle, stream, body); + return; + } + + // Tier: Sync iterable - consumed synchronously + if (isSyncIterable(body)) { + consumeSyncSource(handle, stream, body); + return; + } + + throw new ERR_INVALID_ARG_TYPE( + 'body', + ['string', 'ArrayBuffer', 'SharedArrayBuffer', 'TypedArray', + 'Blob', 'Iterable', 'AsyncIterable', 'Promise', 'null'], + body, + ); +} + +// Waits for the stream's drain callback to fire, indicating the +// outbound has capacity for more data. +function waitForDrain(stream) { + const { promise, resolve } = PromiseWithResolvers(); + const prevDrain = stream[kDrain]; + stream[kDrain] = () => { + stream[kDrain] = prevDrain; + resolve(); + }; + return promise; +} + +// Writes a batch to the handle, awaiting drain if backpressured. +// Returns true if the stream was destroyed during the wait. +async function writeBatchWithDrain(handle, stream, batch) { + const result = handle.write(batch); + if (result !== undefined) return false; + // Write rejected (flow control) - wait for drain + await waitForDrain(stream); + if (stream.destroyed) return true; + handle.write(batch); + return false; +} + +async function consumeAsyncSource(handle, stream, source) { + handle.initStreamingSource(); + try { + // Normalize to AsyncIterable + const normalized = streamFrom(source); + for await (const batch of normalized) { + if (stream.destroyed) return; + if (await writeBatchWithDrain(handle, stream, batch)) return; + } + handle.endWrite(); + } catch { + if (!stream.destroyed) { + handle.resetStream(0n); + } + } +} + +async function consumeSyncSource(handle, stream, source) { + handle.initStreamingSource(); + // Normalize to Iterable. Manually iterate so we can + // pause between next() calls when backpressure hits. + const normalized = streamFromSync(source); + const iter = normalized[SymbolIterator](); + try { + while (true) { + if (stream.destroyed) return; + const { value: batch, done } = iter.next(); + if (done) break; + if (await writeBatchWithDrain(handle, stream, batch)) return; + } + handle.endWrite(); + } catch { + if (!stream.destroyed) { + handle.resetStream(0n); + } + } +} + +function isAsyncIterable(obj) { + return obj != null && typeof obj[SymbolAsyncIterator] === 'function'; +} + +function isSyncIterable(obj) { + return obj != null && typeof obj[SymbolIterator] === 'function'; +} + +// Functions used specifically for internal or assertion purposes only. let getQuicStreamState; let getQuicSessionState; let getQuicEndpointState; +let assertIsQuicEndpoint; +let assertEndpointNotClosedOrClosing; +let assertEndpointIsNotBusy; + +function maybeGetCloseError(context, status, pendingError) { + switch (context) { + case kCloseContextClose: { + return pendingError; + } + case kCloseContextBindFailure: { + return new ERR_QUIC_ENDPOINT_CLOSED('Bind failure', status); + } + case kCloseContextListenFailure: { + return new ERR_QUIC_ENDPOINT_CLOSED('Listen failure', status); + } + case kCloseContextReceiveFailure: { + return new ERR_QUIC_ENDPOINT_CLOSED('Receive failure', status); + } + case kCloseContextSendFailure: { + return new ERR_QUIC_ENDPOINT_CLOSED('Send failure', status); + } + case kCloseContextStartFailure: { + return new ERR_QUIC_ENDPOINT_CLOSED('Start failure', status); + } + } + // Otherwise return undefined. +} class QuicStream { /** @type {object} */ @@ -614,11 +906,18 @@ class QuicStream { #onheaders = undefined; /** @type {OnTrailersCallback|undefined} */ #ontrailers = undefined; + /** @type {Function|undefined} */ + #onwanttrailers = undefined; + /** @type {object|undefined} */ + #headers = undefined; + /** @type {object|undefined} */ + #pendingTrailers = undefined; /** @type {Promise} */ - #pendingClose = Promise.withResolvers(); // eslint-disable-line node-core/prefer-primordials + #pendingClose = PromiseWithResolvers(); #reader; - /** @type {ReadableStream} */ - #readable; + #iteratorLocked = false; + #writer = undefined; + #outboundSet = false; static { getQuicStreamState = function(stream) { @@ -633,6 +932,13 @@ class QuicStream { } } + #assertHeadersSupported() { + if (getQuicSessionState(this.#session).headersSupported === 2) { + throw new ERR_INVALID_STATE( + 'The negotiated QUIC application protocol does not support headers'); + } + } + /** * @param {symbol} privateSymbol * @param {object} handle @@ -659,17 +965,32 @@ class QuicStream { } } + get [kValidatedSource]() { return true; } + /** - * Returns a ReadableStream to consume incoming data on the stream. - * @type {ReadableStream} + * Returns an AsyncIterator that yields Uint8Array[] batches of + * incoming data. Only one iterator can be obtained per stream. + * Non-readable streams return an immediately-finished iterator. + * @yields {Uint8Array[]} */ - get readable() { + async *[SymbolAsyncIterator]() { QuicStream.#assertIsQuicStream(this); - if (this.#readable === undefined) { - assert(this.#reader); - this.#readable = createBlobReaderStream(this.#reader); + if (this.#iteratorLocked) { + throw new ERR_INVALID_STATE('Stream is already being read'); } - return this.#readable; + this.#iteratorLocked = true; + + // Non-readable stream (outbound-only unidirectional, or closed) + if (!this.#reader) return; + + yield* createBlobReaderIterable(this.#reader, { + getReadError: () => { + if (this.#state.reset) { + return new ERR_QUIC_STREAM_RESET(Number(this.#state.resetCode)); + } + return new ERR_INVALID_STATE('The stream is not readable'); + }, + }); } /** @@ -695,7 +1016,7 @@ class QuicStream { this.#state.wantsBlock = false; } else { validateFunction(fn, 'onblocked'); - this.#onblocked = fn.bind(this); + this.#onblocked = FunctionPrototypeBind(fn, this); this.#state.wantsBlock = true; } } @@ -713,41 +1034,100 @@ class QuicStream { this.#state.wantsReset = false; } else { validateFunction(fn, 'onreset'); - this.#onreset = fn.bind(this); + this.#onreset = FunctionPrototypeBind(fn, this); this.#state.wantsReset = true; } } /** @type {OnHeadersCallback} */ - get [kOnHeaders]() { + get onheaders() { + QuicStream.#assertIsQuicStream(this); return this.#onheaders; } - set [kOnHeaders](fn) { + set onheaders(fn) { + QuicStream.#assertIsQuicStream(this); if (fn === undefined) { this.#onheaders = undefined; this.#state[kWantsHeaders] = false; } else { + this.#assertHeadersSupported(); validateFunction(fn, 'onheaders'); - this.#onheaders = fn.bind(this); + this.#onheaders = FunctionPrototypeBind(fn, this); this.#state[kWantsHeaders] = true; } } /** @type {OnTrailersCallback} */ - get [kOnTrailers]() { return this.#ontrailers; } + get ontrailers() { + QuicStream.#assertIsQuicStream(this); + return this.#ontrailers; + } - set [kOnTrailers](fn) { + set ontrailers(fn) { + QuicStream.#assertIsQuicStream(this); if (fn === undefined) { this.#ontrailers = undefined; this.#state[kWantsTrailers] = false; } else { + this.#assertHeadersSupported(); validateFunction(fn, 'ontrailers'); - this.#ontrailers = fn.bind(this); + this.#ontrailers = FunctionPrototypeBind(fn, this); this.#state[kWantsTrailers] = true; } } + /** @type {Function|undefined} */ + get onwanttrailers() { + QuicStream.#assertIsQuicStream(this); + return this.#onwanttrailers; + } + + set onwanttrailers(fn) { + QuicStream.#assertIsQuicStream(this); + if (fn === undefined) { + this.#onwanttrailers = undefined; + } else { + this.#assertHeadersSupported(); + validateFunction(fn, 'onwanttrailers'); + this.#onwanttrailers = FunctionPrototypeBind(fn, this); + } + } + + /** + * The buffered initial headers received on this stream, or undefined + * if the application does not support headers or no headers have + * been received yet. + * @type {object|undefined} + */ + get headers() { + QuicStream.#assertIsQuicStream(this); + return this.#headers; + } + + /** + * Set trailing headers to be sent when nghttp3 asks for them. + * @type {object|undefined} + */ + get pendingTrailers() { + QuicStream.#assertIsQuicStream(this); + return this.#pendingTrailers; + } + + set pendingTrailers(headers) { + QuicStream.#assertIsQuicStream(this); + if (headers === undefined) { + this.#pendingTrailers = undefined; + return; + } + if (getQuicSessionState(this.#session).headersSupported === 2) { + throw new ERR_INVALID_STATE( + 'The negotiated QUIC application protocol does not support headers'); + } + validateObject(headers, 'headers'); + this.#pendingTrailers = headers; + } + /** * The statistics collected for this stream. * @type {QuicStreamStats} @@ -792,7 +1172,7 @@ class QuicStream { /** * True if the stream has been destroyed. - * @returns {boolean} + * @type {boolean} */ get destroyed() { QuicStream.#assertIsQuicStream(this); @@ -827,6 +1207,236 @@ class QuicStream { this.#handle.attachSource(validateBody(outbound)); } + /** + * Send initial or response headers on this stream. Throws if the + * application does not support headers. + * @param {object} headers + * @param {{ terminal?: boolean }} [options] + * @returns {boolean} + */ + sendHeaders(headers, options = kEmptyObject) { + QuicStream.#assertIsQuicStream(this); + if (this.destroyed) return false; + if (getQuicSessionState(this.#session).headersSupported === 2) { + throw new ERR_INVALID_STATE( + 'The negotiated QUIC application protocol does not support headers'); + } + validateObject(headers, 'headers'); + const { terminal = false } = options; + const headerString = buildNgHeaderString( + headers, assertValidPseudoHeader, true); + const flags = terminal ? kHeadersFlagsTerminal : kHeadersFlagsNone; + return this.#handle.sendHeaders(kHeadersKindInitial, headerString, flags); + } + + /** + * Send informational (1xx) headers on this stream. Server only. + * Throws if the application does not support headers. + * @param {object} headers + * @returns {boolean} + */ + sendInformationalHeaders(headers) { + QuicStream.#assertIsQuicStream(this); + if (this.destroyed) return false; + if (getQuicSessionState(this.#session).headersSupported === 2) { + throw new ERR_INVALID_STATE( + 'The negotiated QUIC application protocol does not support headers'); + } + validateObject(headers, 'headers'); + const headerString = buildNgHeaderString( + headers, assertValidPseudoHeader, true); + return this.#handle.sendHeaders( + kHeadersKindHints, headerString, kHeadersFlagsNone); + } + + /** + * Send trailing headers on this stream. Must be called synchronously + * during the onwanttrailers callback, or set via pendingTrailers before + * the body completes. Throws if the application does not support headers. + * @param {object} headers + * @returns {boolean} + */ + sendTrailers(headers) { + QuicStream.#assertIsQuicStream(this); + if (this.destroyed) return false; + if (getQuicSessionState(this.#session).headersSupported === 2) { + throw new ERR_INVALID_STATE( + 'The negotiated QUIC application protocol does not support headers'); + } + validateObject(headers, 'headers'); + const headerString = buildNgHeaderString(headers); + return this.#handle.sendHeaders( + kHeadersKindTrailing, headerString, kHeadersFlagsNone); + } + + /** + * Returns a Writer for pushing data to this stream incrementally. + * Only available when no body source was provided at creation time + * or via setBody(). Non-writable streams return an already-closed Writer. + * @type {object} + */ + get writer() { + QuicStream.#assertIsQuicStream(this); + if (this.#writer !== undefined) return this.#writer; + if (this.#outboundSet) { + throw new ERR_INVALID_STATE( + 'Stream outbound already configured with a body source'); + } + + const handle = this.#handle; + const stream = this; + let closed = false; + let errored = false; + let error = null; + let totalBytesWritten = 0; + let drainWakeup = null; + + // Drain callback - C++ fires this when send buffer has space + stream[kDrain] = () => { + if (drainWakeup) { + drainWakeup.resolve(true); + drainWakeup = null; + } + }; + + function writeSync(chunk) { + if (closed || errored) return false; + chunk = toUint8Array(chunk); + const result = handle.write([chunk]); + if (result === undefined) return false; + totalBytesWritten += chunk.byteLength; + return true; + } + + async function write(chunk, options) { + if (options?.signal?.aborted) { + throw options.signal.reason; + } + if (errored) throw error; + if (closed) throw new ERR_INVALID_STATE('Writer is closed'); + chunk = toUint8Array(chunk); + const result = handle.write([chunk]); + if (result === undefined) { + throw new ERR_INVALID_STATE('Stream write buffer is full'); + } + totalBytesWritten += chunk.byteLength; + } + + function writevSync(chunks) { + if (closed || errored) return false; + chunks = convertChunks(chunks); + const result = handle.write(chunks); + if (result === undefined) return false; + for (const c of chunks) totalBytesWritten += c.byteLength; + return true; + } + + async function writev(chunks, options) { + if (options?.signal?.aborted) { + throw options.signal.reason; + } + if (errored) throw error; + if (closed) throw new ERR_INVALID_STATE('Writer is closed'); + chunks = convertChunks(chunks); + const result = handle.write(chunks); + if (result === undefined) { + throw new ERR_INVALID_STATE('Stream write buffer is full'); + } + for (const c of chunks) totalBytesWritten += c.byteLength; + } + + function endSync() { + if (errored) return -1; + if (closed) return totalBytesWritten; + handle.endWrite(); + closed = true; + return totalBytesWritten; + } + + async function end(options) { + const n = endSync(); + if (n >= 0) return n; + if (errored) throw error; + drainWakeup = PromiseWithResolvers(); + await drainWakeup.promise; + drainWakeup = null; + return endSync(); + } + + function fail(reason) { + if (closed || errored) return; + errored = true; + error = reason; + handle.resetStream(0n); + if (drainWakeup) { + drainWakeup.reject(reason); + drainWakeup = null; + } + } + + const writer = { + __proto__: null, + get desiredSize() { + if (closed || errored) return null; + return Number(stream.#state.writeDesiredSize); + }, + writeSync, + write, + writevSync, + writev, + endSync, + end, + fail, + [drainableProtocol]() { + if (closed || errored) return null; + if (Number(stream.#state.writeDesiredSize) > 0) return null; + drainWakeup = PromiseWithResolvers(); + return drainWakeup.promise; + }, + [SymbolAsyncDispose]() { + if (!closed && !errored) fail(); + return PromiseResolve(); + }, + [SymbolDispose]() { + if (!closed && !errored) fail(); + }, + }; + + // Non-writable stream - return a pre-closed writer + if (!handle || this.destroyed || this.#state.writeEnded) { + closed = true; + this.#writer = writer; + return this.#writer; + } + + // Initialize the outbound DataQueue for streaming writes + handle.initStreamingSource(); + + this.#writer = writer; + return this.#writer; + } + + /** + * Sets the outbound body source for this stream. Accepts all body + * source types (string, TypedArray, Blob, AsyncIterable, Promise, null). + * Can only be called once. Mutually exclusive with stream.writer. + * @param {*} body + */ + setBody(body) { + QuicStream.#assertIsQuicStream(this); + if (this.destroyed) { + throw new ERR_INVALID_STATE('Stream is destroyed'); + } + if (this.#outboundSet) { + throw new ERR_INVALID_STATE('Stream outbound already configured'); + } + if (this.#writer !== undefined) { + throw new ERR_INVALID_STATE('Stream writer already accessed'); + } + this.#outboundSet = true; + configureOutbound(this.#handle, this, body); + } + /** * Tells the peer to stop sending data for this stream. The optional error * code will be sent to the peer as part of the request. If the stream is @@ -857,24 +1467,42 @@ 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 {'default' | 'low' | 'high' | null} + * @type {{ level: 'default' | 'low' | 'high', incremental: boolean } | null} */ get priority() { QuicStream.#assertIsQuicStream(this); - if (this.destroyed || !this.session.state.isPrioritySupported) return null; - const priority = this.#handle.getPriority(); - return priority < 3 ? 'high' : priority > 3 ? 'low' : 'default'; + if (this.destroyed || + !getQuicSessionState(this.#session).isPrioritySupported) return null; + const packed = this.#handle.getPriority(); + const urgency = packed >> 1; + const incremental = !!(packed & 1); + const level = urgency < 3 ? 'high' : urgency > 3 ? 'low' : 'default'; + return { level, incremental }; } - set priority(val) { + /** + * Sets the priority of the stream. + * @param {{ + * level?: 'default' | 'low' | 'high', + * incremental?: boolean, + * }} options + */ + setPriority(options = kEmptyObject) { QuicStream.#assertIsQuicStream(this); - if (this.destroyed || !this.session.state.isPrioritySupported) return; - validateOneOf(val, 'priority', ['default', 'low', 'high']); - switch (val) { - case 'default': this.#handle.setPriority(3, 1); break; - case 'low': this.#handle.setPriority(7, 1); break; - case 'high': this.#handle.setPriority(0, 1); break; + if (this.destroyed) return; + if (!getQuicSessionState(this.#session).isPrioritySupported) { + throw new ERR_INVALID_STATE( + 'The session does not support stream priority'); } + validateObject(options, 'options'); + const { + level = 'default', + incremental = false, + } = options; + validateOneOf(level, 'options.level', ['default', 'low', 'high']); + validateBoolean(incremental, 'options.incremental'); + const urgency = level === 'high' ? 0 : level === 'low' ? 7 : 3; + this.#handle.setPriority((urgency << 1) | (incremental ? 1 : 0)); } /** @@ -889,8 +1517,13 @@ class QuicStream { * @param {object} headers * @returns {boolean} true if the headers were scheduled to be sent. */ - [kSendHeaders](headers) { + [kSendHeaders](headers, kind = kHeadersKindInitial, + flags = kHeadersFlagsTerminal) { validateObject(headers, 'headers'); + if (getQuicSessionState(this.#session).headersSupported === 2) { + throw new ERR_INVALID_STATE( + 'The negotiated QUIC application protocol does not support headers'); + } if (this.pending) { debug('pending stream enqueuing headers', headers); } else { @@ -901,8 +1534,7 @@ class QuicStream { assertValidPseudoHeader, true, // This could become an option in future ); - // TODO(@jasnell): Support differentiating between early headers, primary headers, etc - return this.#handle.sendHeaders(1, headerString, 1); + return this.#handle.sendHeaders(kind, headerString, flags); } [kFinishClose](error) { @@ -932,6 +1564,9 @@ class QuicStream { this.#onreset = undefined; this.#onheaders = undefined; this.#ontrailers = undefined; + this.#onwanttrailers = undefined; + this.#headers = undefined; + this.#pendingTrailers = undefined; this.#handle = undefined; } @@ -942,6 +1577,11 @@ class QuicStream { this.#onblocked(); } + [kDrain]() { + // No-op by default. Overridden by the writer closure when + // stream.writer is accessed. + } + [kReset](error) { // The reset event should only be called if the stream was created with // an onreset callback. The callback should always exist here. @@ -950,30 +1590,36 @@ class QuicStream { } [kHeaders](headers, kind) { - // The headers event should only be called if the stream was created with - // an onheaders callback. The callback should always exist here. assert(this.#onheaders, 'Unexpected stream headers event'); - assert(ArrayIsArray(headers)); - assert(headers.length % 2 === 0); - const block = { - __proto__: null, - }; - for (let n = 0; n + 1 < headers.length; n += 2) { - if (block[headers[n]] !== undefined) { - block[headers[n]] = [block[headers[n]], headers[n + 1]]; - } else { - block[headers[n]] = headers[n + 1]; - } + const block = parseHeaderPairs(headers); + + // Buffer initial headers so stream.headers returns them. + if (kind === 'initial' && this.#headers === undefined) { + this.#headers = block; } this.#onheaders(block, kind); } - [kTrailers]() { - // The trailers event should only be called if the stream was created with - // an ontrailers callback. The callback should always exist here. - assert(this.#ontrailers, 'Unexpected stream trailers event'); - this.#ontrailers(); + [kTrailers](headers) { + if (this.destroyed) return; + + // If we received trailers from the peer, dispatch them. + if (headers !== undefined) { + if (this.#ontrailers) { + this.#ontrailers(parseHeaderPairs(headers)); + } + return; + } + + // Otherwise, nghttp3 is asking us to provide trailers to send. + // Check for pre-set pendingTrailers first, then the callback. + if (this.#pendingTrailers) { + this.sendTrailers(this.#pendingTrailers); + this.#pendingTrailers = undefined; + } else if (this.#onwanttrailers) { + this.#onwanttrailers(); + } } [kInspect](depth, options) { @@ -981,11 +1627,12 @@ class QuicStream { return this; const opts = { + __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; - return `Stream ${inspect({ + return `QuicStream ${inspect({ __proto__: null, id: this.id, direction: this.direction, @@ -1005,9 +1652,9 @@ class QuicSession { /** @type {object|undefined} */ #handle; /** @type {PromiseWithResolvers} */ - #pendingClose = Promise.withResolvers(); // eslint-disable-line node-core/prefer-primordials + #pendingClose = PromiseWithResolvers(); /** @type {PromiseWithResolvers} */ - #pendingOpen = Promise.withResolvers(); // eslint-disable-line node-core/prefer-primordials + #pendingOpen = PromiseWithResolvers(); /** @type {QuicSessionState} */ #state; /** @type {QuicSessionStats} */ @@ -1018,8 +1665,25 @@ class QuicSession { #onstream = undefined; /** @type {OnDatagramCallback|undefined} */ #ondatagram = undefined; - /** @type {object|undefined} */ - #sessionticket = undefined; + /** @type {OnDatagramStatusCallback|undefined} */ + #ondatagramstatus = undefined; + /** @type {Function|undefined} */ + #onpathvalidation = undefined; + /** @type {Function|undefined} */ + #onsessionticket = undefined; + /** @type {Function|undefined} */ + #onversionnegotiation = undefined; + /** @type {Function|undefined} */ + #onhandshake = undefined; + /** @type {Function|undefined} */ + #onnewtoken = undefined; + /** @type {Function|undefined} */ + #onorigin = undefined; + /** @type {{ local: SocketAddress, remote: SocketAddress }|undefined} */ + #path = undefined; + #certificate = undefined; + #peerCertificate = undefined; + #ephemeralKeyInfo = undefined; static { getQuicSessionState = function(session) { @@ -1050,9 +1714,6 @@ class QuicSession { this.#handle[kOwner] = this; this.#stats = new QuicSessionStats(kPrivateConstructor, handle.stats); this.#state = new QuicSessionState(kPrivateConstructor, handle.state); - this.#state.hasVersionNegotiationListener = true; - this.#state.hasPathValidationListener = true; - this.#state.hasSessionTicketListener = true; debug('session created'); } @@ -1062,15 +1723,6 @@ class QuicSession { return this.#handle === undefined || this.#isPendingClose; } - /** - * Get the session ticket associated with this session, if any. - * @type {object|undefined} - */ - get sessionticket() { - QuicSession.#assertIsQuicSession(this); - return this.#sessionticket; - } - /** @type {OnStreamCallback} */ get onstream() { QuicSession.#assertIsQuicSession(this); @@ -1083,7 +1735,7 @@ class QuicSession { this.#onstream = undefined; } else { validateFunction(fn, 'onstream'); - this.#onstream = fn.bind(this); + this.#onstream = FunctionPrototypeBind(fn, this); } } @@ -1100,11 +1752,147 @@ class QuicSession { this.#state.hasDatagramListener = false; } else { validateFunction(fn, 'ondatagram'); - this.#ondatagram = fn.bind(this); + this.#ondatagram = FunctionPrototypeBind(fn, this); this.#state.hasDatagramListener = true; } } + /** + * The ondatagramstatus callback is called when the status of a sent datagram + * is received. This is best-effort only. + * @type {OnDatagramStatusCallback} + */ + get ondatagramstatus() { + QuicSession.#assertIsQuicSession(this); + return this.#ondatagramstatus; + } + + set ondatagramstatus(fn) { + QuicSession.#assertIsQuicSession(this); + if (fn === undefined) { + this.#ondatagramstatus = undefined; + this.#state.hasDatagramStatusListener = false; + } else { + validateFunction(fn, 'ondatagramstatus'); + this.#ondatagramstatus = FunctionPrototypeBind(fn, this); + this.#state.hasDatagramStatusListener = true; + } + } + + /** @type {Function|undefined} */ + get onpathvalidation() { + QuicSession.#assertIsQuicSession(this); + return this.#onpathvalidation; + } + + set onpathvalidation(fn) { + QuicSession.#assertIsQuicSession(this); + if (fn === undefined) { + this.#onpathvalidation = undefined; + this.#state.hasPathValidationListener = false; + } else { + validateFunction(fn, 'onpathvalidation'); + this.#onpathvalidation = FunctionPrototypeBind(fn, this); + this.#state.hasPathValidationListener = true; + } + } + + /** @type {Function|undefined} */ + get onsessionticket() { + QuicSession.#assertIsQuicSession(this); + return this.#onsessionticket; + } + + set onsessionticket(fn) { + QuicSession.#assertIsQuicSession(this); + if (fn === undefined) { + this.#onsessionticket = undefined; + this.#state.hasSessionTicketListener = false; + } else { + validateFunction(fn, 'onsessionticket'); + this.#onsessionticket = FunctionPrototypeBind(fn, this); + this.#state.hasSessionTicketListener = true; + } + } + + /** @type {Function|undefined} */ + get onversionnegotiation() { + QuicSession.#assertIsQuicSession(this); + return this.#onversionnegotiation; + } + + set onversionnegotiation(fn) { + QuicSession.#assertIsQuicSession(this); + if (fn === undefined) { + this.#onversionnegotiation = undefined; + } else { + validateFunction(fn, 'onversionnegotiation'); + this.#onversionnegotiation = FunctionPrototypeBind(fn, this); + } + } + + /** @type {Function|undefined} */ + get onhandshake() { + QuicSession.#assertIsQuicSession(this); + return this.#onhandshake; + } + + set onhandshake(fn) { + QuicSession.#assertIsQuicSession(this); + if (fn === undefined) { + this.#onhandshake = undefined; + } else { + validateFunction(fn, 'onhandshake'); + this.#onhandshake = FunctionPrototypeBind(fn, this); + } + } + + /** @type {Function|undefined} */ + get onnewtoken() { + QuicSession.#assertIsQuicSession(this); + return this.#onnewtoken; + } + + set onnewtoken(fn) { + QuicSession.#assertIsQuicSession(this); + if (fn === undefined) { + this.#onnewtoken = undefined; + this.#state.hasNewTokenListener = false; + } else { + validateFunction(fn, 'onnewtoken'); + this.#onnewtoken = FunctionPrototypeBind(fn, this); + this.#state.hasNewTokenListener = true; + } + } + + /** @type {Function|undefined} */ + get onorigin() { + QuicSession.#assertIsQuicSession(this); + return this.#onorigin; + } + + set onorigin(fn) { + QuicSession.#assertIsQuicSession(this); + if (fn === undefined) { + this.#onorigin = undefined; + this.#state.hasOriginListener = false; + } else { + validateFunction(fn, 'onorigin'); + this.#onorigin = FunctionPrototypeBind(fn, this); + this.#state.hasOriginListener = true; + } + } + + /** + * The maximum datagram size the peer will accept, or 0 if datagrams + * are not supported or the handshake has not yet completed. + * @type {bigint} + */ + get maxDatagramSize() { + QuicSession.#assertIsQuicSession(this); + return this.#state.maxDatagramSize; + } + /** * The statistics collected for this session. * @type {QuicSessionStats} @@ -1125,6 +1913,53 @@ class QuicSession { return this.#endpoint; } + /** + * The local and remote socket addresses associated with the session. + * @type {{ local: SocketAddress, remote: SocketAddress } | undefined} + */ + get path() { + QuicSession.#assertIsQuicSession(this); + if (this.destroyed) return undefined; + return this.#path ??= { + __proto__: null, + local: new InternalSocketAddress(this.#handle.getLocalAddress()), + remote: new InternalSocketAddress(this.#handle.getRemoteAddress()), + }; + } + + /** + * The local certificate as an object, or undefined if not available. + * @type {object|undefined} + */ + get certificate() { + QuicSession.#assertIsQuicSession(this); + if (this.destroyed) return undefined; + return this.#certificate ??= this.#handle.getCertificate(); + } + + /** + * The peer's certificate as an object, or undefined if the peer did + * not present a certificate or the session is destroyed. + * @type {object|undefined} + */ + get peerCertificate() { + QuicSession.#assertIsQuicSession(this); + if (this.destroyed) return undefined; + return this.#peerCertificate ??= this.#handle.getPeerCertificate(); + } + + /** + * The ephemeral key info for the session. Only available on client + * sessions. Returns undefined for server sessions or if the session + * is destroyed. + * @type {object|undefined} + */ + get ephemeralKeyInfo() { + QuicSession.#assertIsQuicSession(this); + if (this.destroyed) return undefined; + return this.#ephemeralKeyInfo ??= this.#handle.getEphemeralKey(); + } + /** * @param {number} direction * @param {OpenStreamOptions} options @@ -1144,15 +1979,16 @@ class QuicSession { validateObject(options, 'options'); const { body, - sendOrder = 50, - [kHeaders]: headers, + priority = 'default', + incremental = false, + headers, + onheaders, + ontrailers, + onwanttrailers, } = options; - if (headers !== undefined) { - validateObject(headers, 'options.headers'); - } - validateNumber(sendOrder, 'options.sendOrder'); - // TODO(@jasnell): Make use of sendOrder to set the priority + validateOneOf(priority, 'options.priority', ['default', 'low', 'high']); + validateBoolean(incremental, 'options.incremental'); const validatedBody = validateBody(body); @@ -1161,16 +1997,23 @@ class QuicSession { throw new ERR_QUIC_OPEN_STREAM_FAILED(); } - if (headers !== undefined) { - // If headers are specified and there's no body, then we assume - // that the headers are terminal. - handle.sendHeaders(1, buildNgHeaderString(headers), - validatedBody === undefined ? 1 : 0); + if (this.#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); + // Set stream callbacks before sending headers to avoid missing events. + if (onheaders) stream.onheaders = onheaders; + if (ontrailers) stream.ontrailers = ontrailers; + if (onwanttrailers) stream.onwanttrailers = onwanttrailers; + + if (headers !== undefined) { + stream.sendHeaders(headers, { terminal: validatedBody === undefined }); + } + if (onSessionOpenStreamChannel.hasSubscribers) { onSessionOpenStreamChannel.publish({ stream, @@ -1193,6 +2036,8 @@ class QuicSession { } /** + * Creates a new unidirectional stream on this session. If the session + * does not allow new streams to be opened, an error will be thrown. * @param {OpenStreamOptions} [options] * @returns {Promise} */ @@ -1206,42 +2051,93 @@ class QuicSession { * of the sent datagram will be reported via the datagram-status event if * possible. * - * If a string is given it will be encoded as UTF-8. + * If a string is given it will be encoded using the specified encoding. * - * If an ArrayBufferView is given, the view will be copied. - * @param {ArrayBufferView|string} datagram The datagram payload - * @returns {Promise} + * If an ArrayBufferView is given, the underlying ArrayBuffer will be + * transferred if possible, otherwise the data will be copied. + * + * If a Promise is given, it will be awaited before sending. If the + * session closes while awaiting, 0n is returned silently. + * @param {ArrayBufferView|string|Promise} datagram The datagram payload + * @param {string} [encoding] The encoding to use if datagram is a string + * @returns {Promise} The datagram ID */ - async sendDatagram(datagram) { + async sendDatagram(datagram, encoding = 'utf8') { QuicSession.#assertIsQuicSession(this); if (this.#isClosedOrClosing) { throw new ERR_INVALID_STATE('Session is closed'); } + let offset, length, buffer; + + const maxDatagramSize = this.#state.maxDatagramSize; + + // The peer max datagram size is either unknown or they have explicitly + // indicated that they do not support datagrams by setting it to 0. In + // either case, we do not send the datagram. + if (maxDatagramSize === 0n) return kNilDatagramId; + + if (isPromise(datagram)) { + datagram = await datagram; + // Session may have closed while awaiting. Since datagrams are + // inherently unreliable, silently return rather than throwing. + if (this.#isClosedOrClosing) return kNilDatagramId; + } + if (typeof datagram === 'string') { - datagram = Buffer.from(datagram, 'utf8'); + datagram = Buffer.from(datagram, encoding); + length = TypedArrayPrototypeGetByteLength(datagram); + if (length === 0) return kNilDatagramId; } else { if (!isArrayBufferView(datagram)) { throw new ERR_INVALID_ARG_TYPE('datagram', ['ArrayBufferView', 'string'], datagram); } - const length = datagram.byteLength; - const offset = datagram.byteOffset; - datagram = new Uint8Array(ArrayBufferPrototypeTransfer(datagram.buffer), - length, offset); + if (isDataView(datagram)) { + offset = DataViewPrototypeGetByteOffset(datagram); + length = DataViewPrototypeGetByteLength(datagram); + buffer = DataViewPrototypeGetBuffer(datagram); + } else { + offset = TypedArrayPrototypeGetByteOffset(datagram); + length = TypedArrayPrototypeGetByteLength(datagram); + buffer = TypedArrayPrototypeGetBuffer(datagram); + } + + // If the view has zero length (e.g. detached buffer), there's + // nothing to send. + if (length === 0) return kNilDatagramId; + + if (isSharedArrayBuffer(buffer) || + offset !== 0 || + length !== ArrayBufferPrototypeGetByteLength(buffer)) { + // Copy if the buffer is not transferable (SharedArrayBuffer) + // or if the view is over a subset of the buffer (e.g. a + // Node.js Buffer from the pool). + datagram = TypedArrayPrototypeSlice( + new Uint8Array(buffer), offset, offset + length); + } else { + datagram = new Uint8Array( + ArrayBufferPrototypeTransfer(buffer), offset, length); + } } - debug(`sending datagram with ${datagram.byteLength} bytes`); + // The peer max datagram size is less than the datagram we want to send, + // so... don't send it. + if (length > maxDatagramSize) return kNilDatagramId; const id = this.#handle.sendDatagram(datagram); - if (onSessionSendDatagramChannel.hasSubscribers) { + if (id !== kNilDatagramId && onSessionSendDatagramChannel.hasSubscribers) { onSessionSendDatagramChannel.publish({ + __proto__: null, id, - length: datagram.byteLength, + length, session: this, }); } + + debug(`datagram ${id} sent with ${length} bytes`); + return id; } /** @@ -1280,7 +2176,7 @@ class QuicSession { debug('gracefully closing the session'); - this.#handle?.gracefulClose(); + this.#handle.gracefulClose(); if (onSessionClosingChannel.hasSubscribers) { onSessionClosingChannel.publish({ session: this, @@ -1366,7 +2262,17 @@ class QuicSession { this.#onstream = undefined; this.#ondatagram = undefined; - this.#sessionticket = undefined; + this.#ondatagramstatus = undefined; + this.#onpathvalidation = undefined; + this.#onsessionticket = undefined; + this.#onversionnegotiation = undefined; + this.#onhandshake = undefined; + this.#onnewtoken = undefined; + this.#onorigin = undefined; + this.#path = undefined; + this.#certificate = undefined; + this.#peerCertificate = undefined; + this.#ephemeralKeyInfo = undefined; // Destroy the underlying C++ handle this.#handle.destroy(); @@ -1397,46 +2303,38 @@ class QuicSession { debug('finishing closing the session with an error', errorType, code, reason); // Otherwise, errorType indicates the type of error that occurred, code indicates // the specific error, and reason is an optional string describing the error. + // code !== 0n here (the early return above handles code === 0n) switch (errorType) { case 0: /* Transport Error */ - if (code === 0n) { - this.destroy(); - } else { - this.destroy(new ERR_QUIC_TRANSPORT_ERROR(code, reason)); - } + this.destroy(new ERR_QUIC_TRANSPORT_ERROR(code, reason)); break; case 1: /* Application Error */ - if (code === 0n) { - this.destroy(); - } else { - this.destroy(new ERR_QUIC_APPLICATION_ERROR(code, reason)); - } + this.destroy(new ERR_QUIC_APPLICATION_ERROR(code, reason)); break; case 2: /* Version Negotiation Error */ this.destroy(new ERR_QUIC_VERSION_NEGOTIATION_ERROR()); break; - case 3: /* Idle close */ { - // An idle close is not really an error. We can just destroy. + case 3: /* Idle close */ this.destroy(); break; - } } } /** - * @param {Uint8Array} u8 - * @param {boolean} early + * @param {Uint8Array} u8 The datagram payload + * @param {boolean} early A boolean indicating whether this datagram was received before the handshake completed */ [kDatagram](u8, early) { - // The datagram event should only be called if the session was created with + // The datagram event should only be called if the session has // an ondatagram callback. The callback should always exist here. - assert(this.#ondatagram, 'Unexpected datagram event'); + assert(typeof this.#ondatagram === 'function', 'Unexpected datagram event'); if (this.destroyed) return; - const length = u8.byteLength; + const length = TypedArrayPrototypeGetByteLength(u8); this.#ondatagram(u8, early); if (onSessionReceiveDatagramChannel.hasSubscribers) { onSessionReceiveDatagramChannel.publish({ + __proto__: null, length, early, session: this, @@ -1449,9 +2347,15 @@ class QuicSession { * @param {'lost'|'acknowledged'} status */ [kDatagramStatus](id, status) { + // The datagram status event should only be called if the session has + // an ondatagramstatus callback. The callback should always exist here. + assert(typeof this.#ondatagramstatus === 'function', 'Unexpected datagram status event'); if (this.destroyed) return; + this.#ondatagramstatus(id, status); + if (onSessionReceiveDatagramStatusChannel.hasSubscribers) { onSessionReceiveDatagramStatusChannel.publish({ + __proto__: null, id, status, session: this, @@ -1469,14 +2373,23 @@ class QuicSession { */ [kPathValidation](result, newLocalAddress, newRemoteAddress, oldLocalAddress, oldRemoteAddress, preferredAddress) { + assert(typeof this.#onpathvalidation === 'function', + 'Unexpected path validation event'); if (this.destroyed) return; + const newLocal = new InternalSocketAddress(newLocalAddress); + const newRemote = new InternalSocketAddress(newRemoteAddress); + const oldLocal = new InternalSocketAddress(oldLocalAddress); + const oldRemote = new InternalSocketAddress(oldRemoteAddress); + this.#onpathvalidation(result, newLocal, newRemote, + oldLocal, oldRemote, preferredAddress); if (onSessionPathValidationChannel.hasSubscribers) { onSessionPathValidationChannel.publish({ + __proto__: null, result, - newLocalAddress, - newRemoteAddress, - oldLocalAddress, - oldRemoteAddress, + newLocalAddress: newLocal, + newRemoteAddress: newRemote, + oldLocalAddress: oldLocal, + oldRemoteAddress: oldRemote, preferredAddress, session: this, }); @@ -1487,16 +2400,39 @@ class QuicSession { * @param {object} ticket */ [kSessionTicket](ticket) { + assert(typeof this.#onsessionticket === 'function', + 'Unexpected session ticket event'); if (this.destroyed) return; - this.#sessionticket = ticket; + this.#onsessionticket(ticket); if (onSessionTicketChannel.hasSubscribers) { onSessionTicketChannel.publish({ + __proto__: null, ticket, session: this, }); } } + /** + * @param {Buffer} token + * @param {SocketAddress} address + */ + [kNewToken](token, address) { + assert(typeof this.#onnewtoken === 'function', + 'Unexpected new token event'); + if (this.destroyed) return; + const addr = new InternalSocketAddress(address); + this.#onnewtoken(token, addr); + if (onSessionNewTokenChannel.hasSubscribers) { + onSessionNewTokenChannel.publish({ + __proto__: null, + token, + address: addr, + session: this, + }); + } + } + /** * @param {number} version * @param {number[]} requestedVersions @@ -1504,15 +2440,39 @@ class QuicSession { */ [kVersionNegotiation](version, requestedVersions, supportedVersions) { if (this.destroyed) return; - this.destroy(new ERR_QUIC_VERSION_NEGOTIATION_ERROR()); + if (this.#onversionnegotiation) { + this.#onversionnegotiation(version, requestedVersions, supportedVersions); + } if (onSessionVersionNegotiationChannel.hasSubscribers) { onSessionVersionNegotiationChannel.publish({ + __proto__: null, version, requestedVersions, supportedVersions, session: this, }); } + // Version negotiation is always a fatal event - the session must be + // destroyed regardless of whether the callback is set. + this.destroy(new ERR_QUIC_VERSION_NEGOTIATION_ERROR()); + } + + /** + * Called when the session receives an ORIGIN frame (RFC 9412). + * @param {string[]} origins + */ + [kOrigin](origins) { + assert(typeof this.#onorigin === 'function', + 'Unexpected origin event'); + if (this.destroyed) return; + this.#onorigin(origins); + if (onSessionOriginChannel.hasSubscribers) { + onSessionOriginChannel.publish({ + __proto__: null, + origins, + session: this, + }); + } } /** @@ -1524,12 +2484,13 @@ class QuicSession { * @param {number} validationErrorCode */ [kHandshake](servername, protocol, cipher, cipherVersion, validationErrorReason, - validationErrorCode) { + validationErrorCode, earlyDataAttempted, earlyDataAccepted) { if (this.destroyed || !this.#pendingOpen.resolve) return; const addr = this.#handle.getRemoteAddress(); const info = { + __proto__: null, local: this.#endpoint.address, remote: addr !== undefined ? new InternalSocketAddress(addr) : @@ -1540,14 +2501,21 @@ class QuicSession { cipherVersion, validationErrorReason, validationErrorCode, + earlyDataAttempted, + earlyDataAccepted, }; + if (this.#onhandshake) { + this.#onhandshake(info); + } + this.#pendingOpen.resolve?.(info); this.#pendingOpen.resolve = undefined; this.#pendingOpen.reject = undefined; if (onSessionHandshakeChannel.hasSubscribers) { onSessionHandshakeChannel.publish({ + __proto__: null, session: this, ...info, }); @@ -1570,6 +2538,15 @@ class QuicSession { } this.#streams.add(stream); + // Apply default stream callbacks set at listen time before + // notifying onstream, so the user sees them already set. + const scbs = this[kStreamCallbacks]; + if (scbs) { + if (scbs.onheaders) stream.onheaders = scbs.onheaders; + if (scbs.ontrailers) stream.ontrailers = scbs.ontrailers; + if (scbs.onwanttrailers) stream.onwanttrailers = scbs.onwanttrailers; + } + this.#onstream(stream); if (onSessionReceivedStreamChannel.hasSubscribers) { @@ -1589,6 +2566,7 @@ class QuicSession { return this; const opts = { + __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; @@ -1646,7 +2624,7 @@ class QuicEndpoint { * the endpoint closes abruptly due to an error). * @type {PromiseWithResolvers} */ - #pendingClose = Promise.withResolvers(); // eslint-disable-line node-core/prefer-primordials + #pendingClose = PromiseWithResolvers(); /** * If destroy() is called with an error, the error is stored here and used to reject * the pendingClose promise when [kFinishClose] is called. @@ -1675,18 +2653,31 @@ class QuicEndpoint { * @type {OnSessionCallback} */ #onsession = undefined; + #sessionCallbacks = undefined; static { getQuicEndpointState = function(endpoint) { - QuicEndpoint.#assertIsQuicEndpoint(endpoint); + assertIsQuicEndpoint(endpoint); return endpoint.#state; }; - } - static #assertIsQuicEndpoint(val) { - if (val == null || !(#handle in val)) { - throw new ERR_INVALID_THIS('QuicEndpoint'); - } + assertIsQuicEndpoint = function(val) { + if (val == null || !(#handle in val)) { + throw new ERR_INVALID_THIS('QuicEndpoint'); + } + }; + + assertEndpointNotClosedOrClosing = function(endpoint) { + if (endpoint.#isClosedOrClosing) { + throw new ERR_INVALID_STATE('Endpoint is closed'); + } + }; + + assertEndpointIsNotBusy = function(endpoint) { + if (endpoint.#state.isBusy) { + throw new ERR_INVALID_STATE('Endpoint is busy'); + } + }; } /** @@ -1780,7 +2771,7 @@ class QuicEndpoint { * @type {QuicEndpointStats} */ get stats() { - QuicEndpoint.#assertIsQuicEndpoint(this); + assertIsQuicEndpoint(this); return this.#stats; } @@ -1794,7 +2785,7 @@ class QuicEndpoint { * @type {boolean} */ get busy() { - QuicEndpoint.#assertIsQuicEndpoint(this); + assertIsQuicEndpoint(this); return this.#busy; } @@ -1802,10 +2793,8 @@ class QuicEndpoint { * @type {boolean} */ set busy(val) { - QuicEndpoint.#assertIsQuicEndpoint(this); - if (this.#isClosedOrClosing) { - throw new ERR_INVALID_STATE('Endpoint is closed'); - } + assertIsQuicEndpoint(this); + assertEndpointNotClosedOrClosing(this); // The val is allowed to be any truthy value // Non-op if there is no change if (!!val !== this.#busy) { @@ -1826,7 +2815,7 @@ class QuicEndpoint { * @type {SocketAddress|undefined} */ get address() { - QuicEndpoint.#assertIsQuicEndpoint(this); + assertIsQuicEndpoint(this); if (this.#isClosedOrClosing) return undefined; if (this.#address === undefined) { const addr = this.#handle.address(); @@ -1841,20 +2830,50 @@ class QuicEndpoint { * @param {SessionOptions} [options] */ [kListen](onsession, options) { - if (this.#isClosedOrClosing) { - throw new ERR_INVALID_STATE('Endpoint is closed'); - } + assertEndpointNotClosedOrClosing(this); + assertEndpointIsNotBusy(this); if (this.#listening) { throw new ERR_INVALID_STATE('Endpoint is already listening'); } - if (this.#state.isBusy) { - throw new ERR_INVALID_STATE('Endpoint is busy'); - } validateObject(options, 'options'); - this.#onsession = onsession.bind(this); + this.#onsession = FunctionPrototypeBind(onsession, this); + + const { + onstream, + ondatagram, + ondatagramstatus, + onpathvalidation, + onsessionticket, + onversionnegotiation, + onhandshake, + onnewtoken, + onorigin, + // Stream-level callbacks applied to each incoming stream. + onheaders, + ontrailers, + onwanttrailers, + ...rest + } = options; + + // Store session and stream callbacks to apply to each new incoming session. + this.#sessionCallbacks = { + __proto__: null, + onstream, + ondatagram, + ondatagramstatus, + onpathvalidation, + onsessionticket, + onversionnegotiation, + onhandshake, + onnewtoken, + onorigin, + onheaders, + ontrailers, + onwanttrailers, + }; debug('endpoint listening as a server'); - this.#handle.listen(options); + this.#handle.listen(rest); this.#listening = true; } @@ -1865,14 +2884,13 @@ class QuicEndpoint { * @returns {QuicSession} */ [kConnect](address, options) { - if (this.#isClosedOrClosing) { - throw new ERR_INVALID_STATE('Endpoint is closed'); - } - if (this.#state.isBusy) { - throw new ERR_INVALID_STATE('Endpoint is busy'); - } + assertEndpointNotClosedOrClosing(this); + assertEndpointIsNotBusy(this); validateObject(options, 'options'); - const { sessionTicket, ...rest } = options; + const { + sessionTicket, + ...rest + } = options; debug('endpoint connecting as a client'); const handle = this.#handle.connect(address, rest, sessionTicket); @@ -1880,7 +2898,9 @@ class QuicEndpoint { throw new ERR_QUIC_CONNECTION_FAILED(); } const session = this.#newSession(handle); - + // Set callbacks before any async work to avoid missing events + // that fire during or immediately after the handshake. + applyCallbacks(session, options); return session; } @@ -1893,8 +2913,9 @@ class QuicEndpoint { * @returns {Promise} Returns this.closed */ close() { - QuicEndpoint.#assertIsQuicEndpoint(this); + assertIsQuicEndpoint(this); if (!this.#isClosedOrClosing) { + debug('gracefully closing the endpoint'); if (onEndpointClosingChannel.hasSubscribers) { onEndpointClosingChannel.publish({ endpoint: this, @@ -1902,10 +2923,7 @@ class QuicEndpoint { }); } this.#isPendingClose = true; - - debug('gracefully closing the endpoint'); - - this.#handle?.closeGracefully(); + this.#handle.closeGracefully(); } return this.closed; } @@ -1917,7 +2935,7 @@ class QuicEndpoint { * @type {Promise} */ get closed() { - QuicEndpoint.#assertIsQuicEndpoint(this); + assertIsQuicEndpoint(this); return this.#pendingClose.promise; } @@ -1926,13 +2944,13 @@ class QuicEndpoint { * @type {boolean} */ get closing() { - QuicEndpoint.#assertIsQuicEndpoint(this); + assertIsQuicEndpoint(this); return this.#isPendingClose; } /** @type {boolean} */ get destroyed() { - QuicEndpoint.#assertIsQuicEndpoint(this); + assertIsQuicEndpoint(this); return this.#handle === undefined; } @@ -1945,7 +2963,7 @@ class QuicEndpoint { * @returns {Promise} Returns this.closed */ destroy(error) { - QuicEndpoint.#assertIsQuicEndpoint(this); + assertIsQuicEndpoint(this); debug('destroying the endpoint'); if (!this.#isClosedOrClosing) { this.#pendingError = error; @@ -1972,7 +2990,7 @@ class QuicEndpoint { * @param {{replace?: boolean}} [options] */ setSNIContexts(entries, options = kEmptyObject) { - QuicEndpoint.#assertIsQuicEndpoint(this); + assertIsQuicEndpoint(this); if (this.#handle === undefined) { throw new ERR_INVALID_STATE('Endpoint is destroyed'); } @@ -1999,30 +3017,6 @@ class QuicEndpoint { this.#handle.setSNIContexts(processed, replace); } - #maybeGetCloseError(context, status) { - switch (context) { - case kCloseContextClose: { - return this.#pendingError; - } - case kCloseContextBindFailure: { - return new ERR_QUIC_ENDPOINT_CLOSED('Bind failure', status); - } - case kCloseContextListenFailure: { - return new ERR_QUIC_ENDPOINT_CLOSED('Listen failure', status); - } - case kCloseContextReceiveFailure: { - return new ERR_QUIC_ENDPOINT_CLOSED('Receive failure', status); - } - case kCloseContextSendFailure: { - return new ERR_QUIC_ENDPOINT_CLOSED('Send failure', status); - } - case kCloseContextStartFailure: { - return new ERR_QUIC_ENDPOINT_CLOSED('Start failure', status); - } - } - // Otherwise return undefined. - } - [kFinishClose](context, status) { if (this.#handle === undefined) return; debug('endpoint is finishing close', context, status); @@ -2053,7 +3047,7 @@ class QuicEndpoint { // set. Or, if context indicates an error condition that caused the endpoint // to be closed, the status will indicate the error code. In either case, // we will reject the pending close promise at this point. - const maybeCloseError = this.#maybeGetCloseError(context, status); + const maybeCloseError = maybeGetCloseError(context, status, this.#pendingError); if (maybeCloseError !== undefined) { if (onEndpointErrorChannel.hasSubscribers) { onEndpointErrorChannel.publish({ @@ -2081,6 +3075,12 @@ class QuicEndpoint { [kNewSession](handle) { const session = this.#newSession(handle); + // Apply session callbacks stored at listen time before notifying + // the onsession callback, to avoid missing events that fire + // during or immediately after the handshake. + if (this.#sessionCallbacks) { + applyCallbacks(session, this.#sessionCallbacks); + } if (onEndpointServerSessionChannel.hasSubscribers) { onEndpointServerSessionChannel.publish({ endpoint: this, @@ -2103,6 +3103,7 @@ class QuicEndpoint { return this; const opts = { + __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; @@ -2225,6 +3226,8 @@ function processTlsOptions(tls, forServer) { groups = DEFAULT_GROUPS, keylog = false, verifyClient = false, + rejectUnauthorized = true, + enableEarlyData = true, tlsTrace = false, sni, // Client-only: identity options are specified directly (no sni map) @@ -2246,6 +3249,8 @@ function processTlsOptions(tls, forServer) { } validateBoolean(keylog, 'options.keylog'); validateBoolean(verifyClient, 'options.verifyClient'); + validateBoolean(rejectUnauthorized, 'options.rejectUnauthorized'); + validateBoolean(enableEarlyData, 'options.enableEarlyData'); validateBoolean(tlsTrace, 'options.tlsTrace'); // Encode the ALPN option to wire format (length-prefixed protocol names). @@ -2288,6 +3293,8 @@ function processTlsOptions(tls, forServer) { groups, keylog, verifyClient, + rejectUnauthorized, + enableEarlyData, tlsTrace, }; @@ -2324,11 +3331,18 @@ function processTlsOptions(tls, forServer) { if (identity.certs === undefined) { throw new ERR_MISSING_ARGS(`options.sni['${hostname}'].certs`); } - // Build a full TLS options object: shared + identity. + // Extract ORIGIN frame options from the SNI entry. + const { + port, + authoritative, + } = sni[hostname]; + // Build a full TLS options object: shared + identity + origin options. sniEntries[hostname] = { __proto__: null, ...shared, ...identity, + ...(port !== undefined ? { port } : {}), + ...(authoritative !== undefined ? { authoritative } : {}), }; } @@ -2370,7 +3384,7 @@ function getPreferredAddressPolicy(policy = 'default') { * @param {{forServer: boolean, addressFamily: string}} [config] * @returns {SessionOptions} */ -function processSessionOptions(options, config = {}) { +function processSessionOptions(options, config = { __proto__: null }) { validateObject(options, 'options'); const { endpoint, @@ -2380,18 +3394,43 @@ function processSessionOptions(options, config = {}) { transportParams = kEmptyObject, qlog = false, sessionTicket, + token, maxPayloadSize, unacknowledgedPacketThreshold = 0, handshakeTimeout, + keepAlive, maxStreamWindow, maxWindow, cc, + // Session callbacks that can be set at construction time to avoid + // race conditions with events that fire during or immediately + // after the handshake. + onstream, + ondatagram, + ondatagramstatus, + onpathvalidation, + onsessionticket, + onversionnegotiation, + onhandshake, + onnewtoken, + onorigin, + // Stream-level callbacks. + onheaders, + ontrailers, + onwanttrailers, } = options; const { forServer = false, } = config; + if (token !== undefined) { + if (!isArrayBufferView(token)) { + throw new ERR_INVALID_ARG_TYPE('options.token', + ['ArrayBufferView'], token); + } + } + if (cc !== undefined) { validateOneOf(cc, 'options.cc', [CC_ALGO_RENO, CC_ALGO_BBR, CC_ALGO_CUBIC]); } @@ -2410,10 +3449,24 @@ function processSessionOptions(options, config = {}) { maxPayloadSize, unacknowledgedPacketThreshold, handshakeTimeout, + keepAlive, maxStreamWindow, maxWindow, sessionTicket, + token, cc, + onstream, + ondatagram, + ondatagramstatus, + onpathvalidation, + onsessionticket, + onversionnegotiation, + onhandshake, + onnewtoken, + onorigin, + onheaders, + ontrailers, + onwanttrailers, }; } @@ -2481,8 +3534,8 @@ async function connect(address, options = kEmptyObject) { ObjectDefineProperties(QuicEndpoint, { Stats: { __proto__: null, - writable: true, - configurable: true, + writable: false, + configurable: false, enumerable: true, value: QuicEndpointStats, }, @@ -2490,8 +3543,8 @@ ObjectDefineProperties(QuicEndpoint, { ObjectDefineProperties(QuicSession, { Stats: { __proto__: null, - writable: true, - configurable: true, + writable: false, + configurable: false, enumerable: true, value: QuicSessionStats, }, @@ -2499,8 +3552,8 @@ ObjectDefineProperties(QuicSession, { ObjectDefineProperties(QuicStream, { Stats: { __proto__: null, - writable: true, - configurable: true, + writable: false, + configurable: false, enumerable: true, value: QuicStreamStats, }, diff --git a/lib/internal/quic/state.js b/lib/internal/quic/state.js index f8075457825630..97bfb3f2efd1c7 100644 --- a/lib/internal/quic/state.js +++ b/lib/internal/quic/state.js @@ -5,11 +5,27 @@ const { DataView, DataViewPrototypeGetBigInt64, DataViewPrototypeGetBigUint64, + DataViewPrototypeGetByteLength, + DataViewPrototypeGetUint32, DataViewPrototypeGetUint8, + DataViewPrototypeSetUint32, DataViewPrototypeSetUint8, + Float32Array, JSONStringify, + Uint8Array, } = primordials; +// Determine native byte order. The shared state buffer is written by +// C++ in native byte order, so DataView reads must match. +const kIsLittleEndian = (() => { + // -1 as float32 is 0xBF800000. On little-endian, the bytes are + // [0x00, 0x00, 0x80, 0xBF], so byte[3] is 0xBF (non-zero). + // On big-endian, the bytes are [0xBF, 0x80, 0x00, 0x00], so byte[3] is 0. + const buf = new Float32Array(1); + buf[0] = -1; + return new Uint8Array(buf.buffer)[3] !== 0; +})(); + const { getOptionValue, } = require('internal/options'); @@ -49,10 +65,7 @@ const { // prevent further updates to the buffer. const { - IDX_STATE_SESSION_PATH_VALIDATION, - IDX_STATE_SESSION_VERSION_NEGOTIATION, - IDX_STATE_SESSION_DATAGRAM, - IDX_STATE_SESSION_SESSION_TICKET, + IDX_STATE_SESSION_LISTENER_FLAGS, IDX_STATE_SESSION_CLOSING, IDX_STATE_SESSION_GRACEFUL_CLOSE, IDX_STATE_SESSION_SILENT_CLOSE, @@ -61,8 +74,10 @@ const { IDX_STATE_SESSION_HANDSHAKE_CONFIRMED, IDX_STATE_SESSION_STREAM_OPEN_ALLOWED, IDX_STATE_SESSION_PRIORITY_SUPPORTED, + IDX_STATE_SESSION_HEADERS_SUPPORTED, IDX_STATE_SESSION_WRAPPED, IDX_STATE_SESSION_APPLICATION_TYPE, + IDX_STATE_SESSION_MAX_DATAGRAM_SIZE, IDX_STATE_SESSION_LAST_DATAGRAM_ID, IDX_STATE_ENDPOINT_BOUND, @@ -85,12 +100,11 @@ const { IDX_STATE_STREAM_WANTS_HEADERS, IDX_STATE_STREAM_WANTS_RESET, IDX_STATE_STREAM_WANTS_TRAILERS, + IDX_STATE_STREAM_WRITE_DESIRED_SIZE, + IDX_STATE_STREAM_RESET_CODE, } = internalBinding('quic'); -assert(IDX_STATE_SESSION_PATH_VALIDATION !== undefined); -assert(IDX_STATE_SESSION_VERSION_NEGOTIATION !== undefined); -assert(IDX_STATE_SESSION_DATAGRAM !== undefined); -assert(IDX_STATE_SESSION_SESSION_TICKET !== undefined); +assert(IDX_STATE_SESSION_LISTENER_FLAGS !== undefined); assert(IDX_STATE_SESSION_CLOSING !== undefined); assert(IDX_STATE_SESSION_GRACEFUL_CLOSE !== undefined); assert(IDX_STATE_SESSION_SILENT_CLOSE !== undefined); @@ -99,8 +113,10 @@ assert(IDX_STATE_SESSION_HANDSHAKE_COMPLETED !== undefined); assert(IDX_STATE_SESSION_HANDSHAKE_CONFIRMED !== undefined); assert(IDX_STATE_SESSION_STREAM_OPEN_ALLOWED !== undefined); assert(IDX_STATE_SESSION_PRIORITY_SUPPORTED !== undefined); +assert(IDX_STATE_SESSION_HEADERS_SUPPORTED !== undefined); assert(IDX_STATE_SESSION_WRAPPED !== undefined); assert(IDX_STATE_SESSION_APPLICATION_TYPE !== undefined); +assert(IDX_STATE_SESSION_MAX_DATAGRAM_SIZE !== undefined); assert(IDX_STATE_SESSION_LAST_DATAGRAM_ID !== undefined); assert(IDX_STATE_ENDPOINT_BOUND !== undefined); assert(IDX_STATE_ENDPOINT_RECEIVING !== undefined); @@ -121,6 +137,8 @@ assert(IDX_STATE_STREAM_WANTS_BLOCK !== undefined); assert(IDX_STATE_STREAM_WANTS_HEADERS !== undefined); assert(IDX_STATE_STREAM_WANTS_RESET !== undefined); assert(IDX_STATE_STREAM_WANTS_TRAILERS !== undefined); +assert(IDX_STATE_STREAM_WRITE_DESIRED_SIZE !== undefined); +assert(IDX_STATE_STREAM_RESET_CODE !== undefined); class QuicEndpointState { /** @type {DataView} */ @@ -142,43 +160,43 @@ class QuicEndpointState { /** @type {boolean} */ get isBound() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_BOUND); } /** @type {boolean} */ get isReceiving() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_RECEIVING); } /** @type {boolean} */ get isListening() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_LISTENING); } /** @type {boolean} */ get isClosing() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_CLOSING); } /** @type {boolean} */ get isBusy() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_BUSY); } /** - * The number of underlying callbacks that are pending. If the session - * is closing, these are the number of callbacks that the session is + * The number of underlying callbacks that are pending. If the endpoint + * is closing, these are the number of callbacks that the endpoint is * waiting on before it can be closed. * @type {bigint} */ get pendingCallbacks() { - if (this.#handle.byteLength === 0) return undefined; - return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_ENDPOINT_PENDING_CALLBACKS); + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; + return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_ENDPOINT_PENDING_CALLBACKS, kIsLittleEndian); } toString() { @@ -186,7 +204,7 @@ class QuicEndpointState { } toJSON() { - if (this.#handle.byteLength === 0) return {}; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return {}; return { __proto__: null, isBound: this.isBound, @@ -202,11 +220,12 @@ class QuicEndpointState { if (depth < 0) return this; - if (this.#handle.byteLength === 0) { + if (DataViewPrototypeGetByteLength(this.#handle) === 0) { return 'QuicEndpointState { }'; } const opts = { + __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; @@ -224,7 +243,7 @@ class QuicEndpointState { [kFinishClose]() { // Snapshot the state into a new DataView since the underlying // buffer will be destroyed. - if (this.#handle.byteLength === 0) return; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; this.#handle = new DataView(new ArrayBuffer(0)); } } @@ -247,118 +266,158 @@ class QuicSessionState { this.#handle = new DataView(buffer); } - /** @type {boolean} */ - get hasPathValidationListener() { - if (this.#handle.byteLength === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_PATH_VALIDATION); - } + // Listener flags are packed into a single uint32_t bitfield. The bit + // positions must match the SessionListenerFlags enum in session.cc. + static #LISTENER_PATH_VALIDATION = 1 << 0; + static #LISTENER_DATAGRAM = 1 << 1; + static #LISTENER_DATAGRAM_STATUS = 1 << 2; + static #LISTENER_SESSION_TICKET = 1 << 3; + static #LISTENER_NEW_TOKEN = 1 << 4; + static #LISTENER_ORIGIN = 1 << 5; - /** @type {boolean} */ - set hasPathValidationListener(val) { - if (this.#handle.byteLength === 0) return; - DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_PATH_VALIDATION, val ? 1 : 0); + #getListenerFlag(flag) { + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; + return !!(DataViewPrototypeGetUint32( + this.#handle, IDX_STATE_SESSION_LISTENER_FLAGS, kIsLittleEndian) & flag); } - /** @type {boolean} */ - get hasVersionNegotiationListener() { - if (this.#handle.byteLength === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_VERSION_NEGOTIATION); + #setListenerFlag(flag, val) { + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; + const current = DataViewPrototypeGetUint32( + this.#handle, IDX_STATE_SESSION_LISTENER_FLAGS, kIsLittleEndian); + DataViewPrototypeSetUint32( + this.#handle, IDX_STATE_SESSION_LISTENER_FLAGS, + val ? (current | flag) : (current & ~flag), kIsLittleEndian); } /** @type {boolean} */ - set hasVersionNegotiationListener(val) { - if (this.#handle.byteLength === 0) return; - DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_VERSION_NEGOTIATION, val ? 1 : 0); + get hasPathValidationListener() { + return this.#getListenerFlag(QuicSessionState.#LISTENER_PATH_VALIDATION); + } + set hasPathValidationListener(val) { + this.#setListenerFlag(QuicSessionState.#LISTENER_PATH_VALIDATION, val); } /** @type {boolean} */ get hasDatagramListener() { - if (this.#handle.byteLength === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_DATAGRAM); + return this.#getListenerFlag(QuicSessionState.#LISTENER_DATAGRAM); + } + set hasDatagramListener(val) { + this.#setListenerFlag(QuicSessionState.#LISTENER_DATAGRAM, val); } /** @type {boolean} */ - set hasDatagramListener(val) { - if (this.#handle.byteLength === 0) return; - DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_DATAGRAM, val ? 1 : 0); + get hasDatagramStatusListener() { + return this.#getListenerFlag(QuicSessionState.#LISTENER_DATAGRAM_STATUS); + } + set hasDatagramStatusListener(val) { + this.#setListenerFlag(QuicSessionState.#LISTENER_DATAGRAM_STATUS, val); } /** @type {boolean} */ get hasSessionTicketListener() { - if (this.#handle.byteLength === 0) return undefined; - return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_SESSION_TICKET); + return this.#getListenerFlag(QuicSessionState.#LISTENER_SESSION_TICKET); + } + set hasSessionTicketListener(val) { + this.#setListenerFlag(QuicSessionState.#LISTENER_SESSION_TICKET, val); } /** @type {boolean} */ - set hasSessionTicketListener(val) { - if (this.#handle.byteLength === 0) return; - DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_SESSION_TICKET, val ? 1 : 0); + get hasNewTokenListener() { + return this.#getListenerFlag(QuicSessionState.#LISTENER_NEW_TOKEN); + } + set hasNewTokenListener(val) { + this.#setListenerFlag(QuicSessionState.#LISTENER_NEW_TOKEN, val); + } + + /** @type {boolean} */ + get hasOriginListener() { + return this.#getListenerFlag(QuicSessionState.#LISTENER_ORIGIN); + } + set hasOriginListener(val) { + this.#setListenerFlag(QuicSessionState.#LISTENER_ORIGIN, val); } /** @type {boolean} */ get isClosing() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_CLOSING); } /** @type {boolean} */ get isGracefulClose() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_GRACEFUL_CLOSE); } /** @type {boolean} */ get isSilentClose() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_SILENT_CLOSE); } /** @type {boolean} */ get isStatelessReset() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_STATELESS_RESET); } /** @type {boolean} */ get isHandshakeCompleted() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_HANDSHAKE_COMPLETED); } /** @type {boolean} */ get isHandshakeConfirmed() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_HANDSHAKE_CONFIRMED); } /** @type {boolean} */ get isStreamOpenAllowed() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_STREAM_OPEN_ALLOWED); } /** @type {boolean} */ get isPrioritySupported() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_PRIORITY_SUPPORTED); } + /** + * Whether the negotiated application protocol supports headers. + * Returns 0 (unknown), 1 (supported), or 2 (not supported). + * @type {number} + */ + get headersSupported() { + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; + return DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_HEADERS_SUPPORTED); + } + /** @type {boolean} */ get isWrapped() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_WRAPPED); } /** @type {number} */ get applicationType() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_APPLICATION_TYPE); } + /** @type {bigint} */ + get maxDatagramSize() { + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; + return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_SESSION_MAX_DATAGRAM_SIZE, kIsLittleEndian); + } + /** @type {bigint} */ get lastDatagramId() { - if (this.#handle.byteLength === 0) return undefined; - return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_SESSION_LAST_DATAGRAM_ID); + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; + return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_SESSION_LAST_DATAGRAM_ID, kIsLittleEndian); } toString() { @@ -366,23 +425,27 @@ class QuicSessionState { } toJSON() { - if (this.#handle.byteLength === 0) return {}; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return {}; return { __proto__: null, hasPathValidationListener: this.hasPathValidationListener, - hasVersionNegotiationListener: this.hasVersionNegotiationListener, hasDatagramListener: this.hasDatagramListener, + hasDatagramStatusListener: this.hasDatagramStatusListener, hasSessionTicketListener: this.hasSessionTicketListener, + hasNewTokenListener: this.hasNewTokenListener, + hasOriginListener: this.hasOriginListener, isClosing: this.isClosing, isGracefulClose: this.isGracefulClose, isSilentClose: this.isSilentClose, isStatelessReset: this.isStatelessReset, - isDestroyed: this.isDestroyed, isHandshakeCompleted: this.isHandshakeCompleted, isHandshakeConfirmed: this.isHandshakeConfirmed, isStreamOpenAllowed: this.isStreamOpenAllowed, isPrioritySupported: this.isPrioritySupported, + headersSupported: this.headersSupported, isWrapped: this.isWrapped, + applicationType: this.applicationType, + maxDatagramSize: `${this.maxDatagramSize}`, lastDatagramId: `${this.lastDatagramId}`, }; } @@ -391,31 +454,35 @@ class QuicSessionState { if (depth < 0) return this; - if (this.#handle.byteLength === 0) { + if (DataViewPrototypeGetByteLength(this.#handle) === 0) { return 'QuicSessionState { }'; } const opts = { + __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; return `QuicSessionState ${inspect({ hasPathValidationListener: this.hasPathValidationListener, - hasVersionNegotiationListener: this.hasVersionNegotiationListener, hasDatagramListener: this.hasDatagramListener, + hasDatagramStatusListener: this.hasDatagramStatusListener, hasSessionTicketListener: this.hasSessionTicketListener, + hasNewTokenListener: this.hasNewTokenListener, + hasOriginListener: this.hasOriginListener, isClosing: this.isClosing, isGracefulClose: this.isGracefulClose, isSilentClose: this.isSilentClose, isStatelessReset: this.isStatelessReset, - isDestroyed: this.isDestroyed, isHandshakeCompleted: this.isHandshakeCompleted, isHandshakeConfirmed: this.isHandshakeConfirmed, isStreamOpenAllowed: this.isStreamOpenAllowed, isPrioritySupported: this.isPrioritySupported, + headersSupported: this.headersSupported, isWrapped: this.isWrapped, applicationType: this.applicationType, + maxDatagramSize: this.maxDatagramSize, lastDatagramId: this.lastDatagramId, }, opts)}`; } @@ -423,7 +490,7 @@ class QuicSessionState { [kFinishClose]() { // Snapshot the state into a new DataView since the underlying // buffer will be destroyed. - if (this.#handle.byteLength === 0) return; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; this.#handle = new DataView(new ArrayBuffer(0)); } } @@ -448,113 +515,127 @@ class QuicStreamState { /** @type {bigint} */ get id() { - if (this.#handle.byteLength === 0) return undefined; - return DataViewPrototypeGetBigInt64(this.#handle, IDX_STATE_STREAM_ID); + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; + return DataViewPrototypeGetBigInt64(this.#handle, IDX_STATE_STREAM_ID, kIsLittleEndian); } /** @type {boolean} */ get pending() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_PENDING); } /** @type {boolean} */ get finSent() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_FIN_SENT); } /** @type {boolean} */ get finReceived() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_FIN_RECEIVED); } /** @type {boolean} */ get readEnded() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_READ_ENDED); } /** @type {boolean} */ get writeEnded() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WRITE_ENDED); } /** @type {boolean} */ get reset() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_RESET); } /** @type {boolean} */ get hasOutbound() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_HAS_OUTBOUND); } /** @type {boolean} */ get hasReader() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_HAS_READER); } /** @type {boolean} */ get wantsBlock() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_BLOCK); } /** @type {boolean} */ set wantsBlock(val) { - if (this.#handle.byteLength === 0) return; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_BLOCK, val ? 1 : 0); } /** @type {boolean} */ get [kWantsHeaders]() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_HEADERS); } /** @type {boolean} */ set [kWantsHeaders](val) { - if (this.#handle.byteLength === 0) return; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_HEADERS, val ? 1 : 0); } /** @type {boolean} */ get wantsReset() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_RESET); } /** @type {boolean} */ set wantsReset(val) { - if (this.#handle.byteLength === 0) return; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_RESET, val ? 1 : 0); } /** @type {boolean} */ get [kWantsTrailers]() { - if (this.#handle.byteLength === 0) return undefined; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_TRAILERS); } /** @type {boolean} */ set [kWantsTrailers](val) { - if (this.#handle.byteLength === 0) return; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_TRAILERS, val ? 1 : 0); } + /** @type {bigint} */ + get resetCode() { + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; + return DataViewPrototypeGetBigUint64( + this.#handle, IDX_STATE_STREAM_RESET_CODE, kIsLittleEndian); + } + + /** @type {bigint} */ + get writeDesiredSize() { + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined; + return DataViewPrototypeGetBigUint64( + this.#handle, IDX_STATE_STREAM_WRITE_DESIRED_SIZE, kIsLittleEndian); + } + toString() { return JSONStringify(this.toJSON()); } toJSON() { - if (this.#handle.byteLength === 0) return {}; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return {}; return { __proto__: null, id: `${this.id}`, @@ -575,11 +656,12 @@ class QuicStreamState { if (depth < 0) return this; - if (this.#handle.byteLength === 0) { + if (DataViewPrototypeGetByteLength(this.#handle) === 0) { return 'QuicStreamState { }'; } const opts = { + __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; @@ -602,7 +684,7 @@ class QuicStreamState { [kFinishClose]() { // Snapshot the state into a new DataView since the underlying // buffer will be destroyed. - if (this.#handle.byteLength === 0) return; + if (DataViewPrototypeGetByteLength(this.#handle) === 0) return; this.#handle = new DataView(new ArrayBuffer(0)); } } diff --git a/lib/internal/quic/stats.js b/lib/internal/quic/stats.js index a612356250a06c..4747bc1ba05716 100644 --- a/lib/internal/quic/stats.js +++ b/lib/internal/quic/stats.js @@ -62,7 +62,6 @@ const { IDX_STATS_SESSION_HANDSHAKE_COMPLETED_AT, IDX_STATS_SESSION_HANDSHAKE_CONFIRMED_AT, IDX_STATS_SESSION_BYTES_RECEIVED, - IDX_STATS_SESSION_BYTES_SENT, IDX_STATS_SESSION_BIDI_IN_STREAM_COUNT, IDX_STATS_SESSION_BIDI_OUT_STREAM_COUNT, IDX_STATS_SESSION_UNI_IN_STREAM_COUNT, @@ -76,6 +75,15 @@ const { IDX_STATS_SESSION_RTTVAR, IDX_STATS_SESSION_SMOOTHED_RTT, IDX_STATS_SESSION_SSTHRESH, + IDX_STATS_SESSION_PKT_SENT, + IDX_STATS_SESSION_BYTES_SENT, + IDX_STATS_SESSION_PKT_RECV, + IDX_STATS_SESSION_BYTES_RECV, + IDX_STATS_SESSION_PKT_LOST, + 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, @@ -112,7 +120,6 @@ assert(IDX_STATS_SESSION_CLOSING_AT !== undefined); assert(IDX_STATS_SESSION_HANDSHAKE_COMPLETED_AT !== undefined); assert(IDX_STATS_SESSION_HANDSHAKE_CONFIRMED_AT !== undefined); assert(IDX_STATS_SESSION_BYTES_RECEIVED !== undefined); -assert(IDX_STATS_SESSION_BYTES_SENT !== undefined); assert(IDX_STATS_SESSION_BIDI_IN_STREAM_COUNT !== undefined); assert(IDX_STATS_SESSION_BIDI_OUT_STREAM_COUNT !== undefined); assert(IDX_STATS_SESSION_UNI_IN_STREAM_COUNT !== undefined); @@ -126,6 +133,14 @@ assert(IDX_STATS_SESSION_MIN_RTT !== undefined); assert(IDX_STATS_SESSION_RTTVAR !== undefined); assert(IDX_STATS_SESSION_SMOOTHED_RTT !== undefined); assert(IDX_STATS_SESSION_SSTHRESH !== undefined); +assert(IDX_STATS_SESSION_PKT_SENT !== undefined); +assert(IDX_STATS_SESSION_BYTES_SENT !== undefined); +assert(IDX_STATS_SESSION_PKT_RECV !== undefined); +assert(IDX_STATS_SESSION_BYTES_RECV !== undefined); +assert(IDX_STATS_SESSION_PKT_LOST !== undefined); +assert(IDX_STATS_SESSION_BYTES_LOST !== undefined); +assert(IDX_STATS_SESSION_PING_RECV !== undefined); +assert(IDX_STATS_SESSION_PKT_DISCARDED !== undefined); assert(IDX_STATS_SESSION_DATAGRAMS_RECEIVED !== undefined); assert(IDX_STATS_SESSION_DATAGRAMS_SENT !== undefined); assert(IDX_STATS_SESSION_DATAGRAMS_ACKNOWLEDGED !== undefined); @@ -260,6 +275,7 @@ class QuicEndpointStats { return this; const opts = { + __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; @@ -286,7 +302,7 @@ class QuicEndpointStats { * True if this QuicEndpointStats object is still connected to the underlying * Endpoint stats source. If this returns false, then the stats object is * no longer being updated and should be considered stale. - * @returns {boolean} + * @type {boolean} */ get isConnected() { return !this.#disconnected; @@ -308,7 +324,7 @@ class QuicSessionStats { /** * @param {symbol} privateSymbol - * @param {BigUint64Array} buffer + * @param {ArrayBuffer} buffer */ constructor(privateSymbol, buffer) { // We use the kPrivateConstructor symbol to restrict the ability to @@ -347,11 +363,6 @@ class QuicSessionStats { return this.#handle[IDX_STATS_SESSION_BYTES_RECEIVED]; } - /** @type {bigint} */ - get bytesSent() { - return this.#handle[IDX_STATS_SESSION_BYTES_SENT]; - } - /** @type {bigint} */ get bidiInStreamCount() { return this.#handle[IDX_STATS_SESSION_BIDI_IN_STREAM_COUNT]; @@ -373,7 +384,7 @@ class QuicSessionStats { } /** @type {bigint} */ - get maxBytesInFlights() { + get maxBytesInFlight() { return this.#handle[IDX_STATS_SESSION_MAX_BYTES_IN_FLIGHT]; } @@ -417,6 +428,38 @@ class QuicSessionStats { return this.#handle[IDX_STATS_SESSION_SSTHRESH]; } + get pktSent() { + return this.#handle[IDX_STATS_SESSION_PKT_SENT]; + } + + get bytesSent() { + return this.#handle[IDX_STATS_SESSION_BYTES_SENT]; + } + + get pktRecv() { + return this.#handle[IDX_STATS_SESSION_PKT_RECV]; + } + + get bytesRecv() { + return this.#handle[IDX_STATS_SESSION_BYTES_RECV]; + } + + get pktLost() { + return this.#handle[IDX_STATS_SESSION_PKT_LOST]; + } + + get bytesLost() { + return this.#handle[IDX_STATS_SESSION_BYTES_LOST]; + } + + get pingRecv() { + return this.#handle[IDX_STATS_SESSION_PING_RECV]; + } + + get pktDiscarded() { + return this.#handle[IDX_STATS_SESSION_PKT_DISCARDED]; + } + /** @type {bigint} */ get datagramsReceived() { return this.#handle[IDX_STATS_SESSION_DATAGRAMS_RECEIVED]; @@ -449,17 +492,14 @@ class QuicSessionStats { // support BigInts. createdAt: `${this.createdAt}`, closingAt: `${this.closingAt}`, - destroyedAt: `${this.destroyedAt}`, handshakeCompletedAt: `${this.handshakeCompletedAt}`, handshakeConfirmedAt: `${this.handshakeConfirmedAt}`, - gracefulClosingAt: `${this.gracefulClosingAt}`, bytesReceived: `${this.bytesReceived}`, - bytesSent: `${this.bytesSent}`, bidiInStreamCount: `${this.bidiInStreamCount}`, bidiOutStreamCount: `${this.bidiOutStreamCount}`, uniInStreamCount: `${this.uniInStreamCount}`, uniOutStreamCount: `${this.uniOutStreamCount}`, - maxBytesInFlights: `${this.maxBytesInFlights}`, + maxBytesInFlight: `${this.maxBytesInFlight}`, bytesInFlight: `${this.bytesInFlight}`, blockCount: `${this.blockCount}`, cwnd: `${this.cwnd}`, @@ -468,6 +508,14 @@ class QuicSessionStats { 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}`, @@ -480,6 +528,7 @@ class QuicSessionStats { return this; const opts = { + __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; @@ -488,17 +537,14 @@ class QuicSessionStats { connected: this.isConnected, createdAt: this.createdAt, closingAt: this.closingAt, - destroyedAt: this.destroyedAt, handshakeCompletedAt: this.handshakeCompletedAt, handshakeConfirmedAt: this.handshakeConfirmedAt, - gracefulClosingAt: this.gracefulClosingAt, bytesReceived: this.bytesReceived, - bytesSent: this.bytesSent, bidiInStreamCount: this.bidiInStreamCount, bidiOutStreamCount: this.bidiOutStreamCount, uniInStreamCount: this.uniInStreamCount, uniOutStreamCount: this.uniOutStreamCount, - maxBytesInFlights: this.maxBytesInFlights, + maxBytesInFlight: this.maxBytesInFlight, bytesInFlight: this.bytesInFlight, blockCount: this.blockCount, cwnd: this.cwnd, @@ -507,6 +553,14 @@ class QuicSessionStats { 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, @@ -518,7 +572,7 @@ class QuicSessionStats { * True if this QuicSessionStats object is still connected to the underlying * Session stats source. If this returns false, then the stats object is * no longer being updated and should be considered stale. - * @returns {boolean} + * @type {boolean} */ get isConnected() { return !this.#disconnected; @@ -638,11 +692,12 @@ class QuicStreamStats { return this; const opts = { + __proto__: null, ...options, depth: options.depth == null ? null : options.depth - 1, }; - return `StreamStats ${inspect({ + return `QuicStreamStats ${inspect({ connected: this.isConnected, createdAt: this.createdAt, openedAt: this.openedAt, @@ -662,7 +717,7 @@ class QuicStreamStats { * True if this QuicStreamStats object is still connected to the underlying * Stream stats source. If this returns false, then the stats object is * no longer being updated and should be considered stale. - * @returns {boolean} + * @type {boolean} */ get isConnected() { return !this.#disconnected; diff --git a/lib/internal/quic/symbols.js b/lib/internal/quic/symbols.js index 1a6c56b1a0ae9d..6b20dd4045a259 100644 --- a/lib/internal/quic/symbols.js +++ b/lib/internal/quic/symbols.js @@ -25,6 +25,7 @@ const { const kBlocked = Symbol('kBlocked'); const kConnect = Symbol('kConnect'); +const kDrain = Symbol('kDrain'); const kDatagram = Symbol('kDatagram'); const kDatagramStatus = Symbol('kDatagramStatus'); const kFinishClose = Symbol('kFinishClose'); @@ -33,8 +34,9 @@ const kHeaders = Symbol('kHeaders'); const kListen = Symbol('kListen'); const kNewSession = Symbol('kNewSession'); const kNewStream = Symbol('kNewStream'); -const kOnHeaders = Symbol('kOnHeaders'); -const kOnTrailers = Symbol('kOwnTrailers'); +const kNewToken = Symbol('kNewToken'); +const kStreamCallbacks = Symbol('kStreamCallbacks'); +const kOrigin = Symbol('kOrigin'); const kOwner = Symbol('kOwner'); const kPathValidation = Symbol('kPathValidation'); const kPrivateConstructor = Symbol('kPrivateConstructor'); @@ -53,6 +55,7 @@ module.exports = { kConnect, kDatagram, kDatagramStatus, + kDrain, kFinishClose, kHandshake, kHeaders, @@ -61,8 +64,9 @@ module.exports = { kListen, kNewSession, kNewStream, - kOnHeaders, - kOnTrailers, + kNewToken, + kStreamCallbacks, + kOrigin, kOwner, kPathValidation, kPrivateConstructor, diff --git a/src/node_blob.cc b/src/node_blob.cc index 00deb82f46c322..3a212ed5b50dfd 100644 --- a/src/node_blob.cc +++ b/src/node_blob.cc @@ -156,8 +156,7 @@ Local Blob::GetConstructorTemplate(Environment* env) { Isolate* isolate = env->isolate(); tmpl = NewFunctionTemplate(isolate, nullptr); tmpl->InstanceTemplate()->SetInternalFieldCount(Blob::kInternalFieldCount); - tmpl->SetClassName( - FIXED_ONE_BYTE_STRING(env->isolate(), "Blob")); + tmpl->SetClassName(FIXED_ONE_BYTE_STRING(env->isolate(), "Blob")); SetProtoMethod(isolate, tmpl, "getReader", GetReader); SetProtoMethod(isolate, tmpl, "slice", ToSlice); env->set_blob_constructor_template(tmpl); @@ -255,8 +254,7 @@ void Blob::New(const FunctionCallbackInfo& args) { } auto blob = Create(env, DataQueue::CreateIdempotent(std::move(entries))); - if (blob) - args.GetReturnValue().Set(blob->object()); + if (blob) args.GetReturnValue().Set(blob->object()); } void Blob::GetReader(const FunctionCallbackInfo& args) { @@ -278,8 +276,7 @@ void Blob::ToSlice(const FunctionCallbackInfo& args) { size_t start = args[0].As()->Value(); size_t end = args[1].As()->Value(); BaseObjectPtr slice = blob->Slice(env, start, end); - if (slice) - args.GetReturnValue().Set(slice->object()); + if (slice) args.GetReturnValue().Set(slice->object()); } void Blob::MemoryInfo(MemoryTracker* tracker) const { @@ -343,6 +340,7 @@ void Blob::Reader::Pull(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); Blob::Reader* reader; ASSIGN_OR_RETURN_UNWRAP(&reader, args.This()); + reader->pull_pending_ = false; CHECK(args[0]->IsFunction()); Local fn = args[0].As(); @@ -419,14 +417,14 @@ void Blob::Reader::SetWakeup(const FunctionCallbackInfo& args) { } void Blob::Reader::NotifyPull() { - if (wakeup_.IsEmpty() || !env()->can_call_into_js()) return; + if (pull_pending_ || wakeup_.IsEmpty() || !env()->can_call_into_js()) return; + pull_pending_ = true; HandleScope handle_scope(env()->isolate()); Local fn = wakeup_.Get(env()->isolate()); MakeCallback(fn, 0, nullptr); } -BaseObjectPtr -Blob::BlobTransferData::Deserialize( +BaseObjectPtr Blob::BlobTransferData::Deserialize( Environment* env, Local context, std::unique_ptr self) { @@ -448,10 +446,10 @@ std::unique_ptr Blob::CloneForMessaging() const { void Blob::StoreDataObject(const FunctionCallbackInfo& args) { Realm* realm = Realm::GetCurrent(args); - CHECK(args[0]->IsString()); // ID key + CHECK(args[0]->IsString()); // ID key CHECK(Blob::HasInstance(realm->env(), args[1])); // Blob - CHECK(args[2]->IsUint32()); // Length - CHECK(args[3]->IsString()); // Type + CHECK(args[2]->IsUint32()); // Length + CHECK(args[3]->IsString()); // Type BlobBindingData* binding_data = realm->GetBindingData(); Isolate* isolate = realm->isolate(); @@ -531,12 +529,8 @@ void BlobBindingData::StoredDataObject::MemoryInfo( } BlobBindingData::StoredDataObject::StoredDataObject( - const BaseObjectPtr& blob_, - size_t length_, - const std::string& type_) - : blob(blob_), - length(length_), - type(type_) {} + const BaseObjectPtr& blob_, size_t length_, const std::string& type_) + : blob(blob_), length(length_), type(type_) {} BlobBindingData::BlobBindingData(Realm* realm, Local wrap) : SnapshotableObject(realm, wrap, type_int) { @@ -550,8 +544,7 @@ void BlobBindingData::MemoryInfo(MemoryTracker* tracker) const { } void BlobBindingData::store_data_object( - const std::string& uuid, - const BlobBindingData::StoredDataObject& object) { + const std::string& uuid, const BlobBindingData::StoredDataObject& object) { data_objects_[uuid] = object; } @@ -566,8 +559,7 @@ void BlobBindingData::revoke_data_object(const std::string& uuid) { BlobBindingData::StoredDataObject BlobBindingData::get_data_object( const std::string& uuid) { auto entry = data_objects_.find(uuid); - if (entry == data_objects_.end()) - return BlobBindingData::StoredDataObject {}; + if (entry == data_objects_.end()) return BlobBindingData::StoredDataObject{}; return entry->second; } diff --git a/src/node_blob.h b/src/node_blob.h index 88a56c7ec9a453..06d3a151cfa535 100644 --- a/src/node_blob.h +++ b/src/node_blob.h @@ -23,8 +23,7 @@ namespace node { class Blob : public BaseObject { public: - static void RegisterExternalReferences( - ExternalReferenceRegistry* registry); + static void RegisterExternalReferences(ExternalReferenceRegistry* registry); static void CreatePerIsolateProperties(IsolateData* isolate_data, v8::Local target); @@ -97,6 +96,7 @@ class Blob : public BaseObject { std::shared_ptr inner_; BaseObjectPtr strong_ptr_; bool eos_ = false; + bool pull_pending_ = false; v8::Global wakeup_; }; @@ -134,19 +134,17 @@ class BlobBindingData : public SnapshotableObject { StoredDataObject() = default; - StoredDataObject( - const BaseObjectPtr& blob_, - size_t length_, - const std::string& type_); + StoredDataObject(const BaseObjectPtr& blob_, + size_t length_, + const std::string& type_); void MemoryInfo(MemoryTracker* tracker) const override; SET_SELF_SIZE(StoredDataObject) SET_MEMORY_INFO_NAME(StoredDataObject) }; - void store_data_object( - const std::string& uuid, - const StoredDataObject& object); + void store_data_object(const std::string& uuid, + const StoredDataObject& object); void revoke_data_object(const std::string& uuid); diff --git a/src/quic/application.cc b/src/quic/application.cc index 81c1c0ebe5f49c..9d2aa6e746fde3 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -43,11 +43,13 @@ Session::Application_Options::operator const nghttp3_settings() const { .qpack_blocked_streams = static_cast(qpack_blocked_streams), .enable_connect_protocol = enable_connect_protocol, .h3_datagram = enable_datagrams, - // TODO(@jasnell): Support origin frames? + // origin_list is nullptr here because it is set directly on the + // nghttp3_settings in Http3ApplicationImpl::InitializeConnection() + // from the SNI configuration. .origin_list = nullptr, .glitch_ratelim_burst = 1000, .glitch_ratelim_rate = 33, - .qpack_indexing_strat = NGHTTP3_QPACK_INDEXING_STRAT_NONE, + .qpack_indexing_strat = NGHTTP3_QPACK_INDEXING_STRAT_EAGER, }; } @@ -152,6 +154,18 @@ void Session::Application::BlockStream(int64_t id) { // By default do nothing. } +bool Session::Application::SupportsHeaders() const { + return false; +} + +void Session::Application::BeginShutdown() { + // By default, nothing to do. +} + +void Session::Application::CompleteShutdown() { + // by default, nothing to do. +} + bool Session::Application::CanAddHeader(size_t current_count, size_t current_headers_length, size_t this_header_length) { @@ -203,8 +217,9 @@ void Session::Application::SetStreamPriority(const Stream& stream, // By default do nothing. } -StreamPriority Session::Application::GetStreamPriority(const Stream& stream) { - return StreamPriority::DEFAULT; +Session::Application::StreamPriorityResult +Session::Application::GetStreamPriority(const Stream& stream) { + return {StreamPriority::DEFAULT, StreamPriorityFlags::NON_INCREMENTAL}; } Packet::Ptr Session::Application::CreateStreamDataPacket() { diff --git a/src/quic/application.h b/src/quic/application.h index 11ee977c44967c..a487676210fe6b 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -113,18 +113,37 @@ class Session::Application : public MemoryRetainer { // pending session and stream packets it has accumulated. void SendPendingData(); + // Returns true if the application protocol supports sending and + // receiving headers on streams (e.g. HTTP/3). Applications that + // do not support headers should return false (the default). + virtual bool SupportsHeaders() const; + + // Initiates application-level graceful shutdown signaling (e.g., + // HTTP/3 GOAWAY). Called when Session::Close(GRACEFUL) is invoked. + virtual void BeginShutdown(); + + // Completes the application-level graceful shutdown. Called from + // FinishClose() before CONNECTION_CLOSE is sent. For HTTP/3, this + // sends the final GOAWAY with the actual last accepted stream ID. + virtual void CompleteShutdown(); + // Set the priority level of the stream if supported by the application. Not // all applications support priorities, in which case this function is a // non-op. virtual void SetStreamPriority( const Stream& stream, StreamPriority priority = StreamPriority::DEFAULT, - StreamPriorityFlags flags = StreamPriorityFlags::NONE); + StreamPriorityFlags flags = StreamPriorityFlags::NON_INCREMENTAL); + + struct StreamPriorityResult { + StreamPriority priority; + StreamPriorityFlags flags; + }; // Get the priority level of the stream if supported by the application. Not // all applications support priorities, in which case this function returns // the default stream priority. - virtual StreamPriority GetStreamPriority(const Stream& stream); + virtual StreamPriorityResult GetStreamPriority(const Stream& stream); struct StreamData; diff --git a/src/quic/bindingdata.h b/src/quic/bindingdata.h index 05751d0fbcd01a..a29d9ca451340d 100644 --- a/src/quic/bindingdata.h +++ b/src/quic/bindingdata.h @@ -41,12 +41,14 @@ class Packet; V(session_handshake, SessionHandshake) \ V(session_new, SessionNew) \ V(session_new_token, SessionNewToken) \ + V(session_origin, SessionOrigin) \ V(session_path_validation, SessionPathValidation) \ V(session_ticket, SessionTicket) \ V(session_version_negotiation, SessionVersionNegotiation) \ V(stream_blocked, StreamBlocked) \ V(stream_close, StreamClose) \ V(stream_created, StreamCreated) \ + V(stream_drain, StreamDrain) \ V(stream_headers, StreamHeaders) \ V(stream_reset, StreamReset) \ V(stream_trailers, StreamTrailers) @@ -59,6 +61,7 @@ class Packet; V(active_connection_id_limit, "activeConnectionIDLimit") \ V(address_lru_size, "addressLRUSize") \ V(application, "application") \ + V(authoritative, "authoritative") \ V(bbr, "bbr") \ V(ca, "ca") \ V(cc_algorithm, "cc") \ @@ -77,6 +80,7 @@ class Packet; V(groups, "groups") \ V(handshake_timeout, "handshakeTimeout") \ V(http3_alpn, &NGHTTP3_ALPN_H3[1]) \ + V(keep_alive_timeout, "keepAlive") \ V(initial_max_data, "initialMaxData") \ V(initial_max_stream_data_bidi_local, "initialMaxStreamDataBidiLocal") \ V(initial_max_stream_data_bidi_remote, "initialMaxStreamDataBidiRemote") \ @@ -102,6 +106,7 @@ class Packet; V(max_stream_window, "maxStreamWindow") \ V(max_window, "maxWindow") \ V(min_version, "minVersion") \ + V(port, "port") \ V(preferred_address_strategy, "preferredAddressPolicy") \ V(alpn, "alpn") \ V(qlog, "qlog") \ diff --git a/src/quic/defs.h b/src/quic/defs.h index b26ca5f9a4f12e..bb4f1ce4deaaf0 100644 --- a/src/quic/defs.h +++ b/src/quic/defs.h @@ -83,6 +83,39 @@ bool SetOption(Environment* env, return true; } +template +bool SetOption(Environment* env, + Opt* options, + const v8::Local& object, + const v8::Local& name) { + v8::Local value; + if (!object->Get(env->context(), name).ToLocal(&value)) return false; + if (!value->IsUndefined()) { + if (!value->IsUint32()) { + Utf8Value nameStr(env->isolate(), name); + THROW_ERR_INVALID_ARG_VALUE( + env, "The %s option must be an uint16", nameStr); + return false; + } + v8::Local num; + if (!value->ToUint32(env->context()).ToLocal(&num)) { + Utf8Value nameStr(env->isolate(), name); + THROW_ERR_INVALID_ARG_VALUE( + env, "The %s option must be an uint16", nameStr); + return false; + } + uint32_t val = num->Value(); + if (val > 0xFFFF) { + Utf8Value nameStr(env->isolate(), name); + THROW_ERR_INVALID_ARG_VALUE( + env, "The %s option must fit in a uint16", nameStr); + return false; + } + options->*member = static_cast(val); + } + return true; +} + template bool SetOption(Environment* env, Opt* options, @@ -285,8 +318,14 @@ enum class StreamPriority : uint8_t { }; enum class StreamPriorityFlags : uint8_t { - NONE, NON_INCREMENTAL, + INCREMENTAL, +}; + +enum class HeadersSupportState : uint8_t { + UNKNOWN, + SUPPORTED, + UNSUPPORTED, }; enum class PathValidationResult : uint8_t { diff --git a/src/quic/http3.cc b/src/quic/http3.cc index 2a21c0cf321970..2c6d8403654306 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -11,6 +11,7 @@ #include #include #include +#include #include "application.h" #include "bindingdata.h" #include "defs.h" @@ -25,6 +26,62 @@ using v8::Local; namespace quic { +namespace { +constexpr uint8_t kSessionTicketAppDataVersion = 1; +constexpr size_t kSessionTicketAppDataSize = 39; +constexpr size_t kSessionTicketAppDataHeaderSize = 5; +constexpr size_t kSessionTicketAppDataPayloadSize = + kSessionTicketAppDataSize - kSessionTicketAppDataHeaderSize; + +inline void WriteBE32(uint8_t* buf, uint32_t val) { + buf[0] = static_cast((val >> 24) & 0xff); + buf[1] = static_cast((val >> 16) & 0xff); + buf[2] = static_cast((val >> 8) & 0xff); + buf[3] = static_cast(val & 0xff); +} + +inline uint32_t ReadBE32(const uint8_t* buf) { + return (static_cast(buf[0]) << 24) | + (static_cast(buf[1]) << 16) | + (static_cast(buf[2]) << 8) | static_cast(buf[3]); +} + +inline void WriteBE64(uint8_t* buf, uint64_t val) { + buf[0] = static_cast((val >> 56) & 0xff); + buf[1] = static_cast((val >> 48) & 0xff); + buf[2] = static_cast((val >> 40) & 0xff); + buf[3] = static_cast((val >> 32) & 0xff); + buf[4] = static_cast((val >> 24) & 0xff); + buf[5] = static_cast((val >> 16) & 0xff); + buf[6] = static_cast((val >> 8) & 0xff); + buf[7] = static_cast(val & 0xff); +} + +inline uint64_t ReadBE64(const uint8_t* buf) { + return (static_cast(buf[0]) << 56) | + (static_cast(buf[1]) << 48) | + (static_cast(buf[2]) << 40) | + (static_cast(buf[3]) << 32) | + (static_cast(buf[4]) << 24) | + (static_cast(buf[5]) << 16) | + (static_cast(buf[6]) << 8) | static_cast(buf[7]); +} + +// Serialize an nghttp3_pri into an RFC 9218 priority field value +// (e.g., "u=3" or "u=0, i"). Returns the number of bytes written. +// This is used only for setting the priority field of HTTP/3 streams on +// the client side. +inline size_t FormatPriority(char* buf, size_t buflen, const nghttp3_pri& pri) { + int len; + if (pri.inc) { + len = snprintf(buf, buflen, "u=%d, i", pri.urgency); + } else { + len = snprintf(buf, buflen, "u=%d", pri.urgency); + } + return static_cast(len); +} +} // namespace + struct Http3HeadersTraits { using nv_t = nghttp3_nv; }; @@ -87,7 +144,14 @@ class Http3ApplicationImpl final : public Session::Application { : Application(session, options), allocator_(BindingData::Get(env())), options_(options), - conn_(InitializeConnection()) { + conn_(nullptr) { + // Build the ORIGIN frame payload from the SNI configuration before + // creating the nghttp3 connection, since InitializeConnection needs + // the origin_vec_ to be ready for settings.origin_list. + if (session->is_server()) { + BuildOriginPayload(); + } + conn_ = InitializeConnection(); session->set_priority_supported(); } @@ -97,6 +161,8 @@ class Http3ApplicationImpl final : public Session::Application { error_code GetNoErrorCode() const override { return NGHTTP3_H3_NO_ERROR; } + bool SupportsHeaders() const override { return true; } + bool Start() override { CHECK(!started_); started_ = true; @@ -158,6 +224,10 @@ class Http3ApplicationImpl final : public Session::Application { return ret; } + void BeginShutdown() override { nghttp3_conn_submit_shutdown_notice(*this); } + + void CompleteShutdown() override { nghttp3_conn_shutdown(*this); } + bool ReceiveStreamData(int64_t stream_id, const uint8_t* data, size_t datalen, @@ -170,8 +240,8 @@ class Http3ApplicationImpl final : public Session::Application { stream_id, flags.fin); - ssize_t nread = nghttp3_conn_read_stream( - *this, stream_id, data, datalen, flags.fin ? 1 : 0); + auto nread = nghttp3_conn_read_stream2( + *this, stream_id, data, datalen, flags.fin ? 1 : 0, uv_hrtime()); if (nread < 0) { Debug(&session(), @@ -259,20 +329,65 @@ class Http3ApplicationImpl final : public Session::Application { void CollectSessionTicketAppData( SessionTicket::AppData* app_data) const override { - // TODO(@jasnell): When HTTP/3 settings become dynamic or - // configurable per-connection, store them here so they can be - // validated on 0-RTT resumption. Candidates include: - // max_field_section_size, qpack_max_dtable_capacity, - // qpack_encoder_max_dtable_capacity, qpack_blocked_streams, - // enable_connect_protocol, and enable_datagrams. On extraction, - // compare stored values against current settings and return - // TICKET_IGNORE_RENEW if incompatible. + uint8_t buf[kSessionTicketAppDataSize]; + buf[0] = kSessionTicketAppDataVersion; + + uint8_t* payload = buf + kSessionTicketAppDataHeaderSize; + WriteBE64(payload, options_.max_field_section_size); + WriteBE64(payload + 8, options_.qpack_max_dtable_capacity); + WriteBE64(payload + 16, options_.qpack_encoder_max_dtable_capacity); + WriteBE64(payload + 24, options_.qpack_blocked_streams); + payload[32] = options_.enable_connect_protocol ? 1 : 0; + payload[33] = options_.enable_datagrams ? 1 : 0; + + uLong crc = crc32(0L, Z_NULL, 0); + crc = crc32(crc, payload, kSessionTicketAppDataPayloadSize); + WriteBE32(buf + 1, static_cast(crc)); + + app_data->Set( + uv_buf_init(reinterpret_cast(buf), kSessionTicketAppDataSize)); } SessionTicket::AppData::Status ExtractSessionTicketAppData( const SessionTicket::AppData& app_data, SessionTicket::AppData::Source::Flag flag) override { - // See CollectSessionTicketAppData above. + auto data = app_data.Get(); + if (!data || data->len != kSessionTicketAppDataSize) { + return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; + } + + const uint8_t* buf = reinterpret_cast(data->base); + + if (buf[0] != kSessionTicketAppDataVersion) { + return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; + } + + const uint8_t* payload = buf + kSessionTicketAppDataHeaderSize; + uint32_t stored_crc = ReadBE32(buf + 1); + uLong computed_crc = crc32(0L, Z_NULL, 0); + computed_crc = + crc32(computed_crc, payload, kSessionTicketAppDataPayloadSize); + if (stored_crc != static_cast(computed_crc)) { + return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; + } + + uint64_t stored_max_field_section_size = ReadBE64(payload); + uint64_t stored_qpack_max_dtable_capacity = ReadBE64(payload + 8); + uint64_t stored_qpack_encoder_max_dtable_capacity = ReadBE64(payload + 16); + uint64_t stored_qpack_blocked_streams = ReadBE64(payload + 24); + bool stored_enable_connect_protocol = payload[32] != 0; + bool stored_enable_datagrams = payload[33] != 0; + + if (options_.max_field_section_size < stored_max_field_section_size || + options_.qpack_max_dtable_capacity < stored_qpack_max_dtable_capacity || + options_.qpack_encoder_max_dtable_capacity < + stored_qpack_encoder_max_dtable_capacity || + options_.qpack_blocked_streams < stored_qpack_blocked_streams || + (stored_enable_connect_protocol && !options_.enable_connect_protocol) || + (stored_enable_datagrams && !options_.enable_datagrams)) { + return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; + } + return flag == SessionTicket::AppData::Source::Flag::STATUS_RENEW ? SessionTicket::AppData::Status::TICKET_USE_RENEW : SessionTicket::AppData::Status::TICKET_USE; @@ -398,7 +513,7 @@ class Http3ApplicationImpl final : public Session::Application { StreamPriority priority, StreamPriorityFlags flags) override { nghttp3_pri pri; - pri.inc = (flags == StreamPriorityFlags::NON_INCREMENTAL) ? 0 : 1; + pri.inc = (flags == StreamPriorityFlags::INCREMENTAL) ? 1 : 0; switch (priority) { case StreamPriority::HIGH: pri.urgency = NGHTTP3_URGENCY_HIGH; @@ -412,33 +527,44 @@ class Http3ApplicationImpl final : public Session::Application { } if (session().is_server()) { nghttp3_conn_set_server_stream_priority(*this, stream.id(), &pri); + } else { + // The client API takes a serialized RFC 9218 priority field value + // (e.g., "u=0, i") rather than an nghttp3_pri struct. + char buf[8]; + size_t len = FormatPriority(buf, sizeof(buf), pri); + nghttp3_conn_set_client_stream_priority( + *this, stream.id(), reinterpret_cast(buf), len); } - // Client-side priority is set at request submission time via - // nghttp3_conn_submit_request and is not typically changed - // after the fact. The client API takes a serialized RFC 9218 - // field value rather than an nghttp3_pri struct. } - StreamPriority GetStreamPriority(const Stream& stream) override { + StreamPriorityResult GetStreamPriority(const Stream& stream) override { + // nghttp3_conn_get_stream_priority is only available on the server + // side, where it reflects the peer's requested priority (e.g., from + // PRIORITY_UPDATE frames). Client-side priority is tracked by the + // Stream itself and returned directly from GetPriority in streams.cc. + if (!session().is_server()) { + auto& stored = stream.stored_priority(); + return {stored.priority, stored.flags}; + } nghttp3_pri pri; if (nghttp3_conn_get_stream_priority(*this, &pri, stream.id()) == 0) { - // TODO(@jasnell): The nghttp3_pri.inc (incremental) flag is - // not yet exposed. When priority-based stream scheduling is - // implemented, GetStreamPriority should return both urgency - // and the incremental flag (making get/set symmetrical). - // The inc flag determines whether the server should interleave - // data from this stream with others of the same urgency - // (inc=1) or complete it first (inc=0). + StreamPriority level; switch (pri.urgency) { case NGHTTP3_URGENCY_HIGH: - return StreamPriority::HIGH; + level = StreamPriority::HIGH; + break; case NGHTTP3_URGENCY_LOW: - return StreamPriority::LOW; + level = StreamPriority::LOW; + break; default: - return StreamPriority::DEFAULT; + level = StreamPriority::DEFAULT; + break; } + return {level, + pri.inc ? StreamPriorityFlags::INCREMENTAL + : StreamPriorityFlags::NON_INCREMENTAL}; } - return StreamPriority::DEFAULT; + return {StreamPriority::DEFAULT, StreamPriorityFlags::NON_INCREMENTAL}; } int GetStreamData(StreamData* data) override { @@ -494,9 +620,38 @@ class Http3ApplicationImpl final : public Session::Application { id == qpack_enc_stream_id_; } + void BuildOriginPayload() { + // Build the serialized ORIGIN frame payload from the SNI configuration. + // Each origin entry is: 2-byte BE length + origin string. + // Wildcard ('*') entries and entries with authoritative=false are skipped. + auto& sni = session().config().options.sni; + for (auto& [hostname, opts] : sni) { + if (hostname == "*" || !opts.authoritative) continue; + std::string origin = "https://"; + origin += hostname; + if (opts.port != 443) { + origin += ":"; + origin += std::to_string(opts.port); + } + // 2-byte BE length prefix + uint16_t len = static_cast(origin.size()); + origin_payload_.push_back(static_cast((len >> 8) & 0xff)); + origin_payload_.push_back(static_cast(len & 0xff)); + // Origin string bytes + origin_payload_.insert( + origin_payload_.end(), origin.begin(), origin.end()); + } + if (!origin_payload_.empty()) { + origin_vec_ = {origin_payload_.data(), origin_payload_.size()}; + } + } + Http3ConnectionPointer InitializeConnection() { nghttp3_conn* conn = nullptr; nghttp3_settings settings = options_; + if (!origin_payload_.empty()) { + settings.origin_list = &origin_vec_; + } if (session().is_server()) { CHECK_EQ(nghttp3_conn_server_new( &conn, &kCallbacks, &settings, &allocator_, this), @@ -523,8 +678,7 @@ class Http3ApplicationImpl final : public Session::Application { } void OnBeginHeaders(int64_t stream_id) { - auto stream = session().FindStream(stream_id); - // If the stream does not exist or is destroyed, ignore! + auto stream = FindOrCreateStream(conn_.get(), &session(), stream_id); if (!stream) [[unlikely]] return; Debug(&session(), @@ -574,7 +728,7 @@ class Http3ApplicationImpl final : public Session::Application { } void OnBeginTrailers(int64_t stream_id) { - auto stream = session().FindStream(stream_id); + auto stream = FindOrCreateStream(conn_.get(), &session(), stream_id); if (!stream) [[unlikely]] return; Debug(&session(), @@ -665,13 +819,11 @@ class Http3ApplicationImpl final : public Session::Application { session().Close(Session::CloseMethod::GRACEFUL); } - void OnReceiveSettings(const nghttp3_settings* settings) { + void OnReceiveSettings(const nghttp3_proto_settings* settings) { options_.enable_connect_protocol = settings->enable_connect_protocol; options_.enable_datagrams = settings->h3_datagram; options_.max_field_section_size = settings->max_field_section_size; options_.qpack_blocked_streams = settings->qpack_blocked_streams; - options_.qpack_encoder_max_dtable_capacity = - settings->qpack_encoder_max_dtable_capacity; options_.qpack_max_dtable_capacity = settings->qpack_max_dtable_capacity; Debug(&session(), "HTTP/3 application received updated settings: %s", @@ -686,6 +838,14 @@ class Http3ApplicationImpl final : public Session::Application { int64_t qpack_dec_stream_id_ = -1; int64_t qpack_enc_stream_id_ = -1; + // ORIGIN frame support (RFC 9412). + // origin_payload_ holds the serialized ORIGIN frame payload for sending. + // origin_vec_ points into origin_payload_ for nghttp3_settings.origin_list. + // received_origins_ accumulates origins from received ORIGIN frames. + std::vector origin_payload_; + nghttp3_vec origin_vec_{nullptr, 0}; + std::vector received_origins_; + // ========================================================================== // Static callbacks @@ -955,7 +1115,7 @@ class Http3ApplicationImpl final : public Session::Application { } static int on_receive_settings(nghttp3_conn* conn, - const nghttp3_settings* settings, + const nghttp3_proto_settings* settings, void* conn_user_data) { NGHTTP3_CALLBACK_SCOPE(app); app.OnReceiveSettings(settings); @@ -966,14 +1126,18 @@ class Http3ApplicationImpl final : public Session::Application { const uint8_t* origin, size_t originlen, void* conn_user_data) { - // ORIGIN frames (RFC 8336) are used for connection coalescing - // across multiple origins. Not yet implemented u2014 requires - // connection pooling and multi-origin reuse support. + NGHTTP3_CALLBACK_SCOPE(app); + app.received_origins_.emplace_back(reinterpret_cast(origin), + originlen); return NGTCP2_SUCCESS; } static int on_end_origin(nghttp3_conn* conn, void* conn_user_data) { - // See on_receive_origin above. + NGHTTP3_CALLBACK_SCOPE(app); + if (!app.received_origins_.empty()) { + app.session().EmitOrigins(std::move(app.received_origins_)); + app.received_origins_.clear(); + } return NGTCP2_SUCCESS; } @@ -981,25 +1145,26 @@ class Http3ApplicationImpl final : public Session::Application { CHECK(ncrypto::CSPRNG(dest, destlen)); } - static constexpr nghttp3_callbacks kCallbacks = {on_acked_stream_data, - on_stream_close, - on_receive_data, - on_deferred_consume, - on_begin_headers, - on_receive_header, - on_end_headers, - on_begin_trailers, - on_receive_trailer, - on_end_trailers, - on_stop_sending, - on_end_stream, - on_reset_stream, - on_shutdown, - on_receive_settings, - on_receive_origin, - on_end_origin, - on_rand, - nullptr}; + static constexpr nghttp3_callbacks kCallbacks = { + on_acked_stream_data, + on_stream_close, + on_receive_data, + on_deferred_consume, + on_begin_headers, + on_receive_header, + on_end_headers, + on_begin_trailers, + on_receive_trailer, + on_end_trailers, + on_stop_sending, + on_end_stream, + on_reset_stream, + on_shutdown, + nullptr, // recv_settings (deprecated) + on_receive_origin, + on_end_origin, + on_rand, + on_receive_settings}; }; std::unique_ptr CreateHttp3Application( diff --git a/src/quic/packet.cc b/src/quic/packet.cc index f7a3f3d35d47b7..8220b3bddc2ce5 100644 --- a/src/quic/packet.cc +++ b/src/quic/packet.cc @@ -160,7 +160,7 @@ Packet::Ptr Packet::CreateStatelessResetPacket( if (!packet) return packet; ngtcp2_vec vec = *packet; - ssize_t nwrite = ngtcp2_pkt_write_stateless_reset( + auto nwrite = ngtcp2_pkt_write_stateless_reset2( vec.base, pktlen, token, random, kRandlen); if (nwrite <= static_cast(kMinStatelessResetLen)) return Ptr(); diff --git a/src/quic/session.cc b/src/quic/session.cc index 4877c1789d3fa1..61d52aa8d4c77a 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -57,11 +57,44 @@ using v8::Value; namespace quic { +// Listener flags are packed into a single uint32_t bitfield to reduce +// the size of the shared state buffer. Each bit indicates whether a +// corresponding JS callback is registered. +enum class SessionListenerFlags : uint32_t { + PATH_VALIDATION = 1 << 0, + DATAGRAM = 1 << 1, + DATAGRAM_STATUS = 1 << 2, + SESSION_TICKET = 1 << 3, + NEW_TOKEN = 1 << 4, + ORIGIN = 1 << 5, +}; + +inline SessionListenerFlags operator|(SessionListenerFlags a, + SessionListenerFlags b) { + return static_cast(static_cast(a) | + static_cast(b)); +} + +inline SessionListenerFlags operator&(SessionListenerFlags a, + SessionListenerFlags b) { + return static_cast(static_cast(a) & + static_cast(b)); +} + +inline SessionListenerFlags operator&(uint32_t a, SessionListenerFlags b) { + return static_cast(a & static_cast(b)); +} + +inline bool operator!(SessionListenerFlags a) { + return static_cast(a) == 0; +} + +inline bool HasListenerFlag(uint32_t flags, SessionListenerFlags flag) { + return !!(flags & flag); +} + #define SESSION_STATE(V) \ - V(PATH_VALIDATION, path_validation, uint8_t) \ - V(VERSION_NEGOTIATION, version_negotiation, uint8_t) \ - V(DATAGRAM, datagram, uint8_t) \ - V(SESSION_TICKET, session_ticket, uint8_t) \ + V(LISTENER_FLAGS, listener_flags, uint32_t) \ V(CLOSING, closing, uint8_t) \ V(GRACEFUL_CLOSE, graceful_close, uint8_t) \ V(SILENT_CLOSE, silent_close, uint8_t) \ @@ -70,8 +103,10 @@ namespace quic { V(HANDSHAKE_CONFIRMED, handshake_confirmed, uint8_t) \ V(STREAM_OPEN_ALLOWED, stream_open_allowed, uint8_t) \ V(PRIORITY_SUPPORTED, priority_supported, uint8_t) \ + V(HEADERS_SUPPORTED, headers_supported, uint8_t) \ V(WRAPPED, wrapped, uint8_t) \ V(APPLICATION_TYPE, application_type, uint8_t) \ + V(MAX_DATAGRAM_SIZE, max_datagram_size, uint64_t) \ V(LAST_DATAGRAM_ID, last_datagram_id, datagram_id) #define SESSION_STATS(V) \ @@ -80,7 +115,6 @@ namespace quic { V(HANDSHAKE_COMPLETED_AT, handshake_completed_at) \ V(HANDSHAKE_CONFIRMED_AT, handshake_confirmed_at) \ V(BYTES_RECEIVED, bytes_received) \ - V(BYTES_SENT, bytes_sent) \ V(BIDI_IN_STREAM_COUNT, bidi_in_stream_count) \ V(BIDI_OUT_STREAM_COUNT, bidi_out_stream_count) \ V(UNI_IN_STREAM_COUNT, uni_in_stream_count) \ @@ -94,22 +128,34 @@ namespace quic { V(RTTVAR, rttvar) \ V(SMOOTHED_RTT, smoothed_rtt) \ V(SSTHRESH, ssthresh) \ + V(PKT_SENT, pkt_sent) \ + V(BYTES_SENT, bytes_sent) \ + V(PKT_RECV, pkt_recv) \ + V(BYTES_RECV, bytes_recv) \ + V(PKT_LOST, pkt_lost) \ + V(BYTES_LOST, bytes_lost) \ + V(PING_RECV, ping_recv) \ + V(PKT_DISCARDED, pkt_discarded) \ V(DATAGRAMS_RECEIVED, datagrams_received) \ V(DATAGRAMS_SENT, datagrams_sent) \ V(DATAGRAMS_ACKNOWLEDGED, datagrams_acknowledged) \ V(DATAGRAMS_LOST, datagrams_lost) +#define NO_SIDE_EFFECT true +#define SIDE_EFFECT false + #define SESSION_JS_METHODS(V) \ - V(Destroy, destroy, false) \ - V(GetRemoteAddress, getRemoteAddress, true) \ - V(GetCertificate, getCertificate, true) \ - V(GetEphemeralKeyInfo, getEphemeralKey, true) \ - V(GetPeerCertificate, getPeerCertificate, true) \ - V(GracefulClose, gracefulClose, false) \ - V(SilentClose, silentClose, false) \ - V(UpdateKey, updateKey, false) \ - V(OpenStream, openStream, false) \ - V(SendDatagram, sendDatagram, false) + V(Destroy, destroy, SIDE_EFFECT) \ + V(GetRemoteAddress, getRemoteAddress, NO_SIDE_EFFECT) \ + V(GetLocalAddress, getLocalAddress, NO_SIDE_EFFECT) \ + V(GetCertificate, getCertificate, NO_SIDE_EFFECT) \ + V(GetEphemeralKeyInfo, getEphemeralKey, NO_SIDE_EFFECT) \ + V(GetPeerCertificate, getPeerCertificate, NO_SIDE_EFFECT) \ + V(GracefulClose, gracefulClose, SIDE_EFFECT) \ + V(SilentClose, silentClose, SIDE_EFFECT) \ + V(UpdateKey, updateKey, SIDE_EFFECT) \ + V(OpenStream, openStream, SIDE_EFFECT) \ + V(SendDatagram, sendDatagram, SIDE_EFFECT) struct Session::State final { #define V(_, name, type) type name; @@ -208,7 +254,7 @@ void ngtcp2_debug_log(void* user_data, const char* fmt, ...) { va_end(ap); } -template +template bool SetOption(Environment* env, Opt* options, const Local& object, @@ -223,7 +269,7 @@ bool SetOption(Environment* env, return true; } -template +template bool SetOption(Environment* env, Opt* options, const Local& object, @@ -238,7 +284,7 @@ bool SetOption(Environment* env, return true; } -template +template bool SetOption(Environment* env, Opt* options, const Local& object, @@ -253,7 +299,7 @@ bool SetOption(Environment* env, return true; } -template +template bool SetOption(Environment* env, Opt* options, const Local& object, @@ -319,6 +365,20 @@ Session::Config::Config(Environment* env, ngtcp2_settings_default(&settings); settings.initial_ts = uv_hrtime(); + // Advertise all versions ngtcp2 supports for compatible version + // negotiation (RFC 9368). The preferred list orders the newest + // version first so that negotiation upgrades when possible. The + // initial packet version (options.version) defaults to V1 for + // maximum compatibility with peers that don't support version + // negotiation. + static const uint32_t kSupportedVersions[] = {NGTCP2_PROTO_VER_V2, + NGTCP2_PROTO_VER_V1}; + + settings.preferred_versions = kSupportedVersions; + settings.preferred_versionslen = std::size(kSupportedVersions); + settings.available_versions = kSupportedVersions; + settings.available_versionslen = std::size(kSupportedVersions); + // TODO(@jasnell): Path MTU Discovery is disabled because libuv does not // currently expose the IP_DONTFRAG / IP_MTU_DISCOVER socket options // needed for PMTUD probes to work correctly. Revisit when libuv adds @@ -436,9 +496,9 @@ Maybe Session::Options::From(Environment* env, if (!SET(version) || !SET(min_version) || !SET(preferred_address_strategy) || !SET(transport_params) || !SET(tls_options) || !SET(qlog) || - !SET(handshake_timeout) || !SET(max_stream_window) || !SET(max_window) || - !SET(max_payload_size) || !SET(unacknowledged_packet_threshold) || - !SET(cc_algorithm)) { + !SET(handshake_timeout) || !SET(keep_alive_timeout) || + !SET(max_stream_window) || !SET(max_window) || !SET(max_payload_size) || + !SET(unacknowledged_packet_threshold) || !SET(cc_algorithm)) { return Nothing(); } @@ -589,42 +649,6 @@ struct Session::Impl final : public MemoryRetainer { inline bool is_closing() const { return state_->closing; } - /** - * @returns {boolean} Returns true if the Session can be destroyed - * immediately. - */ - bool Close() { - if (state_->closing) return true; - state_->closing = 1; - STAT_RECORD_TIMESTAMP(Stats, closing_at); - - // Iterate through all of the known streams and close them. The streams - // will remove themselves from the Session as soon as they are closed. - // Note: we create a copy because the streams will remove themselves - // while they are cleaning up which will invalidate the iterator. - StreamsMap streams = streams_; - for (auto& stream : streams) stream.second->Destroy(last_error_); - DCHECK(streams.empty()); - - // Clear the pending streams. - while (!pending_bidi_stream_queue_.IsEmpty()) { - pending_bidi_stream_queue_.PopFront()->reject(last_error_); - } - while (!pending_uni_stream_queue_.IsEmpty()) { - pending_uni_stream_queue_.PopFront()->reject(last_error_); - } - - // If we are able to send packets, we should try sending a connection - // close packet to the remote peer. - if (!state_->silent_close) { - session_->SendConnectionClose(); - } - - timer_.Close(); - - return !state_->wrapped; - } - ~Impl() { // Ensure that Close() was called before dropping DCHECK(is_closing()); @@ -640,9 +664,9 @@ struct Session::Impl final : public MemoryRetainer { ngtcp2_conn_get_scid(*session_, nullptr)); ngtcp2_conn_get_scid(*session_, cids.out()); - MaybeStackBuffer tokens( - ngtcp2_conn_get_active_dcid(*session_, nullptr)); - ngtcp2_conn_get_active_dcid(*session_, tokens.out()); + MaybeStackBuffer tokens( + ngtcp2_conn_get_active_dcid2(*session_, nullptr)); + ngtcp2_conn_get_active_dcid2(*session_, tokens.out()); endpoint->DisassociateCID(config_.dcid); endpoint->DisassociateCID(config_.preferred_address_cid); @@ -654,7 +678,7 @@ struct Session::Impl final : public MemoryRetainer { for (size_t n = 0; n < tokens.length(); n++) { if (tokens[n].token_present) { endpoint->DisassociateStatelessResetToken( - StatelessResetToken(tokens[n].token)); + StatelessResetToken(&tokens[n].token)); } } @@ -721,6 +745,21 @@ struct Session::Impl final : public MemoryRetainer { ->object()); } + JS_METHOD(GetLocalAddress) { + auto env = Environment::GetCurrent(args); + Session* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + + if (session->is_destroyed()) { + return THROW_ERR_INVALID_STATE(env, "Session is destroyed"); + } + + auto address = session->local_address(); + args.GetReturnValue().Set( + SocketAddressBase::Create(env, std::make_shared(address)) + ->object()); + } + JS_METHOD(GetCertificate) { auto env = Environment::GetCurrent(args); Session* session; @@ -888,7 +927,7 @@ struct Session::Impl final : public MemoryRetainer { ngtcp2_connection_id_status_type type, uint64_t seq, const ngtcp2_cid* cid, - const uint8_t* token, + const ngtcp2_stateless_reset_token* token, void* user_data) { NGTCP2_CALLBACK_SCOPE(session) std::optional maybe_reset_token; @@ -960,7 +999,7 @@ struct Session::Impl final : public MemoryRetainer { static int on_get_new_cid(ngtcp2_conn* conn, ngtcp2_cid* cid, - uint8_t* token, + ngtcp2_stateless_reset_token* token, size_t cidlen, void* user_data) { NGTCP2_CALLBACK_SCOPE(session) @@ -1057,7 +1096,7 @@ struct Session::Impl final : public MemoryRetainer { } static int on_receive_stateless_reset(ngtcp2_conn* conn, - const ngtcp2_pkt_stateless_reset* sr, + const ngtcp2_pkt_stateless_reset2* sr, void* user_data) { NGTCP2_CALLBACK_SCOPE(session) session->impl_->state_->stateless_reset = 1; @@ -1226,12 +1265,12 @@ struct Session::Impl final : public MemoryRetainer { on_acknowledge_stream_data_offset, nullptr, on_stream_close, - on_receive_stateless_reset, + nullptr, // recv_stateless_reset (deprecated, use v2 below) ngtcp2_crypto_recv_retry_cb, on_extend_max_streams_bidi, on_extend_max_streams_uni, on_rand, - on_get_new_cid, + nullptr, // get_new_connection_id (deprecated, use v2 below) on_remove_connection_id, ngtcp2_crypto_update_key_cb, on_path_validation, @@ -1240,7 +1279,7 @@ struct Session::Impl final : public MemoryRetainer { on_extend_max_remote_streams_bidi, on_extend_max_remote_streams_uni, on_extend_max_stream_data, - on_cid_status, + nullptr, // dcid_status (deprecated, use v2 below) on_handshake_confirmed, on_receive_new_token, ngtcp2_crypto_delete_crypto_aead_ctx_cb, @@ -1248,13 +1287,17 @@ struct Session::Impl final : public MemoryRetainer { on_receive_datagram, on_acknowledge_datagram, on_lost_datagram, - ngtcp2_crypto_get_path_challenge_data_cb, + nullptr, // get_path_challenge_data (deprecated, use v2 below) on_stream_stop_sending, ngtcp2_crypto_version_negotiation_cb, on_receive_rx_key, nullptr, on_early_data_rejected, - on_begin_path_validation}; + on_begin_path_validation, + on_receive_stateless_reset, + on_get_new_cid, + on_cid_status, + ngtcp2_crypto_get_path_challenge_data2_cb}; static constexpr ngtcp2_callbacks SERVER = { nullptr, @@ -1269,12 +1312,12 @@ struct Session::Impl final : public MemoryRetainer { on_acknowledge_stream_data_offset, nullptr, on_stream_close, - on_receive_stateless_reset, + nullptr, // recv_stateless_reset (deprecated, use v2 below) nullptr, on_extend_max_streams_bidi, on_extend_max_streams_uni, on_rand, - on_get_new_cid, + nullptr, // get_new_connection_id (deprecated, use v2 below) on_remove_connection_id, ngtcp2_crypto_update_key_cb, on_path_validation, @@ -1283,7 +1326,7 @@ struct Session::Impl final : public MemoryRetainer { on_extend_max_remote_streams_bidi, on_extend_max_remote_streams_uni, on_extend_max_stream_data, - on_cid_status, + nullptr, // dcid_status (deprecated, use v2 below) nullptr, nullptr, ngtcp2_crypto_delete_crypto_aead_ctx_cb, @@ -1291,13 +1334,17 @@ struct Session::Impl final : public MemoryRetainer { on_receive_datagram, on_acknowledge_datagram, on_lost_datagram, - ngtcp2_crypto_get_path_challenge_data_cb, + nullptr, // get_path_challenge_data (deprecated, use v2 below) on_stream_stop_sending, ngtcp2_crypto_version_negotiation_cb, nullptr, on_receive_tx_key, on_early_data_rejected, - on_begin_path_validation}; + on_begin_path_validation, + on_receive_stateless_reset, + on_get_new_cid, + on_cid_status, + ngtcp2_crypto_get_path_challenge_data2_cb}; }; #undef NGTCP2_CALLBACK_SCOPE @@ -1356,6 +1403,11 @@ Session::Session(Endpoint* endpoint, if (app) SetApplication(std::move(app)); } + if (config.options.keep_alive_timeout > 0) { + ngtcp2_conn_set_keep_alive_timeout( + *this, config.options.keep_alive_timeout * NGTCP2_MILLISECONDS); + } + MakeWeak(); Debug(this, "Session created."); auto& binding = BindingData::Get(env()); @@ -1480,19 +1532,22 @@ void Session::Close(CloseMethod method) { return FinishClose(); } case CloseMethod::GRACEFUL: { - // If there are no open streams, then we can close just immediately and + // If we are already closing gracefully, do nothing. + if (impl_->state_->graceful_close) [[unlikely]] { + return; + } + impl_->state_->graceful_close = 1; + + // Signal application-level graceful shutdown (e.g., HTTP/3 GOAWAY). + application().BeginShutdown(); + + // If there are no open streams, then we can close immediately and // not worry about waiting around. if (impl_->streams_.empty()) { impl_->state_->silent_close = 0; - impl_->state_->graceful_close = 0; return FinishClose(); } - // If we are already closing gracefully, do nothing. - if (impl_->state_->graceful_close) [[unlikely]] { - return; - } - impl_->state_->graceful_close = 1; Debug(this, "Gracefully closing session (waiting on %zu streams)", impl_->streams_.size()); @@ -1508,22 +1563,50 @@ void Session::FinishClose() { DCHECK(!is_destroyed()); DCHECK(impl_->state_->closing); - // If impl_->Close() returns true, then the session can be destroyed - // immediately without round-tripping through JavaScript. - if (impl_->Close()) { - return Destroy(); + // Clear the graceful_close flag to prevent RemoveStream() from + // re-entering FinishClose() when we destroy streams below. + impl_->state_->graceful_close = 0; + + // Destroy all open streams immediately. We copy the map because + // streams remove themselves during destruction. + StreamsMap streams = impl_->streams_; + for (auto& stream : streams) { + stream.second->Destroy(impl_->last_error_); + } + + // Clear pending stream queues. + while (!impl_->pending_bidi_stream_queue_.IsEmpty()) { + impl_->pending_bidi_stream_queue_.PopFront()->reject(impl_->last_error_); + } + while (!impl_->pending_uni_stream_queue_.IsEmpty()) { + impl_->pending_uni_stream_queue_.PopFront()->reject(impl_->last_error_); } - // Otherwise, we emit a close callback so that the JavaScript side can - // clean up anything it needs to clean up before destroying. - EmitClose(); + // Send final application-level shutdown and CONNECTION_CLOSE + // unless this is a silent close. + if (!impl_->state_->silent_close) { + application().CompleteShutdown(); + SendConnectionClose(); + } + + impl_->timer_.Close(); + + // If the session was passed to JavaScript, we need to round-trip + // through JS so it can clean up before we destroy. The JS side + // will synchronously call destroy(), which calls Session::Destroy(). + if (impl_->state_->wrapped) { + EmitClose(impl_->last_error_); + } else { + Destroy(); + } } void Session::Destroy() { - // Destroy() should be called only after, and as a result of, Close() - // being called first. DCHECK(impl_); - DCHECK(impl_->state_->closing); + // Ensure the closing flag is set for the ~Impl() DCHECK. Normally + // this is set by Session::Close(), but JS destroy() can be called + // directly without going through Close() first. + impl_->state_->closing = 1; Debug(this, "Session destroyed"); impl_.reset(); if (qlog_stream_ || keylog_stream_) { @@ -1597,6 +1680,9 @@ std::unique_ptr Session::SelectApplicationFromAlpn( void Session::SetApplication(std::unique_ptr app) { DCHECK(!impl_->application_); impl_->state_->application_type = static_cast(app->type()); + impl_->state_->headers_supported = static_cast( + app->SupportsHeaders() ? HeadersSupportState::SUPPORTED + : HeadersSupportState::UNSUPPORTED); impl_->application_ = std::move(app); } @@ -1797,8 +1883,6 @@ void Session::Send(Packet::Ptr packet) { } Debug(this, "Session is sending %s", packet->ToString()); - auto& stats_ = impl_->stats_; - STAT_INCREMENT_N(Stats, bytes_sent, packet->length()); endpoint().Send(std::move(packet)); } @@ -1824,17 +1908,22 @@ datagram_id Session::SendDatagram(Store&& data) { const ngtcp2_transport_params* tp = remote_transport_params(); uint64_t max_datagram_size = tp->max_datagram_frame_size; + // These size and length checks should have been caught by the JavaScript + // side, but handle it gracefully here just in case. We might have some future + // case where datagram frames are sent from C++ code directly, so it's good to + // have these checks as a backstop regardless. + if (max_datagram_size == 0) { Debug(this, "Datagrams are disabled"); return 0; } - if (data.length() > max_datagram_size) { + if (data.length() > max_datagram_size) [[unlikely]] { Debug(this, "Ignoring oversized datagram"); return 0; } - if (data.length() == 0) { + if (data.length() == 0) [[unlikely]] { Debug(this, "Ignoring empty datagram"); return 0; } @@ -1845,6 +1934,11 @@ datagram_id Session::SendDatagram(Store&& data) { ngtcp2_vec vec = data; PathStorage path; int flags = NGTCP2_WRITE_DATAGRAM_FLAG_MORE; + // There's always the slightest possibility that the datagram ID could wrap + // around, but that's a lot of datagrams and we would have to be sending + // them at a very high rate for a very long time, so we'll just let it + // wrap around naturally if it ever does. If anyone accomplishes that feat, + // we can throw them a party. datagram_id did = impl_->state_->last_datagram_id + 1; Debug(this, "Sending %zu-byte datagram %" PRIu64, data.length(), did); @@ -1953,7 +2047,7 @@ datagram_id Session::SendDatagram(Store&& data) { break; } } - SetLastError(QuicError::ForTransport(nwrite)); + SetLastError(QuicError::ForNgtcp2Error(nwrite)); Close(CloseMethod::SILENT); return 0; } @@ -1972,7 +2066,6 @@ datagram_id Session::SendDatagram(Store&& data) { Debug(this, "Datagram %" PRIu64 " sent", did); auto& stats_ = impl_->stats_; STAT_INCREMENT(Stats, datagrams_sent); - STAT_INCREMENT_N(Stats, bytes_sent, vec.len); impl_->state_->last_datagram_id = did; return did; } @@ -2252,7 +2345,9 @@ bool Session::is_in_draining_period() const { } bool Session::wants_session_ticket() const { - return !is_destroyed() && impl_->state_->session_ticket == 1; + return !is_destroyed() && + HasListenerFlag(impl_->state_->listener_flags, + SessionListenerFlags::SESSION_TICKET); } void Session::SetStreamOpenAllowed() { @@ -2327,6 +2422,15 @@ void Session::UpdateDataStats() { STAT_SET(Stats, rttvar, info.rttvar); STAT_SET(Stats, smoothed_rtt, info.smoothed_rtt); STAT_SET(Stats, ssthresh, info.ssthresh); + STAT_SET(Stats, pkt_sent, info.pkt_sent); + STAT_SET(Stats, bytes_sent, info.bytes_sent); + STAT_SET(Stats, pkt_recv, info.pkt_recv); + STAT_SET(Stats, bytes_recv, info.bytes_recv); + STAT_SET(Stats, pkt_lost, info.pkt_lost); + STAT_SET(Stats, bytes_lost, info.bytes_lost); + STAT_SET(Stats, ping_recv, info.ping_recv); + STAT_SET(Stats, pkt_discarded, info.pkt_discarded); + STAT_SET( Stats, max_bytes_in_flight, @@ -2437,7 +2541,10 @@ void Session::DatagramStatus(datagram_id datagramId, break; } } - EmitDatagramStatus(datagramId, status); + if (HasListenerFlag(impl_->state_->listener_flags, + SessionListenerFlags::DATAGRAM_STATUS)) { + EmitDatagramStatus(datagramId, status); + } } void Session::DatagramReceived(const uint8_t* data, @@ -2446,7 +2553,10 @@ void Session::DatagramReceived(const uint8_t* data, DCHECK(!is_destroyed()); // If there is nothing watching for the datagram on the JavaScript side, // or if the datagram is zero-length, we just drop it on the floor. - if (impl_->state_->datagram == 0 || datalen == 0) return; + if (!HasListenerFlag(impl_->state_->listener_flags, + SessionListenerFlags::DATAGRAM) || + datalen == 0) + return; Debug(this, "Session is receiving datagram of size %zu", datalen); auto& stats_ = impl_->stats_; @@ -2458,7 +2568,7 @@ void Session::DatagramReceived(const uint8_t* data, void Session::GenerateNewConnectionId(ngtcp2_cid* cid, size_t len, - uint8_t* token) { + ngtcp2_stateless_reset_token* token) { DCHECK(!is_destroyed()); CID cid_ = impl_->config_.options.cid_factory->GenerateInto(cid, len); Debug(this, "Generated new connection id %s", cid_); @@ -2478,6 +2588,11 @@ bool Session::HandshakeCompleted() { STAT_RECORD_TIMESTAMP(Stats, handshake_completed_at); SetStreamOpenAllowed(); + // Capture the peer's max datagram frame size from the remote transport + // parameters so JavaScript can check it without a C++ round-trip. + const ngtcp2_transport_params* tp = remote_transport_params(); + impl_->state_->max_datagram_size = tp->max_datagram_frame_size; + // If early data was attempted but rejected by the server, // tell ngtcp2 so it can retransmit the data as 1-RTT. // The status of early data will only be rejected if an @@ -2744,7 +2859,8 @@ void Session::EmitPathValidation(PathValidationResult result, if (!env()->can_call_into_js()) return; - if (impl_->state_->path_validation == 0) [[likely]] { + if (!HasListenerFlag(impl_->state_->listener_flags, + SessionListenerFlags::PATH_VALIDATION)) [[likely]] { return; } @@ -2788,7 +2904,8 @@ void Session::EmitSessionTicket(Store&& ticket) { // If there is nothing listening for the session ticket, don't bother // emitting. - if (impl_->state_->session_ticket == 0) [[likely]] { + if (!HasListenerFlag(impl_->state_->listener_flags, + SessionListenerFlags::SESSION_TICKET)) [[likely]] { Debug(this, "Session ticket was discarded"); return; } @@ -2812,6 +2929,9 @@ void Session::EmitSessionTicket(Store&& ticket) { void Session::EmitNewToken(const uint8_t* token, size_t len) { DCHECK(!is_destroyed()); + if (!HasListenerFlag(impl_->state_->listener_flags, + SessionListenerFlags::NEW_TOKEN)) + return; if (!env()->can_call_into_js()) return; CallbackScope cb_scope(this); @@ -2883,6 +3003,31 @@ void Session::EmitVersionNegotiation(const ngtcp2_pkt_hd& hd, argv); } +void Session::EmitOrigins(std::vector&& origins) { + DCHECK(!is_destroyed()); + if (!HasListenerFlag(impl_->state_->listener_flags, + SessionListenerFlags::ORIGIN)) + return; + if (!env()->can_call_into_js()) return; + + CallbackScope cb_scope(this); + + auto isolate = env()->isolate(); + + LocalVector elements(env()->isolate(), origins.size()); + for (size_t i = 0; i < origins.size(); i++) { + Local str; + if (!ToV8Value(env()->context(), origins[i]).ToLocal(&str)) [[unlikely]] { + return; + } + elements[i] = str; + } + + Local argv[] = {Array::New(isolate, elements.data(), elements.size())}; + MakeCallback( + BindingData::Get(env()).session_origin_callback(), arraysize(argv), argv); +} + void Session::EmitKeylog(const char* line) { if (!env()->can_call_into_js()) return; if (keylog_stream_) { diff --git a/src/quic/session.h b/src/quic/session.h index 92055e856fac60..6b8d6e30e21c58 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -74,9 +74,9 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // HTTP/3 specific options. uint64_t max_field_section_size = 0; - uint64_t qpack_max_dtable_capacity = 0; - uint64_t qpack_encoder_max_dtable_capacity = 0; - uint64_t qpack_blocked_streams = 0; + uint64_t qpack_max_dtable_capacity = 4096; + uint64_t qpack_encoder_max_dtable_capacity = 4096; + uint64_t qpack_blocked_streams = 100; bool enable_connect_protocol = true; bool enable_datagrams = true; @@ -151,6 +151,11 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // completion of the tls handshake. uint64_t handshake_timeout = UINT64_MAX; + // The keep-alive timeout in milliseconds. When set to a non-zero value, + // ngtcp2 will automatically send PING frames to keep the connection alive + // before the idle timeout fires. Set to 0 to disable (default). + uint64_t keep_alive_timeout = 0; + // Maximum initial flow control window size for a stream. uint64_t max_stream_window = 0; @@ -475,6 +480,7 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { void EmitDatagramStatus(datagram_id id, DatagramStatus status); void EmitHandshakeComplete(); void EmitKeylog(const char* line); + void EmitOrigins(std::vector&& origins); struct ValidatedPath { std::shared_ptr local; @@ -495,7 +501,9 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { void DatagramReceived(const uint8_t* data, size_t datalen, DatagramReceivedFlags flag); - void GenerateNewConnectionId(ngtcp2_cid* cid, size_t len, uint8_t* token); + void GenerateNewConnectionId(ngtcp2_cid* cid, + size_t len, + ngtcp2_stateless_reset_token* token); bool HandshakeCompleted(); void HandshakeConfirmed(); void SelectPreferredAddress(PreferredAddress* preferredAddress); diff --git a/src/quic/streams.cc b/src/quic/streams.cc index 6edbb97d829f9c..184be0393c6933 100644 --- a/src/quic/streams.cc +++ b/src/quic/streams.cc @@ -1,7 +1,6 @@ #if HAVE_OPENSSL && HAVE_QUIC #include "guard.h" #ifndef OPENSSL_NO_QUIC -#include "streams.h" #include #include #include @@ -14,6 +13,7 @@ #include "bindingdata.h" #include "defs.h" #include "session.h" +#include "streams.h" namespace node { @@ -43,6 +43,7 @@ namespace quic { V(READ_ENDED, read_ended, uint8_t) \ V(WRITE_ENDED, write_ended, uint8_t) \ V(RESET, reset, uint8_t) \ + V(RESET_CODE, reset_code, uint64_t) \ V(HAS_OUTBOUND, has_outbound, uint8_t) \ V(HAS_READER, has_reader, uint8_t) \ /* Set when the stream has a block event handler */ \ @@ -52,7 +53,8 @@ namespace quic { /* Set when the stream has a reset event handler */ \ V(WANTS_RESET, wants_reset, uint8_t) \ /* Set when the stream has a trailers event handler */ \ - V(WANTS_TRAILERS, wants_trailers, uint8_t) + V(WANTS_TRAILERS, wants_trailers, uint8_t) \ + V(WRITE_DESIRED_SIZE, write_desired_size, uint64_t) #define STREAM_STATS(V) \ /* Marks the timestamp when the stream object was created. */ \ @@ -272,8 +274,7 @@ struct Stream::Impl { // Sends a block of headers to the peer. If the stream is not yet open, // the headers will be queued and sent immediately when the stream is - // opened. If the application does not support sending headers on streams, - // they will be ignored and dropped on the floor. + // opened. Returns false if the application does not support headers. JS_METHOD(SendHeaders) { Stream* stream; ASSIGN_OR_RETURN_UNWRAP(&stream, args.This()); @@ -287,8 +288,13 @@ struct Stream::Impl { // If the stream is pending, the headers will be queued until the // stream is opened, at which time the queued header block will be - // immediately sent when the stream is opened. + // immediately sent when the stream is opened. If we already know + // that the application does not support headers, return false + // immediately so the JS side can throw an appropriate error. if (stream->is_pending()) { + if (!stream->session().application().SupportsHeaders()) { + return args.GetReturnValue().Set(false); + } stream->EnqueuePendingHeaders(kind, headers, flags); return args.GetReturnValue().Set(true); } @@ -355,18 +361,22 @@ struct Stream::Impl { JS_METHOD(SetPriority) { Stream* stream; ASSIGN_OR_RETURN_UNWRAP(&stream, args.This()); - CHECK(args[0]->IsUint32()); // Priority - CHECK(args[1]->IsUint32()); // Priority flag + CHECK(args[0]->IsUint32()); // Packed: (urgency << 1) | incremental - StreamPriority priority = FromV8Value(args[0]); - StreamPriorityFlags flags = FromV8Value(args[1]); + uint32_t packed = args[0].As()->Value(); + StreamPriority priority = static_cast(packed >> 1); + StreamPriorityFlags flags = (packed & 1) + ? StreamPriorityFlags::INCREMENTAL + : StreamPriorityFlags::NON_INCREMENTAL; - if (stream->is_pending()) { - stream->pending_priority_ = PendingPriority{ - .priority = priority, - .flags = flags, - }; - } else { + // Always update the stored priority on the stream. + stream->priority_ = StoredPriority{ + .priority = priority, + .flags = flags, + .pending = stream->is_pending(), + }; + + if (!stream->is_pending()) { stream->session().application().SetStreamPriority( *stream, priority, flags); } @@ -376,13 +386,23 @@ struct Stream::Impl { Stream* stream; ASSIGN_OR_RETURN_UNWRAP(&stream, args.This()); - if (stream->is_pending()) { - return args.GetReturnValue().Set( - static_cast(StreamPriority::DEFAULT)); + // On the client side, priority is always read from the stream's + // stored value since the client is the one setting it. On the + // server side, we delegate to the application which can read + // the peer's requested priority (e.g., from PRIORITY_UPDATE + // frames in HTTP/3). + if (!stream->session().is_server()) { + auto& pri = stream->priority_; + uint32_t packed = (static_cast(pri.priority) << 1) | + (pri.flags == StreamPriorityFlags::INCREMENTAL ? 1 : 0); + return args.GetReturnValue().Set(packed); } - auto priority = stream->session().application().GetStreamPriority(*stream); - args.GetReturnValue().Set(static_cast(priority)); + auto result = stream->session().application().GetStreamPriority(*stream); + uint32_t packed = + (static_cast(result.priority) << 1) | + (result.flags == StreamPriorityFlags::INCREMENTAL ? 1 : 0); + args.GetReturnValue().Set(packed); } // Returns a Blob::Reader that can be used to read data that has been @@ -505,6 +525,7 @@ class Stream::Outbound final : public MemoryRetainer { bool is_streaming() const { return streaming_; } size_t total() const { return total_; } + size_t uncommitted() const { return uncommitted_; } // Appends an entry to the underlying DataQueue. Only valid when // the Outbound was created in streaming mode. @@ -969,20 +990,28 @@ void Stream::NotifyStreamOpened(stream_id id) { CHECK_EQ(ngtcp2_conn_set_stream_user_data(this->session(), id, this), 0); maybe_pending_stream_.reset(); - if (pending_priority_) { - auto& priority = pending_priority_.value(); + if (priority_.pending) { session().application().SetStreamPriority( - *this, priority.priority, priority.flags); - pending_priority_ = std::nullopt; + *this, priority_.priority, priority_.flags); + priority_.pending = false; } - decltype(pending_headers_queue_) queue; - pending_headers_queue_.swap(queue); - for (auto& headers : queue) { - // TODO(@jasnell): What if the application does not support headers? - session().application().SendHeaders(*this, - headers->kind, - headers->headers.Get(env()->isolate()), - headers->flags); + if (!pending_headers_queue_.empty()) { + if (!session().application().SupportsHeaders()) { + // Headers were enqueued while the application was not yet known + // (headers_supported == 0), and the negotiated application does + // not support headers. This is a fatal mismatch. + Destroy(QuicError::ForApplication(0)); + return; + } + decltype(pending_headers_queue_) queue; + pending_headers_queue_.swap(queue); + for (auto& headers : queue) { + session().application().SendHeaders( + *this, + headers->kind, + headers->headers.Get(env()->isolate()), + headers->flags); + } } // If the stream is not a local undirectional stream and is_readable is // false, then we should shutdown the streams readable side now. @@ -1161,6 +1190,7 @@ void Stream::WriteStreamData(const v8::FunctionCallbackInfo& args) { if (!is_pending()) session_->ResumeStream(id()); + UpdateWriteDesiredSize(); args.GetReturnValue().Set(static_cast(outbound_->total())); } @@ -1256,6 +1286,7 @@ void Stream::Acknowledge(size_t datalen) { // Consumes the given number of bytes in the buffer. outbound_->Acknowledge(datalen); STAT_RECORD_TIMESTAMP(Stats, acked_at); + UpdateWriteDesiredSize(); } void Stream::Commit(size_t datalen, bool fin) { @@ -1383,6 +1414,7 @@ void Stream::ReceiveStreamReset(uint64_t final_size, QuicError error) { "Received stream reset with final size %" PRIu64 " and error %s", final_size, error); + state_->reset_code = error.code(); EndReadable(final_size); EmitReset(error); } @@ -1400,6 +1432,38 @@ void Stream::EmitBlocked() { MakeCallback(BindingData::Get(env()).stream_blocked_callback(), 0, nullptr); } +void Stream::EmitDrain() { + if (!env()->can_call_into_js()) return; + CallbackScope cb_scope(this); + MakeCallback(BindingData::Get(env()).stream_drain_callback(), 0, nullptr); +} + +void Stream::UpdateWriteDesiredSize() { + if (!outbound_ || !outbound_->is_streaming()) return; + + // Calculate available capacity based on QUIC flow control. + // The effective limit is the minimum of stream-level and + // connection-level flow control remaining. + ngtcp2_conn* conn = session(); + uint64_t stream_left = ngtcp2_conn_get_max_stream_data_left(conn, id()); + uint64_t conn_left = ngtcp2_conn_get_max_data_left(conn); + uint64_t available = std::min(stream_left, conn_left); + + // Subtract uncommitted bytes — data queued but not yet sent. + // Committed bytes are already on the wire (retained only for + // retransmission) and don't count toward backpressure. + uint64_t buffered = outbound_->uncommitted(); + uint64_t desired = (available > buffered) ? (available - buffered) : 0; + + uint64_t old_size = state_->write_desired_size; + state_->write_desired_size = desired; + + // Fire drain when transitioning from 0 to non-zero + if (old_size == 0 && desired > 0) { + EmitDrain(); + } +} + void Stream::EmitClose(const QuicError& error) { if (!env()->can_call_into_js()) return; CallbackScope cb_scope(this); diff --git a/src/quic/streams.h b/src/quic/streams.h index 610aac2de334f4..9ba8d2f44c201b 100644 --- a/src/quic/streams.h +++ b/src/quic/streams.h @@ -326,6 +326,14 @@ class Stream final : public AsyncWrap, // blocked because of flow control restriction. void EmitBlocked(); + // Notifies the JavaScript side that the outbound buffer has capacity + // for more data. Fires when write_desired_size transitions from 0 to > 0. + void EmitDrain(); + + // Updates the write_desired_size state field based on current flow control + // and outbound buffer state. Emits drain if transitioning from 0 to > 0. + void UpdateWriteDesiredSize(); + // Delivers the set of inbound headers that have been collected. void EmitHeaders(); @@ -355,11 +363,14 @@ class Stream final : public AsyncWrap, error_code pending_close_read_code_ = 0; error_code pending_close_write_code_ = 0; - struct PendingPriority { - StreamPriority priority; - StreamPriorityFlags flags; + struct StoredPriority { + StreamPriority priority = StreamPriority::DEFAULT; + StreamPriorityFlags flags = StreamPriorityFlags::NON_INCREMENTAL; + bool pending = false; }; - std::optional pending_priority_ = std::nullopt; + StoredPriority priority_; + + const StoredPriority& stored_priority() const { return priority_; } // The headers_ field holds a block of headers that have been received and // are being buffered for delivery to the JavaScript side. diff --git a/src/quic/tlscontext.cc b/src/quic/tlscontext.cc index 358256329984b4..fd3ebd8d7d9db0 100644 --- a/src/quic/tlscontext.cc +++ b/src/quic/tlscontext.cc @@ -697,9 +697,10 @@ Maybe TLSContext::Options::From(Environment* env, if (!SET(verify_client) || !SET(reject_unauthorized) || !SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) || !SET(servername) || !SET(ciphers) || !SET(groups) || - !SET(verify_private_key) || !SET(keylog) || - !SET_VECTOR(crypto::KeyObjectData, keys) || !SET_VECTOR(Store, certs) || - !SET_VECTOR(Store, ca) || !SET_VECTOR(Store, crl)) { + !SET(verify_private_key) || !SET(keylog) || !SET(port) || + !SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) || + !SET_VECTOR(Store, certs) || !SET_VECTOR(Store, ca) || + !SET_VECTOR(Store, crl)) { return Nothing(); } diff --git a/src/quic/tlscontext.h b/src/quic/tlscontext.h index a667b8980da549..335f577e3994c5 100644 --- a/src/quic/tlscontext.h +++ b/src/quic/tlscontext.h @@ -241,6 +241,15 @@ class TLSContext final : public MemoryRetainer, // JavaScript option name "crl" std::vector crl; + // The port to advertise in ORIGIN frames for this hostname. + // Defaults to 443 (the standard HTTPS port). Only relevant for + // server-side SNI entries used with HTTP/3. + uint16_t port = 443; + + // Whether this hostname should be included in ORIGIN frames. + // Only relevant for server-side SNI entries. + bool authoritative = true; + void MemoryInfo(MemoryTracker* tracker) const override; SET_MEMORY_INFO_NAME(TLSContext::Options) SET_SELF_SIZE(Options) diff --git a/src/quic/tokens.cc b/src/quic/tokens.cc index 761c4a63d5ad6b..959566d12a5972 100644 --- a/src/quic/tokens.cc +++ b/src/quic/tokens.cc @@ -61,42 +61,61 @@ std::string TokenSecret::ToString() const { // ============================================================================ // StatelessResetToken -StatelessResetToken::StatelessResetToken() : ptr_(nullptr), buf_() {} +StatelessResetToken::StatelessResetToken() + : ngtcp2_stateless_reset_token(), ptr_(nullptr) {} -StatelessResetToken::StatelessResetToken(const uint8_t* token) : ptr_(token) {} +StatelessResetToken::StatelessResetToken(const uint8_t* token) + : ptr_(reinterpret_cast(token)) {} + +StatelessResetToken::StatelessResetToken( + const ngtcp2_stateless_reset_token* token) + : ptr_(token) {} StatelessResetToken::StatelessResetToken(const TokenSecret& secret, const CID& cid) - : ptr_(buf_) { + : ptr_(this) { CHECK_EQ(ngtcp2_crypto_generate_stateless_reset_token( - buf_, secret, kStatelessTokenLen, cid), + data, secret, kStatelessTokenLen, cid), 0); } StatelessResetToken::StatelessResetToken(uint8_t* token, const TokenSecret& secret, const CID& cid) - : ptr_(token) { + : ptr_(reinterpret_cast(token)) { CHECK_EQ(ngtcp2_crypto_generate_stateless_reset_token( token, secret, kStatelessTokenLen, cid), 0); } +StatelessResetToken::StatelessResetToken(ngtcp2_stateless_reset_token* token, + const TokenSecret& secret, + const CID& cid) + : ptr_(token) { + CHECK_EQ(ngtcp2_crypto_generate_stateless_reset_token( + token->data, secret, kStatelessTokenLen, cid), + 0); +} + StatelessResetToken::StatelessResetToken(const StatelessResetToken& other) - : ptr_(buf_) { + : ngtcp2_stateless_reset_token(), ptr_(this) { if (other) { - memcpy(buf_, other.ptr_, kStatelessTokenLen); + memcpy(data, other.ptr_->data, kStatelessTokenLen); } else { ptr_ = nullptr; } } StatelessResetToken::operator const uint8_t*() const { - return ptr_ != nullptr ? ptr_ : buf_; + return ptr_ != nullptr ? ptr_->data : data; +} + +StatelessResetToken::operator const ngtcp2_stateless_reset_token*() const { + return ptr_; } StatelessResetToken::operator const char*() const { - return reinterpret_cast(ptr_ != nullptr ? ptr_ : buf_); + return reinterpret_cast(ptr_ != nullptr ? ptr_->data : data); } StatelessResetToken::operator bool() const { @@ -109,7 +128,7 @@ bool StatelessResetToken::operator==(const StatelessResetToken& other) const { (ptr_ != nullptr && other.ptr_ == nullptr)) { return false; } - return CRYPTO_memcmp(ptr_, other.ptr_, kStatelessTokenLen) == 0; + return CRYPTO_memcmp(ptr_->data, other.ptr_->data, kStatelessTokenLen) == 0; } bool StatelessResetToken::operator!=(const StatelessResetToken& other) const { @@ -128,7 +147,7 @@ std::string StatelessResetToken::ToString() const { size_t StatelessResetToken::Hash::operator()( const StatelessResetToken& token) const { if (token.ptr_ == nullptr) return 0; - return HashBytes(token.ptr_, kStatelessTokenLen); + return HashBytes(token.ptr_->data, kStatelessTokenLen); } StatelessResetToken StatelessResetToken::kInvalid; diff --git a/src/quic/tokens.h b/src/quic/tokens.h index cfbaa94e344f8d..5438a4d5d8c414 100644 --- a/src/quic/tokens.h +++ b/src/quic/tokens.h @@ -70,7 +70,8 @@ class TokenSecret final : public MemoryRetainer { // // StatlessResetTokens are always kStatelessTokenLen bytes, // as are the secrets used to generate the token. -class StatelessResetToken final : public MemoryRetainer { +class StatelessResetToken final : public ngtcp2_stateless_reset_token, + public MemoryRetainer { public: static constexpr int kStatelessTokenLen = NGTCP2_STATELESS_RESET_TOKENLEN; @@ -78,30 +79,35 @@ class StatelessResetToken final : public MemoryRetainer { // Generates a stateless reset token using HKDF with the cid and token secret // as input. The token secret is either provided by user code when an Endpoint - // is created or is generated randomly. + // is created or is generated randomly. The token is stored in the inherited + // ngtcp2_stateless_reset_token::data and ptr_ is set to this. StatelessResetToken(const TokenSecret& secret, const CID& cid); - // Generates a stateless reset token using the given token storage. + // Generates a stateless reset token into the given external storage. // The StatelessResetToken wraps the token and does not take ownership. - // The token storage must be at least kStatelessTokenLen bytes in length. - // The length is not verified so care must be taken when using this - // constructor. StatelessResetToken(uint8_t* token, const TokenSecret& secret, const CID& cid); + // Generates a stateless reset token into the given external storage. + // The StatelessResetToken wraps the token and does not take ownership. + StatelessResetToken(ngtcp2_stateless_reset_token* token, + const TokenSecret& secret, + const CID& cid); + // Wraps the given token. Does not take over ownership of the token storage. - // The token must be at least kStatelessTokenLen bytes in length. - // The length is not verified so care must be taken when using this - // constructor. explicit StatelessResetToken(const uint8_t* token); + // Wraps the given token. Does not take over ownership of the token storage. + explicit StatelessResetToken(const ngtcp2_stateless_reset_token* token); + StatelessResetToken(const StatelessResetToken& other); DISALLOW_MOVE(StatelessResetToken) std::string ToString() const; operator const uint8_t*() const; + operator const ngtcp2_stateless_reset_token*() const; operator bool() const; bool operator==(const StatelessResetToken& other) const; @@ -124,8 +130,7 @@ class StatelessResetToken final : public MemoryRetainer { private: operator const char*() const; - const uint8_t* ptr_; - uint8_t buf_[NGTCP2_STATELESS_RESET_TOKENLEN]; + const ngtcp2_stateless_reset_token* ptr_; }; // A RETRY packet communicates a retry token to the client. Retry tokens are diff --git a/src/quic/transportparams.cc b/src/quic/transportparams.cc index da665ea01bf35a..605f22efd642e1 100644 --- a/src/quic/transportparams.cc +++ b/src/quic/transportparams.cc @@ -153,6 +153,7 @@ TransportParams::TransportParams(const Config& config, const Options& options) SET_PARAM_V(max_idle_timeout, options.max_idle_timeout * NGTCP2_SECONDS); SET_PARAM_V(disable_active_migration, options.disable_active_migration ? 1 : 0); + SET_PARAM_V(grease_quic_bit, 1); SET_PARAM_V(preferred_addr_present, 0); SET_PARAM_V(stateless_reset_token_present, 0); SET_PARAM_V(retry_scid_present, 0); diff --git a/test/cctest/test_quic_tokens.cc b/test/cctest/test_quic_tokens.cc index 1003b1a0e8005f..f24e0fc50dfc7a 100644 --- a/test/cctest/test_quic_tokens.cc +++ b/test/cctest/test_quic_tokens.cc @@ -56,7 +56,7 @@ TEST(StatelessResetToken, Basic) { CHECK_EQ(token, token2); - // Let's pretend out secret is also a token just for the sake + // Let's pretend our secret is also a token just for the sake // of the test. That's ok because they're the same length. StatelessResetToken token3(secret); @@ -85,6 +85,83 @@ TEST(StatelessResetToken, Basic) { CHECK_EQ(found->second, token); } +TEST(StatelessResetToken, Ngtcp2StructIntegration) { + uint8_t secret[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6}; + uint8_t cid_data[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}; + ngtcp2_cid cid_; + ngtcp2_cid_init(&cid_, cid_data, 10); + TokenSecret fixed_secret(secret); + CID cid(cid_); + + // Owning token — generated into the inherited ngtcp2_stateless_reset_token + StatelessResetToken owning(fixed_secret, cid); + CHECK(owning); + + // The ngtcp2_stateless_reset_token* conversion operator should return + // a valid pointer to the token data. + const ngtcp2_stateless_reset_token* as_struct = owning; + CHECK_NE(as_struct, nullptr); + // The struct's data should match the uint8_t* conversion. + const uint8_t* as_bytes = owning; + CHECK_EQ( + memcmp( + as_struct->data, as_bytes, StatelessResetToken::kStatelessTokenLen), + 0); + + // Non-owning from const ngtcp2_stateless_reset_token* — wraps an + // existing struct without copying. + StatelessResetToken from_struct(as_struct); + CHECK(from_struct); + CHECK_EQ(from_struct, owning); + // The pointer should be the same (non-owning wraps, doesn't copy). + const ngtcp2_stateless_reset_token* from_struct_ptr = from_struct; + CHECK_EQ(from_struct_ptr, as_struct); + + // Owning into external ngtcp2_stateless_reset_token — generates the + // token into a caller-provided struct. + ngtcp2_stateless_reset_token external_struct{}; + StatelessResetToken into_struct(&external_struct, fixed_secret, cid); + CHECK(into_struct); + CHECK_EQ(into_struct, owning); + // The external struct should now contain the generated token. + CHECK_EQ(memcmp(external_struct.data, + as_bytes, + StatelessResetToken::kStatelessTokenLen), + 0); + // The conversion operator should return a pointer to the external struct. + const ngtcp2_stateless_reset_token* into_struct_ptr = into_struct; + CHECK_EQ(into_struct_ptr, &external_struct); + + // Copy of an owning token should itself be owning (independent copy). + StatelessResetToken copy_of_owning = owning; + CHECK_EQ(copy_of_owning, owning); + const ngtcp2_stateless_reset_token* copy_ptr = copy_of_owning; + // Should NOT point to the same memory as the original. + CHECK_NE(copy_ptr, as_struct); + // But data should match. + CHECK_EQ( + memcmp(copy_ptr->data, as_bytes, StatelessResetToken::kStatelessTokenLen), + 0); + + // Copy of a non-owning token should become owning (copies data). + StatelessResetToken copy_of_non_owning = from_struct; + CHECK_EQ(copy_of_non_owning, from_struct); + const ngtcp2_stateless_reset_token* copy_no_ptr = copy_of_non_owning; + // Should NOT point to the original non-owning source. + CHECK_NE(copy_no_ptr, from_struct_ptr); + + // kInvalid conversions. + const ngtcp2_stateless_reset_token* invalid_ptr = + StatelessResetToken::kInvalid; + CHECK_EQ(invalid_ptr, nullptr); + const uint8_t* invalid_bytes = StatelessResetToken::kInvalid; + // When ptr_ is null, falls back to inherited data (zeroed). + uint8_t zeroed[StatelessResetToken::kStatelessTokenLen]{}; + CHECK_EQ( + memcmp(invalid_bytes, zeroed, StatelessResetToken::kStatelessTokenLen), + 0); +} + TEST(RetryToken, Basic) { auto& random = CID::Factory::random(); TokenSecret secret; diff --git a/test/parallel/test-quic-enable-early-data.mjs b/test/parallel/test-quic-enable-early-data.mjs new file mode 100644 index 00000000000000..fa14b533b2ab26 --- /dev/null +++ b/test/parallel/test-quic-enable-early-data.mjs @@ -0,0 +1,55 @@ +// Flags: --experimental-quic --no-warnings + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const cert = fixtures.readKey('agent1-cert.pem'); + +// enableEarlyData must be a boolean +await assert.rejects(connect({ port: 1234 }, { + alpn: 'quic-test', + enableEarlyData: 'yes', +}), { + code: 'ERR_INVALID_ARG_TYPE', +}); + +// With enableEarlyData: false, early data should not be attempted. +// (Without a session ticket, early data is never attempted regardless, +// but this verifies the option is functional and passes through to C++.) + +const serverOpened = Promise.withResolvers(); +const clientOpened = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall((serverSession) => { + serverSession.opened.then(mustCall((info) => { + serverOpened.resolve(); + serverSession.close(); + })); +}), { + sni: { '*': { keys: [key], certs: [cert] } }, + alpn: ['quic-test'], + enableEarlyData: false, +}); + +const clientSession = await connect(serverEndpoint.address, { + alpn: 'quic-test', + servername: 'localhost', + enableEarlyData: false, +}); +clientSession.opened.then(mustCall((info) => { + assert.strictEqual(info.earlyDataAttempted, false); + assert.strictEqual(info.earlyDataAccepted, false); + clientOpened.resolve(); +})); + +await Promise.all([serverOpened.promise, clientOpened.promise]); +clientSession.close(); diff --git a/test/parallel/test-quic-handshake.mjs b/test/parallel/test-quic-handshake.mjs index 7374d4c929398e..38bf55dd9fd15b 100644 --- a/test/parallel/test-quic-handshake.mjs +++ b/test/parallel/test-quic-handshake.mjs @@ -23,6 +23,9 @@ const check = { // The negotiated cipher suite cipher: 'TLS_AES_128_GCM_SHA256', cipherVersion: 'TLSv1.3', + // No session ticket provided, so early data was not attempted + earlyDataAttempted: false, + earlyDataAccepted: false, }; // The opened promise should resolve when the handshake is complete. diff --git a/test/parallel/test-quic-internal-endpoint-stats-state.mjs b/test/parallel/test-quic-internal-endpoint-stats-state.mjs index 94b8167c2d751a..510fa97eeef1ac 100644 --- a/test/parallel/test-quic-internal-endpoint-stats-state.mjs +++ b/test/parallel/test-quic-internal-endpoint-stats-state.mjs @@ -144,9 +144,11 @@ assert.strictEqual(streamState.wantsBlock, false); assert.strictEqual(streamState.wantsReset, false); assert.strictEqual(sessionState.hasPathValidationListener, false); -assert.strictEqual(sessionState.hasVersionNegotiationListener, false); assert.strictEqual(sessionState.hasDatagramListener, false); +assert.strictEqual(sessionState.hasDatagramStatusListener, false); assert.strictEqual(sessionState.hasSessionTicketListener, false); +assert.strictEqual(sessionState.hasNewTokenListener, false); +assert.strictEqual(sessionState.hasOriginListener, false); assert.strictEqual(sessionState.isClosing, false); assert.strictEqual(sessionState.isGracefulClose, false); assert.strictEqual(sessionState.isSilentClose, false); @@ -155,7 +157,9 @@ assert.strictEqual(sessionState.isHandshakeCompleted, false); assert.strictEqual(sessionState.isHandshakeConfirmed, false); assert.strictEqual(sessionState.isStreamOpenAllowed, false); assert.strictEqual(sessionState.isPrioritySupported, false); +assert.strictEqual(sessionState.headersSupported, 0); assert.strictEqual(sessionState.isWrapped, false); +assert.strictEqual(sessionState.maxDatagramSize, 0n); assert.strictEqual(sessionState.lastDatagramId, 0n); assert.strictEqual(typeof streamState.toJSON(), 'object'); @@ -190,7 +194,7 @@ assert.strictEqual(typeof sessionStats.bidiInStreamCount, 'bigint'); assert.strictEqual(typeof sessionStats.bidiOutStreamCount, 'bigint'); assert.strictEqual(typeof sessionStats.uniInStreamCount, 'bigint'); assert.strictEqual(typeof sessionStats.uniOutStreamCount, 'bigint'); -assert.strictEqual(typeof sessionStats.maxBytesInFlights, 'bigint'); +assert.strictEqual(typeof sessionStats.maxBytesInFlight, 'bigint'); assert.strictEqual(typeof sessionStats.bytesInFlight, 'bigint'); assert.strictEqual(typeof sessionStats.blockCount, 'bigint'); assert.strictEqual(typeof sessionStats.cwnd, 'bigint'); @@ -199,6 +203,14 @@ assert.strictEqual(typeof sessionStats.minRtt, 'bigint'); assert.strictEqual(typeof sessionStats.rttVar, 'bigint'); assert.strictEqual(typeof sessionStats.smoothedRtt, 'bigint'); assert.strictEqual(typeof sessionStats.ssthresh, 'bigint'); +assert.strictEqual(typeof sessionStats.pktSent, 'bigint'); +assert.strictEqual(typeof sessionStats.bytesSent, 'bigint'); +assert.strictEqual(typeof sessionStats.pktRecv, 'bigint'); +assert.strictEqual(typeof sessionStats.bytesRecv, 'bigint'); +assert.strictEqual(typeof sessionStats.pktLost, 'bigint'); +assert.strictEqual(typeof sessionStats.bytesLost, 'bigint'); +assert.strictEqual(typeof sessionStats.pingRecv, 'bigint'); +assert.strictEqual(typeof sessionStats.pktDiscarded, 'bigint'); assert.strictEqual(typeof sessionStats.datagramsReceived, 'bigint'); assert.strictEqual(typeof sessionStats.datagramsSent, 'bigint'); assert.strictEqual(typeof sessionStats.datagramsAcknowledged, 'bigint'); diff --git a/test/parallel/test-quic-internal-setcallbacks.mjs b/test/parallel/test-quic-internal-setcallbacks.mjs index cebbee43376d6e..c36aa4e58695df 100644 --- a/test/parallel/test-quic-internal-setcallbacks.mjs +++ b/test/parallel/test-quic-internal-setcallbacks.mjs @@ -19,10 +19,12 @@ const callbacks = { onSessionPathValidation() {}, onSessionTicket() {}, onSessionNewToken() {}, + onSessionOrigin() {}, onSessionVersionNegotiation() {}, onStreamCreated() {}, onStreamBlocked() {}, onStreamClose() {}, + onStreamDrain() {}, onStreamReset() {}, onStreamHeaders() {}, onStreamTrailers() {}, diff --git a/test/parallel/test-quic-new-token.mjs b/test/parallel/test-quic-new-token.mjs new file mode 100644 index 00000000000000..9580b220ecac18 --- /dev/null +++ b/test/parallel/test-quic-new-token.mjs @@ -0,0 +1,53 @@ +// Flags: --experimental-quic --no-warnings + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const cert = fixtures.readKey('agent1-cert.pem'); + +// The token option must be an ArrayBufferView if provided +await assert.rejects(connect({ port: 1234 }, { + alpn: 'quic-test', + token: 'not-a-buffer', +}), { + code: 'ERR_INVALID_ARG_TYPE', +}); + +// After a successful handshake, the server automatically sends a +// NEW_TOKEN frame. The client should receive it via the onnewtoken +// callback set at connection time. + +const clientToken = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall((serverSession) => { + serverSession.opened.then(mustCall()); +}), { + sni: { '*': { keys: [key], certs: [cert] } }, + alpn: ['quic-test'], +}); + +const clientSession = await connect(serverEndpoint.address, { + alpn: 'quic-test', + servername: 'localhost', + // Set onnewtoken at connection time to avoid missing the event. + onnewtoken: mustCall(function(token, address) { + assert.ok(Buffer.isBuffer(token), 'token should be a Buffer'); + assert.ok(token.length > 0, 'token should not be empty'); + assert.ok(address !== undefined, 'address should be defined'); + clientToken.resolve(); + }), +}); + +await clientSession.opened; +await clientToken.promise; + +clientSession.close(); diff --git a/test/parallel/test-quic-reject-unauthorized.mjs b/test/parallel/test-quic-reject-unauthorized.mjs new file mode 100644 index 00000000000000..ca62c7138bfa88 --- /dev/null +++ b/test/parallel/test-quic-reject-unauthorized.mjs @@ -0,0 +1,56 @@ +// Flags: --experimental-quic --no-warnings + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const cert = fixtures.readKey('agent1-cert.pem'); + +// rejectUnauthorized must be a boolean +await assert.rejects(connect({ port: 1234 }, { + alpn: 'quic-test', + rejectUnauthorized: 'yes', +}), { + code: 'ERR_INVALID_ARG_TYPE', +}); + +// With rejectUnauthorized: true (the default), connecting with self-signed +// certs and no CA should produce a validation error in the handshake info. + +const serverOpened = Promise.withResolvers(); +const clientOpened = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall((serverSession) => { + serverSession.opened.then(mustCall((info) => { + serverOpened.resolve(); + serverSession.close(); + })); +}), { + sni: { '*': { keys: [key], certs: [cert] } }, + alpn: ['quic-test'], +}); + +const clientSession = await connect(serverEndpoint.address, { + alpn: 'quic-test', + servername: 'localhost', + // Default: rejectUnauthorized: true +}); +clientSession.opened.then(mustCall((info) => { + // Self-signed cert without CA should produce a validation error. + assert.strictEqual(typeof info.validationErrorReason, 'string'); + assert.ok(info.validationErrorReason.length > 0); + assert.strictEqual(typeof info.validationErrorCode, 'string'); + assert.ok(info.validationErrorCode.length > 0); + clientOpened.resolve(); +})); + +await Promise.all([serverOpened.promise, clientOpened.promise]); +clientSession.close(); diff --git a/test/parallel/test-quic-stream-priority.mjs b/test/parallel/test-quic-stream-priority.mjs new file mode 100644 index 00000000000000..3b56f73df06774 --- /dev/null +++ b/test/parallel/test-quic-stream-priority.mjs @@ -0,0 +1,87 @@ +// Flags: --experimental-quic --no-warnings + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const cert = fixtures.readKey('agent1-cert.pem'); + +const serverOpened = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall((serverSession) => { + serverSession.opened.then(mustCall(() => { + serverOpened.resolve(); + serverSession.close(); + })); +}), { + sni: { '*': { keys: [key], certs: [cert] } }, + alpn: ['quic-test'], +}); + +const clientSession = await connect(serverEndpoint.address, { + alpn: 'quic-test', +}); +await clientSession.opened; +await serverOpened.promise; + +// Test 1: Priority getter returns null for non-HTTP/3 sessions. +// setPriority throws because the session doesn't support priority. +{ + const stream = await clientSession.createBidirectionalStream(); + stream.closed.catch(() => {}); + assert.strictEqual(stream.priority, null); + + assert.throws( + () => stream.setPriority({ level: 'high', incremental: true }), + { code: 'ERR_INVALID_STATE' }, + ); +} + +// Test 2: Validation of createStream priority/incremental options +{ + await assert.rejects( + clientSession.createBidirectionalStream({ priority: 'urgent' }), + { code: 'ERR_INVALID_ARG_VALUE' }, + ); + await assert.rejects( + clientSession.createBidirectionalStream({ priority: 42 }), + { code: 'ERR_INVALID_ARG_VALUE' }, + ); + await assert.rejects( + clientSession.createBidirectionalStream({ incremental: 'yes' }), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + await assert.rejects( + clientSession.createBidirectionalStream({ incremental: 1 }), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); +} + +// Test 3: setPriority throws on non-H3 sessions regardless of arguments +{ + const stream = await clientSession.createBidirectionalStream(); + stream.closed.catch(() => {}); + + assert.throws( + () => stream.setPriority({ level: 'high' }), + { code: 'ERR_INVALID_STATE' }, + ); + assert.throws( + () => stream.setPriority({ level: 'low', incremental: true }), + { code: 'ERR_INVALID_STATE' }, + ); + assert.throws( + () => stream.setPriority(), + { code: 'ERR_INVALID_STATE' }, + ); +} + +clientSession.close(); diff --git a/test/parallel/test-quic-test-client.mjs b/test/parallel/test-quic-test-client.mjs index 25918b17e8b96c..8a8ffdd67f70e4 100644 --- a/test/parallel/test-quic-test-client.mjs +++ b/test/parallel/test-quic-test-client.mjs @@ -1,6 +1,8 @@ // Flags: --experimental-quic import { hasQuic, isAIX, isIBMi, isWindows, skip } from '../common/index.mjs'; import assert from 'node:assert'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; if (!hasQuic) { skip('QUIC support is not enabled'); @@ -20,6 +22,9 @@ if (isWindows) { // required by the ngtcp2 example server/client. skip('QUIC third-party tests are disabled on Windows'); } +if (!existsSync(resolve(process.execPath, '../ngtcp2_test_client'))) { + skip('ngtcp2_test_client binary not built'); +} const { default: QuicTestClient } = await import('../common/quic/test-client.mjs'); diff --git a/test/parallel/test-quic-test-server.mjs b/test/parallel/test-quic-test-server.mjs index ae70a3bc5fc64d..84dbff6b2d69a3 100644 --- a/test/parallel/test-quic-test-server.mjs +++ b/test/parallel/test-quic-test-server.mjs @@ -1,5 +1,7 @@ // Flags: --experimental-quic import { hasQuic, isAIX, isIBMi, isWindows, skip } from '../common/index.mjs'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; if (!hasQuic) { skip('QUIC support is not enabled'); @@ -19,6 +21,9 @@ if (isWindows) { // required by the ngtcp2 example server/client. skip('QUIC third-party tests are disabled on Windows'); } +if (!existsSync(resolve(process.execPath, '../ngtcp2_test_server'))) { + skip('ngtcp2_test_server binary not built'); +} const { default: QuicTestServer } = await import('../common/quic/test-server.mjs'); const fixtures = await import('../common/fixtures.mjs');