diff --git a/.nycrc b/.nycrc index 42aad37414a2ee..1ca428df1c8ef8 100644 --- a/.nycrc +++ b/.nycrc @@ -4,7 +4,7 @@ "test/**", "tools/**", "benchmark/**", - "deps/**" + "deps/**", ], "reporter": [ "html", diff --git a/doc/api/errors.md b/doc/api/errors.md index 10b0aea8754da1..a6aaa57d924f26 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -3644,6 +3644,42 @@ removed: v10.0.0 The `node:repl` module was unable to parse data from the REPL history file. + + +### `ERR_QUIC_CONNECTION_FAILED` + + + +> Stability: 1 - Experimental + +Establishing a QUIC connection failed. + + + +### `ERR_QUIC_ENDPOINT_CLOSED` + + + +> Stability: 1 - Experimental + +A QUIC Endpoint closed with an error. + + + +### `ERR_QUIC_OPEN_STREAM_FAILED` + + + +> Stability: 1 - Experimental + +Opening a QUIC stream failed. + ### `ERR_SOCKET_CANNOT_SEND` diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 2869369262f18c..c7cb7715dbcaef 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1644,6 +1644,9 @@ E('ERR_PARSE_ARGS_UNKNOWN_OPTION', (option, allowPositionals) => { E('ERR_PERFORMANCE_INVALID_TIMESTAMP', '%d is not a valid timestamp', TypeError); E('ERR_PERFORMANCE_MEASURE_INVALID_OPTIONS', '%s', TypeError); +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_REQUIRE_CYCLE_MODULE', '%s', Error); E('ERR_REQUIRE_ESM', function(filename, hasEsmSyntax, parentPath = null, packageJsonPath = null) { diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js new file mode 100644 index 00000000000000..4d0077195d0629 --- /dev/null +++ b/lib/internal/quic/quic.js @@ -0,0 +1,2548 @@ +'use strict'; + +// TODO(@jasnell) Temporarily ignoring c8 covrerage for this file while tests +// are still being developed. +/* c8 ignore start */ + +const { + ArrayBuffer, + ArrayIsArray, + ArrayPrototypePush, + BigUint64Array, + DataView, + DataViewPrototypeGetBigInt64, + DataViewPrototypeGetBigUint64, + DataViewPrototypeGetUint8, + DataViewPrototypeSetUint8, + ObjectDefineProperties, + Symbol, + SymbolAsyncDispose, + Uint8Array, +} = primordials; + +const { + assertCrypto, + customInspectSymbol: kInspect, +} = require('internal/util'); +const { inspect } = require('internal/util/inspect'); +assertCrypto(); + +const { + Endpoint: Endpoint_, + setCallbacks, + + // The constants to be exposed to end users for various options. + CC_ALGO_RENO, + CC_ALGO_CUBIC, + CC_ALGO_BBR, + CC_ALGO_RENO_STR, + CC_ALGO_CUBIC_STR, + CC_ALGO_BBR_STR, + PREFERRED_ADDRESS_IGNORE, + PREFERRED_ADDRESS_USE, + DEFAULT_PREFERRED_ADDRESS_POLICY, + DEFAULT_CIPHERS, + DEFAULT_GROUPS, + STREAM_DIRECTION_BIDIRECTIONAL, + STREAM_DIRECTION_UNIDIRECTIONAL, + + // Internal constants for use by the implementation. + // These are not exposed to end users. + CLOSECONTEXT_CLOSE: kCloseContextClose, + CLOSECONTEXT_BIND_FAILURE: kCloseContextBindFailure, + CLOSECONTEXT_LISTEN_FAILURE: kCloseContextListenFailure, + CLOSECONTEXT_RECEIVE_FAILURE: kCloseContextReceiveFailure, + CLOSECONTEXT_SEND_FAILURE: kCloseContextSendFailure, + CLOSECONTEXT_START_FAILURE: kCloseContextStartFailure, + + // All of the IDX_STATS_* constants are the index positions of the stats + // fields in the relevant BigUint64Array's that underlie the *Stats objects. + // These are not exposed to end users. + IDX_STATS_ENDPOINT_CREATED_AT, + IDX_STATS_ENDPOINT_DESTROYED_AT, + IDX_STATS_ENDPOINT_BYTES_RECEIVED, + IDX_STATS_ENDPOINT_BYTES_SENT, + IDX_STATS_ENDPOINT_PACKETS_RECEIVED, + IDX_STATS_ENDPOINT_PACKETS_SENT, + IDX_STATS_ENDPOINT_SERVER_SESSIONS, + IDX_STATS_ENDPOINT_CLIENT_SESSIONS, + IDX_STATS_ENDPOINT_SERVER_BUSY_COUNT, + IDX_STATS_ENDPOINT_RETRY_COUNT, + IDX_STATS_ENDPOINT_VERSION_NEGOTIATION_COUNT, + IDX_STATS_ENDPOINT_STATELESS_RESET_COUNT, + IDX_STATS_ENDPOINT_IMMEDIATE_CLOSE_COUNT, + + IDX_STATS_SESSION_CREATED_AT, + IDX_STATS_SESSION_CLOSING_AT, + IDX_STATS_SESSION_DESTROYED_AT, + IDX_STATS_SESSION_HANDSHAKE_COMPLETED_AT, + IDX_STATS_SESSION_HANDSHAKE_CONFIRMED_AT, + IDX_STATS_SESSION_GRACEFUL_CLOSING_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, + IDX_STATS_SESSION_UNI_OUT_STREAM_COUNT, + IDX_STATS_SESSION_LOSS_RETRANSMIT_COUNT, + IDX_STATS_SESSION_MAX_BYTES_IN_FLIGHT, + IDX_STATS_SESSION_BYTES_IN_FLIGHT, + IDX_STATS_SESSION_BLOCK_COUNT, + IDX_STATS_SESSION_CWND, + IDX_STATS_SESSION_LATEST_RTT, + IDX_STATS_SESSION_MIN_RTT, + IDX_STATS_SESSION_RTTVAR, + IDX_STATS_SESSION_SMOOTHED_RTT, + IDX_STATS_SESSION_SSTHRESH, + IDX_STATS_SESSION_DATAGRAMS_RECEIVED, + IDX_STATS_SESSION_DATAGRAMS_SENT, + IDX_STATS_SESSION_DATAGRAMS_ACKNOWLEDGED, + IDX_STATS_SESSION_DATAGRAMS_LOST, + + IDX_STATS_STREAM_CREATED_AT, + IDX_STATS_STREAM_RECEIVED_AT, + IDX_STATS_STREAM_ACKED_AT, + IDX_STATS_STREAM_CLOSING_AT, + IDX_STATS_STREAM_DESTROYED_AT, + IDX_STATS_STREAM_BYTES_RECEIVED, + IDX_STATS_STREAM_BYTES_SENT, + IDX_STATS_STREAM_MAX_OFFSET, + IDX_STATS_STREAM_MAX_OFFSET_ACK, + IDX_STATS_STREAM_MAX_OFFSET_RECV, + IDX_STATS_STREAM_FINAL_SIZE, + + IDX_STATE_SESSION_PATH_VALIDATION, + IDX_STATE_SESSION_VERSION_NEGOTIATION, + IDX_STATE_SESSION_DATAGRAM, + IDX_STATE_SESSION_SESSION_TICKET, + IDX_STATE_SESSION_CLOSING, + IDX_STATE_SESSION_GRACEFUL_CLOSE, + IDX_STATE_SESSION_SILENT_CLOSE, + IDX_STATE_SESSION_STATELESS_RESET, + IDX_STATE_SESSION_DESTROYED, + IDX_STATE_SESSION_HANDSHAKE_COMPLETED, + IDX_STATE_SESSION_HANDSHAKE_CONFIRMED, + IDX_STATE_SESSION_STREAM_OPEN_ALLOWED, + IDX_STATE_SESSION_PRIORITY_SUPPORTED, + IDX_STATE_SESSION_WRAPPED, + IDX_STATE_SESSION_LAST_DATAGRAM_ID, + + IDX_STATE_ENDPOINT_BOUND, + IDX_STATE_ENDPOINT_RECEIVING, + IDX_STATE_ENDPOINT_LISTENING, + IDX_STATE_ENDPOINT_CLOSING, + IDX_STATE_ENDPOINT_BUSY, + IDX_STATE_ENDPOINT_PENDING_CALLBACKS, + + IDX_STATE_STREAM_ID, + IDX_STATE_STREAM_FIN_SENT, + IDX_STATE_STREAM_FIN_RECEIVED, + IDX_STATE_STREAM_READ_ENDED, + IDX_STATE_STREAM_WRITE_ENDED, + IDX_STATE_STREAM_DESTROYED, + IDX_STATE_STREAM_PAUSED, + IDX_STATE_STREAM_RESET, + IDX_STATE_STREAM_HAS_READER, + IDX_STATE_STREAM_WANTS_BLOCK, + IDX_STATE_STREAM_WANTS_HEADERS, + IDX_STATE_STREAM_WANTS_RESET, + IDX_STATE_STREAM_WANTS_TRAILERS, +} = internalBinding('quic'); + +const { + isArrayBuffer, + isArrayBufferView, +} = require('util/types'); + +const { + Buffer, +} = require('buffer'); + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_ARG_VALUE, + ERR_INVALID_STATE, + ERR_QUIC_CONNECTION_FAILED, + ERR_QUIC_ENDPOINT_CLOSED, + ERR_QUIC_OPEN_STREAM_FAILED, + }, +} = require('internal/errors'); + +const { + InternalSocketAddress, + SocketAddress, + kHandle: kSocketAddressHandle, +} = require('internal/socketaddress'); + +const { + isKeyObject, + isCryptoKey, +} = require('internal/crypto/keys'); + +const { + kHandle: kKeyObjectHandle, + kKeyObject: kKeyObjectInner, +} = require('internal/crypto/util'); + +const { + validateFunction, + validateObject, +} = require('internal/validators'); + +const kEmptyObject = { __proto__: null }; +const kEnumerable = { __proto__: null, enumerable: true }; + +const kBlocked = Symbol('kBlocked'); +const kDatagram = Symbol('kDatagram'); +const kDatagramStatus = Symbol('kDatagramStatus'); +const kError = Symbol('kError'); +const kFinishClose = Symbol('kFinishClose'); +const kHandshake = Symbol('kHandshake'); +const kHeaders = Symbol('kHeaders'); +const kOwner = Symbol('kOwner'); +const kNewSession = Symbol('kNewSession'); +const kNewStream = Symbol('kNewStream'); +const kPathValidation = Symbol('kPathValidation'); +const kReset = Symbol('kReset'); +const kSessionTicket = Symbol('kSessionTicket'); +const kTrailers = Symbol('kTrailers'); +const kVersionNegotiation = Symbol('kVersionNegotiation'); + +/** + * @typedef {import('../socketaddress.js').SocketAddress} SocketAddress + * @typedef {import('../crypto/keys.js').KeyObject} KeyObject + * @typedef {import('../crypto/keys.js').CryptoKey} CryptoKey + */ + +/** + * @typedef {object} EndpointOptions + * @property {SocketAddress} [address] The local address to bind to + * @property {bigint|number} [retryTokenExpiration] The retry token expiration + * @property {bigint|number} [tokenExpiration] The token expiration + * @property {bigint|number} [maxConnectionsPerHost] The maximum number of connections per host + * @property {bigint|number} [maxConnectionsTotal] The maximum number of total connections + * @property {bigint|number} [maxStatelessResetsPerHost] The maximum number of stateless resets per host + * @property {bigint|number} [addressLRUSize] The size of the address LRU cache + * @property {bigint|number} [maxRetries] The maximum number of retries + * @property {bigint|number} [maxPayloadSize] The maximum payload size + * @property {bigint|number} [unacknowledgedPacketThreshold] The unacknowledged packet threshold + * @property {bigint|number} [handshakeTimeout] The handshake timeout + * @property {bigint|number} [maxStreamWindow] The maximum stream window + * @property {bigint|number} [maxWindow] The maximum window + * @property {number} [rxDiagnosticLoss] The receive diagnostic loss (range 0.0-1.0) + * @property {number} [txDiagnosticLoss] The transmit diagnostic loss (range 0.0-1.0) + * @property {number} [udpReceiveBufferSize] The UDP receive buffer size + * @property {number} [udpSendBufferSize] The UDP send buffer size + * @property {number} [udpTTL] The UDP TTL + * @property {boolean} [noUdpPayloadSizeShaping] Disable UDP payload size shaping + * @property {boolean} [validateAddress] Validate the address + * @property {boolean} [disableActiveMigration] Disable active migration + * @property {boolean} [ipv6Only] Use IPv6 only + * @property {'reno'|'cubic'|'bbr'|number} [cc] The congestion control algorithm + * @property {ArrayBufferView} [resetTokenSecret] The reset token secret + * @property {ArrayBufferView} [tokenSecret] The token secret + */ + +/** + * @typedef {object} TlsOptions + * @property {string} [sni] The server name indication + * @property {string} [alpn] The application layer protocol negotiation + * @property {string} [ciphers] The ciphers + * @property {string} [groups] The groups + * @property {boolean} [keylog] Enable key logging + * @property {boolean} [verifyClient] Verify the client + * @property {boolean} [tlsTrace] Enable TLS tracing + * @property {boolean} [verifyPrivateKey] Verify the private key + * @property {KeyObject|CryptoKey|Array} [keys] The keys + * @property {ArrayBuffer|ArrayBufferView|Array} [certs] The certificates + * @property {ArrayBuffer|ArrayBufferView|Array} [ca] The certificate authority + * @property {ArrayBuffer|ArrayBufferView|Array} [crl] The certificate revocation list + */ + +/** + * @typedef {object} TransportParams + * @property {SocketAddress} [preferredAddressIpv4] The preferred IPv4 address + * @property {SocketAddress} [preferredAddressIpv6] The preferred IPv6 address + * @property {bigint|number} [initialMaxStreamDataBidiLocal] The initial maximum stream data bidirectional local + * @property {bigint|number} [initialMaxStreamDataBidiRemote] The initial maximum stream data bidirectional remote + * @property {bigint|number} [initialMaxStreamDataUni] The initial maximum stream data unidirectional + * @property {bigint|number} [initialMaxData] The initial maximum data + * @property {bigint|number} [initialMaxStreamsBidi] The initial maximum streams bidirectional + * @property {bigint|number} [initialMaxStreamsUni] The initial maximum streams unidirectional + * @property {bigint|number} [maxIdleTimeout] The maximum idle timeout + * @property {bigint|number} [activeConnectionIDLimit] The active connection ID limit + * @property {bigint|number} [ackDelayExponent] The acknowledgment delay exponent + * @property {bigint|number} [maxAckDelay] The maximum acknowledgment delay + * @property {bigint|number} [maxDatagramFrameSize] The maximum datagram frame size + * @property {boolean} [disableActiveMigration] Disable active migration + */ + +/** + * @typedef {object} ApplicationOptions + * @property {bigint|number} [maxHeaderPairs] The maximum header pairs + * @property {bigint|number} [maxHeaderLength] The maximum header length + * @property {bigint|number} [maxFieldSectionSize] The maximum field section size + * @property {bigint|number} [qpackMaxDTableCapacity] The qpack maximum dynamic table capacity + * @property {bigint|number} [qpackEncoderMaxDTableCapacity] The qpack encoder maximum dynamic table capacity + * @property {bigint|number} [qpackBlockedStreams] The qpack blocked streams + * @property {boolean} [enableConnectProtocol] Enable the connect protocol + * @property {boolean} [enableDatagrams] Enable datagrams + */ + +/** + * @typedef {object} SessionOptions + * @property {number} [version] The version + * @property {number} [minVersion] The minimum version + * @property {'use'|'ignore'|'default'} [preferredAddressPolicy] The preferred address policy + * @property {ApplicationOptions} [application] The application options + * @property {TransportParams} [transportParams] The transport parameters + * @property {TlsOptions} [tls] The TLS options + * @property {boolean} [qlog] Enable qlog + * @property {ArrayBufferView} [sessionTicket] The session ticket + */ + +/** + * @typedef {object} Datagrams + * @property {ReadableStream} readable The readable stream + * @property {WritableStream} writable The writable stream + */ + +/** + * @typedef {object} Path + * @property {SocketAddress} local The local address + * @property {SocketAddress} remote The remote address + */ + +/** + * Called when the Endpoint receives a new server-side Session. + * @callback OnSessionCallback + * @param {Session} session + * @param {Endpoint} endpoint + * @returns {void} + */ + +/** + * @callback OnStreamCallback + * @param {QuicStream} stream + * @param {Session} session + * @returns {void} + */ + +/** + * @callback OnDatagramCallback + * @param {Uint8Array} datagram + * @param {Session} session + * @param {boolean} early + * @returns {void} + */ + +/** + * @callback OnDatagramStatusCallback + * @param {bigint} id + * @param {'lost'|'acknowledged'} status + * @param {Session} session + * @returns {void} + */ + +/** + * @callback OnPathValidationCallback + * @param {'aborted'|'failure'|'success'} result + * @param {SocketAddress} newLocalAddress + * @param {SocketAddress} newRemoteAddress + * @param {SocketAddress} oldLocalAddress + * @param {SocketAddress} oldRemoteAddress + * @param {boolean} preferredAddress + * @param {Session} session + * @returns {void} + */ + +/** + * @callback OnSessionTicketCallback + * @param {object} ticket + * @param {Session} session + * @returns {void} + */ + +/** + * @callback OnVersionNegotiationCallback + * @param {number} version + * @param {number[]} requestedVersions + * @param {number[]} supportedVersions + * @param {Session} session + * @returns {void} + */ + +/** + * @callback OnHandshakeCallback + * @param {string} sni + * @param {string} alpn + * @param {string} cipher + * @param {string} cipherVersion + * @param {string} validationErrorReason + * @param {number} validationErrorCode + * @param {boolean} earlyDataAccepted + * @param {Session} session + * @returns {void} + */ + +/** + * @callback OnBlockedCallback + * @param {QuicStream} stream + * @returns {void} + */ + +/** + * @callback OnStreamErrorCallback + * @param {any} error + * @param {QuicStream} stream + * @returns {void} + */ + +/** + * @callback OnHeadersCallback + * @param {object} headers + * @param {string} kind + * @param {QuicStream} stream + * @returns {void} + */ + +/** + * @callback OnTrailersCallback + * @param {QuicStream} stream + * @returns {void} + */ + +/** + * Provdes the callback configuration for Sessions. + * @typedef {object} SessionCallbackConfiguration + * @property {OnStreamCallback} onstream The stream callback + * @property {OnDatagramCallback} [ondatagram] The datagram callback + * @property {OnDatagramStatusCallback} [ondatagramstatus] The datagram status callback + * @property {OnPathValidationCallback} [onpathvalidation] The path validation callback + * @property {OnSessionTicketCallback} [onsessionticket] The session ticket callback + * @property {OnVersionNegotiationCallback} [onversionnegotiation] The version negotiation callback + * @property {OnHandshakeCallback} [onhandshake] The handshake callback + */ + +/** + * @typedef {object} StreamCallbackConfiguration + * @property {OnStreamErrorCallback} onerror The error callback + * @property {OnBlockedCallback} [onblocked] The blocked callback + * @property {OnStreamErrorCallback} [onreset] The reset callback + * @property {OnHeadersCallback} [onheaders] The headers callback + * @property {OnTrailersCallback} [ontrailers] The trailers callback + */ + +/** + * Provides the callback configuration for the Endpoint. + * @typedef {object} EndpointCallbackConfiguration + * @property {OnSessionCallback} onsession The session callback + * @property {SessionCallbackConfiguration} session The session callback configuration + * @property {StreamCallbackConfiguration} stream The stream callback configuration + */ + +setCallbacks({ + onEndpointClose(context, status) { + this[kOwner][kFinishClose](context, status); + }, + onSessionNew(session) { + this[kOwner][kNewSession](session); + }, + onSessionClose(errorType, code, reason) { + this[kOwner][kFinishClose](errorType, code, reason); + }, + onSessionDatagram(uint8Array, early) { + this[kOwner][kDatagram](uint8Array, early); + }, + onSessionDatagramStatus(id, status) { + this[kOwner][kDatagramStatus](id, status); + }, + onSessionHandshake(sni, alpn, cipher, cipherVersion, + validationErrorReason, + validationErrorCode, + earlyDataAccepted) { + this[kOwner][kHandshake](sni, alpn, cipher, cipherVersion, + validationErrorReason, validationErrorCode, + earlyDataAccepted); + }, + onSessionPathValidation(...args) { + this[kOwner][kPathValidation](...args); + }, + onSessionTicket(ticket) { + this[kOwner][kSessionTicket](ticket); + }, + onSessionVersionNegotiation(version, + requestedVersions, + supportedVersions) { + this[kOwner][kVersionNegotiation](version, requestedVersions, supportedVersions); + // Note that immediately following a version negotiation event, the + // session will be destroyed. + }, + onStreamCreated(stream, direction) { + const session = this[kOwner]; + // The event is ignored if the session has been destroyed. + if (session.destroyed) { + stream.destroy(); + return; + }; + session[kNewStream](stream, direction); + }, + onStreamBlocked() { + this[kOwner][kBlocked](); + }, + onStreamClose(error) { + this[kOwner][kError](error); + }, + onStreamReset(error) { + this[kOwner][kReset](error); + }, + onStreamHeaders(headers, kind) { + this[kOwner][kHeaders](headers, kind); + }, + onStreamTrailers() { + this[kOwner][kTrailers](); + }, +}); + +/** + * @param {'use'|'ignore'|'default'} policy + * @returns {number} + */ +function getPreferredAddressPolicy(policy = 'default') { + switch (policy) { + case 'use': return PREFERRED_ADDRESS_USE; + case 'ignore': return PREFERRED_ADDRESS_IGNORE; + case 'default': return DEFAULT_PREFERRED_ADDRESS_POLICY; + } + throw new ERR_INVALID_ARG_VALUE('options.preferredAddressPolicy', policy); +} + +/** + * @param {TlsOptions} tls + */ +function processTlsOptions(tls) { + validateObject(tls, 'options.tls'); + const { + sni, + alpn, + ciphers, + groups, + keylog, + verifyClient, + tlsTrace, + verifyPrivateKey, + keys, + certs, + ca, + crl, + } = tls; + + const keyHandles = []; + if (keys !== undefined) { + const keyInputs = ArrayIsArray(keys) ? keys : [keys]; + for (const key of keyInputs) { + if (isKeyObject(key)) { + if (key.type !== 'private') { + throw new ERR_INVALID_ARG_VALUE('options.tls.keys', key, 'must be a private key'); + } + ArrayPrototypePush(keyHandles, key[kKeyObjectHandle]); + } else if (isCryptoKey(key)) { + if (key.type !== 'private') { + throw new ERR_INVALID_ARG_VALUE('options.tls.keys', key, 'must be a private key'); + } + ArrayPrototypePush(keyHandles, key[kKeyObjectInner][kKeyObjectHandle]); + } else { + throw new ERR_INVALID_ARG_TYPE('options.tls.keys', ['KeyObject', 'CryptoKey'], key); + } + } + } + + return { + sni, + alpn, + ciphers, + groups, + keylog, + verifyClient, + tlsTrace, + verifyPrivateKey, + keys: keyHandles, + certs, + ca, + crl, + }; +} + +class QuicStreamStats { + /** @type {BigUint64Array} */ + #handle; + + /** + * @param {ArrayBuffer} buffer + */ + constructor(buffer) { + if (!isArrayBuffer(buffer)) { + throw new ERR_INVALID_ARG_TYPE('buffer', ['ArrayBuffer'], buffer); + } + this.#handle = new BigUint64Array(buffer); + } + + /** @type {bigint} */ + get createdAt() { + return this.#handle[IDX_STATS_STREAM_CREATED_AT]; + } + + /** @type {bigint} */ + get receivedAt() { + return this.#handle[IDX_STATS_STREAM_RECEIVED_AT]; + } + + /** @type {bigint} */ + get ackedAt() { + return this.#handle[IDX_STATS_STREAM_ACKED_AT]; + } + + /** @type {bigint} */ + get closingAt() { + return this.#handle[IDX_STATS_STREAM_CLOSING_AT]; + } + + /** @type {bigint} */ + get destroyedAt() { + return this.#handle[IDX_STATS_STREAM_DESTROYED_AT]; + } + + /** @type {bigint} */ + get bytesReceived() { + return this.#handle[IDX_STATS_STREAM_BYTES_RECEIVED]; + } + + /** @type {bigint} */ + get bytesSent() { + return this.#handle[IDX_STATS_STREAM_BYTES_SENT]; + } + + /** @type {bigint} */ + get maxOffset() { + return this.#handle[IDX_STATS_STREAM_MAX_OFFSET]; + } + + /** @type {bigint} */ + get maxOffsetAcknowledged() { + return this.#handle[IDX_STATS_STREAM_MAX_OFFSET_ACK]; + } + + /** @type {bigint} */ + get maxOffsetReceived() { + return this.#handle[IDX_STATS_STREAM_MAX_OFFSET_RECV]; + } + + /** @type {bigint} */ + get finalSize() { + return this.#handle[IDX_STATS_STREAM_FINAL_SIZE]; + } + + toJSON() { + return { + __proto__: null, + createdAt: `${this.createdAt}`, + receivedAt: `${this.receivedAt}`, + ackedAt: `${this.ackedAt}`, + closingAt: `${this.closingAt}`, + destroyedAt: `${this.destroyedAt}`, + bytesReceived: `${this.bytesReceived}`, + bytesSent: `${this.bytesSent}`, + maxOffset: `${this.maxOffset}`, + maxOffsetAcknowledged: `${this.maxOffsetAcknowledged}`, + maxOffsetReceived: `${this.maxOffsetReceived}`, + finalSize: `${this.finalSize}`, + }; + } + + [kInspect](depth, options) { + if (depth < 0) + return this; + + const opts = { + ...options, + depth: options.depth == null ? null : options.depth - 1, + }; + + return `StreamStats ${inspect({ + createdAt: this.createdAt, + receivedAt: this.receivedAt, + ackedAt: this.ackedAt, + closingAt: this.closingAt, + destroyedAt: this.destroyedAt, + bytesReceived: this.bytesReceived, + bytesSent: this.bytesSent, + maxOffset: this.maxOffset, + maxOffsetAcknowledged: this.maxOffsetAcknowledged, + maxOffsetReceived: this.maxOffsetReceived, + finalSize: this.finalSize, + }, opts)}`; + } + + [kFinishClose]() { + // Snapshot the stats into a new BigUint64Array since the underlying + // buffer will be destroyed. + this.#handle = new BigUint64Array(this.#handle); + } +} + +class QuicStreamState { + /** @type {DataView} */ + #handle; + + /** + * @param {ArrayBuffer} buffer + */ + constructor(buffer) { + if (!isArrayBuffer(buffer)) { + throw new ERR_INVALID_ARG_TYPE('buffer', ['ArrayBuffer'], buffer); + } + this.#handle = new DataView(buffer); + } + + /** @type {bigint} */ + get id() { + return DataViewPrototypeGetBigInt64(this.#handle, IDX_STATE_STREAM_ID); + } + + /** @type {boolean} */ + get finSent() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_FIN_SENT); + } + + /** @type {boolean} */ + get finReceived() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_FIN_RECEIVED); + } + + /** @type {boolean} */ + get readEnded() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_READ_ENDED); + } + + /** @type {boolean} */ + get writeEnded() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WRITE_ENDED); + } + + /** @type {boolean} */ + get destroyed() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_DESTROYED); + } + + /** @type {boolean} */ + get paused() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_PAUSED); + } + + /** @type {boolean} */ + get reset() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_RESET); + } + + /** @type {boolean} */ + get hasReader() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_HAS_READER); + } + + /** @type {boolean} */ + get wantsBlock() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_BLOCK); + } + + /** @type {boolean} */ + set wantsBlock(val) { + DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_BLOCK, val ? 1 : 0); + } + + /** @type {boolean} */ + get wantsHeaders() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_HEADERS); + } + + /** @type {boolean} */ + set wantsHeaders(val) { + DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_HEADERS, val ? 1 : 0); + } + + /** @type {boolean} */ + get wantsReset() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_RESET); + } + + /** @type {boolean} */ + set wantsReset(val) { + DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_RESET, val ? 1 : 0); + } + + /** @type {boolean} */ + get wantsTrailers() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_TRAILERS); + } + + /** @type {boolean} */ + set wantsTrailers(val) { + DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_TRAILERS, val ? 1 : 0); + } + + toJSON() { + return { + __proto__: null, + id: `${this.id}`, + finSent: this.finSent, + finReceived: this.finReceived, + readEnded: this.readEnded, + writeEnded: this.writeEnded, + destroyed: this.destroyed, + paused: this.paused, + reset: this.reset, + hasReader: this.hasReader, + wantsBlock: this.wantsBlock, + wantsHeaders: this.wantsHeaders, + wantsReset: this.wantsReset, + wantsTrailers: this.wantsTrailers, + }; + } + + [kInspect](depth, options) { + if (depth < 0) + return this; + + const opts = { + ...options, + depth: options.depth == null ? null : options.depth - 1, + }; + + return `StreamState ${inspect({ + id: this.id, + finSent: this.finSent, + finReceived: this.finReceived, + readEnded: this.readEnded, + writeEnded: this.writeEnded, + destroyed: this.destroyed, + paused: this.paused, + reset: this.reset, + hasReader: this.hasReader, + wantsBlock: this.wantsBlock, + wantsHeaders: this.wantsHeaders, + wantsReset: this.wantsReset, + wantsTrailers: this.wantsTrailers, + }, opts)}`; + } + + [kFinishClose]() { + // When the stream is destroyed, the state is no longer available. + this.#handle = new DataView(new ArrayBuffer(0)); + } +} + +class QuicStream { + /** @type {object} */ + #handle; + /** @type {Session} */ + #session; + /** @type {QuicStreamStats} */ + #stats; + /** @type {QuicStreamState} */ + #state; + /** @type {number} */ + #direction; + /** @type {OnStreamErrorCallback} */ + #onerror; + /** @type {OnBlockedCallback|undefined} */ + #onblocked; + /** @type {OnStreamErrorCallback|undefined} */ + #onreset; + /** @type {OnHeadersCallback|undefined} */ + #onheaders; + /** @type {OnTrailersCallback|undefined} */ + #ontrailers; + + /** + * @param {StreamCallbackConfiguration} config + * @param {object} handle + * @param {Session} session + */ + constructor(config, handle, session, direction) { + validateObject(config, 'config'); + this.#stats = new QuicStreamStats(handle.stats); + this.#state = new QuicStreamState(handle.stats); + const { + onblocked, + onerror, + onreset, + onheaders, + ontrailers, + } = config; + if (onblocked !== undefined) { + validateFunction(onblocked, 'config.onblocked'); + this.#state.wantsBlock = true; + this.#onblocked = onblocked; + } + if (onreset !== undefined) { + validateFunction(onreset, 'config.onreset'); + this.#state.wantsReset = true; + this.#onreset = onreset; + } + if (onheaders !== undefined) { + validateFunction(onheaders, 'config.onheaders'); + this.#state.wantsHeaders = true; + this.#onheaders = onheaders; + } + if (ontrailers !== undefined) { + validateFunction(ontrailers, 'config.ontrailers'); + this.#state.wantsTrailers = true; + this.#ontrailers = ontrailers; + } + validateFunction(onerror, 'config.onerror'); + this.#onerror = onerror; + this.#handle = handle; + this.#session = session; + this.#direction = direction; + this.#handle[kOwner] = true; + } + + /** @type {QuicStreamStats} */ + get stats() { return this.#stats; } + + /** @type {QuicStreamState} */ + get state() { return this.#state; } + + /** @type {Session} */ + get session() { return this.#session; } + + /** @type {bigint} */ + get id() { return this.#state.id; } + + /** @type {'bidi'|'uni'} */ + get direction() { + return this.#direction === 0 ? 'bidi' : 'uni'; + } + + [kBlocked]() { + this.#onblocked(this); + } + + [kError](error) { + this.#onerror(error, this); + } + + [kReset](error) { + this.#onreset(error, this); + } + + [kHeaders](headers, kind) { + this.#onheaders(headers, kind, this); + } + + [kTrailers]() { + this.#ontrailers(this); + } + + [kInspect](depth, options) { + if (depth < 0) + return this; + + const opts = { + ...options, + depth: options.depth == null ? null : options.depth - 1, + }; + + return `Stream ${inspect({ + id: this.id, + direction: this.direction, + stats: this.stats, + state: this.state, + session: this.session, + }, opts)}`; + } +} + +class SessionStats { + /** @type {BigUint64Array} */ + #handle; + + /** + * @param {BigUint64Array} buffer + */ + constructor(buffer) { + if (!isArrayBuffer(buffer)) { + throw new ERR_INVALID_ARG_TYPE('buffer', ['ArrayBuffer'], buffer); + } + this.#handle = new BigUint64Array(buffer); + } + + /** @type {bigint} */ + get createdAt() { + return this.#handle[IDX_STATS_SESSION_CREATED_AT]; + } + + /** @type {bigint} */ + get closingAt() { + return this.#handle[IDX_STATS_SESSION_CLOSING_AT]; + } + + /** @type {bigint} */ + get destroyedAt() { + return this.#handle[IDX_STATS_SESSION_DESTROYED_AT]; + } + + /** @type {bigint} */ + get handshakeCompletedAt() { + return this.#handle[IDX_STATS_SESSION_HANDSHAKE_COMPLETED_AT]; + } + + /** @type {bigint} */ + get handshakeConfirmedAt() { + return this.#handle[IDX_STATS_SESSION_HANDSHAKE_CONFIRMED_AT]; + } + + /** @type {bigint} */ + get gracefulClosingAt() { + return this.#handle[IDX_STATS_SESSION_GRACEFUL_CLOSING_AT]; + } + + /** @type {bigint} */ + get bytesReceived() { + 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]; + } + + /** @type {bigint} */ + get bidiOutStreamCount() { + return this.#handle[IDX_STATS_SESSION_BIDI_OUT_STREAM_COUNT]; + } + + /** @type {bigint} */ + get uniInStreamCount() { + return this.#handle[IDX_STATS_SESSION_UNI_IN_STREAM_COUNT]; + } + + /** @type {bigint} */ + get uniOutStreamCount() { + return this.#handle[IDX_STATS_SESSION_UNI_OUT_STREAM_COUNT]; + } + + /** @type {bigint} */ + get lossRetransmitCount() { + return this.#handle[IDX_STATS_SESSION_LOSS_RETRANSMIT_COUNT]; + } + + /** @type {bigint} */ + get maxBytesInFlights() { + return this.#handle[IDX_STATS_SESSION_MAX_BYTES_IN_FLIGHT]; + } + + /** @type {bigint} */ + get bytesInFlight() { + return this.#handle[IDX_STATS_SESSION_BYTES_IN_FLIGHT]; + } + + /** @type {bigint} */ + get blockCount() { + return this.#handle[IDX_STATS_SESSION_BLOCK_COUNT]; + } + + /** @type {bigint} */ + get cwnd() { + return this.#handle[IDX_STATS_SESSION_CWND]; + } + + /** @type {bigint} */ + get latestRtt() { + return this.#handle[IDX_STATS_SESSION_LATEST_RTT]; + } + + /** @type {bigint} */ + get minRtt() { + return this.#handle[IDX_STATS_SESSION_MIN_RTT]; + } + + /** @type {bigint} */ + get rttVar() { + return this.#handle[IDX_STATS_SESSION_RTTVAR]; + } + + /** @type {bigint} */ + get smoothedRtt() { + return this.#handle[IDX_STATS_SESSION_SMOOTHED_RTT]; + } + + /** @type {bigint} */ + get ssthresh() { + return this.#handle[IDX_STATS_SESSION_SSTHRESH]; + } + + /** @type {bigint} */ + get datagramsReceived() { + return this.#handle[IDX_STATS_SESSION_DATAGRAMS_RECEIVED]; + } + + /** @type {bigint} */ + get datagramsSent() { + return this.#handle[IDX_STATS_SESSION_DATAGRAMS_SENT]; + } + + /** @type {bigint} */ + get datagramsAcknowledged() { + return this.#handle[IDX_STATS_SESSION_DATAGRAMS_ACKNOWLEDGED]; + } + + /** @type {bigint} */ + get datagramsLost() { + return this.#handle[IDX_STATS_SESSION_DATAGRAMS_LOST]; + } + + toJSON() { + return { + __proto__: null, + 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}`, + lossRetransmitCount: `${this.lossRetransmitCount}`, + maxBytesInFlights: `${this.maxBytesInFlights}`, + bytesInFlight: `${this.bytesInFlight}`, + blockCount: `${this.blockCount}`, + cwnd: `${this.cwnd}`, + latestRtt: `${this.latestRtt}`, + minRtt: `${this.minRtt}`, + rttVar: `${this.rttVar}`, + smoothedRtt: `${this.smoothedRtt}`, + ssthresh: `${this.ssthresh}`, + datagramsReceived: `${this.datagramsReceived}`, + datagramsSent: `${this.datagramsSent}`, + datagramsAcknowledged: `${this.datagramsAcknowledged}`, + datagramsLost: `${this.datagramsLost}`, + }; + } + + [kInspect](depth, options) { + if (depth < 0) + return this; + + const opts = { + ...options, + depth: options.depth == null ? null : options.depth - 1, + }; + + return `SessionStats ${inspect({ + 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, + lossRetransmitCount: this.lossRetransmitCount, + maxBytesInFlights: this.maxBytesInFlights, + bytesInFlight: this.bytesInFlight, + blockCount: this.blockCount, + cwnd: this.cwnd, + latestRtt: this.latestRtt, + minRtt: this.minRtt, + rttVar: this.rttVar, + smoothedRtt: this.smoothedRtt, + ssthresh: this.ssthresh, + datagramsReceived: this.datagramsReceived, + datagramsSent: this.datagramsSent, + datagramsAcknowledged: this.datagramsAcknowledged, + datagramsLost: this.datagramsLost, + }, opts)}`; + } + + [kFinishClose]() { + // Snapshot the stats into a new BigUint64Array since the underlying + // buffer will be destroyed. + this.#handle = new BigUint64Array(this.#handle); + } +} + +class SessionState { + /** @type {DataView} */ + #handle; + + /** + * @param {ArrayBuffer} buffer + */ + constructor(buffer) { + if (!isArrayBuffer(buffer)) { + throw new ERR_INVALID_ARG_TYPE('buffer', ['ArrayBuffer'], buffer); + } + this.#handle = new DataView(buffer); + } + + /** @type {boolean} */ + get hasPathValidationListener() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_PATH_VALIDATION); + } + + /** @type {boolean} */ + set hasPathValidationListener(val) { + DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_PATH_VALIDATION, val ? 1 : 0); + } + + /** @type {boolean} */ + get hasVersionNegotiationListener() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_VERSION_NEGOTIATION); + } + + /** @type {boolean} */ + set hasVersionNegotiationListener(val) { + DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_VERSION_NEGOTIATION, val ? 1 : 0); + } + + /** @type {boolean} */ + get hasDatagramListener() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_DATAGRAM); + } + + /** @type {boolean} */ + set hasDatagramListener(val) { + DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_DATAGRAM, val ? 1 : 0); + } + + /** @type {boolean} */ + get hasSessionTicketListener() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_SESSION_TICKET); + } + + /** @type {boolean} */ + set hasSessionTicketListener(val) { + DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_SESSION_TICKET, val ? 1 : 0); + } + + /** @type {boolean} */ + get isClosing() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_CLOSING); + } + + /** @type {boolean} */ + get isGracefulClose() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_GRACEFUL_CLOSE); + } + + /** @type {boolean} */ + get isSilentClose() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_SILENT_CLOSE); + } + + /** @type {boolean} */ + get isStatelessReset() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_STATELESS_RESET); + } + + /** @type {boolean} */ + get isDestroyed() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_DESTROYED); + } + + /** @type {boolean} */ + get isHandshakeCompleted() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_HANDSHAKE_COMPLETED); + } + + /** @type {boolean} */ + get isHandshakeConfirmed() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_HANDSHAKE_CONFIRMED); + } + + /** @type {boolean} */ + get isStreamOpenAllowed() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_STREAM_OPEN_ALLOWED); + } + + /** @type {boolean} */ + get isPrioritySupported() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_PRIORITY_SUPPORTED); + } + + /** @type {boolean} */ + get isWrapped() { + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_WRAPPED); + } + + /** @type {bigint} */ + get lastDatagramId() { + return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_SESSION_LAST_DATAGRAM_ID); + } + + toJSON() { + return { + __proto__: null, + hasPathValidationListener: this.hasPathValidationListener, + hasVersionNegotiationListener: this.hasVersionNegotiationListener, + hasDatagramListener: this.hasDatagramListener, + hasSessionTicketListener: this.hasSessionTicketListener, + 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, + isWrapped: this.isWrapped, + lastDatagramId: `${this.lastDatagramId}`, + }; + } + + [kInspect](depth, options) { + if (depth < 0) + return this; + + const opts = { + ...options, + depth: options.depth == null ? null : options.depth - 1, + }; + + return `SessionState ${inspect({ + hasPathValidationListener: this.hasPathValidationListener, + hasVersionNegotiationListener: this.hasVersionNegotiationListener, + hasDatagramListener: this.hasDatagramListener, + hasSessionTicketListener: this.hasSessionTicketListener, + 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, + isWrapped: this.isWrapped, + lastDatagramId: this.lastDatagramId, + }, opts)}`; + } + + [kFinishClose]() { + // Snapshot the state into a new DataView since the underlying + // buffer will be destroyed. + this.#handle = new DataView(new ArrayBuffer(0)); + } +} + +class Session { + /** @type {Endpoint} */ + #endpoint = undefined; + /** @type {boolean} */ + #isPendingClose = false; + /** @type {object|undefined} */ + #handle; + /** @type {PromiseWithResolvers} */ + #pendingClose; + /** @type {SocketAddress|undefined} */ + #remoteAddress = undefined; + /** @type {SessionState} */ + #state; + /** @type {SessionStats} */ + #stats; + /** @type {QuicStream[]} */ + #streams = []; + /** @type {OnStreamCallback} */ + #onstream; + /** @type {OnDatagramCallback|undefined} */ + #ondatagram; + /** @type {OnDatagramStatusCallback|undefined} */ + #ondatagramstatus; + /** @type {OnPathValidationCallback|undefined} */ + #onpathvalidation; + /** @type {OnSessionTicketCallback|undefined} */ + #onsessionticket; + /** @type {OnVersionNegotiationCallback|undefined} */ + #onversionnegotiation; + /** @type {OnHandshakeCallback} */ + #onhandshake; + /** @type {StreamCallbackConfiguration} */ + #streamConfig; + + /** + * @param {SessionCallbackConfiguration} config + * @param {StreamCallbackConfiguration} streamConfig + * @param {object} [handle] + * @param {Endpoint} [endpoint] + */ + constructor(config, streamConfig, handle, endpoint) { + validateObject(config, 'config'); + this.#stats = new SessionStats(handle.stats); + this.#state = new SessionState(handle.state); + const { + ondatagram, + ondatagramstatus, + onhandshake, + onpathvalidation, + onsessionticket, + onstream, + onversionnegotiation, + } = config; + if (ondatagram !== undefined) { + validateFunction(ondatagram, 'config.ondatagram'); + validateFunction(ondatagramstatus, 'config.ondatagramstatus'); + this.#state.hasDatagramListener = true; + this.#ondatagram = ondatagram; + this.#ondatagramstatus = ondatagramstatus; + } + validateFunction(onhandshake, 'config.onhandshake'); + if (onpathvalidation !== undefined) { + validateFunction(onpathvalidation, 'config.onpathvalidation'); + this.#state.hasPathValidationListener = true; + this.#onpathvalidation = onpathvalidation; + } + if (onsessionticket !== undefined) { + validateFunction(onsessionticket, 'config.onsessionticket'); + this.#state.hasSessionTicketListener = true; + this.#onsessionticket = onsessionticket; + } + validateFunction(onstream, 'config.onstream'); + if (onversionnegotiation !== undefined) { + validateFunction(onversionnegotiation, 'config.onversionnegotiation'); + this.#state.hasVersionNegotiationListener = true; + this.#onversionnegotiation = onversionnegotiation; + } + this.#onhandshake = onhandshake; + this.#onstream = onstream; + this.#handle = handle; + this.#endpoint = endpoint; + this.#pendingClose = Promise.withResolvers(); // eslint-disable-line node-core/prefer-primordials + this.#handle[kOwner] = this; + } + + /** @type {boolean} */ + get #isClosedOrClosing() { + return this.#handle === undefined || this.#isPendingClose; + } + + /** @type {SessionStats} */ + get stats() { return this.#stats; } + + /** @type {SessionState} */ + get state() { return this.#state; } + + /** @type {Endpoint} */ + get endpoint() { return this.#endpoint; } + + /** @type {Path} */ + get path() { + if (this.#isClosedOrClosing) return undefined; + if (this.#remoteAddress === undefined) { + const addr = this.#handle.getRemoteAddress(); + if (addr !== undefined) { + this.#remoteAddress = new InternalSocketAddress(addr); + } + } + return { + local: this.#endpoint.address, + remote: this.#remoteAddress, + }; + } + + /** + * @returns {QuicStream} + */ + openBidirectionalStream() { + if (this.#isClosedOrClosing) { + throw new ERR_INVALID_STATE('Session is closed'); + } + if (!this.state.isStreamOpenAllowed) { + throw new ERR_QUIC_OPEN_STREAM_FAILED(); + } + const handle = this.#handle.openStream(STREAM_DIRECTION_BIDIRECTIONAL); + if (handle === undefined) { + throw new ERR_QUIC_OPEN_STREAM_FAILED(); + } + const stream = new QuicStream(this.#streamConfig, handle, this, 0); + ArrayPrototypePush(this.#streams, stream); + return stream; + } + + /** + * @returns {QuicStream} + */ + openUnidirectionalStream() { + if (this.#isClosedOrClosing) { + throw new ERR_INVALID_STATE('Session is closed'); + } + if (!this.state.isStreamOpenAllowed) { + throw new ERR_QUIC_OPEN_STREAM_FAILED(); + } + const handle = this.#handle.openStream(STREAM_DIRECTION_UNIDIRECTIONAL); + if (handle === undefined) { + throw new ERR_QUIC_OPEN_STREAM_FAILED(); + } + const stream = new QuicStream(this.#streamConfig, handle, this, 1); + ArrayPrototypePush(this.#streams, stream); + return stream; + } + + /** + * Initiate a key update. + */ + updateKey() { + if (this.#isClosedOrClosing) { + throw new ERR_INVALID_STATE('Session is closed'); + } + this.#handle.updateKey(); + } + + /** + * Gracefully closes the session. Any streams created on the session will be + * allowed to complete gracefully and any datagrams that have already been + * queued for sending will be allowed to complete. Once all streams have been + * completed and all datagrams have been sent, the session will be closed. + * New streams will not be allowed to be created. The returned promise will + * be resolved when the session closes, or will be rejected if the session + * closes abruptly due to an error. + * @returns {Promise} + */ + close() { + if (!this.#isClosedOrClosing) { + this.#isPendingClose = true; + this.#handle?.gracefulClose(); + } + return this.closed; + } + + /** + * A promise that is resolved when the session is closed, or is rejected if + * the session is closed abruptly due to an error. + * @type {Promise} + */ + get closed() { return this.#pendingClose.promise; } + + /** @type {boolean} */ + get destroyed() { return this.#handle === undefined; } + + /** + * Forcefully closes the session abruptly without waiting for streams to be + * completed naturally. Any streams that are still open will be immediately + * destroyed and any queued datagrams will be dropped. If an error is given, + * the closed promise will be rejected with that error. If no error is given, + * the closed promise will be resolved. + * @param {any} error + */ + destroy(error) { + // TODO(@jasnell): Implement. + } + + /** + * Send a datagram. The id of the sent datagram will be returned. The status + * 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 an ArrayBufferView is given, the view will be copied. + * @param {ArrayBufferView|string} datagram The datagram payload + * @returns {bigint} The datagram ID + */ + sendDatagram(datagram) { + if (this.#isClosedOrClosing) { + throw new ERR_INVALID_STATE('Session is closed'); + } + if (typeof datagram === 'string') { + datagram = Buffer.from(datagram, 'utf8'); + } else { + if (!isArrayBufferView(datagram)) { + throw new ERR_INVALID_ARG_TYPE('datagram', + ['ArrayBufferView', 'string'], + datagram); + } + datagram = new Uint8Array(datagram.buffer.transfer(), + datagram.byteOffset, + datagram.byteLength); + } + return this.#handle.sendDatagram(datagram); + } + + /** + * @param {Uint8Array} u8 + * @param {boolean} early + */ + [kDatagram](u8, early) { + if (this.destroyed) return; + this.#ondatagram(u8, this, early); + } + + /** + * @param {bigint} id + * @param {'lost'|'acknowledged'} status + */ + [kDatagramStatus](id, status) { + if (this.destroyed) return; + this.#ondatagramstatus(id, status, this); + } + + /** + * @param {'aborted'|'failure'|'success'} result + * @param {SocketAddress} newLocalAddress + * @param {SocketAddress} newRemoteAddress + * @param {SocketAddress} oldLocalAddress + * @param {SocketAddress} oldRemoteAddress + * @param {boolean} preferredAddress + */ + [kPathValidation](result, newLocalAddress, newRemoteAddress, oldLocalAddress, + oldRemoteAddress, preferredAddress) { + if (this.destroyed) return; + this.#onpathvalidation(result, newLocalAddress, newRemoteAddress, + oldLocalAddress, oldRemoteAddress, preferredAddress, + this); + } + + /** + * @param {object} ticket + */ + [kSessionTicket](ticket) { + if (this.destroyed) return; + this.#onsessionticket(ticket, this); + } + + /** + * @param {number} version + * @param {number[]} requestedVersions + * @param {number[]} supportedVersions + */ + [kVersionNegotiation](version, requestedVersions, supportedVersions) { + if (this.destroyed) return; + this.#onversionnegotiation(version, requestedVersions, supportedVersions, this); + } + + /** + * @param {string} sni + * @param {string} alpn + * @param {string} cipher + * @param {string} cipherVersion + * @param {string} validationErrorReason + * @param {number} validationErrorCode + * @param {boolean} earlyDataAccepted + */ + [kHandshake](sni, alpn, cipher, cipherVersion, validationErrorReason, + validationErrorCode, earlyDataAccepted) { + if (this.destroyed) return; + this.#onhandshake(sni, alpn, cipher, cipherVersion, validationErrorReason, + validationErrorCode, earlyDataAccepted, this); + } + + /** + * @param {object} handle + * @param {number} direction + */ + [kNewStream](handle, direction) { + const stream = new QuicStream(this.#streamConfig, handle, this, direction); + ArrayPrototypePush(this.#streams, stream); + this.#onstream(stream, this); + } + + [kInspect](depth, options) { + if (depth < 0) + return this; + + const opts = { + ...options, + depth: options.depth == null ? null : options.depth - 1, + }; + + return `Session ${inspect({ + closed: this.closed, + closing: this.#isPendingClose, + destroyed: this.destroyed, + endpoint: this.endpoint, + path: this.path, + state: this.state, + stats: this.stats, + streams: this.#streams, + }, opts)}`; + } + + [kFinishClose](errorType, code, reason) { + // TODO(@jasnell): Finish the implementation + } + + async [SymbolAsyncDispose]() { await this.close(); } +} + +class EndpointStats { + /** @type {BigUint64Array} */ + #handle; + + /** + * @param {ArrayBuffer} buffer + */ + constructor(buffer) { + if (!isArrayBuffer(buffer)) { + throw new ERR_INVALID_ARG_TYPE('buffer', ['ArrayBuffer'], buffer); + } + this.#handle = new BigUint64Array(buffer); + } + + /** @type {bigint} */ + get createdAt() { + return this.#handle[IDX_STATS_ENDPOINT_CREATED_AT]; + } + + /** @type {bigint} */ + get destroyedAt() { + return this.#handle[IDX_STATS_ENDPOINT_DESTROYED_AT]; + } + + /** @type {bigint} */ + get bytesReceived() { + return this.#handle[IDX_STATS_ENDPOINT_BYTES_RECEIVED]; + } + + /** @type {bigint} */ + get bytesSent() { + return this.#handle[IDX_STATS_ENDPOINT_BYTES_SENT]; + } + + /** @type {bigint} */ + get packetsReceived() { + return this.#handle[IDX_STATS_ENDPOINT_PACKETS_RECEIVED]; + } + + /** @type {bigint} */ + get packetsSent() { + return this.#handle[IDX_STATS_ENDPOINT_PACKETS_SENT]; + } + + /** @type {bigint} */ + get serverSessions() { + return this.#handle[IDX_STATS_ENDPOINT_SERVER_SESSIONS]; + } + + /** @type {bigint} */ + get clientSessions() { + return this.#handle[IDX_STATS_ENDPOINT_CLIENT_SESSIONS]; + } + + /** @type {bigint} */ + get serverBusyCount() { + return this.#handle[IDX_STATS_ENDPOINT_SERVER_BUSY_COUNT]; + } + + /** @type {bigint} */ + get retryCount() { + return this.#handle[IDX_STATS_ENDPOINT_RETRY_COUNT]; + } + + /** @type {bigint} */ + get versionNegotiationCount() { + return this.#handle[IDX_STATS_ENDPOINT_VERSION_NEGOTIATION_COUNT]; + } + + /** @type {bigint} */ + get statelessResetCount() { + return this.#handle[IDX_STATS_ENDPOINT_STATELESS_RESET_COUNT]; + } + + /** @type {bigint} */ + get immediateCloseCount() { + return this.#handle[IDX_STATS_ENDPOINT_IMMEDIATE_CLOSE_COUNT]; + } + + toJSON() { + return { + __proto__: null, + createdAt: `${this.createdAt}`, + destroyedAt: `${this.destroyedAt}`, + bytesReceived: `${this.bytesReceived}`, + bytesSent: `${this.bytesSent}`, + packetsReceived: `${this.packetsReceived}`, + packetsSent: `${this.packetsSent}`, + serverSessions: `${this.serverSessions}`, + clientSessions: `${this.clientSessions}`, + serverBusyCount: `${this.serverBusyCount}`, + retryCount: `${this.retryCount}`, + versionNegotiationCount: `${this.versionNegotiationCount}`, + statelessResetCount: `${this.statelessResetCount}`, + immediateCloseCount: `${this.immediateCloseCount}`, + }; + } + + [kInspect](depth, options) { + if (depth < 0) + return this; + + const opts = { + ...options, + depth: options.depth == null ? null : options.depth - 1, + }; + + return `EndpointStats ${inspect({ + createdAt: this.createdAt, + destroyedAt: this.destroyedAt, + bytesReceived: this.bytesReceived, + bytesSent: this.bytesSent, + packetsReceived: this.packetsReceived, + packetsSent: this.packetsSent, + serverSessions: this.serverSessions, + clientSessions: this.clientSessions, + serverBusyCount: this.serverBusyCount, + retryCount: this.retryCount, + versionNegotiationCount: this.versionNegotiationCount, + statelessResetCount: this.statelessResetCount, + immediateCloseCount: this.immediateCloseCount, + }, opts)}`; + } + + [kFinishClose]() { + // Snapshot the stats into a new BigUint64Array since the underlying + // buffer will be destroyed. + this.#handle = new BigUint64Array(this.#handle); + } +} + +class EndpointState { + /** @type {DataView} */ + #handle; + + /** + * @param {ArrayBuffer} buffer + */ + constructor(buffer) { + if (!isArrayBuffer(buffer)) { + throw new ERR_INVALID_ARG_TYPE('buffer', ['ArrayBuffer'], buffer); + } + this.#handle = new DataView(buffer); + } + + #assertNotClosed() { + if (this.#handle.byteLength === 0) { + throw new ERR_INVALID_STATE('Endpoint is closed'); + } + } + + /** @type {boolean} */ + get isBound() { + this.#assertNotClosed(); + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_BOUND); + } + + /** @type {boolean} */ + get isReceiving() { + this.#assertNotClosed(); + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_RECEIVING); + } + + /** @type {boolean} */ + get isListening() { + this.#assertNotClosed(); + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_LISTENING); + } + + /** @type {boolean} */ + get isClosing() { + this.#assertNotClosed(); + return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_CLOSING); + } + + /** @type {boolean} */ + get isBusy() { + this.#assertNotClosed(); + 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 + * waiting on before it can be closed. + * @type {bigint} + */ + get pendingCallbacks() { + this.#assertNotClosed(); + return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_ENDPOINT_PENDING_CALLBACKS); + } + + toJSON() { + if (this.#handle.byteLength === 0) return {}; + return { + __proto__: null, + isBound: this.isBound, + isReceiving: this.isReceiving, + isListening: this.isListening, + isClosing: this.isClosing, + isBusy: this.isBusy, + pendingCallbacks: `${this.pendingCallbacks}`, + }; + } + + [kInspect](depth, options) { + if (depth < 0) + return this; + + if (this.#handle.byteLength === 0) { + return 'EndpointState { }'; + } + + const opts = { + ...options, + depth: options.depth == null ? null : options.depth - 1, + }; + + return `EndpointState ${inspect({ + isBound: this.isBound, + isReceiving: this.isReceiving, + isListening: this.isListening, + isClosing: this.isClosing, + isBusy: this.isBusy, + pendingCallbacks: this.pendingCallbacks, + }, opts)}`; + } + + [kFinishClose]() { + // Snapshot the state into a new DataView since the underlying + // buffer will be destroyed. + if (this.#handle.byteLength === 0) return; + this.#handle = new DataView(new ArrayBuffer(0)); + } +} + +function validateStreamConfig(config, name = 'config') { + validateObject(config, name); + if (config.onerror !== undefined) + validateFunction(config.onerror, `${name}.onerror`); + if (config.onblocked !== undefined) + validateFunction(config.onblocked, `${name}.onblocked`); + if (config.onreset !== undefined) + validateFunction(config.onreset, `${name}.onreset`); + if (config.onheaders !== undefined) + validateFunction(config.onheaders, `${name}.onheaders`); + if (config.ontrailers !== undefined) + validateFunction(config.ontrailers, `${name}.ontrailers`); + return config; +} + +function validateSessionConfig(config, name = 'config') { + validateObject(config, name); + if (config.onstream !== undefined) + validateFunction(config.onstream, `${name}.onstream`); + if (config.ondatagram !== undefined) + validateFunction(config.ondatagram, `${name}.ondatagram`); + if (config.ondatagramstatus !== undefined) + validateFunction(config.ondatagramstatus, `${name}.ondatagramstatus`); + if (config.onpathvalidation !== undefined) + validateFunction(config.onpathvalidation, `${name}.onpathvalidation`); + if (config.onsessionticket !== undefined) + validateFunction(config.onsessionticket, `${name}.onsessionticket`); + if (config.onversionnegotiation !== undefined) + validateFunction(config.onversionnegotiation, `${name}.onversionnegotiation`); + if (config.onhandshake !== undefined) + validateFunction(config.onhandshake, `${name}.onhandshake`); + return config; +} + +function validateEndpointConfig(config) { + validateObject(config, 'config'); + validateFunction(config.onsession, 'config.onsession'); + if (config.session !== undefined) + validateSessionConfig(config.session, 'config.session'); + if (config.stream !== undefined) + validateStreamConfig(config.stream, 'config.stream'); + return config; +} + +class Endpoint { + /** @type {SocketAddress|undefined} */ + #address = undefined; + /** @type {boolean} */ + #busy = false; + /** @type {object} */ + #handle; + /** @type {boolean} */ + #isPendingClose = false; + /** @type {boolean} */ + #listening = false; + /** @type {PromiseWithResolvers} */ + #pendingClose; + /** @type {any} */ + #pendingError = undefined; + /** @type {Session[]} */ + #sessions = []; + /** @type {EndpointState} */ + #state; + /** @type {EndpointStats} */ + #stats; + /** @type {OnSessionCallback} */ + #onsession; + /** @type {SessionCallbackConfiguration} */ + #sessionConfig; + /** @type {StreamCallbackConfiguration */ + #streamConfig; + + /** + * @param {EndpointCallbackConfiguration} config + * @param {EndpointOptions} [options] + */ + constructor(config, options = kEmptyObject) { + validateEndpointConfig(config); + this.#onsession = config.onsession; + this.#sessionConfig = config.session; + this.#streamConfig = config.stream; + + validateObject(options, 'options'); + let { address } = options; + const { + retryTokenExpiration, + tokenExpiration, + maxConnectionsPerHost, + maxConnectionsTotal, + maxStatelessResetsPerHost, + addressLRUSize, + maxRetries, + maxPayloadSize, + unacknowledgedPacketThreshold, + handshakeTimeout, + maxStreamWindow, + maxWindow, + rxDiagnosticLoss, + txDiagnosticLoss, + udpReceiveBufferSize, + udpSendBufferSize, + udpTTL, + noUdpPayloadSizeShaping, + validateAddress, + disableActiveMigration, + ipv6Only, + cc, + resetTokenSecret, + tokenSecret, + } = options; + + // All of the other options will be validated internally by the C++ code + if (address !== undefined && !SocketAddress.isSocketAddress(address)) { + if (typeof address === 'object' && address !== null) { + address = new SocketAddress(address); + } else { + throw new ERR_INVALID_ARG_TYPE('options.address', 'SocketAddress', address); + } + } + + this.#pendingClose = Promise.withResolvers(); // eslint-disable-line node-core/prefer-primordials + this.#handle = new Endpoint_({ + __proto__: null, + address: address?.[kSocketAddressHandle], + retryTokenExpiration, + tokenExpiration, + maxConnectionsPerHost, + maxConnectionsTotal, + maxStatelessResetsPerHost, + addressLRUSize, + maxRetries, + maxPayloadSize, + unacknowledgedPacketThreshold, + handshakeTimeout, + maxStreamWindow, + maxWindow, + rxDiagnosticLoss, + txDiagnosticLoss, + udpReceiveBufferSize, + udpSendBufferSize, + udpTTL, + noUdpPayloadSizeShaping, + validateAddress, + disableActiveMigration, + ipv6Only, + cc, + resetTokenSecret, + tokenSecret, + }); + this.#handle[kOwner] = this; + this.#stats = new EndpointStats(this.#handle.stats); + this.#state = new EndpointState(this.#handle.state); + } + + /** @type {EndpointStats} */ + get stats() { return this.#stats; } + + /** @type {EndpointState} */ + get state() { return this.#state; } + + get #isClosedOrClosing() { + return this.#handle === undefined || this.#isPendingClose; + } + + /** + * When an endpoint is marked as busy, it will not accept new connections. + * Existing connections will continue to work. + * @type {boolean} + */ + get busy() { return this.#busy; } + + /** + * @type {boolean} + */ + set busy(val) { + if (this.#isClosedOrClosing) { + throw new ERR_INVALID_STATE('Endpoint is closed'); + } + // The val is allowed to be any truthy value + val = !!val; + // Non-op if there is no change + if (val !== this.#busy) { + this.#busy = val; + this.#handle.markBusy(this.#busy); + } + } + + /** + * The local address the endpoint is bound to (if any) + * @type {SocketAddress|undefined} + */ + get address() { + if (this.#isClosedOrClosing) return undefined; + if (this.#address === undefined) { + const addr = this.#handle.address(); + if (addr !== undefined) this.#address = new InternalSocketAddress(addr); + } + return this.#address; + } + + /** + * Configures the endpoint to listen for incoming connections. + * @param {SessionOptions} [options] + */ + listen(options = kEmptyObject) { + if (this.#isClosedOrClosing) { + throw new ERR_INVALID_STATE('Endpoint is closed'); + } + if (this.#listening) { + throw new ERR_INVALID_STATE('Endpoint is already listening'); + } + + validateObject(options, 'options'); + + const { + version, + minVersion, + preferredAddressPolicy = 'default', + application = kEmptyObject, + transportParams = kEmptyObject, + tls = kEmptyObject, + qlog = false, + } = options; + + this.#handle.listen({ + version, + minVersion, + preferredAddressPolicy: getPreferredAddressPolicy(preferredAddressPolicy), + application, + transportParams, + tls: processTlsOptions(tls), + qlog, + }); + this.#listening = true; + } + + /** + * Initiates a session with a remote endpoint. + * @param {SocketAddress} address + * @param {SessionOptions} [options] + * @returns {Session} + */ + connect(address, options = kEmptyObject) { + if (this.#isClosedOrClosing) { + throw new ERR_INVALID_STATE('Endpoint is closed'); + } + if (this.#busy) { + throw new ERR_INVALID_STATE('Endpoint is busy'); + } + + if (address === undefined || !SocketAddress.isSocketAddress(address)) { + if (typeof address === 'object' && address !== null) { + address = new SocketAddress(address); + } else { + throw new ERR_INVALID_ARG_TYPE('address', 'SocketAddress', address); + } + } + + validateObject(options, 'options'); + const { + version, + minVersion, + preferredAddressPolicy = 'default', + application = kEmptyObject, + transportParams = kEmptyObject, + tls = kEmptyObject, + qlog = false, + sessionTicket, + } = options; + + const handle = this.#handle.connect(address[kSocketAddressHandle], { + version, + minVersion, + preferredAddressPolicy: getPreferredAddressPolicy(preferredAddressPolicy), + application, + transportParams, + tls: processTlsOptions(tls), + qlog, + }, sessionTicket); + + if (handle === undefined) { + throw new ERR_QUIC_CONNECTION_FAILED(); + } + const session = new Session(this.#sessionConfig, this.#streamConfig, handle, this); + ArrayPrototypePush(this.#sessions, session); + return session; + } + + /** + * Gracefully closes the endpoint. Any existing sessions will be permitted to + * end gracefully, after which the endpoint will be closed. New sessions will + * not be accepted or created. The returned promise will be resolved when + * closing is complete, or will be rejected if the endpoint is closed abruptly + * due to an error. + * @returns {Promise} + */ + close() { + if (!this.#isClosedOrClosing) { + this.#isPendingClose = true; + this.#handle?.closeGracefully(); + } + return this.closed; + } + + /** + * Returns a promise that is resolved when the endpoint is closed or rejects + * if the endpoint is closed abruptly due to an error. The closed property + * is set to the same promise that is returned by the close() method. + * @type {Promise} + */ + get closed() { return this.#pendingClose.promise; } + + /** @type {boolean} */ + get destroyed() { return this.#handle === undefined; } + + /** + * Forcefully terminates the endpoint by immediately destroying all sessions + * after calling close. If an error is given, the closed promise will be + * rejected with that error. If no error is given, the closed promise will + * be resolved. + * @param {any} error + */ + destroy(error) { + if (!this.#isClosedOrClosing) { + // Start closing the endpoint. + this.#pendingError = error; + // Trigger a graceful close of the endpoint that'll ensure that the + // endpoint is closed down after all sessions are closed... + this.close(); + } + // Now, force all sessions to be abruptly closed... + for (const session of this.#sessions) { + session.destroy(error); + } + } + + ref() { + if (this.#handle !== undefined) this.#handle.ref(true); + } + + unref() { + if (this.#handle !== undefined) this.#handle.ref(false); + } + + [kFinishClose](context, status) { + if (this.#handle === undefined) return; + this.#isPendingClose = false; + this.#address = undefined; + this.#busy = false; + this.#listening = false; + this.#isPendingClose = false; + this.#stats[kFinishClose](); + this.#state[kFinishClose](); + this.#sessions = []; + + switch (context) { + case kCloseContextClose: { + if (this.#pendingError !== undefined) { + this.#pendingClose.reject(this.#pendingError); + } else { + this.#pendingClose.resolve(); + } + break; + } + case kCloseContextBindFailure: { + this.#pendingClose.reject( + new ERR_QUIC_ENDPOINT_CLOSED('Bind failure', status)); + break; + } + case kCloseContextListenFailure: { + this.#pendingClose.reject( + new ERR_QUIC_ENDPOINT_CLOSED('Listen failure', status)); + break; + } + case kCloseContextReceiveFailure: { + this.#pendingClose.reject( + new ERR_QUIC_ENDPOINT_CLOSED('Receive failure', status)); + break; + } + case kCloseContextSendFailure: { + this.#pendingClose.reject( + new ERR_QUIC_ENDPOINT_CLOSED('Send failure', status)); + break; + } + case kCloseContextStartFailure: { + this.#pendingClose.reject( + new ERR_QUIC_ENDPOINT_CLOSED('Start failure', status)); + break; + } + } + + this.#pendingError = undefined; + this.#pendingClose.resolve = undefined; + this.#pendingClose.reject = undefined; + this.#handle = undefined; + } + + [kNewSession](handle) { + const session = new Session(this.#sessionConfig, this.#streamConfig, handle, this); + ArrayPrototypePush(this.#sessions, session); + this.#onsession(session, this); + } + + async [SymbolAsyncDispose]() { await this.close(); } + + [kInspect](depth, options) { + if (depth < 0) + return this; + + const opts = { + ...options, + depth: options.depth == null ? null : options.depth - 1, + }; + + return `Endpoint ${inspect({ + address: this.address, + busy: this.busy, + closed: this.closed, + closing: this.#isPendingClose, + destroyed: this.destroyed, + listening: this.#listening, + sessions: this.#sessions, + stats: this.stats, + state: this.state, + }, opts)}`; + } +}; + +ObjectDefineProperties(QuicStreamStats.prototype, { + createdAt: kEnumerable, + receivedAt: kEnumerable, + ackedAt: kEnumerable, + closingAt: kEnumerable, + destroyedAt: kEnumerable, + bytesReceived: kEnumerable, + bytesSent: kEnumerable, + maxOffset: kEnumerable, + maxOffsetAcknowledged: kEnumerable, + maxOffsetReceived: kEnumerable, + finalSize: kEnumerable, +}); +ObjectDefineProperties(QuicStreamState.prototype, { + id: kEnumerable, + finSent: kEnumerable, + finReceived: kEnumerable, + readEnded: kEnumerable, + writeEnded: kEnumerable, + destroyed: kEnumerable, + paused: kEnumerable, + reset: kEnumerable, + hasReader: kEnumerable, + wantsBlock: kEnumerable, + wantsHeaders: kEnumerable, + wantsReset: kEnumerable, + wantsTrailers: kEnumerable, +}); +ObjectDefineProperties(QuicStream.prototype, { + stats: kEnumerable, + state: kEnumerable, + session: kEnumerable, + id: kEnumerable, +}); +ObjectDefineProperties(SessionStats.prototype, { + createdAt: kEnumerable, + closingAt: kEnumerable, + destroyedAt: kEnumerable, + handshakeCompletedAt: kEnumerable, + handshakeConfirmedAt: kEnumerable, + gracefulClosingAt: kEnumerable, + bytesReceived: kEnumerable, + bytesSent: kEnumerable, + bidiInStreamCount: kEnumerable, + bidiOutStreamCount: kEnumerable, + uniInStreamCount: kEnumerable, + uniOutStreamCount: kEnumerable, + lossRetransmitCount: kEnumerable, + maxBytesInFlights: kEnumerable, + bytesInFlight: kEnumerable, + blockCount: kEnumerable, + cwnd: kEnumerable, + latestRtt: kEnumerable, + minRtt: kEnumerable, + rttVar: kEnumerable, + smoothedRtt: kEnumerable, + ssthresh: kEnumerable, + datagramsReceived: kEnumerable, + datagramsSent: kEnumerable, + datagramsAcknowledged: kEnumerable, + datagramsLost: kEnumerable, +}); +ObjectDefineProperties(SessionState.prototype, { + hasPathValidationListener: kEnumerable, + hasVersionNegotiationListener: kEnumerable, + hasDatagramListener: kEnumerable, + hasSessionTicketListener: kEnumerable, + isClosing: kEnumerable, + isGracefulClose: kEnumerable, + isSilentClose: kEnumerable, + isStatelessReset: kEnumerable, + isDestroyed: kEnumerable, + isHandshakeCompleted: kEnumerable, + isHandshakeConfirmed: kEnumerable, + isStreamOpenAllowed: kEnumerable, + isPrioritySupported: kEnumerable, + isWrapped: kEnumerable, + lastDatagramId: kEnumerable, +}); +ObjectDefineProperties(Session.prototype, { + closed: kEnumerable, + destroyed: kEnumerable, + endpoint: kEnumerable, + path: kEnumerable, + state: kEnumerable, + stats: kEnumerable, +}); +ObjectDefineProperties(EndpointStats.prototype, { + createdAt: kEnumerable, + destroyedAt: kEnumerable, + bytesReceived: kEnumerable, + bytesSent: kEnumerable, + packetsReceived: kEnumerable, + packetsSent: kEnumerable, + serverSessions: kEnumerable, + clientSessions: kEnumerable, + serverBusyCount: kEnumerable, + retryCount: kEnumerable, + versionNegotiationCount: kEnumerable, + statelessResetCount: kEnumerable, + immediateCloseCount: kEnumerable, +}); +ObjectDefineProperties(EndpointState.prototype, { + isBound: kEnumerable, + isReceiving: kEnumerable, + isListening: kEnumerable, + isClosing: kEnumerable, + isBusy: kEnumerable, + pendingCallbacks: kEnumerable, +}); +ObjectDefineProperties(Endpoint.prototype, { + address: kEnumerable, + busy: kEnumerable, + closed: kEnumerable, + destroyed: kEnumerable, + state: kEnumerable, + stats: kEnumerable, +}); +ObjectDefineProperties(Endpoint, { + CC_ALGO_RENO: { + __proto__: null, + value: CC_ALGO_RENO, + writable: false, + configurable: false, + enumerable: true, + }, + CC_ALGO_CUBIC: { + __proto__: null, + value: CC_ALGO_CUBIC, + writable: false, + configurable: false, + enumerable: true, + }, + CC_ALGO_BBR: { + __proto__: null, + value: CC_ALGO_BBR, + writable: false, + configurable: false, + enumerable: true, + }, + CC_ALGO_RENO_STR: { + __proto__: null, + value: CC_ALGO_RENO_STR, + writable: false, + configurable: false, + enumerable: true, + }, + CC_ALGO_CUBIC_STR: { + __proto__: null, + value: CC_ALGO_CUBIC_STR, + writable: false, + configurable: false, + enumerable: true, + }, + CC_ALGO_BBR_STR: { + __proto__: null, + value: CC_ALGO_BBR_STR, + writable: false, + configurable: false, + enumerable: true, + }, +}); +ObjectDefineProperties(Session, { + DEFAULT_CIPHERS: { + __proto__: null, + value: DEFAULT_CIPHERS, + writable: false, + configurable: false, + enumerable: true, + }, + DEFAULT_GROUPS: { + __proto__: null, + value: DEFAULT_GROUPS, + writable: false, + configurable: false, + enumerable: true, + }, +}); + +module.exports = { + Endpoint, + Session, + QuicStream, + // Exported for testing + kFinishClose, + SessionState, + SessionStats, + QuicStreamState, + QuicStreamStats, + EndpointState, + EndpointStats, +}; + +/* c8 ignore stop */ diff --git a/src/node_builtins.cc b/src/node_builtins.cc index 2bc7155f7c075e..4d6e616a1f3590 100644 --- a/src/node_builtins.cc +++ b/src/node_builtins.cc @@ -132,6 +132,9 @@ BuiltinLoader::BuiltinCategories BuiltinLoader::GetBuiltinCategories() const { "internal/http2/core", "internal/http2/compat", "internal/streams/lazy_transform", #endif // !HAVE_OPENSSL +#if !NODE_OPENSSL_HAS_QUIC + "internal/quic/quic", +#endif // !NODE_OPENSSL_HAS_QUIC "sqlite", // Experimental. "sys", // Deprecated. "wasi", // Experimental. diff --git a/src/quic/endpoint.cc b/src/quic/endpoint.cc index 8fb6900b9cfe6f..40e700083987ef 100644 --- a/src/quic/endpoint.cc +++ b/src/quic/endpoint.cc @@ -233,6 +233,7 @@ bool SetOption(Environment* env, Maybe Endpoint::Options::From(Environment* env, Local value) { if (value.IsEmpty() || !value->IsObject()) { + if (value->IsUndefined()) return Just(Endpoint::Options()); THROW_ERR_INVALID_ARG_TYPE(env, "options must be an object"); return Nothing(); } @@ -656,6 +657,25 @@ void Endpoint::InitPerContext(Realm* realm, Local target) { NODE_DEFINE_CONSTANT(target, DEFAULT_REGULARTOKEN_EXPIRATION); NODE_DEFINE_CONSTANT(target, DEFAULT_MAX_PACKET_LENGTH); + static constexpr auto CLOSECONTEXT_CLOSE = + static_cast(CloseContext::CLOSE); + static constexpr auto CLOSECONTEXT_BIND_FAILURE = + static_cast(CloseContext::BIND_FAILURE); + static constexpr auto CLOSECONTEXT_LISTEN_FAILURE = + static_cast(CloseContext::LISTEN_FAILURE); + static constexpr auto CLOSECONTEXT_RECEIVE_FAILURE = + static_cast(CloseContext::RECEIVE_FAILURE); + static constexpr auto CLOSECONTEXT_SEND_FAILURE = + static_cast(CloseContext::SEND_FAILURE); + static constexpr auto CLOSECONTEXT_START_FAILURE = + static_cast(CloseContext::START_FAILURE); + NODE_DEFINE_CONSTANT(target, CLOSECONTEXT_CLOSE); + NODE_DEFINE_CONSTANT(target, CLOSECONTEXT_BIND_FAILURE); + NODE_DEFINE_CONSTANT(target, CLOSECONTEXT_LISTEN_FAILURE); + NODE_DEFINE_CONSTANT(target, CLOSECONTEXT_RECEIVE_FAILURE); + NODE_DEFINE_CONSTANT(target, CLOSECONTEXT_SEND_FAILURE); + NODE_DEFINE_CONSTANT(target, CLOSECONTEXT_START_FAILURE); + SetConstructorFunction(realm->context(), target, "Endpoint", @@ -682,6 +702,7 @@ Endpoint::Endpoint(Environment* env, udp_(this), addrLRU_(options_.address_lru_size) { MakeWeak(); + STAT_RECORD_TIMESTAMP(Stats, created_at); IF_QUIC_DEBUG(env) { Debug(this, "Endpoint created. Options %s", options.ToString()); } @@ -704,6 +725,7 @@ SocketAddress Endpoint::local_address() const { void Endpoint::MarkAsBusy(bool on) { Debug(this, "Marking endpoint as %s", on ? "busy" : "not busy"); + if (on) STAT_INCREMENT(Stats, server_busy_count); state_->busy = on ? 1 : 0; } @@ -1087,6 +1109,7 @@ void Endpoint::Destroy(CloseContext context, int status) { state_->bound = 0; state_->receiving = 0; BindingData::Get(env()).listening_endpoints.erase(this); + STAT_RECORD_TIMESTAMP(Stats, destroyed_at); EmitClose(close_context_, close_status_); } diff --git a/src/quic/session.cc b/src/quic/session.cc index e3cc27b2c66286..94432c94e0e0d1 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -1682,10 +1682,16 @@ void Session::EmitStream(BaseObjectPtr stream) { if (is_destroyed()) return; if (!env()->can_call_into_js()) return; CallbackScope cb_scope(this); - Local arg = stream->object(); + auto isolate = env()->isolate(); + Local argv[] = { + stream->object(), + Integer::NewFromUnsigned(isolate, + static_cast(stream->direction())), + }; Debug(this, "Notifying JavaScript of stream created"); - MakeCallback(BindingData::Get(env()).stream_created_callback(), 1, &arg); + MakeCallback( + BindingData::Get(env()).stream_created_callback(), arraysize(argv), argv); } void Session::EmitVersionNegotiation(const ngtcp2_pkt_hd& hd, @@ -2358,6 +2364,11 @@ void Session::InitPerContext(Realm* realm, Local target) { NODE_DEFINE_CONSTANT(target, QUIC_PROTO_MAX); NODE_DEFINE_CONSTANT(target, QUIC_PROTO_MIN); + NODE_DEFINE_STRING_CONSTANT( + target, "DEFAULT_CIPHERS", TLSContext::DEFAULT_CIPHERS); + NODE_DEFINE_STRING_CONSTANT( + target, "DEFAULT_GROUPS", TLSContext::DEFAULT_GROUPS); + #define V(name, _) IDX_STATS_SESSION_##name, enum SessionStatsIdx { SESSION_STATS(V) IDX_STATS_SESSION_COUNT }; #undef V diff --git a/src/quic/tlscontext.cc b/src/quic/tlscontext.cc index 76d11c3d837e2e..995f69ba060c20 100644 --- a/src/quic/tlscontext.cc +++ b/src/quic/tlscontext.cc @@ -274,7 +274,7 @@ crypto::SSLCtxPointer TLSContext::Initialize() { break; } case Side::CLIENT: { - ctx_.reset(SSL_CTX_new(TLS_client_method())); + ctx.reset(SSL_CTX_new(TLS_client_method())); CHECK_EQ(ngtcp2_crypto_quictls_configure_client_context(ctx.get()), 0); SSL_CTX_set_session_cache_mode( diff --git a/test/parallel/test-quic-internal-endpoint-listen-defaults.js b/test/parallel/test-quic-internal-endpoint-listen-defaults.js index 9fb9f9461c58b6..7b073104a148ce 100644 --- a/test/parallel/test-quic-internal-endpoint-listen-defaults.js +++ b/test/parallel/test-quic-internal-endpoint-listen-defaults.js @@ -1,76 +1,76 @@ -// Flags: --expose-internals +// Flags: --expose-internals --no-warnings 'use strict'; -const common = require('../common'); -if (!common.hasQuic) - common.skip('missing quic'); +const { hasQuic } = require('../common'); -const { internalBinding } = require('internal/test/binding'); const { - ok, - strictEqual, - deepStrictEqual, -} = require('node:assert'); + describe, + it, +} = require('node:test'); -const { - SocketAddress: _SocketAddress, - AF_INET, -} = internalBinding('block_list'); -const quic = internalBinding('quic'); - -quic.setCallbacks({ - onEndpointClose: common.mustCall((...args) => { - deepStrictEqual(args, [0, 0]); - }), - - // The following are unused in this test - onSessionNew() {}, - onSessionClose() {}, - onSessionDatagram() {}, - onSessionDatagramStatus() {}, - onSessionHandshake() {}, - onSessionPathValidation() {}, - onSessionTicket() {}, - onSessionVersionNegotiation() {}, - onStreamCreated() {}, - onStreamBlocked() {}, - onStreamClose() {}, - onStreamReset() {}, - onStreamHeaders() {}, - onStreamTrailers() {}, -}); +describe('quic internal endpoint listen defaults', { skip: !hasQuic }, async () => { + const { + ok, + strictEqual, + throws, + } = require('node:assert'); -const endpoint = new quic.Endpoint({}); + const { + SocketAddress, + } = require('net'); -const state = new DataView(endpoint.state); -ok(!state.getUint8(quic.IDX_STATE_ENDPOINT_LISTENING)); -ok(!state.getUint8(quic.IDX_STATE_ENDPOINT_RECEIVING)); -ok(!state.getUint8(quic.IDX_STATE_ENDPOINT_BOUND)); -strictEqual(endpoint.address(), undefined); + const { + Endpoint, + } = require('internal/quic/quic'); -endpoint.listen({}); + it('are reasonable and work as expected', async () => { + const endpoint = new Endpoint({ + onsession() {}, + session: {}, + stream: {}, + }, {}); -ok(state.getUint8(quic.IDX_STATE_ENDPOINT_LISTENING)); -ok(state.getUint8(quic.IDX_STATE_ENDPOINT_RECEIVING)); -ok(state.getUint8(quic.IDX_STATE_ENDPOINT_BOUND)); -const address = endpoint.address(); -ok(address instanceof _SocketAddress); + ok(!endpoint.state.isBound); + ok(!endpoint.state.isReceiving); + ok(!endpoint.state.isListening); -const detail = address.detail({ - address: undefined, - port: undefined, - family: undefined, - flowlabel: undefined, -}); + strictEqual(endpoint.address, undefined); + + throws(() => endpoint.listen(123), { + code: 'ERR_INVALID_ARG_TYPE', + }); + + endpoint.listen(); + throws(() => endpoint.listen(), { + code: 'ERR_INVALID_STATE', + }); -strictEqual(detail.address, '127.0.0.1'); -strictEqual(detail.family, AF_INET); -strictEqual(detail.flowlabel, 0); -ok(detail.port !== 0); + ok(endpoint.state.isBound); + ok(endpoint.state.isReceiving); + ok(endpoint.state.isListening); -endpoint.closeGracefully(); + const address = endpoint.address; + ok(address instanceof SocketAddress); -ok(!state.getUint8(quic.IDX_STATE_ENDPOINT_LISTENING)); -ok(!state.getUint8(quic.IDX_STATE_ENDPOINT_RECEIVING)); -ok(!state.getUint8(quic.IDX_STATE_ENDPOINT_BOUND)); -strictEqual(endpoint.address(), undefined); + strictEqual(address.address, '127.0.0.1'); + strictEqual(address.family, 'ipv4'); + strictEqual(address.flowlabel, 0); + ok(address.port !== 0); + + ok(!endpoint.destroyed); + endpoint.destroy(); + strictEqual(endpoint.closed, endpoint.close()); + await endpoint.closed; + ok(endpoint.destroyed); + + throws(() => endpoint.listen(), { + code: 'ERR_INVALID_STATE', + }); + throws(() => { endpoint.busy = true; }, { + code: 'ERR_INVALID_STATE', + }); + await endpoint[Symbol.asyncDispose](); + + strictEqual(endpoint.address, undefined); + }); +}); diff --git a/test/parallel/test-quic-internal-endpoint-options.js b/test/parallel/test-quic-internal-endpoint-options.js index 672fac18b43779..88dc42c4dd7ade 100644 --- a/test/parallel/test-quic-internal-endpoint-options.js +++ b/test/parallel/test-quic-internal-endpoint-options.js @@ -1,215 +1,230 @@ // Flags: --expose-internals 'use strict'; -const common = require('../common'); -if (!common.hasQuic) - common.skip('missing quic'); +const { hasQuic } = require('../common'); + const { - throws, -} = require('node:assert'); + describe, + it, +} = require('node:test'); -const { internalBinding } = require('internal/test/binding'); -const quic = internalBinding('quic'); +describe('quic internal endpoint options', { skip: !hasQuic }, async () => { + const { + strictEqual, + throws, + } = require('node:assert'); -quic.setCallbacks({ - onEndpointClose() {}, - onSessionNew() {}, - onSessionClose() {}, - onSessionDatagram() {}, - onSessionDatagramStatus() {}, - onSessionHandshake() {}, - onSessionPathValidation() {}, - onSessionTicket() {}, - onSessionVersionNegotiation() {}, - onStreamCreated() {}, - onStreamBlocked() {}, - onStreamClose() {}, - onStreamReset() {}, - onStreamHeaders() {}, - onStreamTrailers() {}, -}); + const { + Endpoint, + } = require('internal/quic/quic'); -throws(() => new quic.Endpoint(), { - code: 'ERR_INVALID_ARG_TYPE', - message: 'options must be an object' -}); + const { + inspect, + } = require('util'); -throws(() => new quic.Endpoint('a'), { - code: 'ERR_INVALID_ARG_TYPE', - message: 'options must be an object' -}); + const callbackConfig = { + onsession() {}, + session: {}, + stream: {}, + }; -throws(() => new quic.Endpoint(null), { - code: 'ERR_INVALID_ARG_TYPE', - message: 'options must be an object' -}); + it('invalid options', async () => { + ['a', null, false, NaN].forEach((i) => { + throws(() => new Endpoint(callbackConfig, i), { + code: 'ERR_INVALID_ARG_TYPE', + }); + }); + }); -throws(() => new quic.Endpoint(false), { - code: 'ERR_INVALID_ARG_TYPE', - message: 'options must be an object' -}); + it('valid options', async () => { + // Just Works... using all defaults + new Endpoint(callbackConfig, {}); + new Endpoint(callbackConfig); + new Endpoint(callbackConfig, undefined); + }); + + it('various cases', async () => { + const cases = [ + { + key: 'retryTokenExpiration', + valid: [ + 1, 10, 100, 1000, 10000, 10000n, + ], + invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}] + }, + { + key: 'tokenExpiration', + valid: [ + 1, 10, 100, 1000, 10000, 10000n, + ], + invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}] + }, + { + key: 'maxConnectionsPerHost', + valid: [ + 1, 10, 100, 1000, 10000, 10000n, + ], + invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}] + }, + { + key: 'maxConnectionsTotal', + valid: [ + 1, 10, 100, 1000, 10000, 10000n, + ], + invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}] + }, + { + key: 'maxStatelessResetsPerHost', + valid: [ + 1, 10, 100, 1000, 10000, 10000n, + ], + invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}] + }, + { + key: 'addressLRUSize', + valid: [ + 1, 10, 100, 1000, 10000, 10000n, + ], + invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}] + }, + { + key: 'maxRetries', + valid: [ + 1, 10, 100, 1000, 10000, 10000n, + ], + invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}] + }, + { + key: 'maxPayloadSize', + valid: [ + 1, 10, 100, 1000, 10000, 10000n, + ], + invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}] + }, + { + key: 'unacknowledgedPacketThreshold', + valid: [ + 1, 10, 100, 1000, 10000, 10000n, + ], + invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}] + }, + { + key: 'validateAddress', + valid: [true, false, 0, 1, 'a'], + invalid: [], + }, + { + key: 'disableStatelessReset', + valid: [true, false, 0, 1, 'a'], + invalid: [], + }, + { + key: 'ipv6Only', + valid: [true, false, 0, 1, 'a'], + invalid: [], + }, + { + key: 'cc', + valid: [ + Endpoint.CC_ALGO_RENO, + Endpoint.CC_ALGO_CUBIC, + Endpoint.CC_ALGO_BBR, + Endpoint.CC_ALGO_RENO_STR, + Endpoint.CC_ALGO_CUBIC_STR, + Endpoint.CC_ALGO_BBR_STR, + ], + invalid: [-1, 4, 1n, 'a', null, false, true, {}, [], () => {}], + }, + { + key: 'udpReceiveBufferSize', + valid: [0, 1, 2, 3, 4, 1000], + invalid: [-1, 'a', null, false, true, {}, [], () => {}], + }, + { + key: 'udpSendBufferSize', + valid: [0, 1, 2, 3, 4, 1000], + invalid: [-1, 'a', null, false, true, {}, [], () => {}], + }, + { + key: 'udpTTL', + valid: [0, 1, 2, 3, 4, 255], + invalid: [-1, 256, 'a', null, false, true, {}, [], () => {}], + }, + { + key: 'resetTokenSecret', + valid: [ + new Uint8Array(16), + new Uint16Array(8), + new Uint32Array(4), + ], + invalid: [ + 'a', null, false, true, {}, [], () => {}, + new Uint8Array(15), + new Uint8Array(17), + new ArrayBuffer(16), + ], + }, + { + key: 'tokenSecret', + valid: [ + new Uint8Array(16), + new Uint16Array(8), + new Uint32Array(4), + ], + invalid: [ + 'a', null, false, true, {}, [], () => {}, + new Uint8Array(15), + new Uint8Array(17), + new ArrayBuffer(16), + ], + }, + { + // Unknown options are ignored entirely for any value type + key: 'ignored', + valid: ['a', null, false, true, {}, [], () => {}], + invalid: [], + }, + ]; + + for (const { key, valid, invalid } of cases) { + for (const value of valid) { + const options = {}; + options[key] = value; + new Endpoint(callbackConfig, options); + } -{ - // Just Works... using all defaults - new quic.Endpoint({}); -} + for (const value of invalid) { + const options = {}; + options[key] = value; + throws(() => new Endpoint(callbackConfig, options), { + code: 'ERR_INVALID_ARG_VALUE', + }); + } + } + }); -const cases = [ - { - key: 'retryTokenExpiration', - valid: [ - 1, 10, 100, 1000, 10000, 10000n, - ], - invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}] - }, - { - key: 'tokenExpiration', - valid: [ - 1, 10, 100, 1000, 10000, 10000n, - ], - invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}] - }, - { - key: 'maxConnectionsPerHost', - valid: [ - 1, 10, 100, 1000, 10000, 10000n, - ], - invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}] - }, - { - key: 'maxConnectionsTotal', - valid: [ - 1, 10, 100, 1000, 10000, 10000n, - ], - invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}] - }, - { - key: 'maxStatelessResetsPerHost', - valid: [ - 1, 10, 100, 1000, 10000, 10000n, - ], - invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}] - }, - { - key: 'addressLRUSize', - valid: [ - 1, 10, 100, 1000, 10000, 10000n, - ], - invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}] - }, - { - key: 'maxRetries', - valid: [ - 1, 10, 100, 1000, 10000, 10000n, - ], - invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}] - }, - { - key: 'maxPayloadSize', - valid: [ - 1, 10, 100, 1000, 10000, 10000n, - ], - invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}] - }, - { - key: 'unacknowledgedPacketThreshold', - valid: [ - 1, 10, 100, 1000, 10000, 10000n, - ], - invalid: [-1, -1n, 'a', null, false, true, {}, [], () => {}] - }, - { - key: 'validateAddress', - valid: [true, false, 0, 1, 'a'], - invalid: [], - }, - { - key: 'disableStatelessReset', - valid: [true, false, 0, 1, 'a'], - invalid: [], - }, - { - key: 'ipv6Only', - valid: [true, false, 0, 1, 'a'], - invalid: [], - }, - { - key: 'cc', - valid: [ - quic.CC_ALGO_RENO, - quic.CC_ALGO_CUBIC, - quic.CC_ALGO_BBR, - quic.CC_ALGO_BBR2, - quic.CC_ALGO_RENO_STR, - quic.CC_ALGO_CUBIC_STR, - quic.CC_ALGO_BBR_STR, - quic.CC_ALGO_BBR2_STR, - ], - invalid: [-1, 4, 1n, 'a', null, false, true, {}, [], () => {}], - }, - { - key: 'udpReceiveBufferSize', - valid: [0, 1, 2, 3, 4, 1000], - invalid: [-1, 'a', null, false, true, {}, [], () => {}], - }, - { - key: 'udpSendBufferSize', - valid: [0, 1, 2, 3, 4, 1000], - invalid: [-1, 'a', null, false, true, {}, [], () => {}], - }, - { - key: 'udpTTL', - valid: [0, 1, 2, 3, 4, 255], - invalid: [-1, 256, 'a', null, false, true, {}, [], () => {}], - }, - { - key: 'resetTokenSecret', - valid: [ - new Uint8Array(16), - new Uint16Array(8), - new Uint32Array(4), - ], - invalid: [ - 'a', null, false, true, {}, [], () => {}, - new Uint8Array(15), - new Uint8Array(17), - new ArrayBuffer(16), - ], - }, - { - key: 'tokenSecret', - valid: [ - new Uint8Array(16), - new Uint16Array(8), - new Uint32Array(4), - ], - invalid: [ - 'a', null, false, true, {}, [], () => {}, - new Uint8Array(15), - new Uint8Array(17), - new ArrayBuffer(16), - ], - }, - { - // Unknown options are ignored entirely for any value type - key: 'ignored', - valid: ['a', null, false, true, {}, [], () => {}], - invalid: [], - }, -]; + it('endpoint can be ref/unrefed without error', async () => { + const endpoint = new Endpoint(callbackConfig, {}); + endpoint.unref(); + endpoint.ref(); + endpoint.close(); + await endpoint.closed; + }); -for (const { key, valid, invalid } of cases) { - for (const value of valid) { - const options = {}; - options[key] = value; - new quic.Endpoint(options); - } + it('endpoint can be inspected', async () => { + const endpoint = new Endpoint(callbackConfig, {}); + strictEqual(typeof inspect(endpoint), 'string'); + endpoint.close(); + await endpoint.closed; + }); - for (const value of invalid) { - const options = {}; - options[key] = value; - throws(() => new quic.Endpoint(options), { - code: 'ERR_INVALID_ARG_VALUE', + it('endpoint with object address', () => { + new Endpoint(callbackConfig, { + address: { host: '127.0.0.1:0' }, }); - } -} + throws(() => new Endpoint(callbackConfig, { address: '127.0.0.1:0' }), { + code: 'ERR_INVALID_ARG_TYPE', + }); + }); + +}); diff --git a/test/parallel/test-quic-internal-endpoint-stats-state.js b/test/parallel/test-quic-internal-endpoint-stats-state.js index 566dd675d73e26..18a6ed6b138048 100644 --- a/test/parallel/test-quic-internal-endpoint-stats-state.js +++ b/test/parallel/test-quic-internal-endpoint-stats-state.js @@ -1,79 +1,245 @@ // Flags: --expose-internals 'use strict'; -const common = require('../common'); -if (!common.hasQuic) - common.skip('missing quic'); +const { hasQuic } = require('../common'); + const { - strictEqual, -} = require('node:assert'); + describe, + it, +} = require('node:test'); -const { internalBinding } = require('internal/test/binding'); -const quic = internalBinding('quic'); +describe('quic internal endpoint stats and state', { skip: !hasQuic }, () => { + const { + Endpoint, + QuicStreamState, + QuicStreamStats, + SessionState, + SessionStats, + kFinishClose, + } = require('internal/quic/quic'); -const { - IDX_STATS_ENDPOINT_CREATED_AT, - IDX_STATS_ENDPOINT_DESTROYED_AT, - IDX_STATS_ENDPOINT_BYTES_RECEIVED, - IDX_STATS_ENDPOINT_BYTES_SENT, - IDX_STATS_ENDPOINT_PACKETS_RECEIVED, - IDX_STATS_ENDPOINT_PACKETS_SENT, - IDX_STATS_ENDPOINT_SERVER_SESSIONS, - IDX_STATS_ENDPOINT_CLIENT_SESSIONS, - IDX_STATS_ENDPOINT_SERVER_BUSY_COUNT, - IDX_STATS_ENDPOINT_RETRY_COUNT, - IDX_STATS_ENDPOINT_VERSION_NEGOTIATION_COUNT, - IDX_STATS_ENDPOINT_STATELESS_RESET_COUNT, - IDX_STATS_ENDPOINT_IMMEDIATE_CLOSE_COUNT, - IDX_STATS_ENDPOINT_COUNT, - IDX_STATE_ENDPOINT_BOUND, - IDX_STATE_ENDPOINT_BOUND_SIZE, - IDX_STATE_ENDPOINT_RECEIVING, - IDX_STATE_ENDPOINT_RECEIVING_SIZE, - IDX_STATE_ENDPOINT_LISTENING, - IDX_STATE_ENDPOINT_LISTENING_SIZE, - IDX_STATE_ENDPOINT_CLOSING, - IDX_STATE_ENDPOINT_CLOSING_SIZE, - IDX_STATE_ENDPOINT_BUSY, - IDX_STATE_ENDPOINT_BUSY_SIZE, - IDX_STATE_ENDPOINT_PENDING_CALLBACKS, - IDX_STATE_ENDPOINT_PENDING_CALLBACKS_SIZE, -} = quic; - -const endpoint = new quic.Endpoint({}); - -const state = new DataView(endpoint.state); -strictEqual(IDX_STATE_ENDPOINT_BOUND_SIZE, 1); -strictEqual(IDX_STATE_ENDPOINT_RECEIVING_SIZE, 1); -strictEqual(IDX_STATE_ENDPOINT_LISTENING_SIZE, 1); -strictEqual(IDX_STATE_ENDPOINT_CLOSING_SIZE, 1); -strictEqual(IDX_STATE_ENDPOINT_BUSY_SIZE, 1); -strictEqual(IDX_STATE_ENDPOINT_PENDING_CALLBACKS_SIZE, 8); - -strictEqual(state.getUint8(IDX_STATE_ENDPOINT_BOUND), 0); -strictEqual(state.getUint8(IDX_STATE_ENDPOINT_RECEIVING), 0); -strictEqual(state.getUint8(IDX_STATE_ENDPOINT_LISTENING), 0); -strictEqual(state.getUint8(IDX_STATE_ENDPOINT_CLOSING), 0); -strictEqual(state.getUint8(IDX_STATE_ENDPOINT_BUSY), 0); -strictEqual(state.getBigUint64(IDX_STATE_ENDPOINT_PENDING_CALLBACKS), 0n); - -endpoint.markBusy(true); -strictEqual(state.getUint8(IDX_STATE_ENDPOINT_BUSY), 1); -endpoint.markBusy(false); -strictEqual(state.getUint8(IDX_STATE_ENDPOINT_BUSY), 0); - -const stats = new BigUint64Array(endpoint.stats); -strictEqual(stats[IDX_STATS_ENDPOINT_CREATED_AT], 0n); -strictEqual(stats[IDX_STATS_ENDPOINT_DESTROYED_AT], 0n); -strictEqual(stats[IDX_STATS_ENDPOINT_BYTES_RECEIVED], 0n); -strictEqual(stats[IDX_STATS_ENDPOINT_BYTES_SENT], 0n); -strictEqual(stats[IDX_STATS_ENDPOINT_PACKETS_RECEIVED], 0n); -strictEqual(stats[IDX_STATS_ENDPOINT_PACKETS_SENT], 0n); -strictEqual(stats[IDX_STATS_ENDPOINT_SERVER_SESSIONS], 0n); -strictEqual(stats[IDX_STATS_ENDPOINT_CLIENT_SESSIONS], 0n); -strictEqual(stats[IDX_STATS_ENDPOINT_SERVER_BUSY_COUNT], 0n); -strictEqual(stats[IDX_STATS_ENDPOINT_RETRY_COUNT], 0n); -strictEqual(stats[IDX_STATS_ENDPOINT_VERSION_NEGOTIATION_COUNT], 0n); -strictEqual(stats[IDX_STATS_ENDPOINT_STATELESS_RESET_COUNT], 0n); -strictEqual(stats[IDX_STATS_ENDPOINT_IMMEDIATE_CLOSE_COUNT], 0n); -strictEqual(IDX_STATS_ENDPOINT_COUNT, 13); + const { + inspect, + } = require('util'); + + const { + deepStrictEqual, + strictEqual, + throws, + } = require('node:assert'); + + it('endpoint state', () => { + const endpoint = new Endpoint({ + onsession() {}, + session: {}, + stream: {}, + }); + + strictEqual(endpoint.state.isBound, false); + strictEqual(endpoint.state.isReceiving, false); + strictEqual(endpoint.state.isListening, false); + strictEqual(endpoint.state.isClosing, false); + strictEqual(endpoint.state.isBusy, false); + strictEqual(endpoint.state.pendingCallbacks, 0n); + + deepStrictEqual(JSON.parse(JSON.stringify(endpoint.state)), { + isBound: false, + isReceiving: false, + isListening: false, + isClosing: false, + isBusy: false, + pendingCallbacks: '0', + }); + + endpoint.busy = true; + strictEqual(endpoint.state.isBusy, true); + endpoint.busy = false; + strictEqual(endpoint.state.isBusy, false); + + it('state can be inspected without errors', () => { + strictEqual(typeof inspect(endpoint.state), 'string'); + }); + }); + + it('state is not readable after close', () => { + const endpoint = new Endpoint({ + onsession() {}, + session: {}, + stream: {}, + }, {}); + endpoint.state[kFinishClose](); + throws(() => endpoint.state.isBound, { + name: 'Error', + }); + }); + + it('state constructor argument is ArrayBuffer', () => { + const endpoint = new Endpoint({ + onsession() {}, + session: {}, + stream: {}, + }, {}); + const Cons = endpoint.state.constructor; + throws(() => new Cons(1), { + code: 'ERR_INVALID_ARG_TYPE' + }); + }); + + it('endpoint stats', () => { + const endpoint = new Endpoint({ + onsession() {}, + session: {}, + stream: {}, + }); + + strictEqual(typeof endpoint.stats.createdAt, 'bigint'); + strictEqual(typeof endpoint.stats.destroyedAt, 'bigint'); + strictEqual(typeof endpoint.stats.bytesReceived, 'bigint'); + strictEqual(typeof endpoint.stats.bytesSent, 'bigint'); + strictEqual(typeof endpoint.stats.packetsReceived, 'bigint'); + strictEqual(typeof endpoint.stats.packetsSent, 'bigint'); + strictEqual(typeof endpoint.stats.serverSessions, 'bigint'); + strictEqual(typeof endpoint.stats.clientSessions, 'bigint'); + strictEqual(typeof endpoint.stats.serverBusyCount, 'bigint'); + strictEqual(typeof endpoint.stats.retryCount, 'bigint'); + strictEqual(typeof endpoint.stats.versionNegotiationCount, 'bigint'); + strictEqual(typeof endpoint.stats.statelessResetCount, 'bigint'); + strictEqual(typeof endpoint.stats.immediateCloseCount, 'bigint'); + + deepStrictEqual(Object.keys(endpoint.stats.toJSON()), [ + 'createdAt', + 'destroyedAt', + 'bytesReceived', + 'bytesSent', + 'packetsReceived', + 'packetsSent', + 'serverSessions', + 'clientSessions', + 'serverBusyCount', + 'retryCount', + 'versionNegotiationCount', + 'statelessResetCount', + 'immediateCloseCount', + ]); + + it('stats can be inspected without errors', () => { + strictEqual(typeof inspect(endpoint.stats), 'string'); + }); + }); + + it('stats are still readble after close', () => { + const endpoint = new Endpoint({ + onsession() {}, + session: {}, + stream: {}, + }, {}); + strictEqual(typeof endpoint.stats.toJSON(), 'object'); + endpoint.stats[kFinishClose](); + strictEqual(typeof endpoint.stats.destroyedAt, 'bigint'); + strictEqual(typeof endpoint.stats.toJSON(), 'object'); + }); + + it('stats constructor argument is ArrayBuffer', () => { + const endpoint = new Endpoint({ + onsession() {}, + session: {}, + stream: {}, + }, {}); + const Cons = endpoint.stats.constructor; + throws(() => new Cons(1), { + code: 'ERR_INVALID_ARG_TYPE', + }); + }); + + // TODO(@jasnell): The following tests are largely incomplete. + // This is largely here to boost the code coverage numbers + // temporarily while the rest of the functionality is being + // implemented. + it('stream and session states', () => { + const streamState = new QuicStreamState(new ArrayBuffer(1024)); + const sessionState = new SessionState(new ArrayBuffer(1024)); + + strictEqual(streamState.finSent, false); + strictEqual(streamState.finReceived, false); + strictEqual(streamState.readEnded, false); + strictEqual(streamState.writeEnded, false); + strictEqual(streamState.destroyed, false); + strictEqual(streamState.paused, false); + strictEqual(streamState.reset, false); + strictEqual(streamState.hasReader, false); + strictEqual(streamState.wantsBlock, false); + strictEqual(streamState.wantsHeaders, false); + strictEqual(streamState.wantsReset, false); + strictEqual(streamState.wantsTrailers, false); + + strictEqual(sessionState.hasPathValidationListener, false); + strictEqual(sessionState.hasVersionNegotiationListener, false); + strictEqual(sessionState.hasDatagramListener, false); + strictEqual(sessionState.hasSessionTicketListener, false); + strictEqual(sessionState.isClosing, false); + strictEqual(sessionState.isGracefulClose, false); + strictEqual(sessionState.isSilentClose, false); + strictEqual(sessionState.isStatelessReset, false); + strictEqual(sessionState.isDestroyed, false); + strictEqual(sessionState.isHandshakeCompleted, false); + strictEqual(sessionState.isHandshakeConfirmed, false); + strictEqual(sessionState.isStreamOpenAllowed, false); + strictEqual(sessionState.isPrioritySupported, false); + strictEqual(sessionState.isWrapped, false); + strictEqual(sessionState.lastDatagramId, 0n); + + strictEqual(typeof streamState.toJSON(), 'object'); + strictEqual(typeof sessionState.toJSON(), 'object'); + strictEqual(typeof inspect(streamState), 'string'); + strictEqual(typeof inspect(sessionState), 'string'); + }); + + it('stream and session stats', () => { + const streamStats = new QuicStreamStats(new ArrayBuffer(1024)); + const sessionStats = new SessionStats(new ArrayBuffer(1024)); + strictEqual(streamStats.createdAt, undefined); + strictEqual(streamStats.receivedAt, undefined); + strictEqual(streamStats.ackedAt, undefined); + strictEqual(streamStats.closingAt, undefined); + strictEqual(streamStats.destroyedAt, undefined); + strictEqual(streamStats.bytesReceived, undefined); + strictEqual(streamStats.bytesSent, undefined); + strictEqual(streamStats.maxOffset, undefined); + strictEqual(streamStats.maxOffsetAcknowledged, undefined); + strictEqual(streamStats.maxOffsetReceived, undefined); + strictEqual(streamStats.finalSize, undefined); + strictEqual(typeof streamStats.toJSON(), 'object'); + strictEqual(typeof inspect(streamStats), 'string'); + streamStats[kFinishClose](); + + strictEqual(typeof sessionStats.createdAt, 'bigint'); + strictEqual(typeof sessionStats.closingAt, 'bigint'); + strictEqual(typeof sessionStats.destroyedAt, 'bigint'); + strictEqual(typeof sessionStats.handshakeCompletedAt, 'bigint'); + strictEqual(typeof sessionStats.handshakeConfirmedAt, 'bigint'); + strictEqual(typeof sessionStats.gracefulClosingAt, 'bigint'); + strictEqual(typeof sessionStats.bytesReceived, 'bigint'); + strictEqual(typeof sessionStats.bytesSent, 'bigint'); + strictEqual(typeof sessionStats.bidiInStreamCount, 'bigint'); + strictEqual(typeof sessionStats.bidiOutStreamCount, 'bigint'); + strictEqual(typeof sessionStats.uniInStreamCount, 'bigint'); + strictEqual(typeof sessionStats.uniOutStreamCount, 'bigint'); + strictEqual(typeof sessionStats.lossRetransmitCount, 'bigint'); + strictEqual(typeof sessionStats.maxBytesInFlights, 'bigint'); + strictEqual(typeof sessionStats.bytesInFlight, 'bigint'); + strictEqual(typeof sessionStats.blockCount, 'bigint'); + strictEqual(typeof sessionStats.cwnd, 'bigint'); + strictEqual(typeof sessionStats.latestRtt, 'bigint'); + strictEqual(typeof sessionStats.minRtt, 'bigint'); + strictEqual(typeof sessionStats.rttVar, 'bigint'); + strictEqual(typeof sessionStats.smoothedRtt, 'bigint'); + strictEqual(typeof sessionStats.ssthresh, 'bigint'); + strictEqual(typeof sessionStats.datagramsReceived, 'bigint'); + strictEqual(typeof sessionStats.datagramsSent, 'bigint'); + strictEqual(typeof sessionStats.datagramsAcknowledged, 'bigint'); + strictEqual(typeof sessionStats.datagramsLost, 'bigint'); + strictEqual(typeof sessionStats.toJSON(), 'object'); + strictEqual(typeof inspect(sessionStats), 'string'); + streamStats[kFinishClose](); + }); +}); diff --git a/test/parallel/test-quic-internal-setcallbacks.js b/test/parallel/test-quic-internal-setcallbacks.js index 881e9161ca9dcc..c503f5f4f25691 100644 --- a/test/parallel/test-quic-internal-setcallbacks.js +++ b/test/parallel/test-quic-internal-setcallbacks.js @@ -1,38 +1,47 @@ -// Flags: --expose-internals +// Flags: --expose-internals --no-warnings 'use strict'; -const common = require('../common'); -if (!common.hasQuic) - common.skip('missing quic'); -const { internalBinding } = require('internal/test/binding'); -const quic = internalBinding('quic'); +const { hasQuic } = require('../common'); -const { throws } = require('assert'); +const { + describe, + it, +} = require('node:test'); -const callbacks = { - onEndpointClose() {}, - onSessionNew() {}, - onSessionClose() {}, - onSessionDatagram() {}, - onSessionDatagramStatus() {}, - onSessionHandshake() {}, - onSessionPathValidation() {}, - onSessionTicket() {}, - onSessionVersionNegotiation() {}, - onStreamCreated() {}, - onStreamBlocked() {}, - onStreamClose() {}, - onStreamReset() {}, - onStreamHeaders() {}, - onStreamTrailers() {}, -}; -// Fail if any callback is missing -for (const fn of Object.keys(callbacks)) { - // eslint-disable-next-line no-unused-vars - const { [fn]: _, ...rest } = callbacks; - throws(() => quic.setCallbacks(rest), { - code: 'ERR_MISSING_ARGS', +describe('quic internal setCallbacks', { skip: !hasQuic }, () => { + const { internalBinding } = require('internal/test/binding'); + const quic = internalBinding('quic'); + + it('require all callbacks to be set', (t) => { + const callbacks = { + onEndpointClose() {}, + onSessionNew() {}, + onSessionClose() {}, + onSessionDatagram() {}, + onSessionDatagramStatus() {}, + onSessionHandshake() {}, + onSessionPathValidation() {}, + onSessionTicket() {}, + onSessionVersionNegotiation() {}, + onStreamCreated() {}, + onStreamBlocked() {}, + onStreamClose() {}, + onStreamReset() {}, + onStreamHeaders() {}, + onStreamTrailers() {}, + }; + // Fail if any callback is missing + for (const fn of Object.keys(callbacks)) { + // eslint-disable-next-line no-unused-vars + const { [fn]: _, ...rest } = callbacks; + t.assert.throws(() => quic.setCallbacks(rest), { + code: 'ERR_MISSING_ARGS', + }); + } + // If all callbacks are present it should work + quic.setCallbacks(callbacks); + + // Multiple calls should just be ignored. + quic.setCallbacks(callbacks); }); -} -// If all callbacks are present it should work -quic.setCallbacks(callbacks); +});