quic: complete the internal implementation of QUIC

Signed-off-by: James M Snell <jasnell@gmail.com>
Assisted-by: Opencode:Opus 4.6
PR-URL: https://github.com/nodejs/node/pull/62876
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Filip Skokan <panva.ip@gmail.com>
Reviewed-By: Paolo Insogna <paolo@cowtech.it>
Reviewed-By: Robert Nagy <ronagy@icloud.com>
This commit is contained in:
James M Snell authored and James M Snell committed 2026-05-05 19:16:19 -07:00
1 parent 2b112e70fe
commit cf91d181fb
287 files changed
+26391 -2573

No files matched your search

+9 -2
View File
@@ -206,6 +206,7 @@
'defines': [
'BUILDING_NGHTTP3',
'NGHTTP3_STATICLIB',
'DEBUGBUILD',
],
'dependencies': [
'ngtcp2'
@@ -247,7 +248,10 @@
},
{
'target_name': 'ngtcp2_test_server',
'type': 'executable',
# Disabled: ngtcp2 examples now require C++23 (<print>, <expected>,
# std::println, std::expected) which is not yet supported on all
# Node.js platforms. Re-enable when C++23 is available.
'type': 'none',
'cflags': [ '-Wno-everything' ],
'include_dirs': [
'',
@@ -305,7 +309,10 @@
},
{
'target_name': 'ngtcp2_test_client',
'type': 'executable',
# Disabled: ngtcp2 examples now require C++23 (<print>, <expected>,
# std::println, std::expected) which is not yet supported on all
# Node.js platforms. Re-enable when C++23 is available.
'type': 'none',
'cflags': [ '-Wno-everything' ],
'include_dirs': [
'',
+27
View File
@@ -2651,6 +2651,32 @@ added:
Opening a QUIC stream failed.
<a id="ERR_QUIC_STREAM_ABORTED"></a>
### `ERR_QUIC_STREAM_ABORTED`
<!-- YAML
added: REPLACEME
-->
> Stability: 1 - Experimental
The Node.js error code for a [`QuicError`][] thrown to abort a QUIC stream
or session with an explicit application or transport error code.
<a id="ERR_QUIC_STREAM_RESET"></a>
### `ERR_QUIC_STREAM_RESET`
<!-- YAML
added: REPLACEME
-->
> Stability: 1 - Experimental
A QUIC stream was reset by the peer. The error includes the reset code
provided by the peer.
<a id="ERR_QUIC_TRANSPORT_ERROR"></a>
### `ERR_QUIC_TRANSPORT_ERROR`
@@ -4436,6 +4462,7 @@ An error occurred trying to allocate memory. This should never happen.
[`MessagePort`]: worker_threads.md#class-messageport
[`Object.getPrototypeOf`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/getPrototypeOf
[`Object.setPrototypeOf`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/setPrototypeOf
[`QuicError`]: quic.md#class-quicerror
[`REPL`]: repl.md
[`ServerResponse`]: http.md#class-httpserverresponse
[`Writable`]: stream.md#class-streamwritable
+1771 -50
View File
File diff suppressed because it is too large. Load diff
+71 -1
View File
@@ -1,6 +1,7 @@
'use strict';
const {
ArrayPrototypePush,
MathMax,
MathMin,
ObjectDefineProperties,
@@ -536,13 +537,82 @@ function createBlobReaderStream(reader) {
}, { highWaterMark: 0 });
}
// Maximum number of chunks to collect in a single batch to prevent
// unbounded memory growth when the DataQueue has a large burst of data.
const kMaxBatchChunks = 16;
async function* createBlobReaderIterable(reader, options = {}) {
const { getReadError } = options;
let wakeup = PromiseWithResolvers();
reader.setWakeup(wakeup.resolve);
try {
while (true) {
const batch = [];
let blocked = false;
let eos = false;
let error = null;
// Pull as many chunks as available synchronously.
// reader.pull(callback) calls the callback synchronously via
// MakeCallback, so we can collect multiple chunks per iteration
// step without any async overhead.
while (true) {
let pullResult;
reader.pull((status, buffer) => {
pullResult = { status, buffer };
});
if (pullResult.status === 0) {
eos = true;
break;
}
if (pullResult.status < 0) {
error = typeof getReadError === 'function' ?
getReadError(pullResult.status) :
new ERR_INVALID_STATE('The reader is not readable');
break;
}
if (pullResult.status === 2) {
blocked = true;
break;
}
ArrayPrototypePush(batch, new Uint8Array(pullResult.buffer));
if (batch.length >= kMaxBatchChunks) break;
}
if (batch.length > 0) {
yield batch;
}
if (eos) return;
if (error) throw error;
if (blocked) {
const fin = await wakeup.promise;
wakeup = PromiseWithResolvers();
reader.setWakeup(wakeup.resolve);
// If the wakeup was triggered by FIN (EndReadable), the DataQueue
// is capped. Continue the loop to pull again -- the next pull will
// return EOS. Without this, a race between the data notification
// and the FIN notification can leave the iterator waiting for a
// wakeup that will never come.
if (fin) continue;
}
}
} finally {
reader.setWakeup(undefined);
}
}
module.exports = {
Blob,
createBlob,
createBlobFromFilePath,
createBlobReaderIterable,
createBlobReaderStream,
isBlob,
kHandle,
resolveObjectURL,
TransferableBlob,
createBlobReaderStream,
};
+3
View File
@@ -1691,6 +1691,9 @@ E('ERR_QUIC_APPLICATION_ERROR', 'A QUIC application error occurred. %d [%s]', Er
E('ERR_QUIC_CONNECTION_FAILED', 'QUIC connection failed', Error);
E('ERR_QUIC_ENDPOINT_CLOSED', 'QUIC endpoint closed: %s (%d)', Error);
E('ERR_QUIC_OPEN_STREAM_FAILED', 'Failed to open QUIC stream', Error);
E('ERR_QUIC_STREAM_ABORTED', '%s', Error);
E('ERR_QUIC_STREAM_RESET',
'The QUIC stream was reset by the peer with error code %d', Error);
E('ERR_QUIC_TRANSPORT_ERROR', 'A QUIC transport error occurred. %d [%s]', Error);
E('ERR_QUIC_VERSION_NEGOTIATION_ERROR', 'The QUIC session requires version negotiation', Error);
E('ERR_REQUIRE_ASYNC_MODULE', function(filename, parentFilename) {
+2
View File
@@ -1994,6 +1994,8 @@ module.exports = {
},
FileHandle,
kHandle,
kLocked,
kRef,
kUnref,
};
+3
View File
@@ -27,6 +27,7 @@ const {
NODE_PERFORMANCE_ENTRY_TYPE_HTTP,
NODE_PERFORMANCE_ENTRY_TYPE_NET,
NODE_PERFORMANCE_ENTRY_TYPE_DNS,
NODE_PERFORMANCE_ENTRY_TYPE_QUIC,
},
installGarbageCollectionTracking,
observerCounts,
@@ -87,6 +88,7 @@ const kSupportedEntryTypes = ObjectFreeze([
'mark',
'measure',
'net',
'quic',
'resource',
]);
@@ -131,6 +133,7 @@ function getObserverType(type) {
case 'http': return NODE_PERFORMANCE_ENTRY_TYPE_HTTP;
case 'net': return NODE_PERFORMANCE_ENTRY_TYPE_NET;
case 'dns': return NODE_PERFORMANCE_ENTRY_TYPE_DNS;
case 'quic': return NODE_PERFORMANCE_ENTRY_TYPE_QUIC;
}
}
+71
View File
@@ -0,0 +1,71 @@
'use strict';
const dc = require('diagnostics_channel');
const onEndpointCreatedChannel = dc.channel('quic.endpoint.created');
const onEndpointListeningChannel = dc.channel('quic.endpoint.listen');
const onEndpointClosingChannel = dc.channel('quic.endpoint.closing');
const onEndpointClosedChannel = dc.channel('quic.endpoint.closed');
const onEndpointErrorChannel = dc.channel('quic.endpoint.error');
const onEndpointBusyChangeChannel = dc.channel('quic.endpoint.busy.change');
const onEndpointClientSessionChannel = dc.channel('quic.session.created.client');
const onEndpointServerSessionChannel = dc.channel('quic.session.created.server');
const onSessionOpenStreamChannel = dc.channel('quic.session.open.stream');
const onSessionReceivedStreamChannel = dc.channel('quic.session.received.stream');
const onSessionSendDatagramChannel = dc.channel('quic.session.send.datagram');
const onSessionUpdateKeyChannel = dc.channel('quic.session.update.key');
const onSessionClosingChannel = dc.channel('quic.session.closing');
const onSessionClosedChannel = dc.channel('quic.session.closed');
const onSessionReceiveDatagramChannel = dc.channel('quic.session.receive.datagram');
const onSessionReceiveDatagramStatusChannel = dc.channel('quic.session.receive.datagram.status');
const onSessionPathValidationChannel = dc.channel('quic.session.path.validation');
const onSessionNewTokenChannel = dc.channel('quic.session.new.token');
const onSessionTicketChannel = dc.channel('quic.session.ticket');
const onSessionVersionNegotiationChannel = dc.channel('quic.session.version.negotiation');
const onSessionOriginChannel = dc.channel('quic.session.receive.origin');
const onSessionHandshakeChannel = dc.channel('quic.session.handshake');
const onSessionGoawayChannel = dc.channel('quic.session.goaway');
const onSessionEarlyRejectedChannel = dc.channel('quic.session.early.rejected');
const onStreamClosedChannel = dc.channel('quic.stream.closed');
const onStreamHeadersChannel = dc.channel('quic.stream.headers');
const onStreamTrailersChannel = dc.channel('quic.stream.trailers');
const onStreamInfoChannel = dc.channel('quic.stream.info');
const onStreamResetChannel = dc.channel('quic.stream.reset');
const onStreamBlockedChannel = dc.channel('quic.stream.blocked');
const onSessionErrorChannel = dc.channel('quic.session.error');
const onEndpointConnectChannel = dc.channel('quic.endpoint.connect');
module.exports = {
onEndpointCreatedChannel,
onEndpointListeningChannel,
onEndpointClosingChannel,
onEndpointClosedChannel,
onEndpointErrorChannel,
onEndpointBusyChangeChannel,
onEndpointClientSessionChannel,
onEndpointServerSessionChannel,
onSessionOpenStreamChannel,
onSessionReceivedStreamChannel,
onSessionSendDatagramChannel,
onSessionUpdateKeyChannel,
onSessionClosingChannel,
onSessionClosedChannel,
onSessionReceiveDatagramChannel,
onSessionReceiveDatagramStatusChannel,
onSessionPathValidationChannel,
onSessionNewTokenChannel,
onSessionTicketChannel,
onSessionVersionNegotiationChannel,
onSessionOriginChannel,
onSessionHandshakeChannel,
onSessionGoawayChannel,
onSessionEarlyRejectedChannel,
onStreamClosedChannel,
onStreamHeadersChannel,
onStreamTrailersChannel,
onStreamInfoChannel,
onStreamResetChannel,
onStreamBlockedChannel,
onSessionErrorChannel,
onEndpointConnectChannel,
};
+2967 -402
View File
File diff suppressed because it is too large. Load diff
+282 -88
View File
@@ -5,11 +5,29 @@ const {
DataView,
DataViewPrototypeGetBigInt64,
DataViewPrototypeGetBigUint64,
DataViewPrototypeGetByteLength,
DataViewPrototypeGetUint16,
DataViewPrototypeGetUint32,
DataViewPrototypeGetUint8,
DataViewPrototypeSetUint16,
DataViewPrototypeSetUint32,
DataViewPrototypeSetUint8,
Float32Array,
JSONStringify,
Uint8Array,
} = primordials;
// Determine native byte order. The shared state buffer is written by
// C++ in native byte order, so DataView reads must match.
const kIsLittleEndian = (() => {
// -1 as float32 is 0xBF800000. On little-endian, the bytes are
// [0x00, 0x00, 0x80, 0xBF], so byte[3] is 0xBF (non-zero).
// On big-endian, the bytes are [0xBF, 0x80, 0x00, 0x00], so byte[3] is 0.
const buf = new Float32Array(1);
buf[0] = -1;
return new Uint8Array(buf.buffer)[3] !== 0;
})();
const {
getOptionValue,
} = require('internal/options');
@@ -49,10 +67,7 @@ const {
// prevent further updates to the buffer.
const {
IDX_STATE_SESSION_PATH_VALIDATION,
IDX_STATE_SESSION_VERSION_NEGOTIATION,
IDX_STATE_SESSION_DATAGRAM,
IDX_STATE_SESSION_SESSION_TICKET,
IDX_STATE_SESSION_LISTENER_FLAGS,
IDX_STATE_SESSION_CLOSING,
IDX_STATE_SESSION_GRACEFUL_CLOSE,
IDX_STATE_SESSION_SILENT_CLOSE,
@@ -61,15 +76,22 @@ const {
IDX_STATE_SESSION_HANDSHAKE_CONFIRMED,
IDX_STATE_SESSION_STREAM_OPEN_ALLOWED,
IDX_STATE_SESSION_PRIORITY_SUPPORTED,
IDX_STATE_SESSION_HEADERS_SUPPORTED,
IDX_STATE_SESSION_WRAPPED,
IDX_STATE_SESSION_APPLICATION_TYPE,
IDX_STATE_SESSION_NO_ERROR_CODE,
IDX_STATE_SESSION_INTERNAL_ERROR_CODE,
IDX_STATE_SESSION_MAX_DATAGRAM_SIZE,
IDX_STATE_SESSION_LAST_DATAGRAM_ID,
IDX_STATE_SESSION_MAX_PENDING_DATAGRAMS,
IDX_STATE_ENDPOINT_BOUND,
IDX_STATE_ENDPOINT_RECEIVING,
IDX_STATE_ENDPOINT_LISTENING,
IDX_STATE_ENDPOINT_CLOSING,
IDX_STATE_ENDPOINT_BUSY,
IDX_STATE_ENDPOINT_MAX_CONNECTIONS_PER_HOST,
IDX_STATE_ENDPOINT_MAX_CONNECTIONS_TOTAL,
IDX_STATE_ENDPOINT_PENDING_CALLBACKS,
IDX_STATE_STREAM_ID,
@@ -85,12 +107,13 @@ const {
IDX_STATE_STREAM_WANTS_HEADERS,
IDX_STATE_STREAM_WANTS_RESET,
IDX_STATE_STREAM_WANTS_TRAILERS,
IDX_STATE_STREAM_RECEIVED_EARLY_DATA,
IDX_STATE_STREAM_WRITE_DESIRED_SIZE,
IDX_STATE_STREAM_HIGH_WATER_MARK,
IDX_STATE_STREAM_RESET_CODE,
} = internalBinding('quic');
assert(IDX_STATE_SESSION_PATH_VALIDATION !== undefined);
assert(IDX_STATE_SESSION_VERSION_NEGOTIATION !== undefined);
assert(IDX_STATE_SESSION_DATAGRAM !== undefined);
assert(IDX_STATE_SESSION_SESSION_TICKET !== undefined);
assert(IDX_STATE_SESSION_LISTENER_FLAGS !== undefined);
assert(IDX_STATE_SESSION_CLOSING !== undefined);
assert(IDX_STATE_SESSION_GRACEFUL_CLOSE !== undefined);
assert(IDX_STATE_SESSION_SILENT_CLOSE !== undefined);
@@ -99,8 +122,12 @@ assert(IDX_STATE_SESSION_HANDSHAKE_COMPLETED !== undefined);
assert(IDX_STATE_SESSION_HANDSHAKE_CONFIRMED !== undefined);
assert(IDX_STATE_SESSION_STREAM_OPEN_ALLOWED !== undefined);
assert(IDX_STATE_SESSION_PRIORITY_SUPPORTED !== undefined);
assert(IDX_STATE_SESSION_HEADERS_SUPPORTED !== undefined);
assert(IDX_STATE_SESSION_WRAPPED !== undefined);
assert(IDX_STATE_SESSION_APPLICATION_TYPE !== undefined);
assert(IDX_STATE_SESSION_NO_ERROR_CODE !== undefined);
assert(IDX_STATE_SESSION_INTERNAL_ERROR_CODE !== undefined);
assert(IDX_STATE_SESSION_MAX_DATAGRAM_SIZE !== undefined);
assert(IDX_STATE_SESSION_LAST_DATAGRAM_ID !== undefined);
assert(IDX_STATE_ENDPOINT_BOUND !== undefined);
assert(IDX_STATE_ENDPOINT_RECEIVING !== undefined);
@@ -121,6 +148,8 @@ assert(IDX_STATE_STREAM_WANTS_BLOCK !== undefined);
assert(IDX_STATE_STREAM_WANTS_HEADERS !== undefined);
assert(IDX_STATE_STREAM_WANTS_RESET !== undefined);
assert(IDX_STATE_STREAM_WANTS_TRAILERS !== undefined);
assert(IDX_STATE_STREAM_WRITE_DESIRED_SIZE !== undefined);
assert(IDX_STATE_STREAM_RESET_CODE !== undefined);
class QuicEndpointState {
/** @type {DataView} */
@@ -142,43 +171,69 @@ class QuicEndpointState {
/** @type {boolean} */
get isBound() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_BOUND);
}
/** @type {boolean} */
get isReceiving() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_RECEIVING);
}
/** @type {boolean} */
get isListening() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_LISTENING);
}
/** @type {boolean} */
get isClosing() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_CLOSING);
}
/** @type {boolean} */
get isBusy() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_ENDPOINT_BUSY);
}
/** @type {number} */
get maxConnectionsPerHost() {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return DataViewPrototypeGetUint16(
this.#handle, IDX_STATE_ENDPOINT_MAX_CONNECTIONS_PER_HOST, kIsLittleEndian);
}
set maxConnectionsPerHost(val) {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return;
DataViewPrototypeSetUint16(
this.#handle, IDX_STATE_ENDPOINT_MAX_CONNECTIONS_PER_HOST, val, kIsLittleEndian);
}
/** @type {number} */
get maxConnectionsTotal() {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return DataViewPrototypeGetUint16(
this.#handle, IDX_STATE_ENDPOINT_MAX_CONNECTIONS_TOTAL, kIsLittleEndian);
}
set maxConnectionsTotal(val) {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return;
DataViewPrototypeSetUint16(
this.#handle, IDX_STATE_ENDPOINT_MAX_CONNECTIONS_TOTAL, val, kIsLittleEndian);
}
/**
* The number of underlying callbacks that are pending. If the session
* is closing, these are the number of callbacks that the session is
* The number of underlying callbacks that are pending. If the endpoint
* is closing, these are the number of callbacks that the endpoint is
* waiting on before it can be closed.
* @type {bigint}
*/
get pendingCallbacks() {
if (this.#handle.byteLength === 0) return undefined;
return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_ENDPOINT_PENDING_CALLBACKS);
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_ENDPOINT_PENDING_CALLBACKS, kIsLittleEndian);
}
toString() {
@@ -186,7 +241,7 @@ class QuicEndpointState {
}
toJSON() {
if (this.#handle.byteLength === 0) return {};
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return {};
return {
__proto__: null,
isBound: this.isBound,
@@ -194,6 +249,8 @@ class QuicEndpointState {
isListening: this.isListening,
isClosing: this.isClosing,
isBusy: this.isBusy,
maxConnectionsPerHost: this.maxConnectionsPerHost,
maxConnectionsTotal: this.maxConnectionsTotal,
pendingCallbacks: `${this.pendingCallbacks}`,
};
}
@@ -202,11 +259,12 @@ class QuicEndpointState {
if (depth < 0)
return this;
if (this.#handle.byteLength === 0) {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) {
return 'QuicEndpointState { <Closed> }';
}
const opts = {
__proto__: null,
...options,
depth: options.depth == null ? null : options.depth - 1,
};
@@ -224,7 +282,7 @@ class QuicEndpointState {
[kFinishClose]() {
// Snapshot the state into a new DataView since the underlying
// buffer will be destroyed.
if (this.#handle.byteLength === 0) return;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return;
this.#handle = new DataView(new ArrayBuffer(0));
}
}
@@ -247,118 +305,199 @@ class QuicSessionState {
this.#handle = new DataView(buffer);
}
// Listener flags are packed into a single uint32_t bitfield. The bit
// positions must match the SessionListenerFlags enum in session.cc.
static #LISTENER_PATH_VALIDATION = 1 << 0;
static #LISTENER_DATAGRAM = 1 << 1;
static #LISTENER_DATAGRAM_STATUS = 1 << 2;
static #LISTENER_SESSION_TICKET = 1 << 3;
static #LISTENER_NEW_TOKEN = 1 << 4;
static #LISTENER_ORIGIN = 1 << 5;
#getListenerFlag(flag) {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!(DataViewPrototypeGetUint32(
this.#handle, IDX_STATE_SESSION_LISTENER_FLAGS, kIsLittleEndian) & flag);
}
#setListenerFlag(flag, val) {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return;
const current = DataViewPrototypeGetUint32(
this.#handle, IDX_STATE_SESSION_LISTENER_FLAGS, kIsLittleEndian);
DataViewPrototypeSetUint32(
this.#handle, IDX_STATE_SESSION_LISTENER_FLAGS,
val ? (current | flag) : (current & ~flag), kIsLittleEndian);
}
/** @type {boolean} */
get hasPathValidationListener() {
if (this.#handle.byteLength === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_PATH_VALIDATION);
return this.#getListenerFlag(QuicSessionState.#LISTENER_PATH_VALIDATION);
}
/** @type {boolean} */
set hasPathValidationListener(val) {
if (this.#handle.byteLength === 0) return;
DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_PATH_VALIDATION, val ? 1 : 0);
}
/** @type {boolean} */
get hasVersionNegotiationListener() {
if (this.#handle.byteLength === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_VERSION_NEGOTIATION);
}
/** @type {boolean} */
set hasVersionNegotiationListener(val) {
if (this.#handle.byteLength === 0) return;
DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_VERSION_NEGOTIATION, val ? 1 : 0);
this.#setListenerFlag(QuicSessionState.#LISTENER_PATH_VALIDATION, val);
}
/** @type {boolean} */
get hasDatagramListener() {
if (this.#handle.byteLength === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_DATAGRAM);
return this.#getListenerFlag(QuicSessionState.#LISTENER_DATAGRAM);
}
set hasDatagramListener(val) {
this.#setListenerFlag(QuicSessionState.#LISTENER_DATAGRAM, val);
}
/** @type {boolean} */
set hasDatagramListener(val) {
if (this.#handle.byteLength === 0) return;
DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_DATAGRAM, val ? 1 : 0);
get hasDatagramStatusListener() {
return this.#getListenerFlag(QuicSessionState.#LISTENER_DATAGRAM_STATUS);
}
set hasDatagramStatusListener(val) {
this.#setListenerFlag(QuicSessionState.#LISTENER_DATAGRAM_STATUS, val);
}
/** @type {boolean} */
get hasSessionTicketListener() {
if (this.#handle.byteLength === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_SESSION_TICKET);
return this.#getListenerFlag(QuicSessionState.#LISTENER_SESSION_TICKET);
}
set hasSessionTicketListener(val) {
this.#setListenerFlag(QuicSessionState.#LISTENER_SESSION_TICKET, val);
}
/** @type {boolean} */
set hasSessionTicketListener(val) {
if (this.#handle.byteLength === 0) return;
DataViewPrototypeSetUint8(this.#handle, IDX_STATE_SESSION_SESSION_TICKET, val ? 1 : 0);
get hasNewTokenListener() {
return this.#getListenerFlag(QuicSessionState.#LISTENER_NEW_TOKEN);
}
set hasNewTokenListener(val) {
this.#setListenerFlag(QuicSessionState.#LISTENER_NEW_TOKEN, val);
}
/** @type {boolean} */
get hasOriginListener() {
return this.#getListenerFlag(QuicSessionState.#LISTENER_ORIGIN);
}
set hasOriginListener(val) {
this.#setListenerFlag(QuicSessionState.#LISTENER_ORIGIN, val);
}
/** @type {boolean} */
get isClosing() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_CLOSING);
}
/** @type {boolean} */
get isGracefulClose() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_GRACEFUL_CLOSE);
}
/** @type {boolean} */
get isSilentClose() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_SILENT_CLOSE);
}
/** @type {boolean} */
get isStatelessReset() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_STATELESS_RESET);
}
/** @type {boolean} */
get isHandshakeCompleted() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_HANDSHAKE_COMPLETED);
}
/** @type {boolean} */
get isHandshakeConfirmed() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_HANDSHAKE_CONFIRMED);
}
/** @type {boolean} */
get isStreamOpenAllowed() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_STREAM_OPEN_ALLOWED);
}
/** @type {boolean} */
get isPrioritySupported() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_PRIORITY_SUPPORTED);
}
/**
* Whether the negotiated application protocol supports headers.
* Returns 0 (unknown), 1 (supported), or 2 (not supported).
* @type {number}
*/
get headersSupported() {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_HEADERS_SUPPORTED);
}
/** @type {boolean} */
get isWrapped() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_WRAPPED);
}
/** @type {number} */
get applicationType() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return DataViewPrototypeGetUint8(this.#handle, IDX_STATE_SESSION_APPLICATION_TYPE);
}
/**
* The negotiated application protocol's "no error" code, populated
* by the C++ layer when the application is selected during ALPN
* negotiation. For raw QUIC this is `0n`; for HTTP/3 this is
* `0x100n` (`H3_NO_ERROR`).
* @type {bigint}
*/
get noErrorCode() {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return DataViewPrototypeGetBigUint64(
this.#handle, IDX_STATE_SESSION_NO_ERROR_CODE, kIsLittleEndian);
}
/**
* The negotiated application protocol's "internal error" code,
* populated by the C++ layer when the application is selected
* during ALPN negotiation. Used as the wire code for `RESET_STREAM`
* frames when a stream is aborted without a more specific code.
* For raw QUIC this is `0x1n` (NGTCP2_INTERNAL_ERROR); for HTTP/3
* this is `0x102n` (`H3_INTERNAL_ERROR`).
* @type {bigint}
*/
get internalErrorCode() {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return DataViewPrototypeGetBigUint64(
this.#handle, IDX_STATE_SESSION_INTERNAL_ERROR_CODE, kIsLittleEndian);
}
/** @type {number} */
get maxDatagramSize() {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return DataViewPrototypeGetUint16(this.#handle, IDX_STATE_SESSION_MAX_DATAGRAM_SIZE, kIsLittleEndian);
}
/** @type {bigint} */
get lastDatagramId() {
if (this.#handle.byteLength === 0) return undefined;
return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_SESSION_LAST_DATAGRAM_ID);
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return DataViewPrototypeGetBigUint64(this.#handle, IDX_STATE_SESSION_LAST_DATAGRAM_ID, kIsLittleEndian);
}
/** @type {number} */
get maxPendingDatagrams() {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return DataViewPrototypeGetUint16(
this.#handle, IDX_STATE_SESSION_MAX_PENDING_DATAGRAMS, kIsLittleEndian);
}
set maxPendingDatagrams(val) {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return;
DataViewPrototypeSetUint16(
this.#handle, IDX_STATE_SESSION_MAX_PENDING_DATAGRAMS, val, kIsLittleEndian);
}
toString() {
@@ -366,24 +505,31 @@ class QuicSessionState {
}
toJSON() {
if (this.#handle.byteLength === 0) return {};
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return {};
return {
__proto__: null,
hasPathValidationListener: this.hasPathValidationListener,
hasVersionNegotiationListener: this.hasVersionNegotiationListener,
hasDatagramListener: this.hasDatagramListener,
hasDatagramStatusListener: this.hasDatagramStatusListener,
hasSessionTicketListener: this.hasSessionTicketListener,
hasNewTokenListener: this.hasNewTokenListener,
hasOriginListener: this.hasOriginListener,
isClosing: this.isClosing,
isGracefulClose: this.isGracefulClose,
isSilentClose: this.isSilentClose,
isStatelessReset: this.isStatelessReset,
isDestroyed: this.isDestroyed,
isHandshakeCompleted: this.isHandshakeCompleted,
isHandshakeConfirmed: this.isHandshakeConfirmed,
isStreamOpenAllowed: this.isStreamOpenAllowed,
isPrioritySupported: this.isPrioritySupported,
headersSupported: this.headersSupported,
isWrapped: this.isWrapped,
applicationType: this.applicationType,
noErrorCode: `${this.noErrorCode}`,
internalErrorCode: `${this.internalErrorCode}`,
maxDatagramSize: `${this.maxDatagramSize}`,
lastDatagramId: `${this.lastDatagramId}`,
maxPendingDatagrams: this.maxPendingDatagrams,
};
}
@@ -391,31 +537,37 @@ class QuicSessionState {
if (depth < 0)
return this;
if (this.#handle.byteLength === 0) {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) {
return 'QuicSessionState { <Closed> }';
}
const opts = {
__proto__: null,
...options,
depth: options.depth == null ? null : options.depth - 1,
};
return `QuicSessionState ${inspect({
hasPathValidationListener: this.hasPathValidationListener,
hasVersionNegotiationListener: this.hasVersionNegotiationListener,
hasDatagramListener: this.hasDatagramListener,
hasDatagramStatusListener: this.hasDatagramStatusListener,
hasSessionTicketListener: this.hasSessionTicketListener,
hasNewTokenListener: this.hasNewTokenListener,
hasOriginListener: this.hasOriginListener,
isClosing: this.isClosing,
isGracefulClose: this.isGracefulClose,
isSilentClose: this.isSilentClose,
isStatelessReset: this.isStatelessReset,
isDestroyed: this.isDestroyed,
isHandshakeCompleted: this.isHandshakeCompleted,
isHandshakeConfirmed: this.isHandshakeConfirmed,
isStreamOpenAllowed: this.isStreamOpenAllowed,
isPrioritySupported: this.isPrioritySupported,
headersSupported: this.headersSupported,
isWrapped: this.isWrapped,
applicationType: this.applicationType,
noErrorCode: this.noErrorCode,
internalErrorCode: this.internalErrorCode,
maxDatagramSize: this.maxDatagramSize,
lastDatagramId: this.lastDatagramId,
}, opts)}`;
}
@@ -423,7 +575,7 @@ class QuicSessionState {
[kFinishClose]() {
// Snapshot the state into a new DataView since the underlying
// buffer will be destroyed.
if (this.#handle.byteLength === 0) return;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return;
this.#handle = new DataView(new ArrayBuffer(0));
}
}
@@ -448,113 +600,152 @@ class QuicStreamState {
/** @type {bigint} */
get id() {
if (this.#handle.byteLength === 0) return undefined;
return DataViewPrototypeGetBigInt64(this.#handle, IDX_STATE_STREAM_ID);
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return DataViewPrototypeGetBigInt64(this.#handle, IDX_STATE_STREAM_ID, kIsLittleEndian);
}
/** @type {boolean} */
get pending() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_PENDING);
}
/** @type {boolean} */
get finSent() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_FIN_SENT);
}
/** @type {boolean} */
get finReceived() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_FIN_RECEIVED);
}
/** @type {boolean} */
get readEnded() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_READ_ENDED);
}
/** @type {boolean} */
get writeEnded() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WRITE_ENDED);
}
/** @type {boolean} */
get reset() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_RESET);
}
/** @type {boolean} */
get hasOutbound() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_HAS_OUTBOUND);
}
/** @type {boolean} */
get hasReader() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_HAS_READER);
}
/** @type {boolean} */
get wantsBlock() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_BLOCK);
}
/** @type {boolean} */
set wantsBlock(val) {
if (this.#handle.byteLength === 0) return;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return;
DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_BLOCK, val ? 1 : 0);
}
/** @type {boolean} */
get [kWantsHeaders]() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_HEADERS);
}
/** @type {boolean} */
set [kWantsHeaders](val) {
if (this.#handle.byteLength === 0) return;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return;
DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_HEADERS, val ? 1 : 0);
}
/** @type {boolean} */
get wantsReset() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_RESET);
}
/** @type {boolean} */
set wantsReset(val) {
if (this.#handle.byteLength === 0) return;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return;
DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_RESET, val ? 1 : 0);
}
/** @type {boolean} */
get [kWantsTrailers]() {
if (this.#handle.byteLength === 0) return undefined;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_WANTS_TRAILERS);
}
/** @type {boolean} */
set [kWantsTrailers](val) {
if (this.#handle.byteLength === 0) return;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return;
DataViewPrototypeSetUint8(this.#handle, IDX_STATE_STREAM_WANTS_TRAILERS, val ? 1 : 0);
}
/** @type {boolean} */
get early() {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return !!DataViewPrototypeGetUint8(this.#handle, IDX_STATE_STREAM_RECEIVED_EARLY_DATA);
}
/** @type {bigint} */
get resetCode() {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return DataViewPrototypeGetBigUint64(
this.#handle, IDX_STATE_STREAM_RESET_CODE, kIsLittleEndian);
}
/** @type {bigint} */
get writeDesiredSize() {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return DataViewPrototypeGetUint32(
this.#handle, IDX_STATE_STREAM_WRITE_DESIRED_SIZE, kIsLittleEndian);
}
set writeDesiredSize(val) {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return;
DataViewPrototypeSetUint32(
this.#handle, IDX_STATE_STREAM_WRITE_DESIRED_SIZE, val, kIsLittleEndian);
}
/** @type {number} */
get highWaterMark() {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return undefined;
return DataViewPrototypeGetUint32(
this.#handle, IDX_STATE_STREAM_HIGH_WATER_MARK, kIsLittleEndian);
}
set highWaterMark(val) {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return;
DataViewPrototypeSetUint32(
this.#handle, IDX_STATE_STREAM_HIGH_WATER_MARK, val, kIsLittleEndian);
}
toString() {
return JSONStringify(this.toJSON());
}
toJSON() {
if (this.#handle.byteLength === 0) return {};
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return {};
return {
__proto__: null,
id: `${this.id}`,
@@ -568,6 +759,7 @@ class QuicStreamState {
hasReader: this.hasReader,
wantsBlock: this.wantsBlock,
wantsReset: this.wantsReset,
early: this.early,
};
}
@@ -575,11 +767,12 @@ class QuicStreamState {
if (depth < 0)
return this;
if (this.#handle.byteLength === 0) {
if (DataViewPrototypeGetByteLength(this.#handle) === 0) {
return 'QuicStreamState { <Closed> }';
}
const opts = {
__proto__: null,
...options,
depth: options.depth == null ? null : options.depth - 1,
};
@@ -596,13 +789,14 @@ class QuicStreamState {
hasReader: this.hasReader,
wantsBlock: this.wantsBlock,
wantsReset: this.wantsReset,
early: this.early,
}, opts)}`;
}
[kFinishClose]() {
// Snapshot the state into a new DataView since the underlying
// buffer will be destroyed.
if (this.#handle.byteLength === 0) return;
if (DataViewPrototypeGetByteLength(this.#handle) === 0) return;
this.#handle = new DataView(new ArrayBuffer(0));
}
}
+83 -21
View File
@@ -58,11 +58,11 @@ const {
IDX_STATS_ENDPOINT_IMMEDIATE_CLOSE_COUNT,
IDX_STATS_SESSION_CREATED_AT,
IDX_STATS_SESSION_DESTROYED_AT,
IDX_STATS_SESSION_CLOSING_AT,
IDX_STATS_SESSION_HANDSHAKE_COMPLETED_AT,
IDX_STATS_SESSION_HANDSHAKE_CONFIRMED_AT,
IDX_STATS_SESSION_BYTES_RECEIVED,
IDX_STATS_SESSION_BYTES_SENT,
IDX_STATS_SESSION_BIDI_IN_STREAM_COUNT,
IDX_STATS_SESSION_BIDI_OUT_STREAM_COUNT,
IDX_STATS_SESSION_UNI_IN_STREAM_COUNT,
@@ -76,6 +76,15 @@ const {
IDX_STATS_SESSION_RTTVAR,
IDX_STATS_SESSION_SMOOTHED_RTT,
IDX_STATS_SESSION_SSTHRESH,
IDX_STATS_SESSION_PKT_SENT,
IDX_STATS_SESSION_BYTES_SENT,
IDX_STATS_SESSION_PKT_RECV,
IDX_STATS_SESSION_BYTES_RECV,
IDX_STATS_SESSION_PKT_LOST,
IDX_STATS_SESSION_BYTES_LOST,
IDX_STATS_SESSION_PING_RECV,
IDX_STATS_SESSION_PKT_DISCARDED,
IDX_STATS_SESSION_DATAGRAMS_RECEIVED,
IDX_STATS_SESSION_DATAGRAMS_SENT,
IDX_STATS_SESSION_DATAGRAMS_ACKNOWLEDGED,
@@ -108,11 +117,11 @@ assert(IDX_STATS_ENDPOINT_VERSION_NEGOTIATION_COUNT !== undefined);
assert(IDX_STATS_ENDPOINT_STATELESS_RESET_COUNT !== undefined);
assert(IDX_STATS_ENDPOINT_IMMEDIATE_CLOSE_COUNT !== undefined);
assert(IDX_STATS_SESSION_CREATED_AT !== undefined);
assert(IDX_STATS_SESSION_DESTROYED_AT !== undefined);
assert(IDX_STATS_SESSION_CLOSING_AT !== undefined);
assert(IDX_STATS_SESSION_HANDSHAKE_COMPLETED_AT !== undefined);
assert(IDX_STATS_SESSION_HANDSHAKE_CONFIRMED_AT !== undefined);
assert(IDX_STATS_SESSION_BYTES_RECEIVED !== undefined);
assert(IDX_STATS_SESSION_BYTES_SENT !== undefined);
assert(IDX_STATS_SESSION_BIDI_IN_STREAM_COUNT !== undefined);
assert(IDX_STATS_SESSION_BIDI_OUT_STREAM_COUNT !== undefined);
assert(IDX_STATS_SESSION_UNI_IN_STREAM_COUNT !== undefined);
@@ -126,6 +135,14 @@ assert(IDX_STATS_SESSION_MIN_RTT !== undefined);
assert(IDX_STATS_SESSION_RTTVAR !== undefined);
assert(IDX_STATS_SESSION_SMOOTHED_RTT !== undefined);
assert(IDX_STATS_SESSION_SSTHRESH !== undefined);
assert(IDX_STATS_SESSION_PKT_SENT !== undefined);
assert(IDX_STATS_SESSION_BYTES_SENT !== undefined);
assert(IDX_STATS_SESSION_PKT_RECV !== undefined);
assert(IDX_STATS_SESSION_BYTES_RECV !== undefined);
assert(IDX_STATS_SESSION_PKT_LOST !== undefined);
assert(IDX_STATS_SESSION_BYTES_LOST !== undefined);
assert(IDX_STATS_SESSION_PING_RECV !== undefined);
assert(IDX_STATS_SESSION_PKT_DISCARDED !== undefined);
assert(IDX_STATS_SESSION_DATAGRAMS_RECEIVED !== undefined);
assert(IDX_STATS_SESSION_DATAGRAMS_SENT !== undefined);
assert(IDX_STATS_SESSION_DATAGRAMS_ACKNOWLEDGED !== undefined);
@@ -260,6 +277,7 @@ class QuicEndpointStats {
return this;
const opts = {
__proto__: null,
...options,
depth: options.depth == null ? null : options.depth - 1,
};
@@ -286,7 +304,7 @@ class QuicEndpointStats {
* True if this QuicEndpointStats object is still connected to the underlying
* Endpoint stats source. If this returns false, then the stats object is
* no longer being updated and should be considered stale.
* @returns {boolean}
* @type {boolean}
*/
get isConnected() {
return !this.#disconnected;
@@ -308,7 +326,7 @@ class QuicSessionStats {
/**
* @param {symbol} privateSymbol
* @param {BigUint64Array} buffer
* @param {ArrayBuffer} buffer
*/
constructor(privateSymbol, buffer) {
// We use the kPrivateConstructor symbol to restrict the ability to
@@ -327,6 +345,11 @@ class QuicSessionStats {
return this.#handle[IDX_STATS_SESSION_CREATED_AT];
}
/** @type {bigint} */
get destroyedAt() {
return this.#handle[IDX_STATS_SESSION_DESTROYED_AT];
}
/** @type {bigint} */
get closingAt() {
return this.#handle[IDX_STATS_SESSION_CLOSING_AT];
@@ -347,11 +370,6 @@ class QuicSessionStats {
return this.#handle[IDX_STATS_SESSION_BYTES_RECEIVED];
}
/** @type {bigint} */
get bytesSent() {
return this.#handle[IDX_STATS_SESSION_BYTES_SENT];
}
/** @type {bigint} */
get bidiInStreamCount() {
return this.#handle[IDX_STATS_SESSION_BIDI_IN_STREAM_COUNT];
@@ -373,7 +391,7 @@ class QuicSessionStats {
}
/** @type {bigint} */
get maxBytesInFlights() {
get maxBytesInFlight() {
return this.#handle[IDX_STATS_SESSION_MAX_BYTES_IN_FLIGHT];
}
@@ -417,6 +435,38 @@ class QuicSessionStats {
return this.#handle[IDX_STATS_SESSION_SSTHRESH];
}
get pktSent() {
return this.#handle[IDX_STATS_SESSION_PKT_SENT];
}
get bytesSent() {
return this.#handle[IDX_STATS_SESSION_BYTES_SENT];
}
get pktRecv() {
return this.#handle[IDX_STATS_SESSION_PKT_RECV];
}
get bytesRecv() {
return this.#handle[IDX_STATS_SESSION_BYTES_RECV];
}
get pktLost() {
return this.#handle[IDX_STATS_SESSION_PKT_LOST];
}
get bytesLost() {
return this.#handle[IDX_STATS_SESSION_BYTES_LOST];
}
get pingRecv() {
return this.#handle[IDX_STATS_SESSION_PING_RECV];
}
get pktDiscarded() {
return this.#handle[IDX_STATS_SESSION_PKT_DISCARDED];
}
/** @type {bigint} */
get datagramsReceived() {
return this.#handle[IDX_STATS_SESSION_DATAGRAMS_RECEIVED];
@@ -449,17 +499,14 @@ class QuicSessionStats {
// support BigInts.
createdAt: `${this.createdAt}`,
closingAt: `${this.closingAt}`,
destroyedAt: `${this.destroyedAt}`,
handshakeCompletedAt: `${this.handshakeCompletedAt}`,
handshakeConfirmedAt: `${this.handshakeConfirmedAt}`,
gracefulClosingAt: `${this.gracefulClosingAt}`,
bytesReceived: `${this.bytesReceived}`,
bytesSent: `${this.bytesSent}`,
bidiInStreamCount: `${this.bidiInStreamCount}`,
bidiOutStreamCount: `${this.bidiOutStreamCount}`,
uniInStreamCount: `${this.uniInStreamCount}`,
uniOutStreamCount: `${this.uniOutStreamCount}`,
maxBytesInFlights: `${this.maxBytesInFlights}`,
maxBytesInFlight: `${this.maxBytesInFlight}`,
bytesInFlight: `${this.bytesInFlight}`,
blockCount: `${this.blockCount}`,
cwnd: `${this.cwnd}`,
@@ -468,6 +515,14 @@ class QuicSessionStats {
rttVar: `${this.rttVar}`,
smoothedRtt: `${this.smoothedRtt}`,
ssthresh: `${this.ssthresh}`,
pktSent: `${this.pktSent}`,
bytesSent: `${this.bytesSent}`,
pktRecv: `${this.pktRecv}`,
bytesRecv: `${this.bytesRecv}`,
pktLost: `${this.pktLost}`,
bytesLost: `${this.bytesLost}`,
pingRecv: `${this.pingRecv}`,
pktDiscarded: `${this.pktDiscarded}`,
datagramsReceived: `${this.datagramsReceived}`,
datagramsSent: `${this.datagramsSent}`,
datagramsAcknowledged: `${this.datagramsAcknowledged}`,
@@ -480,6 +535,7 @@ class QuicSessionStats {
return this;
const opts = {
__proto__: null,
...options,
depth: options.depth == null ? null : options.depth - 1,
};
@@ -488,17 +544,14 @@ class QuicSessionStats {
connected: this.isConnected,
createdAt: this.createdAt,
closingAt: this.closingAt,
destroyedAt: this.destroyedAt,
handshakeCompletedAt: this.handshakeCompletedAt,
handshakeConfirmedAt: this.handshakeConfirmedAt,
gracefulClosingAt: this.gracefulClosingAt,
bytesReceived: this.bytesReceived,
bytesSent: this.bytesSent,
bidiInStreamCount: this.bidiInStreamCount,
bidiOutStreamCount: this.bidiOutStreamCount,
uniInStreamCount: this.uniInStreamCount,
uniOutStreamCount: this.uniOutStreamCount,
maxBytesInFlights: this.maxBytesInFlights,
maxBytesInFlight: this.maxBytesInFlight,
bytesInFlight: this.bytesInFlight,
blockCount: this.blockCount,
cwnd: this.cwnd,
@@ -507,6 +560,14 @@ class QuicSessionStats {
rttVar: this.rttVar,
smoothedRtt: this.smoothedRtt,
ssthresh: this.ssthresh,
pktSent: this.pktSent,
bytesSent: this.bytesSent,
pktRecv: this.pktRecv,
bytesRecv: this.bytesRecv,
pktLost: this.pktLost,
bytesLost: this.bytesLost,
pingRecv: this.pingRecv,
pktDiscarded: this.pktDiscarded,
datagramsReceived: this.datagramsReceived,
datagramsSent: this.datagramsSent,
datagramsAcknowledged: this.datagramsAcknowledged,
@@ -518,7 +579,7 @@ class QuicSessionStats {
* True if this QuicSessionStats object is still connected to the underlying
* Session stats source. If this returns false, then the stats object is
* no longer being updated and should be considered stale.
* @returns {boolean}
* @type {boolean}
*/
get isConnected() {
return !this.#disconnected;
@@ -638,11 +699,12 @@ class QuicStreamStats {
return this;
const opts = {
__proto__: null,
...options,
depth: options.depth == null ? null : options.depth - 1,
};
return `StreamStats ${inspect({
return `QuicStreamStats ${inspect({
connected: this.isConnected,
createdAt: this.createdAt,
openedAt: this.openedAt,
@@ -662,7 +724,7 @@ class QuicStreamStats {
* True if this QuicStreamStats object is still connected to the underlying
* Stream stats source. If this returns false, then the stats object is
* no longer being updated and should be considered stale.
* @returns {boolean}
* @type {boolean}
*/
get isConnected() {
return !this.#disconnected;
+20 -4
View File
@@ -23,18 +23,26 @@ const {
// Symbols used to hide various private properties and methods from the
// public API.
const kAttachFileHandle = Symbol('kAttachFileHandle');
const kBlocked = Symbol('kBlocked');
const kConnect = Symbol('kConnect');
const kDrain = Symbol('kDrain');
const kDatagram = Symbol('kDatagram');
const kDatagramStatus = Symbol('kDatagramStatus');
const kEarlyDataRejected = Symbol('kEarlyDataRejected');
const kFinishClose = Symbol('kFinishClose');
const kGoaway = Symbol('kGoaway');
const kHandshake = Symbol('kHandshake');
const kHandshakeCompleted = Symbol('kHandshakeCompleted');
const kHeaders = Symbol('kHeaders');
const kKeylog = Symbol('kKeylog');
const kListen = Symbol('kListen');
const kQlog = Symbol('kQlog');
const kNewSession = Symbol('kNewSession');
const kNewStream = Symbol('kNewStream');
const kOnHeaders = Symbol('kOnHeaders');
const kOnTrailers = Symbol('kOwnTrailers');
const kNewToken = Symbol('kNewToken');
const kStreamCallbacks = Symbol('kStreamCallbacks');
const kOrigin = Symbol('kOrigin');
const kOwner = Symbol('kOwner');
const kPathValidation = Symbol('kPathValidation');
const kPrivateConstructor = Symbol('kPrivateConstructor');
@@ -49,21 +57,29 @@ const kWantsHeaders = Symbol('kWantsHeaders');
const kWantsTrailers = Symbol('kWantsTrailers');
module.exports = {
kAttachFileHandle,
kBlocked,
kConnect,
kDatagram,
kDatagramStatus,
kDrain,
kEarlyDataRejected,
kFinishClose,
kGoaway,
kHandshake,
kHandshakeCompleted,
kHeaders,
kInspect,
kKeylog,
kKeyObjectHandle,
kListen,
kNewSession,
kNewStream,
kOnHeaders,
kOnTrailers,
kNewToken,
kStreamCallbacks,
kOrigin,
kOwner,
kQlog,
kPathValidation,
kPrivateConstructor,
kRemoveSession,
+2
View File
@@ -14,6 +14,7 @@ const {
connect,
listen,
QuicEndpoint,
QuicError,
QuicSession,
QuicStream,
CC_ALGO_RENO,
@@ -52,6 +53,7 @@ module.exports = ObjectSeal(ObjectCreate(null, {
connect: getEnumerableConstant(connect),
listen: getEnumerableConstant(listen),
QuicEndpoint: getEnumerableConstant(QuicEndpoint),
QuicError: getEnumerableConstant(QuicError),
QuicSession: getEnumerableConstant(QuicSession),
QuicStream: getEnumerableConstant(QuicStream),
constants: getEnumerableConstant(constants),
+2 -2
View File
@@ -348,7 +348,6 @@
'src/quic/bindingdata.cc',
'src/quic/cid.cc',
'src/quic/data.cc',
'src/quic/logstream.cc',
'src/quic/packet.cc',
'src/quic/preferredaddress.cc',
'src/quic/sessionticket.cc',
@@ -357,6 +356,7 @@
'src/quic/endpoint.cc',
'src/quic/http3.cc',
'src/quic/session.cc',
'src/quic/session_manager.cc',
'src/quic/streams.cc',
'src/quic/tlscontext.cc',
'src/quic/transportparams.cc',
@@ -366,7 +366,6 @@
'src/quic/cid.h',
'src/quic/data.h',
'src/quic/defs.h',
'src/quic/logstream.h',
'src/quic/packet.h',
'src/quic/preferredaddress.h',
'src/quic/sessionticket.h',
@@ -376,6 +375,7 @@
'src/quic/endpoint.h',
'src/quic/http3.h',
'src/quic/session.h',
'src/quic/session_manager.h',
'src/quic/streams.h',
'src/quic/tlscontext.h',
'src/quic/guard.h',
+27 -9
View File
@@ -391,10 +391,11 @@ class NonIdempotentDataQueueReader final
// If the collection of entries is empty, there's nothing currently left to
// read. How we respond depends on whether the data queue has been capped
// or not.
if (data_queue_->entries_.empty()) {
// If the data_queue_ is empty, and not capped, then we can reasonably
// expect more data to be provided later, but we don't know exactly when
// that'll happe, so the proper response here is to return a blocked
// that'll happen, so the proper response here is to return a blocked
// status.
if (!data_queue_->is_capped()) {
std::move(next)(bob::Status::STATUS_BLOCK, nullptr, 0, [](uint64_t) {});
@@ -437,8 +438,11 @@ class NonIdempotentDataQueueReader final
CHECK(!pull_pending_);
pull_pending_ = true;
int status = current_reader->Pull(
[this, next = std::move(next)](
int status, const DataQueue::Vec* vecs, uint64_t count, Done done) {
[this, next = std::move(next), options, data, count, max_count_hint](
int status,
const DataQueue::Vec* vecs,
uint64_t vcount,
Done done) mutable {
pull_pending_ = false;
// In each of these cases, we do not expect that the source will
@@ -446,13 +450,27 @@ class NonIdempotentDataQueueReader final
CHECK_IMPLIES(status == bob::Status::STATUS_BLOCK ||
status == bob::Status::STATUS_WAIT ||
status == bob::Status::STATUS_EOS,
vecs == nullptr && count == 0);
vecs == nullptr && vcount == 0);
if (status == bob::Status::STATUS_EOS) {
data_queue_->entries_.erase(data_queue_->entries_.begin());
ended_ = data_queue_->entries_.empty();
current_reader_ = nullptr;
if (!ended_) status = bob::Status::STATUS_CONTINUE;
std::move(next)(status, nullptr, 0, [](uint64_t) {});
if (!data_queue_->entries_.empty()) {
// More entries remain. Pull from the next entry immediately
// rather than returning empty CONTINUE, which would leave
// callers with no data and no way to know they should retry.
Pull(std::move(next), options, data, count, max_count_hint);
} else if (!data_queue_->is_capped()) {
// The queue is empty but not capped — more data may arrive
// later. Return BLOCK so the consumer waits rather than
// falsely treating this as end-of-stream.
std::move(next)(
bob::Status::STATUS_BLOCK, nullptr, 0, [](uint64_t) {});
} else {
// Empty and capped — truly done.
ended_ = true;
std::move(next)(
bob::Status::STATUS_EOS, nullptr, 0, [](uint64_t) {});
}
return;
}
@@ -461,7 +479,7 @@ class NonIdempotentDataQueueReader final
if (data_queue_->HasBackpressureListeners()) {
// How much did we actually read?
size_t read = 0;
for (uint64_t n = 0; n < count; n++) {
for (uint64_t n = 0; n < vcount; n++) {
read += vecs[n].len;
}
data_queue_->NotifyBackpressure(read);
@@ -469,7 +487,7 @@ class NonIdempotentDataQueueReader final
// Now that we have updated this readers state, we can forward
// everything on to the outer next.
std::move(next)(status, vecs, count, std::move(done));
std::move(next)(status, vecs, vcount, std::move(done));
},
options,
data,
+2 -1
View File
@@ -54,7 +54,8 @@ void NODE_EXTERN_PRIVATE FWrite(FILE* file, const std::string& str);
V(INSPECTOR_CLIENT) \
V(INSPECTOR_PROFILER) \
V(CODE_CACHE) \
V(NGTCP2_DEBUG) \
V(NGTCP2) \
V(NGHTTP3) \
V(SEA) \
V(WASI) \
V(MODULE) \
+27 -23
View File
@@ -156,8 +156,7 @@ Local<FunctionTemplate> Blob::GetConstructorTemplate(Environment* env) {
Isolate* isolate = env->isolate();
tmpl = NewFunctionTemplate(isolate, nullptr);
tmpl->InstanceTemplate()->SetInternalFieldCount(Blob::kInternalFieldCount);
tmpl->SetClassName(
FIXED_ONE_BYTE_STRING(env->isolate(), "Blob"));
tmpl->SetClassName(FIXED_ONE_BYTE_STRING(env->isolate(), "Blob"));
SetProtoMethod(isolate, tmpl, "getReader", GetReader);
SetProtoMethod(isolate, tmpl, "slice", ToSlice);
env->set_blob_constructor_template(tmpl);
@@ -255,8 +254,7 @@ void Blob::New(const FunctionCallbackInfo<Value>& args) {
}
auto blob = Create(env, DataQueue::CreateIdempotent(std::move(entries)));
if (blob)
args.GetReturnValue().Set(blob->object());
if (blob) args.GetReturnValue().Set(blob->object());
}
void Blob::GetReader(const FunctionCallbackInfo<Value>& args) {
@@ -278,8 +276,7 @@ void Blob::ToSlice(const FunctionCallbackInfo<Value>& args) {
size_t start = args[0].As<Uint32>()->Value();
size_t end = args[1].As<Uint32>()->Value();
BaseObjectPtr<Blob> slice = blob->Slice(env, start, end);
if (slice)
args.GetReturnValue().Set(slice->object());
if (slice) args.GetReturnValue().Set(slice->object());
}
void Blob::MemoryInfo(MemoryTracker* tracker) const {
@@ -343,6 +340,7 @@ void Blob::Reader::Pull(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
Blob::Reader* reader;
ASSIGN_OR_RETURN_UNWRAP(&reader, args.This());
reader->pull_pending_ = false;
CHECK(args[0]->IsFunction());
Local<Function> fn = args[0].As<Function>();
@@ -414,19 +412,31 @@ void Blob::Reader::Pull(const FunctionCallbackInfo<Value>& args) {
void Blob::Reader::SetWakeup(const FunctionCallbackInfo<Value>& args) {
Blob::Reader* reader;
ASSIGN_OR_RETURN_UNWRAP(&reader, args.This());
if (args[0]->IsUndefined()) {
reader->wakeup_.Reset();
return;
}
CHECK(args[0]->IsFunction());
reader->wakeup_.Reset(args.GetIsolate(), args[0].As<Function>());
}
void Blob::Reader::NotifyPull() {
void Blob::Reader::NotifyPull(bool fin) {
if (wakeup_.IsEmpty() || !env()->can_call_into_js()) return;
// FIN notifications always fire — they must not be suppressed by
// pull_pending_ because there will be no further notifications to
// wake the iterator. Regular data notifications respect pull_pending_
// to coalesce multiple deliveries within a single packet.
if (!fin && pull_pending_) return;
pull_pending_ = true;
HandleScope handle_scope(env()->isolate());
Local<Function> fn = wakeup_.Get(env()->isolate());
MakeCallback(fn, 0, nullptr);
// Pass fin as the first argument so the JS iterator knows EOS is
// imminent and should pull again without waiting for another wakeup.
Local<Value> argv[] = {v8::Boolean::New(env()->isolate(), fin)};
MakeCallback(fn, 1, argv);
}
BaseObjectPtr<BaseObject>
Blob::BlobTransferData::Deserialize(
BaseObjectPtr<BaseObject> Blob::BlobTransferData::Deserialize(
Environment* env,
Local<Context> context,
std::unique_ptr<worker::TransferData> self) {
@@ -448,10 +458,10 @@ std::unique_ptr<worker::TransferData> Blob::CloneForMessaging() const {
void Blob::StoreDataObject(const FunctionCallbackInfo<Value>& args) {
Realm* realm = Realm::GetCurrent(args);
CHECK(args[0]->IsString()); // ID key
CHECK(args[0]->IsString()); // ID key
CHECK(Blob::HasInstance(realm->env(), args[1])); // Blob
CHECK(args[2]->IsUint32()); // Length
CHECK(args[3]->IsString()); // Type
CHECK(args[2]->IsUint32()); // Length
CHECK(args[3]->IsString()); // Type
BlobBindingData* binding_data = realm->GetBindingData<BlobBindingData>();
Isolate* isolate = realm->isolate();
@@ -531,12 +541,8 @@ void BlobBindingData::StoredDataObject::MemoryInfo(
}
BlobBindingData::StoredDataObject::StoredDataObject(
const BaseObjectPtr<Blob>& blob_,
size_t length_,
const std::string& type_)
: blob(blob_),
length(length_),
type(type_) {}
const BaseObjectPtr<Blob>& blob_, size_t length_, const std::string& type_)
: blob(blob_), length(length_), type(type_) {}
BlobBindingData::BlobBindingData(Realm* realm, Local<Object> wrap)
: SnapshotableObject(realm, wrap, type_int) {
@@ -550,8 +556,7 @@ void BlobBindingData::MemoryInfo(MemoryTracker* tracker) const {
}
void BlobBindingData::store_data_object(
const std::string& uuid,
const BlobBindingData::StoredDataObject& object) {
const std::string& uuid, const BlobBindingData::StoredDataObject& object) {
data_objects_[uuid] = object;
}
@@ -566,8 +571,7 @@ void BlobBindingData::revoke_data_object(const std::string& uuid) {
BlobBindingData::StoredDataObject BlobBindingData::get_data_object(
const std::string& uuid) {
auto entry = data_objects_.find(uuid);
if (entry == data_objects_.end())
return BlobBindingData::StoredDataObject {};
if (entry == data_objects_.end()) return BlobBindingData::StoredDataObject{};
return entry->second;
}
+8 -10
View File
@@ -23,8 +23,7 @@ namespace node {
class Blob : public BaseObject {
public:
static void RegisterExternalReferences(
ExternalReferenceRegistry* registry);
static void RegisterExternalReferences(ExternalReferenceRegistry* registry);
static void CreatePerIsolateProperties(IsolateData* isolate_data,
v8::Local<v8::ObjectTemplate> target);
@@ -83,7 +82,7 @@ class Blob : public BaseObject {
BaseObjectPtr<Blob> blob);
static void Pull(const v8::FunctionCallbackInfo<v8::Value>& args);
static void SetWakeup(const v8::FunctionCallbackInfo<v8::Value>& args);
void NotifyPull();
void NotifyPull(bool fin = false);
explicit Reader(Environment* env,
v8::Local<v8::Object> obj,
@@ -97,6 +96,7 @@ class Blob : public BaseObject {
std::shared_ptr<DataQueue::Reader> inner_;
BaseObjectPtr<Blob> strong_ptr_;
bool eos_ = false;
bool pull_pending_ = false;
v8::Global<v8::Function> wakeup_;
};
@@ -134,19 +134,17 @@ class BlobBindingData : public SnapshotableObject {
StoredDataObject() = default;
StoredDataObject(
const BaseObjectPtr<Blob>& blob_,
size_t length_,
const std::string& type_);
StoredDataObject(const BaseObjectPtr<Blob>& blob_,
size_t length_,
const std::string& type_);
void MemoryInfo(MemoryTracker* tracker) const override;
SET_SELF_SIZE(StoredDataObject)
SET_MEMORY_INFO_NAME(StoredDataObject)
};
void store_data_object(
const std::string& uuid,
const StoredDataObject& object);
void store_data_object(const std::string& uuid,
const StoredDataObject& object);
void revoke_data_object(const std::string& uuid);
+1
View File
@@ -338,6 +338,7 @@ class FileHandle final : public AsyncWrap, public StreamBase {
static void New(const v8::FunctionCallbackInfo<v8::Value>& args);
int GetFD() override { return fd_; }
const std::string& original_name() const { return original_name_; }
int Release();
+7 -6
View File
@@ -34,12 +34,13 @@ extern uint64_t performance_v8_start;
V(LOOP_EXIT, "loopExit") \
V(BOOTSTRAP_COMPLETE, "bootstrapComplete")
#define NODE_PERFORMANCE_ENTRY_TYPES(V) \
V(GC, "gc") \
V(HTTP, "http") \
V(HTTP2, "http2") \
V(NET, "net") \
V(DNS, "dns")
#define NODE_PERFORMANCE_ENTRY_TYPES(V) \
V(GC, "gc") \
V(HTTP, "http") \
V(HTTP2, "http2") \
V(NET, "net") \
V(DNS, "dns") \
V(QUIC, "quic")
enum PerformanceMilestone {
#define V(name, _) NODE_PERFORMANCE_MILESTONE_##name,
+172 -183
View File
@@ -40,41 +40,31 @@ SocketAddress FromUVHandle(F fn, const T& handle) {
}
} // namespace
bool SocketAddress::ToSockAddr(
int32_t family,
const char* host,
uint32_t port,
sockaddr_storage* addr) {
bool SocketAddress::ToSockAddr(int32_t family,
const char* host,
uint32_t port,
sockaddr_storage* addr) {
switch (family) {
case AF_INET:
return uv_ip4_addr(
host,
port,
reinterpret_cast<sockaddr_in*>(addr)) == 0;
return uv_ip4_addr(host, port, reinterpret_cast<sockaddr_in*>(addr)) == 0;
case AF_INET6:
return uv_ip6_addr(
host,
port,
reinterpret_cast<sockaddr_in6*>(addr)) == 0;
return uv_ip6_addr(host, port, reinterpret_cast<sockaddr_in6*>(addr)) ==
0;
default:
UNREACHABLE();
}
}
bool SocketAddress::New(
const char* host,
uint32_t port,
SocketAddress* addr) {
bool SocketAddress::New(const char* host, uint32_t port, SocketAddress* addr) {
return New(AF_INET, host, port, addr) || New(AF_INET6, host, port, addr);
}
bool SocketAddress::New(
int32_t family,
const char* host,
uint32_t port,
SocketAddress* addr) {
return ToSockAddr(family, host, port,
reinterpret_cast<sockaddr_storage*>(addr->storage()));
bool SocketAddress::New(int32_t family,
const char* host,
uint32_t port,
SocketAddress* addr) {
return ToSockAddr(
family, host, port, reinterpret_cast<sockaddr_storage*>(addr->storage()));
}
size_t SocketAddress::Hash::operator()(const SocketAddress& addr) const {
@@ -102,6 +92,43 @@ size_t SocketAddress::Hash::operator()(const SocketAddress& addr) const {
}
}
size_t SocketAddress::IpHash::operator()(const SocketAddress& addr) const {
// Hash only the IP address bytes, ignoring the port.
switch (addr.family()) {
case AF_INET: {
const sockaddr_in* ipv4 =
reinterpret_cast<const sockaddr_in*>(addr.raw());
return HashBytes(reinterpret_cast<const uint8_t*>(&ipv4->sin_addr), 4);
}
case AF_INET6: {
const sockaddr_in6* ipv6 =
reinterpret_cast<const sockaddr_in6*>(addr.raw());
return HashBytes(reinterpret_cast<const uint8_t*>(&ipv6->sin6_addr), 16);
}
default:
UNREACHABLE();
}
}
bool SocketAddress::IpEqual::operator()(const SocketAddress& a,
const SocketAddress& b) const {
if (a.family() != b.family()) return false;
switch (a.family()) {
case AF_INET: {
const sockaddr_in* a4 = reinterpret_cast<const sockaddr_in*>(a.raw());
const sockaddr_in* b4 = reinterpret_cast<const sockaddr_in*>(b.raw());
return memcmp(&a4->sin_addr, &b4->sin_addr, 4) == 0;
}
case AF_INET6: {
const sockaddr_in6* a6 = reinterpret_cast<const sockaddr_in6*>(a.raw());
const sockaddr_in6* b6 = reinterpret_cast<const sockaddr_in6*>(b.raw());
return memcmp(&a6->sin6_addr, &b6->sin6_addr, 16) == 0;
}
default:
UNREACHABLE();
}
}
SocketAddress SocketAddress::FromSockName(const uv_tcp_t& handle) {
return FromUVHandle(uv_tcp_getsockname, handle);
}
@@ -119,21 +146,15 @@ SocketAddress SocketAddress::FromPeerName(const uv_udp_t& handle) {
}
namespace {
constexpr uint8_t mask[] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff };
constexpr uint8_t mask[] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff};
bool is_match_ipv4(
const SocketAddress& one,
const SocketAddress& two) {
const sockaddr_in* one_in =
reinterpret_cast<const sockaddr_in*>(one.data());
const sockaddr_in* two_in =
reinterpret_cast<const sockaddr_in*>(two.data());
bool is_match_ipv4(const SocketAddress& one, const SocketAddress& two) {
const sockaddr_in* one_in = reinterpret_cast<const sockaddr_in*>(one.data());
const sockaddr_in* two_in = reinterpret_cast<const sockaddr_in*>(two.data());
return memcmp(&one_in->sin_addr, &two_in->sin_addr, sizeof(uint32_t)) == 0;
}
bool is_match_ipv6(
const SocketAddress& one,
const SocketAddress& two) {
bool is_match_ipv6(const SocketAddress& one, const SocketAddress& two) {
const sockaddr_in6* one_in =
reinterpret_cast<const sockaddr_in6*>(one.data());
const sockaddr_in6* two_in =
@@ -141,29 +162,23 @@ bool is_match_ipv6(
return memcmp(&one_in->sin6_addr, &two_in->sin6_addr, 16) == 0;
}
bool is_match_ipv4_ipv6(
const SocketAddress& ipv4,
const SocketAddress& ipv6) {
bool is_match_ipv4_ipv6(const SocketAddress& ipv4, const SocketAddress& ipv6) {
const sockaddr_in* check_ipv4 =
reinterpret_cast<const sockaddr_in*>(ipv4.data());
const sockaddr_in6* check_ipv6 =
reinterpret_cast<const sockaddr_in6*>(ipv6.data());
const uint8_t* ptr =
reinterpret_cast<const uint8_t*>(&check_ipv6->sin6_addr);
const uint8_t* ptr = reinterpret_cast<const uint8_t*>(&check_ipv6->sin6_addr);
return memcmp(ptr, mask, sizeof(mask)) == 0 &&
memcmp(ptr + sizeof(mask),
&check_ipv4->sin_addr,
sizeof(uint32_t)) == 0;
memcmp(ptr + sizeof(mask), &check_ipv4->sin_addr, sizeof(uint32_t)) ==
0;
}
std::partial_ordering compare_ipv4(const SocketAddress& one,
const SocketAddress& two) {
const sockaddr_in* one_in =
reinterpret_cast<const sockaddr_in*>(one.data());
const sockaddr_in* two_in =
reinterpret_cast<const sockaddr_in*>(two.data());
const sockaddr_in* one_in = reinterpret_cast<const sockaddr_in*>(one.data());
const sockaddr_in* two_in = reinterpret_cast<const sockaddr_in*>(two.data());
const uint32_t s_addr_one = ntohl(one_in->sin_addr.s_addr);
const uint32_t s_addr_two = ntohl(two_in->sin_addr.s_addr);
@@ -193,19 +208,15 @@ std::partial_ordering compare_ipv4_ipv6(const SocketAddress& ipv4,
const SocketAddress& ipv6) {
const sockaddr_in* ipv4_in =
reinterpret_cast<const sockaddr_in*>(ipv4.data());
const sockaddr_in6 * ipv6_in =
const sockaddr_in6* ipv6_in =
reinterpret_cast<const sockaddr_in6*>(ipv6.data());
const uint8_t* ptr =
reinterpret_cast<const uint8_t*>(&ipv6_in->sin6_addr);
const uint8_t* ptr = reinterpret_cast<const uint8_t*>(&ipv6_in->sin6_addr);
if (memcmp(ptr, mask, sizeof(mask)) != 0)
return std::partial_ordering::unordered;
int ret = memcmp(
&ipv4_in->sin_addr,
ptr + sizeof(mask),
sizeof(uint32_t));
int ret = memcmp(&ipv4_in->sin_addr, ptr + sizeof(mask), sizeof(uint32_t));
if (ret < 0)
return std::partial_ordering::less;
@@ -214,25 +225,21 @@ std::partial_ordering compare_ipv4_ipv6(const SocketAddress& ipv4,
return std::partial_ordering::equivalent;
}
bool in_network_ipv4(
const SocketAddress& ip,
const SocketAddress& net,
int prefix) {
bool in_network_ipv4(const SocketAddress& ip,
const SocketAddress& net,
int prefix) {
uint32_t mask = ((1ull << prefix) - 1) << (32 - prefix);
const sockaddr_in* ip_in =
reinterpret_cast<const sockaddr_in*>(ip.data());
const sockaddr_in* net_in =
reinterpret_cast<const sockaddr_in*>(net.data());
const sockaddr_in* ip_in = reinterpret_cast<const sockaddr_in*>(ip.data());
const sockaddr_in* net_in = reinterpret_cast<const sockaddr_in*>(net.data());
return (htonl(ip_in->sin_addr.s_addr) & mask) ==
(htonl(net_in->sin_addr.s_addr) & mask);
}
bool in_network_ipv6(
const SocketAddress& ip,
const SocketAddress& net,
int prefix) {
bool in_network_ipv6(const SocketAddress& ip,
const SocketAddress& net,
int prefix) {
// Special case, if prefix == 128, then just do a
// straight comparison.
if (prefix == 128)
@@ -242,27 +249,23 @@ bool in_network_ipv6(
int len = (prefix - r) / 8;
uint8_t mask = ((1 << r) - 1) << (8 - r);
const sockaddr_in6* ip_in =
reinterpret_cast<const sockaddr_in6*>(ip.data());
const sockaddr_in6* ip_in = reinterpret_cast<const sockaddr_in6*>(ip.data());
const sockaddr_in6* net_in =
reinterpret_cast<const sockaddr_in6*>(net.data());
if (memcmp(&ip_in->sin6_addr, &net_in->sin6_addr, len) != 0)
return false;
if (memcmp(&ip_in->sin6_addr, &net_in->sin6_addr, len) != 0) return false;
const uint8_t* p1 = reinterpret_cast<const uint8_t*>(
ip_in->sin6_addr.s6_addr);
const uint8_t* p2 = reinterpret_cast<const uint8_t*>(
net_in->sin6_addr.s6_addr);
const uint8_t* p1 =
reinterpret_cast<const uint8_t*>(ip_in->sin6_addr.s6_addr);
const uint8_t* p2 =
reinterpret_cast<const uint8_t*>(net_in->sin6_addr.s6_addr);
return (p1[len] & mask) == (p2[len] & mask);
}
bool in_network_ipv4_ipv6(
const SocketAddress& ip,
const SocketAddress& net,
int prefix) {
bool in_network_ipv4_ipv6(const SocketAddress& ip,
const SocketAddress& net,
int prefix) {
if (prefix == 128)
return compare_ipv4_ipv6(ip, net) == std::partial_ordering::equivalent;
@@ -270,8 +273,7 @@ bool in_network_ipv4_ipv6(
int len = (prefix - r) / 8;
uint8_t mask = ((1 << r) - 1) << (8 - r);
const sockaddr_in* ip_in =
reinterpret_cast<const sockaddr_in*>(ip.data());
const sockaddr_in* ip_in = reinterpret_cast<const sockaddr_in*>(ip.data());
const sockaddr_in6* net_in =
reinterpret_cast<const sockaddr_in6*>(net.data());
@@ -279,35 +281,29 @@ bool in_network_ipv4_ipv6(
uint8_t* ptr = ip_mask;
memcpy(ptr + 12, &ip_in->sin_addr, 4);
if (memcmp(ptr, &net_in->sin6_addr, len) != 0)
return false;
if (memcmp(ptr, &net_in->sin6_addr, len) != 0) return false;
ptr += len;
const uint8_t* p2 = reinterpret_cast<const uint8_t*>(
net_in->sin6_addr.s6_addr);
const uint8_t* p2 =
reinterpret_cast<const uint8_t*>(net_in->sin6_addr.s6_addr);
return (ptr[0] & mask) == (p2[len] & mask);
}
bool in_network_ipv6_ipv4(
const SocketAddress& ip,
const SocketAddress& net,
int prefix) {
bool in_network_ipv6_ipv4(const SocketAddress& ip,
const SocketAddress& net,
int prefix) {
if (prefix == 32)
return compare_ipv4_ipv6(net, ip) == std::partial_ordering::equivalent;
uint32_t m = ((1ull << prefix) - 1) << (32 - prefix);
const sockaddr_in6* ip_in =
reinterpret_cast<const sockaddr_in6*>(ip.data());
const sockaddr_in* net_in =
reinterpret_cast<const sockaddr_in*>(net.data());
const sockaddr_in6* ip_in = reinterpret_cast<const sockaddr_in6*>(ip.data());
const sockaddr_in* net_in = reinterpret_cast<const sockaddr_in*>(net.data());
const uint8_t* ptr =
reinterpret_cast<const uint8_t*>(&ip_in->sin6_addr);
const uint8_t* ptr = reinterpret_cast<const uint8_t*>(&ip_in->sin6_addr);
if (memcmp(ptr, mask, sizeof(mask)) != 0)
return false;
if (memcmp(ptr, mask, sizeof(mask)) != 0) return false;
ptr += sizeof(mask);
uint32_t check = nbytes::ReadUint32BE(ptr);
@@ -324,14 +320,18 @@ bool SocketAddress::is_match(const SocketAddress& other) const {
switch (family()) {
case AF_INET:
switch (other.family()) {
case AF_INET: return is_match_ipv4(*this, other);
case AF_INET6: return is_match_ipv4_ipv6(*this, other);
case AF_INET:
return is_match_ipv4(*this, other);
case AF_INET6:
return is_match_ipv4_ipv6(*this, other);
}
break;
case AF_INET6:
switch (other.family()) {
case AF_INET: return is_match_ipv4_ipv6(other, *this);
case AF_INET6: return is_match_ipv6(*this, other);
case AF_INET:
return is_match_ipv4_ipv6(other, *this);
case AF_INET6:
return is_match_ipv6(*this, other);
}
break;
}
@@ -342,8 +342,10 @@ std::partial_ordering SocketAddress::compare(const SocketAddress& other) const {
switch (family()) {
case AF_INET:
switch (other.family()) {
case AF_INET: return compare_ipv4(*this, other);
case AF_INET6: return compare_ipv4_ipv6(*this, other);
case AF_INET:
return compare_ipv4(*this, other);
case AF_INET6:
return compare_ipv4_ipv6(*this, other);
}
break;
case AF_INET6:
@@ -361,28 +363,31 @@ std::partial_ordering SocketAddress::compare(const SocketAddress& other) const {
}
break;
}
case AF_INET6: return compare_ipv6(*this, other);
case AF_INET6:
return compare_ipv6(*this, other);
}
break;
}
return std::partial_ordering::unordered;
}
bool SocketAddress::is_in_network(
const SocketAddress& other,
int prefix) const {
bool SocketAddress::is_in_network(const SocketAddress& other,
int prefix) const {
switch (family()) {
case AF_INET:
switch (other.family()) {
case AF_INET: return in_network_ipv4(*this, other, prefix);
case AF_INET6: return in_network_ipv4_ipv6(*this, other, prefix);
case AF_INET:
return in_network_ipv4(*this, other, prefix);
case AF_INET6:
return in_network_ipv4_ipv6(*this, other, prefix);
}
break;
case AF_INET6:
switch (other.family()) {
case AF_INET: return in_network_ipv6_ipv4(*this, other, prefix);
case AF_INET6: return in_network_ipv6(*this, other, prefix);
case AF_INET:
return in_network_ipv6_ipv4(*this, other, prefix);
case AF_INET6:
return in_network_ipv6(*this, other, prefix);
}
break;
}
@@ -397,8 +402,7 @@ SocketAddressBlockList::SocketAddressBlockList(
void SocketAddressBlockList::AddSocketAddress(
const std::shared_ptr<SocketAddress>& address) {
Mutex::ScopedLock lock(mutex_);
std::unique_ptr<Rule> rule =
std::make_unique<SocketAddressRule>(address);
std::unique_ptr<Rule> rule = std::make_unique<SocketAddressRule>(address);
rules_.emplace_front(std::move(rule));
address_rules_[*address.get()] = rules_.begin();
}
@@ -423,8 +427,7 @@ void SocketAddressBlockList::AddSocketAddressRange(
}
void SocketAddressBlockList::AddSocketAddressMask(
const std::shared_ptr<SocketAddress>& network,
int prefix) {
const std::shared_ptr<SocketAddress>& network, int prefix) {
Mutex::ScopedLock lock(mutex_);
std::unique_ptr<Rule> rule =
std::make_unique<SocketAddressMaskRule>(network, prefix);
@@ -435,8 +438,7 @@ bool SocketAddressBlockList::Apply(
const std::shared_ptr<SocketAddress>& address) {
Mutex::ScopedLock lock(mutex_);
for (const auto& rule : rules_) {
if (rule->Apply(address))
return true;
if (rule->Apply(address)) return true;
}
return parent_ ? parent_->Apply(address) : false;
}
@@ -448,14 +450,11 @@ SocketAddressBlockList::SocketAddressRule::SocketAddressRule(
SocketAddressBlockList::SocketAddressRangeRule::SocketAddressRangeRule(
const std::shared_ptr<SocketAddress>& start_,
const std::shared_ptr<SocketAddress>& end_)
: start(start_),
end(end_) {}
: start(start_), end(end_) {}
SocketAddressBlockList::SocketAddressMaskRule::SocketAddressMaskRule(
const std::shared_ptr<SocketAddress>& network_,
int prefix_)
: network(network_),
prefix(prefix_) {}
const std::shared_ptr<SocketAddress>& network_, int prefix_)
: network(network_), prefix(prefix_) {}
bool SocketAddressBlockList::SocketAddressRule::Apply(
const std::shared_ptr<SocketAddress>& address) {
@@ -472,8 +471,7 @@ std::string SocketAddressBlockList::SocketAddressRule::ToString() {
bool SocketAddressBlockList::SocketAddressRangeRule::Apply(
const std::shared_ptr<SocketAddress>& address) {
return *address.get() >= *start.get() &&
*address.get() <= *end.get();
return *address.get() >= *start.get() && *address.get() <= *end.get();
}
std::string SocketAddressBlockList::SocketAddressRangeRule::ToString() {
@@ -503,19 +501,16 @@ std::string SocketAddressBlockList::SocketAddressMaskRule::ToString() {
MaybeLocal<Array> SocketAddressBlockList::ListRules(Environment* env) {
Mutex::ScopedLock lock(mutex_);
LocalVector<Value> rules(env->isolate());
if (!ListRules(env, &rules))
return MaybeLocal<Array>();
if (!ListRules(env, &rules)) return MaybeLocal<Array>();
return Array::New(env->isolate(), rules.data(), rules.size());
}
bool SocketAddressBlockList::ListRules(Environment* env,
LocalVector<Value>* rules) {
if (parent_ && !parent_->ListRules(env, rules))
return false;
if (parent_ && !parent_->ListRules(env, rules)) return false;
for (const auto& rule : rules_) {
Local<Value> str;
if (!rule->ToV8String(env).ToLocal(&str))
return false;
if (!rule->ToV8String(env).ToLocal(&str)) return false;
rules->push_back(str);
}
return true;
@@ -545,8 +540,7 @@ SocketAddressBlockListWrap::SocketAddressBlockListWrap(
Environment* env,
Local<Object> wrap,
std::shared_ptr<SocketAddressBlockList> blocklist)
: BaseObject(env, wrap),
blocklist_(std::move(blocklist)) {
: BaseObject(env, wrap), blocklist_(std::move(blocklist)) {
MakeWeak();
}
@@ -554,8 +548,9 @@ BaseObjectPtr<SocketAddressBlockListWrap> SocketAddressBlockListWrap::New(
Environment* env) {
Local<Object> obj;
if (!env->blocklist_constructor_template()
->InstanceTemplate()
->NewInstance(env->context()).ToLocal(&obj)) {
->InstanceTemplate()
->NewInstance(env->context())
.ToLocal(&obj)) {
return nullptr;
}
BaseObjectPtr<SocketAddressBlockListWrap> wrap =
@@ -565,25 +560,22 @@ BaseObjectPtr<SocketAddressBlockListWrap> SocketAddressBlockListWrap::New(
}
BaseObjectPtr<SocketAddressBlockListWrap> SocketAddressBlockListWrap::New(
Environment* env,
std::shared_ptr<SocketAddressBlockList> blocklist) {
Environment* env, std::shared_ptr<SocketAddressBlockList> blocklist) {
Local<Object> obj;
if (!env->blocklist_constructor_template()
->InstanceTemplate()
->NewInstance(env->context()).ToLocal(&obj)) {
->InstanceTemplate()
->NewInstance(env->context())
.ToLocal(&obj)) {
return nullptr;
}
BaseObjectPtr<SocketAddressBlockListWrap> wrap =
MakeBaseObject<SocketAddressBlockListWrap>(
env,
obj,
std::move(blocklist));
env, obj, std::move(blocklist));
CHECK(wrap);
return wrap;
}
void SocketAddressBlockListWrap::New(
const FunctionCallbackInfo<Value>& args) {
void SocketAddressBlockListWrap::New(const FunctionCallbackInfo<Value>& args) {
CHECK(args.IsConstructCall());
Environment* env = Environment::GetCurrent(args);
new SocketAddressBlockListWrap(env, args.This());
@@ -622,9 +614,8 @@ void SocketAddressBlockListWrap::AddRange(
if (*start_addr->address().get() > *end_addr->address().get())
return args.GetReturnValue().Set(false);
wrap->blocklist_->AddSocketAddressRange(
start_addr->address(),
end_addr->address());
wrap->blocklist_->AddSocketAddressRange(start_addr->address(),
end_addr->address());
args.GetReturnValue().Set(true);
}
@@ -687,9 +678,8 @@ SocketAddressBlockListWrap::CloneForMessaging() const {
return std::make_unique<TransferData>(this);
}
bool SocketAddressBlockListWrap::HasInstance(
Environment* env,
Local<Value> value) {
bool SocketAddressBlockListWrap::HasInstance(Environment* env,
Local<Value> value) {
return GetConstructorTemplate(env)->HasInstance(value);
}
@@ -711,11 +701,10 @@ Local<FunctionTemplate> SocketAddressBlockListWrap::GetConstructorTemplate(
return tmpl;
}
void SocketAddressBlockListWrap::Initialize(
Local<Object> target,
Local<Value> unused,
Local<Context> context,
void* priv) {
void SocketAddressBlockListWrap::Initialize(Local<Object> target,
Local<Value> unused,
Local<Context> context,
void* priv) {
Environment* env = Environment::GetCurrent(context);
SetConstructorFunction(context,
@@ -772,12 +761,12 @@ void SocketAddressBase::Initialize(Environment* env, Local<Object> target) {
}
BaseObjectPtr<SocketAddressBase> SocketAddressBase::Create(
Environment* env,
std::shared_ptr<SocketAddress> address) {
Environment* env, std::shared_ptr<SocketAddress> address) {
Local<Object> obj;
if (!GetConstructorTemplate(env)
->InstanceTemplate()
->NewInstance(env->context()).ToLocal(&obj)) {
->InstanceTemplate()
->NewInstance(env->context())
.ToLocal(&obj)) {
return nullptr;
}
@@ -788,8 +777,8 @@ void SocketAddressBase::New(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
CHECK(args.IsConstructCall());
CHECK(args[0]->IsString()); // address
CHECK(args[1]->IsInt32()); // port
CHECK(args[2]->IsInt32()); // family
CHECK(args[1]->IsInt32()); // port
CHECK(args[2]->IsInt32()); // family
CHECK(args[3]->IsUint32()); // flow label
Utf8Value address(env->isolate(), args[0]);
@@ -820,19 +809,21 @@ void SocketAddressBase::Detail(const FunctionCallbackInfo<Value>& args) {
return;
if (detail->Set(env->context(), env->address_string(), address).IsJust() &&
detail->Set(
env->context(),
env->port_string(),
Int32::New(env->isolate(), base->address_->port())).IsJust() &&
detail->Set(
env->context(),
env->family_string(),
Int32::New(env->isolate(), base->address_->family())).IsJust() &&
detail->Set(
env->context(),
env->flowlabel_string(),
Uint32::New(env->isolate(), base->address_->flow_label()))
.IsJust()) {
detail
->Set(env->context(),
env->port_string(),
Int32::New(env->isolate(), base->address_->port()))
.IsJust() &&
detail
->Set(env->context(),
env->family_string(),
Int32::New(env->isolate(), base->address_->family()))
.IsJust() &&
detail
->Set(env->context(),
env->flowlabel_string(),
Uint32::New(env->isolate(), base->address_->flow_label()))
.IsJust()) {
args.GetReturnValue().Set(detail);
}
}
@@ -852,12 +843,10 @@ void SocketAddressBase::LegacyDetail(const FunctionCallbackInfo<Value>& args) {
args.GetReturnValue().Set(address);
}
SocketAddressBase::SocketAddressBase(
Environment* env,
Local<Object> wrap,
std::shared_ptr<SocketAddress> address)
: BaseObject(env, wrap),
address_(std::move(address)) {
SocketAddressBase::SocketAddressBase(Environment* env,
Local<Object> wrap,
std::shared_ptr<SocketAddress> address)
: BaseObject(env, wrap), address_(std::move(address)) {
MakeWeak();
}
@@ -865,8 +854,8 @@ void SocketAddressBase::MemoryInfo(MemoryTracker* tracker) const {
tracker->TrackField("address", address_);
}
std::unique_ptr<worker::TransferData>
SocketAddressBase::CloneForMessaging() const {
std::unique_ptr<worker::TransferData> SocketAddressBase::CloneForMessaging()
const {
return std::make_unique<TransferData>(this);
}
+40 -40
View File
@@ -3,9 +3,9 @@
#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
#include "base_object.h"
#include "env.h"
#include "memory_tracker.h"
#include "base_object.h"
#include "node.h"
#include "node_worker.h"
#include "uv.h"
@@ -27,6 +27,16 @@ class SocketAddress : public MemoryRetainer {
size_t operator()(const SocketAddress& addr) const;
};
// Hashes and compares only the IP address, ignoring the port.
// Useful for per-host connection counting where clients from
// the same IP but different ports should be treated as one host.
struct IpHash {
size_t operator()(const SocketAddress& addr) const;
};
struct IpEqual {
bool operator()(const SocketAddress& a, const SocketAddress& b) const;
};
inline bool operator==(const SocketAddress& other) const;
inline bool operator!=(const SocketAddress& other) const;
@@ -36,23 +46,18 @@ class SocketAddress : public MemoryRetainer {
inline static bool is_numeric_host(const char* hostname, int family);
// Returns true if converting {family, host, port} to *addr succeeded.
static bool ToSockAddr(
int32_t family,
const char* host,
uint32_t port,
sockaddr_storage* addr);
static bool ToSockAddr(int32_t family,
const char* host,
uint32_t port,
sockaddr_storage* addr);
// Returns true if converting {family, host, port} to *addr succeeded.
static bool New(
int32_t family,
const char* host,
uint32_t port,
SocketAddress* addr);
static bool New(int32_t family,
const char* host,
uint32_t port,
SocketAddress* addr);
static bool New(
const char* host,
uint32_t port,
SocketAddress* addr);
static bool New(const char* host, uint32_t port, SocketAddress* addr);
// Returns the port for an IPv4 or IPv6 address.
inline static int GetPort(const sockaddr* addr);
@@ -135,6 +140,9 @@ class SocketAddress : public MemoryRetainer {
template <typename T>
using Map = std::unordered_map<SocketAddress, T, Hash>;
template <typename T>
using IpMap = std::unordered_map<SocketAddress, T, IpHash, IpEqual>;
private:
sockaddr_storage address_;
};
@@ -146,18 +154,16 @@ class SocketAddressBase : public BaseObject {
Environment* env);
static void Initialize(Environment* env, v8::Local<v8::Object> target);
static BaseObjectPtr<SocketAddressBase> Create(
Environment* env,
std::shared_ptr<SocketAddress> address);
Environment* env, std::shared_ptr<SocketAddress> address);
static void New(const v8::FunctionCallbackInfo<v8::Value>& args);
static void Detail(const v8::FunctionCallbackInfo<v8::Value>& args);
static void LegacyDetail(const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetFlowLabel(const v8::FunctionCallbackInfo<v8::Value>& args);
SocketAddressBase(
Environment* env,
v8::Local<v8::Object> wrap,
std::shared_ptr<SocketAddress> address);
SocketAddressBase(Environment* env,
v8::Local<v8::Object> wrap,
std::shared_ptr<SocketAddress> address);
inline const std::shared_ptr<SocketAddress>& address() const {
return address_;
@@ -245,13 +251,11 @@ class SocketAddressBlockList : public MemoryRetainer {
void RemoveSocketAddress(const std::shared_ptr<SocketAddress>& address);
void AddSocketAddressRange(
const std::shared_ptr<SocketAddress>& start,
const std::shared_ptr<SocketAddress>& end);
void AddSocketAddressRange(const std::shared_ptr<SocketAddress>& start,
const std::shared_ptr<SocketAddress>& end);
void AddSocketAddressMask(
const std::shared_ptr<SocketAddress>& address,
int prefix);
void AddSocketAddressMask(const std::shared_ptr<SocketAddress>& address,
int prefix);
bool Apply(const std::shared_ptr<SocketAddress>& address);
@@ -282,9 +286,8 @@ class SocketAddressBlockList : public MemoryRetainer {
std::shared_ptr<SocketAddress> start;
std::shared_ptr<SocketAddress> end;
SocketAddressRangeRule(
const std::shared_ptr<SocketAddress>& start,
const std::shared_ptr<SocketAddress>& end);
SocketAddressRangeRule(const std::shared_ptr<SocketAddress>& start,
const std::shared_ptr<SocketAddress>& end);
bool Apply(const std::shared_ptr<SocketAddress>& address) override;
std::string ToString() override;
@@ -298,9 +301,8 @@ class SocketAddressBlockList : public MemoryRetainer {
std::shared_ptr<SocketAddress> network;
int prefix;
SocketAddressMaskRule(
const std::shared_ptr<SocketAddress>& address,
int prefix);
SocketAddressMaskRule(const std::shared_ptr<SocketAddress>& address,
int prefix);
bool Apply(const std::shared_ptr<SocketAddress>& address) override;
std::string ToString() override;
@@ -336,8 +338,7 @@ class SocketAddressBlockListWrap : public BaseObject {
static BaseObjectPtr<SocketAddressBlockListWrap> New(Environment* env);
static BaseObjectPtr<SocketAddressBlockListWrap> New(
Environment* env,
std::shared_ptr<SocketAddressBlockList> blocklist);
Environment* env, std::shared_ptr<SocketAddressBlockList> blocklist);
static void New(const v8::FunctionCallbackInfo<v8::Value>& args);
static void AddAddress(const v8::FunctionCallbackInfo<v8::Value>& args);
@@ -346,11 +347,10 @@ class SocketAddressBlockListWrap : public BaseObject {
static void Check(const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetRules(const v8::FunctionCallbackInfo<v8::Value>& args);
SocketAddressBlockListWrap(
Environment* env,
v8::Local<v8::Object> wrap,
std::shared_ptr<SocketAddressBlockList> blocklist =
std::make_shared<SocketAddressBlockList>());
SocketAddressBlockListWrap(Environment* env,
v8::Local<v8::Object> wrap,
std::shared_ptr<SocketAddressBlockList> blocklist =
std::make_shared<SocketAddressBlockList>());
void MemoryInfo(node::MemoryTracker* tracker) const override;
SET_MEMORY_INFO_NAME(SocketAddressBlockListWrap)
+11
View File
@@ -460,6 +460,15 @@ void ConstructSharedArrayBuffer(const FunctionCallbackInfo<Value>& args) {
args.GetReturnValue().Set(sab);
}
// Marks a promise as handled and silent to prevent unhandled rejection
// tracking from triggering.
void MarkPromiseAsHandled(const FunctionCallbackInfo<Value>& args) {
CHECK(args[0]->IsPromise());
Local<Promise> promise = args[0].As<Promise>();
promise->MarkAsHandled();
promise->MarkAsSilent();
}
void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
registry->Register(GetPromiseDetails);
registry->Register(GetProxyDetails);
@@ -478,6 +487,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
registry->Register(DefineLazyProperties);
registry->Register(DefineLazyPropertiesGetter);
registry->Register(ConstructSharedArrayBuffer);
registry->Register(MarkPromiseAsHandled);
}
void Initialize(Local<Object> target,
@@ -583,6 +593,7 @@ void Initialize(Local<Object> target,
target,
"constructSharedArrayBuffer",
ConstructSharedArrayBuffer);
SetMethod(context, target, "markPromiseAsHandled", MarkPromiseAsHandled);
Local<String> should_abort_on_uncaught_toggle =
FIXED_ONE_BYTE_STRING(env->isolate(), "shouldAbortOnUncaughtToggle");
+418
View File
@@ -0,0 +1,418 @@
# Node.js QUIC Implementation (`src/quic/`)
This directory contains the C++ implementation of the Node.js experimental QUIC
support (`--experimental-quic`). The implementation builds on three external
libraries: **ngtcp2** (QUIC transport), **nghttp3** (HTTP/3 framing), and
**OpenSSL** (TLS 1.3).
## Architecture Overview
The stack is layered as:
```text
┌─────────────────────────────────────────────┐
│ JavaScript API (lib/internal/quic/) │
├─────────────────────────────────────────────┤
│ Endpoint — UDP socket, packet I/O │
│ Session — QUIC connection (ngtcp2) │
│ Application — ALPN protocol logic │
│ Stream — Bidirectional data flow │
├─────────────────────────────────────────────┤
│ ngtcp2 / nghttp3 / OpenSSL │
├─────────────────────────────────────────────┤
│ libuv — UDP, timers, thread pool │
└─────────────────────────────────────────────┘
```
An **Endpoint** binds a UDP socket and dispatches incoming packets to
**Sessions**. Each Session wraps an `ngtcp2_conn` and delegates
protocol-specific behavior to an **Application** (selected by ALPN
negotiation). Sessions contain **Streams** — bidirectional or unidirectional
data channels that carry application data.
## File Map
### Foundation
| File | Purpose |
| ------------- | ------------------------------------------------------------------ |
| `guard.h` | OpenSSL QUIC guard macro |
| `defs.h` | Core enums, typedefs, constants, macros |
| `arena.h` | Block-based arena allocator (header-only template) |
| `data.h/cc` | `Path`, `PathStorage`, `Store`, `QuicError` |
| `cid.h/cc` | `CID` — Connection ID with hash, factory, map alias |
| `tokens.h/cc` | `TokenSecret`, `StatelessResetToken`, `RetryToken`, `RegularToken` |
### Security
| File | Purpose |
| -------------------- | ----------------------------------------------------------- |
| `tlscontext.h/cc` | `TLSContext`, `TLSSession` — OpenSSL integration, SNI, ALPN |
| `sessionticket.h/cc` | `SessionTicket` — TLS 1.3 session resumption and 0-RTT |
### Core
| File | Purpose |
| ------------------ | ------------------------------------------------------------ |
| `endpoint.h/cc` | `Endpoint` — UDP binding, packet dispatch, retry/validation |
| `session.h/cc` | `Session` — QUIC connection state machine (\~3,500 lines) |
| `streams.h/cc` | `Stream`, `Outbound`, `PendingStream` — data flow |
| `application.h/cc` | `Session::Application` base + `DefaultApplication` |
| `http3.h/cc` | `Http3ApplicationImpl` — nghttp3 integration (\~1,400 lines) |
### Infrastructure
| File | Purpose |
| ----------------------- | ------------------------------------------------------------- |
| `bindingdata.h/cc` | `BindingData` — JS binding state, callback scopes, allocators |
| `session_manager.h/cc` | `SessionManager` — per-Realm CID→Session routing |
| `transportparams.h/cc` | `TransportParams` — QUIC transport parameter encoding |
| `packet.h/cc` | `Packet` — arena-allocated outbound packets |
| `preferredaddress.h/cc` | `PreferredAddress` — server preferred address helper |
| `quic.cc` | Module entry point (binding registration) |
## Key Design Patterns
### SendPendingDataScope (RAII Send Coalescing)
Every entry point that may generate outbound data creates a
`SendPendingDataScope`. Scopes nest — an internal depth counter ensures
`Application::SendPendingData()` is called exactly once, when the outermost
scope exits:
```cpp
{
SendPendingDataScope outer(session); // depth 1
{
SendPendingDataScope inner(session); // depth 2
// ... generate data ...
} // depth 1 — no send yet
} // depth 0 — SendPendingData() fires
```
This is used in `Session::Receive`, `Endpoint::Connect`, `Session::Close`,
`Session::ResumeStream`, and all stream write operations.
### NgTcp2CallbackScope / NgHttp3CallbackScope
Per-session RAII guards that prevent re-entrant calls into ngtcp2/nghttp3.
While active, `can_send_packets()` returns false, blocking the send loop.
If `Destroy()` is called during a callback (e.g., via JS `MakeCallback`),
destruction is deferred until the scope exits, preventing use-after-free.
### Bob Protocol (Pull-Based Streaming)
Data flows through the stack using the **bob** (Bytes-Over-Buffers) pull
protocol defined in `src/node_bob.h`. The consumer calls `Pull()` on a
source, which responds with one of four status codes:
| Status | Meaning |
| --------------------- | ---------------------------------------------------------------- |
| `STATUS_EOS` (0) | End of stream — no more data |
| `STATUS_CONTINUE` (1) | Data delivered; pull again |
| `STATUS_BLOCK` (2) | No data now; try later |
| `STATUS_WAIT` (3) | Async — source will invoke the `next` callback when data arrives |
The `Done` callback passed with each pull signals that the consumer is
finished with the buffer memory, enabling zero-copy transfer.
### Three-Phase Buffer Lifecycle
Data in `Stream::Outbound` moves through three states:
```text
Pulled (uncommitted) → Committed (in-flight) → Acknowledged (freed)
```
* **Uncommitted**: Read from the DataQueue but not yet accepted by ngtcp2
* **Committed**: Accepted into a QUIC packet by `ngtcp2_conn_writev_stream`
* **Acknowledged**: Peer ACKed the data; buffer memory is released
Separate cursors on each buffer entry track progression. This allows ngtcp2
to retry uncommitted data (e.g., after pacing/congestion clears) without
re-reading from the source.
### Application Abstraction
`Session::Application` is a virtual interface that the Session delegates
ALPN-specific behavior to. Two implementations exist:
* **`DefaultApplication`** (`application.cc`): Used for non-HTTP/3 ALPN
protocols. Maintains its own stream scheduling queue. Streams are scheduled
via an intrusive linked list.
* **`Http3ApplicationImpl`** (`http3.cc`): Used when ALPN negotiates `h3`.
Wraps `nghttp3_conn` for HTTP/3 framing, header compression (QPACK),
server push, and stream prioritization. Manages unidirectional control
streams internally.
The Application is selected during ALPN negotiation — immediately for
clients (ALPN known upfront), during the `OnSelectAlpn` TLS callback for
servers.
### Thread-Local Allocator
Both ngtcp2 and nghttp3 require custom allocators (`ngtcp2_mem`,
`nghttp3_mem`). These allocator structs must outlive every object they
create. Some nghttp3 objects (notably `rcbuf`s backing V8 external strings)
can survive past `BindingData` destruction during isolate teardown.
The solution uses `thread_local` storage:
```cpp
struct QuicAllocState {
BindingData* binding = nullptr; // Nulled in ~BindingData
ngtcp2_mem ngtcp2;
nghttp3_mem nghttp3;
};
thread_local QuicAllocState quic_alloc_state;
```
Each allocation prepends its size before the returned pointer. This allows
`free` and `realloc` to report correct sizes for memory tracking. When
`binding` is null (after `BindingData` destruction), allocations still
succeed but memory tracking is silently skipped.
## Session Lifecycle
### Creation
**Client**: `Endpoint::Connect()` builds a `Session::Config` with
`Side::CLIENT`, creates a `TLSContext`, and calls `Session::Create()` →
`ngtcp2_conn_client_new()`. The Application is selected immediately.
**Server**: `Endpoint::Receive()` processes an Initial packet through
address validation (retry tokens, LRU cache), then calls `Session::Create()`
→ `ngtcp2_conn_server_new()`. The Application is selected later, during ALPN
negotiation in the TLS handshake.
### The Receive Path
```text
uv_udp_recv_cb
→ Endpoint::Receive()
→ FindSession(dcid) // CID lookup across endpoints
├── Found → Session::Receive()
└── Not found:
├── Stateless reset? → process
├── Short header? → SendStatelessReset()
└── Long header? → acceptInitialPacket()
├── ngtcp2_accept()
├── Address validation (retry tokens, LRU)
└── Session::Create()
Session::Receive()
→ SendPendingDataScope // will send after processing
→ NgTcp2CallbackScope // re-entrancy guard
→ ngtcp2_conn_read_pkt() // decrypt, process frames
triggers callbacks:
├── recv_stream_data → Application::ReceiveStreamData()
├── stream_open → Application::ReceiveStreamOpen()
├── acked_stream_data → Application::AcknowledgeStreamData()
├── handshake_completed → Session::HandshakeCompleted()
└── ... others
→ Application::PostReceive() // deferred operations (e.g., GOAWAY)
```
### The Send Loop
```text
SendPendingDataScope::~SendPendingDataScope()
→ Application::SendPendingData()
Loop (up to max_packet_count):
├── GetStreamData() // pull data from next stream
│ └── stream->Pull() // bob pull from Outbound→DataQueue
├── WriteVStream() // ngtcp2_conn_writev_stream()
│ encrypts, frames, paces
├── if ndatalen > 0: StreamCommit()
│ stream->Commit(datalen, fin)
├── if nwrite > 0: Send() // uv_udp_send()
├── if WRITE_MORE: continue // room for more in this packet
├── if STREAM_DATA_BLOCKED: // flow control
│ StreamDataBlocked(), continue
└── if nwrite == 0: // pacing/congestion
ResumeStream() if data pending, return
On exit: UpdateTimer(), UpdateDataStats()
```
When `nwrite == 0` and the stream had unsent data (payload or FIN), the
stream is re-scheduled via `Application::ResumeStream()` so the next
timer-triggered `SendPendingData` retries it.
### Close Methods
| Method | Behavior |
| ------------ | ----------------------------------------------------------- |
| **DEFAULT** | Destroys all streams, sends CONNECTION\_CLOSE, emits to JS |
| **SILENT** | Same but skips CONNECTION\_CLOSE (errors, stateless resets) |
| **GRACEFUL** | Sends GOAWAY (H3), waits for streams to close naturally |
### Timer
`Session::UpdateTimer()` queries `ngtcp2_conn_get_expiry()` and sets a libuv
timer. When it fires, `OnTimeout()` calls `ngtcp2_conn_handle_expiry()` then
`SendPendingData()` to retransmit lost packets, send PINGs, or retry
pacing-blocked sends.
## Stream Lifecycle
### Creation
**Local streams**: `Session::OpenStream()` calls
`ngtcp2_conn_open_bidi_stream()` or `ngtcp2_conn_open_uni_stream()`. If the
handshake is incomplete or the concurrency limit is reached, the stream is
created in **pending** state and queued. When the peer grants capacity
(`ExtendMaxStreams`), pending streams are fulfilled with real stream IDs.
**Remote streams**: ngtcp2 notifies via callbacks. The Application creates a
`Stream` object and emits it to JavaScript.
### Outbound Data Flow
The `Stream::Outbound` class bridges a `DataQueue` (the data source) to
ngtcp2's packet-writing loop. A `DataQueue::Reader` provides the bob
pull interface.
Supported body source types (via `GetDataQueueFromSource`):
| Source | Strategy |
| ------------------- | -------------------------------------------- |
| `ArrayBuffer` | Zero-copy detach, or copy if non-detachable |
| `SharedArrayBuffer` | Always copy |
| `ArrayBufferView` | Zero-copy detach of underlying buffer |
| `Blob` | Slice of Blob's existing DataQueue |
| `String` | UTF-8 encode into BackingStore |
| `FileHandle` | `FdEntry` — async file reads via thread pool |
For `FileHandle` bodies, the `FdEntry::ReaderImpl` dispatches `uv_fs_read`
to the libuv thread pool and returns `STATUS_WAIT`. When the read completes,
the callback appends data to the Outbound buffer and calls
`session().ResumeStream(id)` to re-enter the send loop.
### Inbound Data Flow
Received stream data is delivered by ngtcp2 via
`Application::ReceiveStreamData()`, which calls `stream->ReceiveData()`.
Data is appended to the stream's inbound `DataQueue`. The JavaScript side
consumes this via an async iterator (the `stream/iter` `bytes()` helper).
The stream implements `DataQueue::BackpressureListener` to extend the
QUIC flow control window as data is consumed.
### Streaming Mode (Writer API)
When no body is provided at stream creation, the JavaScript `stream.writer`
API uses streaming mode. The Outbound creates a non-idempotent DataQueue.
Each `writeSync()` / `write()` call appends an in-memory entry. The
`endSync()` / `end()` call caps the queue, signaling EOS to the send loop.
## SessionManager
The `SessionManager` is a per-Realm singleton that owns the authoritative
CID→Session mapping. It enables:
* **Cross-endpoint routing**: A session's CIDs are registered globally so
packets arriving on any endpoint find the right session.
* **Connection migration**: When a session migrates to a new path, the
SessionManager updates the routing without requiring endpoint-specific
knowledge.
* **Stateless reset token mapping**: Maps reset tokens to sessions for
detecting stateless resets on any endpoint.
CID lookup uses a three-tier strategy:
1. Direct SCID match in `SessionManager::sessions_`
2. Cross-endpoint DCID→SCID in `SessionManager::dcid_to_scid_`
3. Per-endpoint DCID→SCID in `Endpoint::dcid_to_scid_` (peer-chosen CIDs)
## Address Validation
The endpoint uses an LRU cache to track validated remote addresses. For
unvalidated addresses:
1. If no token is present, a **Retry** packet is sent with a cryptographic
token (HKDF-derived, time-limited).
2. The client retransmits the Initial with the retry token.
3. The token is validated; the session is created with the original DCID
preserved for transport parameter verification.
Regular tokens (from `NEW_TOKEN` frames) follow the same validation path
but without the retry handshake. The LRU cache allows subsequent
connections from the same address to skip validation entirely.
## HTTP/3 Application (`http3.cc`)
The `Http3ApplicationImpl` wraps `nghttp3_conn` and handles:
* **Header compression**: QPACK encoding/decoding via nghttp3's internal
encoder/decoder streams (unidirectional).
* **Stream management**: Only bidirectional streams are exposed to
JavaScript. Unidirectional control, encoder, and decoder streams are
managed internally.
* **FIN management**: `stream_fin_managed_by_application()` returns true.
nghttp3 controls when FIN is sent based on HTTP/3 framing (DATA frames,
trailing HEADERS). The `EndWriting()` notification from JavaScript is
forwarded to `nghttp3_conn_shutdown_stream_write()`.
* **Data read callback**: `on_read_data_callback` pulls data from the
stream's Outbound during `nghttp3_conn_writev_stream`. Bytes must be
committed inside the callback (before `StreamCommit`) because QPACK can
cause re-entrant `read_data` calls.
* **GOAWAY**: `BeginShutdown()` sends a GOAWAY frame. The goaway ID is
deferred to `PostReceive()` (outside callback scopes) so it can safely
invoke JavaScript.
* **Settings**: HTTP/3 SETTINGS (max field section size, QPACK capacities,
CONNECT protocol, datagrams) are negotiated and enforced. Datagram
support follows RFC 9297 — when the peer's SETTINGS disable datagrams,
`sendDatagram()` is blocked.
* **0-RTT**: Early data settings are validated during ticket extraction
(`ValidateTicketData` in `ExtractSessionTicketAppData`). If the server's
settings changed incompatibly, the ticket is rejected before TLS accepts
it.
## Error Handling
`QuicError` (`data.h`) encapsulates QUIC error codes with a type namespace
(transport, application, version negotiation, idle close). Factory methods
wrap ngtcp2 error codes, TLS alerts, and application errors.
On the JavaScript side, `convertQuicError()` transforms the C++ error
representation into `ERR_QUIC_TRANSPORT_ERROR` or
`ERR_QUIC_APPLICATION_ERROR` objects. Clean closes (transport NO\_ERROR,
H3 NO\_ERROR, or idle close) resolve `stream.closed`; all other errors
reject it.
## Packet Allocation
Outbound packets are allocated from an `ArenaPool<Packet>` owned by the
Endpoint. The arena provides O(1) allocation from contiguous memory blocks
(128 slots per block), avoiding per-packet heap allocation and V8 object
overhead. Packets are returned to the pool when the UDP send completes
(via the `Packet::Listener::PacketDone` callback).
## Debug Logging
Use the `NODE_DEBUG_NATIVE` environment variable to enable detailed debug
logging:
* `QUIC` - general QUIC events (sessions, streams, packets)
* `NGTCP2` - ngtcp2 callback events and error codes
* `NGHTTP3` - nghttp3 callback events and error codes
```bash
NODE_DEBUG_NATIVE=QUIC,NGTCP2,NGHTTP3 node --experimental-quic ...
```
The debug output will be printed to stderr and can be extremely verbose.
Used in combination with `qlog` and `keylog` options when creating a
`QuicSession`, this can help significantly with debugging and understanding
QUIC behavior and identifying bugs / performance issues in the implementation.
## External Dependencies
| Library | Role | Location |
| ------- | --------------------------------------- | ------------------------- |
| ngtcp2 | QUIC transport protocol | `deps/ngtcp2/ngtcp2/` |
| nghttp3 | HTTP/3 framing, QPACK | `deps/ngtcp2/nghttp3/` |
| OpenSSL | TLS 1.3 handshake, encryption | system or `deps/openssl/` |
| libuv | UDP sockets, timers, thread pool | `deps/uv/` |
| V8 | JavaScript engine, GC, external strings | `deps/v8/` |
+295 -115
View File
@@ -1,3 +1,4 @@
#include "util.h"
#if HAVE_OPENSSL && HAVE_QUIC
#include "guard.h"
#ifndef OPENSSL_NO_QUIC
@@ -34,6 +35,8 @@ const Session::Application_Options Session::Application_Options::kDefault = {};
Session::Application_Options::operator const nghttp3_settings() const {
// In theory, Application::Options might contain options for more than just
// HTTP/3. Here we extract only the properties that are relevant to HTTP/3.
// Later if we add more application types we can add more properties or
// divide this up into multiple option structs.
return nghttp3_settings{
.max_field_section_size = max_field_section_size,
.qpack_max_dtable_capacity =
@@ -43,11 +46,13 @@ Session::Application_Options::operator const nghttp3_settings() const {
.qpack_blocked_streams = static_cast<size_t>(qpack_blocked_streams),
.enable_connect_protocol = enable_connect_protocol,
.h3_datagram = enable_datagrams,
// TODO(@jasnell): Support origin frames?
// origin_list is nullptr here because it is set directly on the
// nghttp3_settings in Http3ApplicationImpl::InitializeConnection()
// from the SNI configuration.
.origin_list = nullptr,
.glitch_ratelim_burst = 1000,
.glitch_ratelim_rate = 33,
.qpack_indexing_strat = NGHTTP3_QPACK_INDEXING_STRAT_NONE,
.qpack_indexing_strat = NGHTTP3_QPACK_INDEXING_STRAT_EAGER,
};
}
@@ -139,53 +144,21 @@ bool Session::Application::Start() {
return true;
}
bool Session::Application::AcknowledgeStreamData(int64_t stream_id,
size_t datalen) {
if (auto stream = session().FindStream(stream_id)) [[likely]] {
bool Session::Application::AcknowledgeStreamData(stream_id id, size_t datalen) {
if (auto stream = session().FindStream(id)) [[likely]] {
stream->Acknowledge(datalen);
return true;
}
return false;
}
void Session::Application::BlockStream(int64_t id) {
// By default do nothing.
}
bool Session::Application::CanAddHeader(size_t current_count,
size_t current_headers_length,
size_t this_header_length) {
// By default headers are not supported.
return false;
}
bool Session::Application::SendHeaders(const Stream& stream,
HeadersKind kind,
const Local<v8::Array>& headers,
HeadersFlags flags) {
// By default do nothing.
return false;
}
void Session::Application::ResumeStream(int64_t id) {
// By default do nothing.
}
void Session::Application::ExtendMaxStreams(EndpointLabel label,
Direction direction,
uint64_t max_streams) {
// By default do nothing.
}
void Session::Application::ExtendMaxStreamData(Stream* stream,
uint64_t max_data) {
Debug(session_, "Application extending max stream data");
// By default do nothing.
// Returning true even when the stream is not found is intentional.
// After a stream is destroyed, the peer can still ACK data that was
// previously sent. This is benign and should not be treated as an error.
return true;
}
void Session::Application::CollectSessionTicketAppData(
SessionTicket::AppData* app_data) const {
// By default do nothing.
// By default, write just the application type byte.
uint8_t buf[1] = {static_cast<uint8_t>(type())};
app_data->Set(uv_buf_init(reinterpret_cast<char*>(buf), 1));
}
SessionTicket::AppData::Status
@@ -197,14 +170,39 @@ Session::Application::ExtractSessionTicketAppData(
: SessionTicket::AppData::Status::TICKET_USE;
}
void Session::Application::SetStreamPriority(const Stream& stream,
StreamPriority priority,
StreamPriorityFlags flags) {
// By default do nothing.
std::optional<PendingTicketAppData> Session::Application::ParseTicketData(
const uv_buf_t& data) {
if (data.len == 0 || data.base == nullptr) return std::nullopt;
auto app_type =
static_cast<Type>(reinterpret_cast<const uint8_t*>(data.base)[0]);
switch (app_type) {
case Type::DEFAULT:
return DefaultTicketData{};
case Type::HTTP3:
return ParseHttp3TicketData(data);
default:
return std::nullopt;
}
}
StreamPriority Session::Application::GetStreamPriority(const Stream& stream) {
return StreamPriority::DEFAULT;
bool Session::Application::ValidateTicketData(
const PendingTicketAppData& data, const Application_Options& options) {
if (std::holds_alternative<Http3TicketData>(data)) {
// TODO(@jasnell): This validation probably belongs in http3.cc but keeping
// it here for now.
const auto& ticket = std::get<Http3TicketData>(data);
return options.max_field_section_size >= ticket.max_field_section_size &&
options.qpack_max_dtable_capacity >=
ticket.qpack_max_dtable_capacity &&
options.qpack_encoder_max_dtable_capacity >=
ticket.qpack_encoder_max_dtable_capacity &&
options.qpack_blocked_streams >= ticket.qpack_blocked_streams &&
(!ticket.enable_connect_protocol ||
options.enable_connect_protocol) &&
(!ticket.enable_datagrams || options.enable_datagrams);
}
// DefaultTicketData always validates.
return true;
}
Packet::Ptr Session::Application::CreateStreamDataPacket() {
@@ -212,23 +210,120 @@ Packet::Ptr Session::Application::CreateStreamDataPacket() {
session_->remote_address(), session_->max_packet_size(), "stream data");
}
void Session::Application::StreamClose(Stream* stream, QuicError&& error) {
void Session::Application::ReceiveStreamClose(Stream* stream,
QuicError&& error) {
DCHECK_NOT_NULL(stream);
stream->Destroy(std::move(error));
}
void Session::Application::StreamStopSending(Stream* stream,
QuicError&& error) {
void Session::Application::ReceiveStreamStopSending(Stream* stream,
QuicError&& error) {
DCHECK_NOT_NULL(stream);
stream->ReceiveStopSending(std::move(error));
}
void Session::Application::StreamReset(Stream* stream,
uint64_t final_size,
QuicError&& error) {
void Session::Application::ReceiveStreamReset(Stream* stream,
uint64_t final_size,
QuicError&& error) {
stream->ReceiveStreamReset(final_size, std::move(error));
}
// Attempts to pack a pending datagram into the current packet.
// Returns the nwrite value from ngtcp2_conn_writev_datagram.
// On fatal error, closes the session and returns the error code.
// The caller should check:
// > 0: packet is complete, send it (pos was NOT advanced — caller
// must add nwrite to pos and send)
// NGTCP2_ERR_WRITE_MORE: datagram packed, room for more
// 0: congestion controlled or doesn't fit, datagram stays in queue
// < 0 (other): fatal error, session already closed
ssize_t Session::Application::TryWritePendingDatagram(PathStorage* path,
uint8_t* dest,
size_t destlen) {
CHECK(session_->HasPendingDatagrams());
auto max_attempts = session_->config().options.max_datagram_send_attempts;
// Skip datagrams that have already exceeded the send attempt limit
// from a previous SendPendingData cycle.
while (session_->HasPendingDatagrams()) {
auto& front = session_->PeekPendingDatagram();
if (front.send_attempts < max_attempts) break;
Debug(session_,
"Datagram %" PRIu64 " abandoned after %u attempts",
front.id,
front.send_attempts);
session_->DatagramStatus(front.id, DatagramStatus::ABANDONED);
session_->PopPendingDatagram();
}
if (!session_->HasPendingDatagrams()) return 0;
auto& dg = session_->PeekPendingDatagram();
ngtcp2_vec dgvec = dg.data;
int accepted = 0;
int dg_flags = NGTCP2_WRITE_DATAGRAM_FLAG_MORE;
ssize_t dg_nwrite = ngtcp2_conn_writev_datagram(*session_,
&path->path,
nullptr,
dest,
destlen,
&accepted,
dg_flags,
dg.id,
&dgvec,
1,
uv_hrtime());
if (accepted) {
// Nice, the datagram was accepted!
Debug(session_, "Datagram %" PRIu64 " accepted into packet", dg.id);
session_->DatagramSent(dg.id);
session_->PopPendingDatagram();
} else {
Debug(session_, "Datagram %" PRIu64 " not accepted into packet", dg.id);
}
switch (dg_nwrite) {
case 0: {
// If dg_nwrite is 0, we are either congestion controlled or
// there wasn't enough room in the packet for the datagram or
// we aren't in a state where we can send.
// We'll skip this attempt and return 0.
CHECK(!accepted);
dg.send_attempts++;
return 0;
}
case NGTCP2_ERR_WRITE_MORE: {
// There's still room left in the packet!
return NGTCP2_ERR_WRITE_MORE;
}
case NGTCP2_ERR_INVALID_STATE:
case NGTCP2_ERR_INVALID_ARGUMENT: {
// Non-fatal error cases. Peer either does not support datagrams
// or the datagram is too large for the peer's max.
// Abandon the datagram and signal skip by returning std::nullopt.
session_->DatagramStatus(dg.id, DatagramStatus::ABANDONED);
session_->PopPendingDatagram();
return 0;
}
default: {
// Fatal errors: PKT_NUM_EXHAUSTED, CALLBACK_FAILURE, NOMEM, etc.
Debug(session_, "Fatal datagram error: %zd", dg_nwrite);
session_->SetLastError(QuicError::ForNgtcp2Error(dg_nwrite));
session_->Close(CloseMethod::SILENT);
return dg_nwrite;
}
}
UNREACHABLE();
}
// the SendPendingData method is the primary driver for sending data from the
// application layer. It loops through available stream data and pending
// datagrams and generates packets to send until there is either no more
// data to send or we hit the maximum number of packets to send in one go.
// This method is extremely delicate. A bug in this method can break the
// entire QUIC implementation; so be very careful when making changes here
// and make sure to test thoroughly. When in doubt... don't change it.
void Session::Application::SendPendingData() {
DCHECK(!session().is_destroyed());
if (!session().can_send_packets()) [[unlikely]] {
@@ -262,19 +357,14 @@ void Session::Application::SendPendingData() {
size_t packet_send_count = 0;
Packet::Ptr packet;
uint8_t* pos = nullptr;
uint8_t* begin = nullptr;
auto ensure_packet = [&] {
if (!packet) {
packet = CreateStreamDataPacket();
if (!packet) [[unlikely]]
return false;
pos = begin = packet->data();
}
DCHECK(packet);
DCHECK_NOT_NULL(pos);
DCHECK_NOT_NULL(begin);
return true;
};
@@ -302,21 +392,38 @@ void Session::Application::SendPendingData() {
}
// If we got here, we were at least successful in checking for stream data.
// There might not be any stream data to send.
// There might not be any stream data to send. If there is no stream data,
// that's perfectly fine, we still need to serialize any frames we do have
// (pings, acks, datagrams, etc) so we'll just keep going.
if (stream_data.id >= 0) {
Debug(session_, "Application using stream data: %s", stream_data);
} else {
Debug(session_, "No stream data to send");
}
if (session_->HasPendingDatagrams()) {
Debug(session_, "There are pending datagrams to send");
}
// Awesome, let's write our packet!
ssize_t nwrite =
WriteVStream(&path, pos, &ndatalen, max_packet_size, stream_data);
ssize_t nwrite = WriteVStream(
&path, packet->data(), &ndatalen, packet->length(), stream_data);
// When ndatalen is > 0, that's our indication that stream data was accepted
// in to the packet. Yay!
if (ndatalen > 0) {
Debug(session_,
"Application accepted %zu bytes from stream %" PRIi64
" into packet",
ndatalen,
stream_data.id);
if (!StreamCommit(&stream_data, ndatalen)) {
// Data was accepted into the packet, but for some reason adjusting
// the stream's committed data failed. Treat as fatal.
Debug(session_, "Failed to commit accepted bytes in stream");
session_->SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL));
closed = true;
return session_->Close(CloseMethod::SILENT);
}
} else if (stream_data.id >= 0) {
Debug(session_,
"Application did not accept any bytes from stream %" PRIi64
@@ -324,6 +431,23 @@ void Session::Application::SendPendingData() {
stream_data.id);
}
// When nwrite is zero, it means we are congestion limited or it is
// just not our turn to send something. Re-schedule the stream if it
// had unsent data (payload or FIN) so the next timer-triggered
// SendPendingData retries it. Without this, a FIN-only send that
// hits nwrite=0 is lost forever — the stream already returned EOS
// from Pull and won't be re-scheduled by anyone else.
// We call Application::ResumeStream directly (not Session::ResumeStream)
// to avoid creating a SendPendingDataScope — we're already inside
// SendPendingData and re-entering would just hit nwrite=0 again.
if (nwrite == 0) {
Debug(session_, "Congestion or not our turn to send");
if (stream_data.id >= 0 && (stream_data.count > 0 || stream_data.fin)) {
ResumeStream(stream_data.id);
}
return;
}
// A negative nwrite value indicates either an error or that there is more
// data to write into the packet.
if (nwrite < 0) {
@@ -344,7 +468,7 @@ void Session::Application::SendPendingData() {
case NGTCP2_ERR_STREAM_SHUT_WR: {
// Indicates that the writable side of the stream should be closed
// locally or the stream is being reset. In either case, we can't send
// any stream data!
// data for this stream!
Debug(session_,
"Closing stream %" PRIi64 " for writing",
stream_data.id);
@@ -357,16 +481,36 @@ void Session::Application::SendPendingData() {
if (stream_data.stream) [[likely]] {
stream_data.stream->EndWritable();
}
// Notify the application that the stream's write side is shut
// so it stops queuing data. Without this, GetStreamData would
// keep returning the same stream and we'd loop forever.
StreamWriteShut(stream_data.id);
continue;
}
case NGTCP2_ERR_WRITE_MORE: {
if (ndatalen >= 0 && !StreamCommit(&stream_data, ndatalen)) {
Debug(session_,
"Failed to commit stream data while writing packets");
session_->SetLastError(
QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL));
closed = true;
return session_->Close(CloseMethod::SILENT);
Debug(session_, "Packet buffer not full, coalesce more data into it");
// Room for more in this packet. Try to pack a pending datagram
// if there is one. Otherwise just loop around and keep going.
if (session_->HasPendingDatagrams()) {
auto result = TryWritePendingDatagram(
&path, packet->data(), packet->length());
// When result is 0, either the datagram was congestion controlled,
// didn't fit in the packet, or was abandoned. Skip and continue.
// When result is > 0, the packet is done and the result is the
// completed size of the packet we're sending.
if (result > 0) {
size_t len = result;
Debug(session_, "Sending packet with %zu bytes", len);
packet->Truncate(len);
session_->Send(std::move(packet), path);
if (++packet_send_count == max_packet_count) return;
} else if (result < 0) {
// Any negative result other than NGTCP2_ERR_WRITE_MORE
// at this point is fatal. The session will have been
// closed.
if (result != NGTCP2_ERR_WRITE_MORE) return;
}
}
continue;
}
@@ -390,46 +534,42 @@ void Session::Application::SendPendingData() {
session_->SetLastError(QuicError::ForNgtcp2Error(nwrite));
closed = true;
return session_->Close(CloseMethod::SILENT);
} else if (ndatalen >= 0 && !StreamCommit(&stream_data, ndatalen)) {
session_->SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL));
closed = true;
return session_->Close(CloseMethod::SILENT);
}
// When nwrite is zero, it means we are congestion limited or it is
// just not our turn now to send something. Stop sending packets.
if (nwrite == 0) {
// If there was stream data selected, we should reschedule it to try
// sending again.
if (stream_data.id >= 0) ResumeStream(stream_data.id);
// There might be a partial packet already prepared. If so, send it.
size_t datalen = pos - begin;
if (datalen) {
Debug(session_, "Sending packet with %zu bytes", datalen);
packet->Truncate(datalen);
session_->Send(std::move(packet), path);
}
// If no data, Ptr destructor releases the packet.
return;
}
// At this point we have a packet prepared to send.
pos += nwrite;
size_t datalen = pos - begin;
Debug(session_, "Sending packet with %zu bytes", datalen);
packet->Truncate(datalen);
// At this point we have a packet prepared to send. The nwrite
// is the size of the packet we are sending.
size_t len = nwrite;
Debug(session_, "Sending packet with %zu bytes", len);
packet->Truncate(len);
session_->Send(std::move(packet), path);
if (++packet_send_count == max_packet_count) return;
// If we have sent the maximum number of packets, we're done.
if (++packet_send_count == max_packet_count) {
return;
// If there are pending datagrams, try sending them in a fresh packet.
// This is necessary because ngtcp2_conn_writev_stream only returns
// NGTCP2_ERR_WRITE_MORE when there is actual stream data — when no
// streams are active, the coalescing path above is never reached and
// datagrams would never be sent.
if (session_->HasPendingDatagrams()) {
if (!ensure_packet()) [[unlikely]] {
Debug(session_, "Failed to create packet for datagram");
session_->SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL));
closed = true;
return session_->Close(CloseMethod::SILENT);
}
auto result =
TryWritePendingDatagram(&path, packet->data(), packet->length());
if (result > 0) {
Debug(session_, "Sending datagram packet with %zd bytes", result);
packet->Truncate(static_cast<size_t>(result));
session_->Send(std::move(packet), path);
if (++packet_send_count == max_packet_count) return;
} else if (result < 0 && result != NGTCP2_ERR_WRITE_MORE) {
// Fatal error — session already closed by TryWritePendingDatagram.
return;
}
// If result == 0 (congestion) or NGTCP2_ERR_WRITE_MORE (datagram
// packed but room for more), the loop continues normally.
}
// Prepare to loop back around to prepare a new packet.
// packet is already empty from the std::move above.
pos = begin = nullptr;
}
}
@@ -455,6 +595,7 @@ ssize_t Session::Application::WriteVStream(PathStorage* path,
uv_hrtime());
}
// ============================================================================
// The DefaultApplication is the default implementation of Session::Application
// that is used for all unrecognized ALPN identifiers.
class DefaultApplication final : public Session::Application {
@@ -470,7 +611,35 @@ class DefaultApplication final : public Session::Application {
error_code GetNoErrorCode() const override { return 0; }
bool ReceiveStreamData(int64_t stream_id,
// Raw QUIC has no application-defined "general failure" code, so
// fall back to the QUIC transport-level INTERNAL_ERROR (0x1) used
// by ngtcp2 for unspecified failures.
error_code GetInternalErrorCode() const override {
return NGTCP2_INTERNAL_ERROR;
}
void EarlyDataRejected() override {
// Destroy all open streams — ngtcp2 has already discarded their
// internal state when it rejected the early data.
session().DestroyAllStreams(QuicError::ForApplication(0));
if (!session().is_destroyed()) {
session().EmitEarlyDataRejected();
}
}
bool ApplySessionTicketData(const PendingTicketAppData& data) override {
return std::holds_alternative<DefaultTicketData>(data);
}
bool ReceiveStreamOpen(stream_id id) override {
auto stream = session().CreateStream(id);
if (!stream || session().is_destroyed()) [[unlikely]] {
return !session().is_destroyed();
}
return true;
}
bool ReceiveStreamData(stream_id id,
const uint8_t* data,
size_t datalen,
const Stream::ReceiveDataFlags& flags,
@@ -478,10 +647,10 @@ class DefaultApplication final : public Session::Application {
BaseObjectPtr<Stream> stream;
if (stream_user_data == nullptr) {
// This is the first time we're seeing this stream. Implicitly create it.
stream = session().CreateStream(stream_id);
if (!stream) [[unlikely]] {
// We couldn't actually create the stream for whatever reason.
Debug(&session(), "Default application failed to create new stream");
stream = session().CreateStream(id);
if (!stream || session().is_destroyed()) [[unlikely]] {
// We couldn't create the stream, or the session was destroyed
// during the onstream callback (via MakeCallback re-entrancy).
return false;
}
} else {
@@ -546,7 +715,6 @@ class DefaultApplication final : public Session::Application {
if (count > 0) {
stream->Schedule(&stream_queue_);
} else {
}
// Not calling done here because we defer committing
@@ -569,14 +737,26 @@ class DefaultApplication final : public Session::Application {
return 0;
}
void ResumeStream(int64_t id) override { ScheduleStream(id); }
void ResumeStream(stream_id id) override { ScheduleStream(id); }
void BlockStream(int64_t id) override {
void BlockStream(stream_id id) override {
if (auto stream = session().FindStream(id)) [[likely]] {
// Remove the stream from the send queue. It will be re-scheduled
// via ExtendMaxStreamData when the peer grants more flow control.
// Without this, SendPendingData would repeatedly pop and retry
// the same blocked stream in an infinite loop.
stream->Unschedule();
stream->EmitBlocked();
}
}
void ExtendMaxStreamData(Stream* stream, uint64_t max_data) override {
// The peer granted more flow control for this stream. Re-schedule
// it so SendPendingData will resume writing.
DCHECK_NOT_NULL(stream);
stream->Schedule(&stream_queue_);
}
bool StreamCommit(StreamData* stream_data, size_t datalen) override {
DCHECK_NOT_NULL(stream_data);
CHECK(stream_data->stream);
@@ -589,7 +769,7 @@ class DefaultApplication final : public Session::Application {
SET_NO_MEMORY_INFO()
private:
void ScheduleStream(int64_t id) {
void ScheduleStream(stream_id id) {
if (auto stream = session().FindStream(id)) [[likely]] {
stream->Schedule(&stream_queue_);
}
+147 -22
View File
@@ -2,6 +2,9 @@
#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
#include <optional>
#include <variant>
#include "base_object.h"
#include "bindingdata.h"
#include "defs.h"
@@ -11,34 +14,82 @@
namespace node::quic {
// Parsed session ticket application data, produced by
// Application::ParseTicketData() before ALPN negotiation and consumed
// by Application::ApplySessionTicketData() after.
struct DefaultTicketData {};
struct Http3TicketData {
uint64_t max_field_section_size;
uint64_t qpack_max_dtable_capacity;
uint64_t qpack_encoder_max_dtable_capacity;
uint64_t qpack_blocked_streams;
bool enable_connect_protocol;
bool enable_datagrams;
};
using PendingTicketAppData =
std::variant<std::monostate, DefaultTicketData, Http3TicketData>;
// An Application implements the ALPN-protocol specific semantics on behalf
// of a QUIC Session.
class Session::Application : public MemoryRetainer {
public:
using Options = Session::Application_Options;
Application(Session* session, const Options& options);
DISALLOW_COPY_AND_MOVE(Application)
// The type of Application, exposed via the session state so JS
// can observe which Application was selected after ALPN negotiation.
// This is used primarily for testing/debugging.
enum class Type : uint8_t {
NONE = 0, // Not yet selected (server pre-negotiation)
DEFAULT = 1, // DefaultApplication (non-h3 ALPN)
HTTP3 = 2, // Http3ApplicationImpl (h3 / h3-XX ALPN)
};
Application(Session* session, const Options& options);
DISALLOW_COPY_AND_MOVE(Application)
virtual Type type() const = 0;
virtual bool Start();
// Returns true if Start() has been called successfully.
virtual bool is_started() const { return false; }
// Called when the server rejects 0-RTT early data. The application
// must destroy all streams that were opened during the 0-RTT phase
// since ngtcp2 has already discarded their internal state.
virtual void EarlyDataRejected() = 0;
// The "no error code" is the application-level error code that signals
// "no error". Per the QUIC spec, this can vary by application protocol
// and is not necessarily 0.
virtual error_code GetNoErrorCode() const = 0;
// The "internal error code" is the application-level error code used
// to signal a non-specific failure when no more specific code has
// been provided by the caller. For example, `writer.fail(reason)` on
// the JS side uses this code when `reason` is not a `QuicError`
// carrying an explicit code. For HTTP/3 this is
// `NGHTTP3_H3_INTERNAL_ERROR` (0x102); for raw QUIC applications
// there is no defined application code so we fall back to
// NGTCP2_INTERNAL_ERROR (0x1).
virtual error_code GetInternalErrorCode() const = 0;
// Called after Session::Receive processes a packet, outside all callback
// scopes. Applications can use this to handle deferred operations that
// require calling into JS (e.g., HTTP/3 GOAWAY processing).
virtual void PostReceive() {}
// Called when ngtcp2 notifies us that a new remote stream has been
// opened. The Application decides whether to create a Stream object
// (and fire the JS onstream callback) based on the stream type. For
// example, HTTP/3 only creates Stream objects for bidi streams since
// uni streams are managed internally by nghttp3.
virtual bool ReceiveStreamOpen(stream_id id) = 0;
// Session will forward all received stream data immediately on to the
// Application. The only additional processing the Session does is to
// automatically adjust the session-level flow control window. It is up to
// the Application to do the same for the Stream-level flow control.
virtual bool ReceiveStreamData(int64_t stream_id,
virtual bool ReceiveStreamData(stream_id id,
const uint8_t* data,
size_t datalen,
const Stream::ReceiveDataFlags& flags,
@@ -46,22 +97,30 @@ class Session::Application : public MemoryRetainer {
// Session will forward all data acknowledgements for a stream to the
// Application.
virtual bool AcknowledgeStreamData(int64_t stream_id, size_t datalen);
virtual bool AcknowledgeStreamData(stream_id id, size_t datalen);
// Called to determine if a Header can be added to this application.
// Applications that do not support headers will always return false.
virtual bool CanAddHeader(size_t current_count,
size_t current_headers_length,
size_t this_header_length);
size_t this_header_length) {
return false;
}
// Called when ngtcp2 reports NGTCP2_ERR_STREAM_SHUT_WR for a stream.
// Applications that manage their own framing (e.g., HTTP/3) must inform
// their protocol layer that the stream's write side is shut so it stops
// queuing data for that stream. The default is a no-op.
virtual void StreamWriteShut(stream_id id) {}
// Called to mark the identified stream as being blocked. Not all
// Application types will support blocked streams, and those that do will do
// so differently.
virtual void BlockStream(int64_t id);
virtual void BlockStream(stream_id id) {}
// Called when the session determines that there is outbound data available
// to send for the given stream.
virtual void ResumeStream(int64_t id);
virtual void ResumeStream(stream_id id) {}
// Called when the Session determines that the maximum number of
// remotely-initiated unidirectional streams has been extended. Not all
@@ -69,16 +128,27 @@ class Session::Application : public MemoryRetainer {
// nothing.
virtual void ExtendMaxStreams(EndpointLabel label,
Direction direction,
uint64_t max_streams);
uint64_t max_streams) {}
// Returns true if the application manages stream FIN internally (e.g.,
// HTTP/3 uses nghttp3 which sends FIN via the fin flag in writev_stream).
// When true, the stream infrastructure must NOT call
// ngtcp2_conn_shutdown_stream_write when the JS write side ends —
// the application protocol layer handles it.
virtual bool stream_fin_managed_by_application() const { return false; }
// Called when the Session determines that the flow control window for the
// given stream has been expanded. Not all Application types will require
// this notification so the default is to do nothing.
virtual void ExtendMaxStreamData(Stream* stream, uint64_t max_data);
virtual void ExtendMaxStreamData(Stream* stream, uint64_t max_data) {
Debug(session_, "Application extending max stream data");
// By default do nothing.
}
// Different Applications may wish to set some application data in the
// session ticket (e.g. http/3 would set server settings in the application
// data). By default, there's nothing to set.
// data). The first byte written MUST be the Application::Type enum value.
// By default, writes just the type byte.
virtual void CollectSessionTicketAppData(
SessionTicket::AppData* app_data) const;
@@ -89,17 +159,37 @@ class Session::Application : public MemoryRetainer {
const SessionTicket::AppData& app_data,
SessionTicket::AppData::Source::Flag flag);
// Validates parsed ticket data against current application options.
// Returns false if the stored settings are more permissive than the
// current config (e.g., a feature was enabled when the ticket was
// issued but is now disabled).
static bool ValidateTicketData(const PendingTicketAppData& data,
const Application_Options& options);
// Parse session ticket app data before ALPN negotiation. Reads the
// type byte and dispatches to the appropriate application-specific
// parser. Returns std::nullopt if parsing fails.
static std::optional<PendingTicketAppData> ParseTicketData(
const uv_buf_t& data);
// Called after ALPN negotiation to validate and apply previously
// parsed session ticket app data. Returns false if the data is
// incompatible (e.g., type mismatch or settings downgrade), which
// causes the handshake to fail.
virtual bool ApplySessionTicketData(const PendingTicketAppData& data) = 0;
// Notifies the Application that the identified stream has been closed.
virtual void StreamClose(Stream* stream, QuicError&& error = QuicError());
virtual void ReceiveStreamClose(Stream* stream,
QuicError&& error = QuicError());
// Notifies the Application that the identified stream has been reset.
virtual void StreamReset(Stream* stream,
uint64_t final_size,
QuicError&& error = QuicError());
virtual void ReceiveStreamReset(Stream* stream,
uint64_t final_size,
QuicError&& error = QuicError());
// Notifies the Application that the identified stream should stop sending.
virtual void StreamStopSending(Stream* stream,
QuicError&& error = QuicError());
virtual void ReceiveStreamStopSending(Stream* stream,
QuicError&& error = QuicError());
// Submits an outbound block of headers for the given stream. Not all
// Application types will support headers, in which case this function
@@ -107,31 +197,57 @@ class Session::Application : public MemoryRetainer {
virtual bool SendHeaders(const Stream& stream,
HeadersKind kind,
const v8::Local<v8::Array>& headers,
HeadersFlags flags = HeadersFlags::NONE);
HeadersFlags flags = HeadersFlags::NONE) {
return false;
}
// Signals to the Application that it should serialize and transmit any
// pending session and stream packets it has accumulated.
void SendPendingData();
// Returns true if the application protocol supports sending and
// receiving headers on streams (e.g. HTTP/3). Applications that
// do not support headers should return false (the default).
virtual bool SupportsHeaders() const { return false; }
// Initiates application-level graceful shutdown signaling (e.g.,
// HTTP/3 GOAWAY). Called when Session::Close(GRACEFUL) is invoked.
virtual void BeginShutdown() {}
// Completes the application-level graceful shutdown. Called from
// FinishClose() before CONNECTION_CLOSE is sent. For HTTP/3, this
// sends the final GOAWAY with the actual last accepted stream ID.
virtual void CompleteShutdown() {}
// Set the priority level of the stream if supported by the application. Not
// all applications support priorities, in which case this function is a
// non-op.
virtual void SetStreamPriority(
const Stream& stream,
StreamPriority priority = StreamPriority::DEFAULT,
StreamPriorityFlags flags = StreamPriorityFlags::NONE);
StreamPriorityFlags flags = StreamPriorityFlags::NON_INCREMENTAL) {}
struct StreamPriorityResult {
StreamPriority priority;
StreamPriorityFlags flags;
};
// Get the priority level of the stream if supported by the application. Not
// all applications support priorities, in which case this function returns
// the default stream priority.
virtual StreamPriority GetStreamPriority(const Stream& stream);
virtual StreamPriorityResult GetStreamPriority(const Stream& stream) {
return {StreamPriority::DEFAULT, StreamPriorityFlags::NON_INCREMENTAL};
}
// The StreamData struct is used by the application to pass pending stream
// data to the session for transmission.
struct StreamData;
virtual int GetStreamData(StreamData* data) = 0;
virtual bool StreamCommit(StreamData* data, size_t datalen) = 0;
inline Environment* env() const { return session().env(); }
inline Session& session() {
CHECK_NOT_NULL(session_);
return *session_;
@@ -144,6 +260,15 @@ class Session::Application : public MemoryRetainer {
private:
Packet::Ptr CreateStreamDataPacket();
// Tries to pack a pending datagram into the current packet buffer.
// If < 0 is returned, either NGTCP2_ERR_WRITE_MORE or a fatal error is
// returned; the caller must check. If > 0 is returned, the packet is done
// and the value is the size of the finalized packet. If 0 is returned,
// the datagram is either congestion limited or was abandoned
ssize_t TryWritePendingDatagram(PathStorage* path,
uint8_t* dest,
size_t destlen);
// Write the given stream_data into the buffer.
ssize_t WriteVStream(PathStorage* path,
uint8_t* buf,
@@ -159,7 +284,7 @@ struct Session::Application::StreamData final {
size_t count = 0;
// The stream identifier. If this is a negative value then no stream is
// identified.
int64_t id = -1;
stream_id id = -1;
int fin = 0;
ngtcp2_vec data[kMaxVectorCount]{};
BaseObjectPtr<Stream> stream;
+183 -33
View File
@@ -11,11 +11,15 @@
#include <node_external_reference.h>
#include <node_mem-inl.h>
#include <node_realm-inl.h>
#include <node_sockaddr-inl.h>
#include <v8.h>
#include "bindingdata.h"
#include "session.h"
#include "session_manager.h"
namespace node {
using mem::kReserveSizeAndAlign;
using v8::Function;
using v8::FunctionTemplate;
using v8::Local;
@@ -25,24 +29,155 @@ using v8::Value;
namespace quic {
// ============================================================================
// Thread-local QUIC allocator.
//
// Both ngtcp2 and nghttp3 take an allocator struct (ngtcp2_mem /
// nghttp3_mem) whose pointer is stored inside every object they
// allocate. Some of those objects — notably nghttp3 rcbufs backing
// V8 external strings — can outlive the BindingData that created them
// (freed during V8 isolate teardown, after Environment cleanup).
//
// To handle this safely, both allocators live in a thread-local static
// struct that is never destroyed. Memory tracking goes through the
// BindingData pointer when it is alive and is silently skipped during
// teardown (after ~BindingData nulls the pointer).
//
// The allocation functions use the same prepended-size-header scheme as
// NgLibMemoryManager (node_mem-inl.h) so that frees always know the
// allocation size regardless of whether BindingData is still around.
namespace {
struct QuicAllocState {
BindingData* binding = nullptr;
ngtcp2_mem ngtcp2 = {};
nghttp3_mem nghttp3 = {};
};
thread_local QuicAllocState quic_alloc_state;
// Core allocation functions shared by both ngtcp2 and nghttp3.
// user_data always points to the thread-local QuicAllocState.
void* QuicRealloc(void* ptr, size_t size, void* user_data) {
auto* state = static_cast<QuicAllocState*>(user_data);
size_t previous_size = 0;
char* original_ptr = nullptr;
if (size > 0) size += kReserveSizeAndAlign;
if (ptr != nullptr) {
original_ptr = static_cast<char*>(ptr) - kReserveSizeAndAlign;
previous_size = *reinterpret_cast<size_t*>(original_ptr);
if (previous_size == 0) {
char* ret = UncheckedRealloc(original_ptr, size);
if (ret != nullptr) ret += kReserveSizeAndAlign;
return ret;
}
}
if (state->binding) {
state->binding->CheckAllocatedSize(previous_size);
}
char* mem = UncheckedRealloc(original_ptr, size);
if (mem != nullptr) {
const int64_t new_size = size - previous_size;
if (state->binding) {
state->binding->IncreaseAllocatedSize(new_size);
state->binding->env()->external_memory_accounter()->Update(
state->binding->env()->isolate(), new_size);
}
*reinterpret_cast<size_t*>(mem) = size;
mem += kReserveSizeAndAlign;
} else if (size == 0) {
if (state->binding) {
state->binding->DecreaseAllocatedSize(previous_size);
state->binding->env()->external_memory_accounter()->Decrease(
state->binding->env()->isolate(), previous_size);
}
}
return mem;
}
void* QuicMalloc(size_t size, void* user_data) {
return QuicRealloc(nullptr, size, user_data);
}
void QuicFree(void* ptr, void* user_data) {
if (ptr == nullptr) return;
CHECK_NULL(QuicRealloc(ptr, 0, user_data));
}
void* QuicCalloc(size_t nmemb, size_t size, void* user_data) {
size_t real_size = MultiplyWithOverflowCheck(nmemb, size);
void* mem = QuicMalloc(real_size, user_data);
if (mem != nullptr) memset(mem, 0, real_size);
return mem;
}
// Thin wrappers with the correct function-pointer types for each
// library. The signatures happen to be identical today, but keeping
// them separate avoids ABI coupling between ngtcp2 and nghttp3.
void* Ngtcp2Malloc(size_t size, void* ud) {
return QuicMalloc(size, ud);
}
void Ngtcp2Free(void* ptr, void* ud) {
QuicFree(ptr, ud);
}
void* Ngtcp2Calloc(size_t n, size_t s, void* ud) {
return QuicCalloc(n, s, ud);
}
void* Ngtcp2Realloc(void* ptr, size_t size, void* ud) {
return QuicRealloc(ptr, size, ud);
}
void* Nghttp3Malloc(size_t size, void* ud) {
return QuicMalloc(size, ud);
}
void Nghttp3Free(void* ptr, void* ud) {
QuicFree(ptr, ud);
}
void* Nghttp3Calloc(size_t n, size_t s, void* ud) {
return QuicCalloc(n, s, ud);
}
void* Nghttp3Realloc(void* ptr, size_t size, void* ud) {
return QuicRealloc(ptr, size, ud);
}
} // namespace
BindingData& BindingData::Get(Environment* env) {
return *(env->principal_realm()->GetBindingData<BindingData>());
}
BindingData::operator ngtcp2_mem() {
return MakeAllocator();
BindingData::~BindingData() {
quic_alloc_state.binding = nullptr;
}
BindingData::operator nghttp3_mem() {
ngtcp2_mem allocator = *this;
nghttp3_mem http3_allocator = {
allocator.user_data,
allocator.malloc,
allocator.free,
allocator.calloc,
allocator.realloc,
ngtcp2_mem* BindingData::ngtcp2_allocator() {
quic_alloc_state.binding = this;
quic_alloc_state.ngtcp2 = {
&quic_alloc_state,
Ngtcp2Malloc,
Ngtcp2Free,
Ngtcp2Calloc,
Ngtcp2Realloc,
};
return http3_allocator;
return &quic_alloc_state.ngtcp2;
}
nghttp3_mem* BindingData::nghttp3_allocator() {
quic_alloc_state.binding = this;
quic_alloc_state.nghttp3 = {
&quic_alloc_state,
Nghttp3Malloc,
Nghttp3Free,
Nghttp3Calloc,
Nghttp3Realloc,
};
return &quic_alloc_state.nghttp3;
}
void BindingData::CheckAllocatedSize(size_t previous_size) const {
@@ -59,7 +194,20 @@ void BindingData::DecreaseAllocatedSize(size_t size) {
current_ngtcp2_memory_ -= size;
}
// Forwards detailed(verbose) debugging information from nghttp3. Enabled using
// the NODE_DEBUG_NATIVE=NGHTTP3 category.
void nghttp3_debug_log(const char* fmt, va_list args) {
auto isolate = v8::Isolate::GetCurrent();
if (isolate == nullptr) return;
auto env = Environment::GetCurrent(isolate);
if (env->enabled_debug_list()->enabled(DebugCategory::NGHTTP3)) {
fprintf(stderr, "nghttp3 ");
vfprintf(stderr, fmt, args);
}
}
void BindingData::InitPerContext(Realm* realm, Local<Object> target) {
nghttp3_set_debug_vprintf_callback(nghttp3_debug_log);
SetMethod(realm->context(), target, "setCallbacks", SetCallbacks);
Realm::GetCurrent(realm->context())->AddBindingData<BindingData>(target);
}
@@ -75,6 +223,13 @@ BindingData::BindingData(Realm* realm, Local<Object> object)
MakeWeak();
}
SessionManager& BindingData::session_manager() {
if (!session_manager_) {
session_manager_ = std::make_unique<SessionManager>(env());
}
return *session_manager_;
}
void BindingData::MemoryInfo(MemoryTracker* tracker) const {
#define V(name, _) tracker->TrackField(#name, name##_callback());
@@ -162,36 +317,31 @@ JS_METHOD_IMPL(BindingData::SetCallbacks) {
#undef V
}
NgTcp2CallbackScope::NgTcp2CallbackScope(Environment* env) : env(env) {
auto& binding = BindingData::Get(env);
CHECK(!binding.in_ngtcp2_callback_scope);
binding.in_ngtcp2_callback_scope = true;
NgTcp2CallbackScope::NgTcp2CallbackScope(Session* session) : session(session) {
CHECK(!session->in_ngtcp2_callback_scope_);
session->in_ngtcp2_callback_scope_ = true;
}
NgTcp2CallbackScope::~NgTcp2CallbackScope() {
auto& binding = BindingData::Get(env);
binding.in_ngtcp2_callback_scope = false;
session->in_ngtcp2_callback_scope_ = false;
if (session->destroy_deferred_) {
session->destroy_deferred_ = false;
session->Destroy();
}
}
bool NgTcp2CallbackScope::in_ngtcp2_callback(Environment* env) {
auto& binding = BindingData::Get(env);
return binding.in_ngtcp2_callback_scope;
}
NgHttp3CallbackScope::NgHttp3CallbackScope(Environment* env) : env(env) {
auto& binding = BindingData::Get(env);
CHECK(!binding.in_nghttp3_callback_scope);
binding.in_nghttp3_callback_scope = true;
NgHttp3CallbackScope::NgHttp3CallbackScope(Session* session)
: session(session) {
CHECK(!session->in_nghttp3_callback_scope_);
session->in_nghttp3_callback_scope_ = true;
}
NgHttp3CallbackScope::~NgHttp3CallbackScope() {
auto& binding = BindingData::Get(env);
binding.in_nghttp3_callback_scope = false;
}
bool NgHttp3CallbackScope::in_nghttp3_callback(Environment* env) {
auto& binding = BindingData::Get(env);
return binding.in_nghttp3_callback_scope;
session->in_nghttp3_callback_scope_ = false;
if (session->destroy_deferred_) {
session->destroy_deferred_ = false;
session->Destroy();
}
}
CallbackScopeBase::CallbackScopeBase(Environment* env)
+48 -13
View File
@@ -11,6 +11,7 @@
#include <node.h>
#include <node_mem.h>
#include <v8.h>
#include <memory>
#include <unordered_map>
#include "defs.h"
@@ -18,13 +19,14 @@ namespace node::quic {
class Endpoint;
class Packet;
class Session;
class SessionManager;
// ============================================================================
// The FunctionTemplates the BindingData will store for us.
#define QUIC_CONSTRUCTORS(V) \
V(endpoint) \
V(logstream) \
V(session) \
V(stream) \
V(udp)
@@ -36,37 +38,48 @@ class Packet;
#define QUIC_JS_CALLBACKS(V) \
V(endpoint_close, EndpointClose) \
V(session_close, SessionClose) \
V(session_early_data_rejected, SessionEarlyDataRejected) \
V(session_goaway, SessionGoaway) \
V(session_datagram, SessionDatagram) \
V(session_datagram_status, SessionDatagramStatus) \
V(session_handshake, SessionHandshake) \
V(session_keylog, SessionKeyLog) \
V(session_qlog, SessionQlog) \
V(session_new, SessionNew) \
V(session_new_token, SessionNewToken) \
V(session_origin, SessionOrigin) \
V(session_path_validation, SessionPathValidation) \
V(session_ticket, SessionTicket) \
V(session_version_negotiation, SessionVersionNegotiation) \
V(stream_blocked, StreamBlocked) \
V(stream_close, StreamClose) \
V(stream_created, StreamCreated) \
V(stream_drain, StreamDrain) \
V(stream_headers, StreamHeaders) \
V(stream_reset, StreamReset) \
V(stream_trailers, StreamTrailers)
// The various JS strings the implementation uses.
#define QUIC_STRINGS(V) \
V(abandoned, "abandoned") \
V(aborted, "aborted") \
V(acknowledged, "acknowledged") \
V(ack_delay_exponent, "ackDelayExponent") \
V(active_connection_id_limit, "activeConnectionIDLimit") \
V(address_lru_size, "addressLRUSize") \
V(application, "application") \
V(authoritative, "authoritative") \
V(bbr, "bbr") \
V(ca, "ca") \
V(cc_algorithm, "cc") \
V(certs, "certs") \
V(code, "code") \
V(ciphers, "ciphers") \
V(crl, "crl") \
V(cubic, "cubic") \
V(datagram_drop_policy, "datagramDropPolicy") \
V(disable_stateless_reset, "disableStatelessReset") \
V(draining_period_multiplier, "drainingPeriodMultiplier") \
V(enable_connect_protocol, "enableConnectProtocol") \
V(enable_early_data, "enableEarlyData") \
V(enable_datagrams, "enableDatagrams") \
@@ -77,6 +90,7 @@ class Packet;
V(groups, "groups") \
V(handshake_timeout, "handshakeTimeout") \
V(http3_alpn, &NGHTTP3_ALPN_H3[1]) \
V(keep_alive_timeout, "keepAlive") \
V(initial_max_data, "initialMaxData") \
V(initial_max_stream_data_bidi_local, "initialMaxStreamDataBidiLocal") \
V(initial_max_stream_data_bidi_remote, "initialMaxStreamDataBidiRemote") \
@@ -86,15 +100,16 @@ class Packet;
V(ipv6_only, "ipv6Only") \
V(keylog, "keylog") \
V(keys, "keys") \
V(logstream, "LogStream") \
V(lost, "lost") \
V(max_ack_delay, "maxAckDelay") \
V(max_connections_per_host, "maxConnectionsPerHost") \
V(max_connections_total, "maxConnectionsTotal") \
V(max_datagram_frame_size, "maxDatagramFrameSize") \
V(max_datagram_send_attempts, "maxDatagramSendAttempts") \
V(max_field_section_size, "maxFieldSectionSize") \
V(max_header_length, "maxHeaderLength") \
V(max_header_pairs, "maxHeaderPairs") \
V(idle_timeout, "idleTimeout") \
V(max_idle_timeout, "maxIdleTimeout") \
V(max_payload_size, "maxPayloadSize") \
V(max_retries, "maxRetries") \
@@ -102,12 +117,16 @@ class Packet;
V(max_stream_window, "maxStreamWindow") \
V(max_window, "maxWindow") \
V(min_version, "minVersion") \
V(port, "port") \
V(preferred_address_ipv4, "preferredAddressIpv4") \
V(preferred_address_ipv6, "preferredAddressIpv6") \
V(preferred_address_strategy, "preferredAddressPolicy") \
V(alpn, "alpn") \
V(qlog, "qlog") \
V(qpack_blocked_streams, "qpackBlockedStreams") \
V(qpack_encoder_max_dtable_capacity, "qpackEncoderMaxDTableCapacity") \
V(qpack_max_dtable_capacity, "qpackMaxDTableCapacity") \
V(reason, "reason") \
V(reject_unauthorized, "rejectUnauthorized") \
V(reno, "reno") \
V(reset_token_secret, "resetTokenSecret") \
@@ -122,7 +141,9 @@ class Packet;
V(token, "token") \
V(token_expiration, "tokenExpiration") \
V(token_secret, "tokenSecret") \
V(transport, "transport") \
V(transport_params, "transportParams") \
V(type, "type") \
V(tx_loss, "txDiagnosticLoss") \
V(udp_receive_buffer_size, "udpReceiveBufferSize") \
V(udp_send_buffer_size, "udpSendBufferSize") \
@@ -151,27 +172,37 @@ class BindingData final
static inline BindingData& Get(Realm* realm) { return Get(realm->env()); }
BindingData(Realm* realm, v8::Local<v8::Object> object);
~BindingData() override;
DISALLOW_COPY_AND_MOVE(BindingData)
void MemoryInfo(MemoryTracker* tracker) const override;
SET_MEMORY_INFO_NAME(BindingData)
SET_SELF_SIZE(BindingData)
// NgLibMemoryManager
operator ngtcp2_mem();
operator nghttp3_mem();
// NgLibMemoryManager — the base class provides CheckAllocatedSize,
// IncreaseAllocatedSize, DecreaseAllocatedSize, and StopTrackingMemory.
// Actual allocations go through the thread-local allocators below.
void CheckAllocatedSize(size_t previous_size) const;
void IncreaseAllocatedSize(size_t size);
void DecreaseAllocatedSize(size_t size);
// Thread-local allocators that outlive BindingData destruction.
// Both ngtcp2 and nghttp3 store the allocator pointer inside every
// object they allocate; some of those objects (e.g., nghttp3 rcbufs
// backing V8 external strings) can be freed after BindingData is gone.
ngtcp2_mem* ngtcp2_allocator();
nghttp3_mem* nghttp3_allocator();
// Installs the set of JavaScript callback functions that are used to
// bridge out to the JS API.
JS_METHOD(SetCallbacks);
// Lazily-created per-Realm SessionManager. Centralizes CID -> Session
// routing so that any endpoint can route packets to any session.
SessionManager& session_manager();
std::unordered_map<Endpoint*, BaseObjectPtr<BaseObject>> listening_endpoints;
bool in_ngtcp2_callback_scope = false;
bool in_nghttp3_callback_scope = false;
size_t current_ngtcp2_memory_ = 0;
// The following set up various storage and accessors for common strings,
@@ -214,6 +245,8 @@ class BindingData final
#define V(name, _) mutable v8::Eternal<v8::String> on_##name##_string_;
QUIC_JS_CALLBACKS(V)
#undef V
std::unique_ptr<SessionManager> session_manager_;
};
JS_METHOD_IMPL(IllegalConstructor);
@@ -221,20 +254,22 @@ JS_METHOD_IMPL(IllegalConstructor);
// The ngtcp2 and nghttp3 callbacks have certain restrictions
// that forbid re-entry. We provide the following scopes for
// use in those to help protect against it.
// These callback scopes are per-session, not per-environment. This ensures
// that one session's ngtcp2/nghttp3 callback does not block an unrelated
// session from sending packets. A BaseObjectPtr prevents the Session from
// being prematurely freed while the scope is alive on the stack.
struct NgTcp2CallbackScope final {
Environment* env;
explicit NgTcp2CallbackScope(Environment* env);
BaseObjectPtr<Session> session;
explicit NgTcp2CallbackScope(Session* session);
DISALLOW_COPY_AND_MOVE(NgTcp2CallbackScope)
~NgTcp2CallbackScope();
static bool in_ngtcp2_callback(Environment* env);
};
struct NgHttp3CallbackScope final {
Environment* env;
explicit NgHttp3CallbackScope(Environment* env);
BaseObjectPtr<Session> session;
explicit NgHttp3CallbackScope(Session* session);
DISALLOW_COPY_AND_MOVE(NgHttp3CallbackScope)
~NgHttp3CallbackScope();
static bool in_nghttp3_callback(Environment* env);
};
struct CallbackScopeBase {
+73 -19
View File
@@ -88,31 +88,45 @@ Store::Store(std::unique_ptr<BackingStore> store, size_t length, size_t offset)
CHECK_LE(length_, store_->ByteLength() - offset_);
}
Maybe<Store> Store::From(Local<ArrayBuffer> buffer, Local<Value> detach_key) {
if (!buffer->IsDetachable()) {
return Nothing<Store>();
}
bool res;
auto backing = buffer->GetBackingStore();
Maybe<Store> Store::From(Local<ArrayBuffer> buffer) {
v8::Isolate* isolate = v8::Isolate::GetCurrent();
Environment* env = Environment::GetCurrent(isolate->GetCurrentContext());
auto length = buffer->ByteLength();
if (!buffer->Detach(detach_key).To(&res) || !res) {
auto dest = ArrayBuffer::NewBackingStore(
isolate,
length,
v8::BackingStoreInitializationMode::kUninitialized,
v8::BackingStoreOnFailureMode::kReturnNull);
if (!dest) {
THROW_ERR_MEMORY_ALLOCATION_FAILED(env);
return Nothing<Store>();
}
return Just(Store(std::move(backing), length, 0));
if (length > 0) {
memcpy(dest->Data(), buffer->Data(), length);
}
return Just(Store(std::move(dest), length, 0));
}
Maybe<Store> Store::From(Local<ArrayBufferView> view, Local<Value> detach_key) {
if (!view->Buffer()->IsDetachable()) {
return Nothing<Store>();
}
bool res;
auto backing = view->Buffer()->GetBackingStore();
Maybe<Store> Store::From(Local<ArrayBufferView> view) {
v8::Isolate* isolate = v8::Isolate::GetCurrent();
Environment* env = Environment::GetCurrent(isolate->GetCurrentContext());
auto length = view->ByteLength();
auto offset = view->ByteOffset();
if (!view->Buffer()->Detach(detach_key).To(&res) || !res) {
auto dest = ArrayBuffer::NewBackingStore(
isolate,
length,
v8::BackingStoreInitializationMode::kUninitialized,
v8::BackingStoreOnFailureMode::kReturnNull);
if (!dest) {
THROW_ERR_MEMORY_ALLOCATION_FAILED(env);
return Nothing<Store>();
}
return Just(Store(std::move(backing), length, offset));
if (length > 0) {
memcpy(dest->Data(),
static_cast<const uint8_t*>(view->Buffer()->Data()) + offset,
length);
}
return Just(Store(std::move(dest), length, 0));
}
Store Store::CopyFrom(Local<ArrayBuffer> buffer) {
@@ -161,8 +175,7 @@ T Store::convert() const {
// We can only safely convert to T if we have a valid store.
CHECK(store_);
T buf;
buf.base =
store_ != nullptr ? static_cast<N*>(store_->Data()) + offset_ : nullptr;
buf.base = static_cast<N*>(store_->Data()) + offset_;
buf.len = length_;
return buf;
}
@@ -223,6 +236,45 @@ QuicError::QuicError(const ngtcp2_ccerr& error)
error_(error),
ptr_(&error_) {}
QuicError::QuicError(QuicError&& other) noexcept
: reason_(std::move(other.reason_)),
error_(other.error_),
ptr_(other.ptr_ == &other.error_ ? &error_ : other.ptr_) {
// Fix up the internal reason pointer after moving.
error_.reason = reason_c_str();
error_.reasonlen = reason_.length();
}
QuicError& QuicError::operator=(QuicError&& other) noexcept {
if (this != &other) {
reason_ = std::move(other.reason_);
error_ = other.error_;
ptr_ = (other.ptr_ == &other.error_) ? &error_ : other.ptr_;
error_.reason = reason_c_str();
error_.reasonlen = reason_.length();
}
return *this;
}
QuicError::QuicError(const QuicError& other)
: reason_(other.reason_),
error_(other.error_),
ptr_(other.ptr_ == &other.error_ ? &error_ : other.ptr_) {
error_.reason = reason_c_str();
error_.reasonlen = reason_.length();
}
QuicError& QuicError::operator=(const QuicError& other) {
if (this != &other) {
reason_ = other.reason_;
error_ = other.error_;
ptr_ = (other.ptr_ == &other.error_) ? &error_ : other.ptr_;
error_.reason = reason_c_str();
error_.reasonlen = reason_.length();
}
return *this;
}
const uint8_t* QuicError::reason_c_str() const {
return reinterpret_cast<const uint8_t*>(reason_.c_str());
}
@@ -296,11 +348,13 @@ std::optional<int> QuicError::get_crypto_error() const {
MaybeLocal<Value> QuicError::ToV8Value(Environment* env) const {
if ((type() == Type::TRANSPORT && code() == NGTCP2_NO_ERROR) ||
(type() == Type::APPLICATION && code() == NGHTTP3_H3_NO_ERROR)) {
(type() == Type::APPLICATION && code() == NGHTTP3_H3_NO_ERROR) ||
type() == Type::IDLE_CLOSE) {
// Note that we only return undefined for *known* no-error application
// codes. It is possible that other application types use other specific
// no-error codes, but since we don't know which application is being used,
// we'll just return the error code value for those below.
// Idle close is always clean — the session timed out normally.
return Undefined(env->isolate());
}
+27 -17
View File
@@ -54,28 +54,28 @@ class Store final : public MemoryRetainer {
size_t length,
size_t offset = 0);
// Creates a Store from the contents of an ArrayBuffer, always detaching
// it in the process. An empty Maybe will be returned if the ArrayBuffer
// is not detachable or detaching failed (likely due to a detach key
// mismatch).
static v8::Maybe<Store> From(
v8::Local<v8::ArrayBuffer> buffer,
v8::Local<v8::Value> detach_key = v8::Local<v8::Value>());
// Creates a Store by copying the contents of an ArrayBuffer into a fresh
// BackingStore. The caller's buffer is not modified, so callers can safely
// reuse or mutate it after the call returns. Returns an empty Maybe on
// allocation failure, in which case an `ERR_MEMORY_ALLOCATION_FAILED`
// exception will have been scheduled on the isolate.
static v8::Maybe<Store> From(v8::Local<v8::ArrayBuffer> buffer);
// Creates a Store from the contents of an ArrayBufferView, always detaching
// it in the process. An empty Maybe will be returned if the ArrayBuffer
// is not detachable or detaching failed (likely due to a detach key
// mismatch).
static v8::Maybe<Store> From(
v8::Local<v8::ArrayBufferView> view,
v8::Local<v8::Value> detach_key = v8::Local<v8::Value>());
// Creates a Store by copying the contents of an ArrayBufferView into a
// fresh BackingStore. The caller's view (and its underlying ArrayBuffer)
// is not modified. Returns an empty Maybe on allocation failure, in which
// case an `ERR_MEMORY_ALLOCATION_FAILED` exception will have been
// scheduled on the isolate.
static v8::Maybe<Store> From(v8::Local<v8::ArrayBufferView> view);
// Creates a Store from the contents of an ArrayBuffer, always copying the
// content.
// content. Equivalent to `From()` but returns a Store directly without
// surfacing allocation failure.
static Store CopyFrom(v8::Local<v8::ArrayBuffer> buffer);
// Creates a Store from the contents of an ArrayBufferView, always copying the
// content.
// Creates a Store from the contents of an ArrayBufferView, always copying
// the content. Equivalent to `From()` but returns a Store directly without
// surfacing allocation failure.
static Store CopyFrom(v8::Local<v8::ArrayBufferView> view);
v8::Local<v8::Uint8Array> ToUint8Array(Environment* env) const;
@@ -208,6 +208,16 @@ class QuicError final : public MemoryRetainer {
explicit QuicError(const ngtcp2_ccerr* ptr);
explicit QuicError(const ngtcp2_ccerr& error);
// Move constructor and assignment must fix up ptr_ when it points
// to the internal error_ member (as set by the default constructor
// and the ForTransport/ForApplication factory methods).
QuicError(QuicError&& other) noexcept;
QuicError& operator=(QuicError&& other) noexcept;
// Copy constructor and assignment must also fix up ptr_.
QuicError(const QuicError& other);
QuicError& operator=(const QuicError& other);
Type type() const;
error_code code() const;
const std::string_view reason() const;
+45 -5
View File
@@ -12,8 +12,8 @@
namespace node::quic {
#define NGTCP2_SUCCESS 0
#define NGTCP2_ERR(V) (V != NGTCP2_SUCCESS)
#define NGTCP2_OK(V) (V == NGTCP2_SUCCESS)
#define NGTCP2_ERR(V) ((V) != NGTCP2_SUCCESS)
#define NGTCP2_OK(V) ((V) == NGTCP2_SUCCESS)
#define IF_QUIC_DEBUG(env) \
if (env->enabled_debug_list()->enabled(DebugCategory::QUIC)) [[unlikely]]
@@ -83,6 +83,39 @@ bool SetOption(Environment* env,
return true;
}
template <typename Opt, uint16_t Opt::*member>
bool SetOption(Environment* env,
Opt* options,
const v8::Local<v8::Object>& object,
const v8::Local<v8::String>& name) {
v8::Local<v8::Value> value;
if (!object->Get(env->context(), name).ToLocal(&value)) return false;
if (!value->IsUndefined()) {
if (!value->IsUint32()) {
Utf8Value nameStr(env->isolate(), name);
THROW_ERR_INVALID_ARG_VALUE(
env, "The %s option must be an uint16", nameStr);
return false;
}
v8::Local<v8::Uint32> num;
if (!value->ToUint32(env->context()).ToLocal(&num)) {
Utf8Value nameStr(env->isolate(), name);
THROW_ERR_INVALID_ARG_VALUE(
env, "The %s option must be an uint16", nameStr);
return false;
}
uint32_t val = num->Value();
if (val > 0xFFFF) {
Utf8Value nameStr(env->isolate(), name);
THROW_ERR_INVALID_ARG_VALUE(
env, "The %s option must fit in a uint16", nameStr);
return false;
}
options->*member = static_cast<uint16_t>(val);
}
return true;
}
template <typename Opt, uint64_t Opt::*member>
bool SetOption(Environment* env,
Opt* options,
@@ -205,7 +238,7 @@ uint64_t GetStat(Stats* stats) {
if (!GetConstructorTemplate(env) \
->InstanceTemplate() \
->NewInstance(env->context()) \
.ToLocal(&obj)) { \
.ToLocal(&name)) { \
return ret; \
}
@@ -214,7 +247,7 @@ uint64_t GetStat(Stats* stats) {
if (!GetConstructorTemplate(env) \
->InstanceTemplate() \
->NewInstance(env->context()) \
.ToLocal(&obj)) { \
.ToLocal(&name)) { \
return; \
}
@@ -285,8 +318,14 @@ enum class StreamPriority : uint8_t {
};
enum class StreamPriorityFlags : uint8_t {
NONE,
NON_INCREMENTAL,
INCREMENTAL,
};
enum class HeadersSupportState : uint8_t {
UNKNOWN,
SUPPORTED,
UNSUPPORTED,
};
enum class PathValidationResult : uint8_t {
@@ -298,6 +337,7 @@ enum class PathValidationResult : uint8_t {
enum class DatagramStatus : uint8_t {
ACKNOWLEDGED,
LOST,
ABANDONED,
};
#define CC_ALGOS(V) \
+258 -210
View File
@@ -11,6 +11,7 @@
#include <node_external_reference.h>
#include <node_process-inl.h>
#include <node_sockaddr-inl.h>
#include <timer_wrap-inl.h>
#include <util-inl.h>
#include <uv.h>
#include <v8.h>
@@ -21,6 +22,7 @@
#include "endpoint.h"
#include "http3.h"
#include "ncrypto.h"
#include "session_manager.h"
namespace node {
@@ -53,6 +55,10 @@ namespace quic {
V(CLOSING, closing, uint8_t) \
/* Temporarily paused serving new initial requests */ \
V(BUSY, busy, uint8_t) \
/* Max concurrent connections per IP (0 = unlimited) */ \
V(MAX_CONNECTIONS_PER_HOST, max_connections_per_host, uint16_t) \
/* Max total concurrent connections (0 = unlimited) */ \
V(MAX_CONNECTIONS_TOTAL, max_connections_total, uint16_t) \
/* The number of pending send callbacks */ \
V(PENDING_CALLBACKS, pending_callbacks, uint64_t)
@@ -189,7 +195,6 @@ Maybe<Endpoint::Options> Endpoint::Options::From(Environment* env,
env, &options, params, state.name##_string())
if (!SET(retry_token_expiration) || !SET(token_expiration) ||
!SET(max_connections_per_host) || !SET(max_connections_total) ||
!SET(max_stateless_resets) || !SET(address_lru_size) ||
!SET(max_retries) || !SET(validate_address) ||
!SET(disable_stateless_reset) || !SET(ipv6_only) ||
@@ -197,7 +202,8 @@ Maybe<Endpoint::Options> Endpoint::Options::From(Environment* env,
!SET(rx_loss) || !SET(tx_loss) ||
#endif
!SET(udp_receive_buffer_size) || !SET(udp_send_buffer_size) ||
!SET(udp_ttl) || !SET(reset_token_secret) || !SET(token_secret)) {
!SET(udp_ttl) || !SET(idle_timeout) || !SET(reset_token_secret) ||
!SET(token_secret)) {
return Nothing<Options>();
}
@@ -245,10 +251,6 @@ std::string Endpoint::Options::ToString() const {
" seconds";
res += prefix + "token expiration: " + std::to_string(token_expiration) +
" seconds";
res += prefix + "max connections per host: " +
std::to_string(max_connections_per_host);
res += prefix +
"max connections total: " + std::to_string(max_connections_total);
res +=
prefix + "max stateless resets: " + std::to_string(max_stateless_resets);
res += prefix + "address lru size: " + std::to_string(address_lru_size);
@@ -268,6 +270,7 @@ std::string Endpoint::Options::ToString() const {
res +=
prefix + "udp send buffer size: " + std::to_string(udp_send_buffer_size);
res += prefix + "udp ttl: " + std::to_string(udp_ttl);
res += prefix + "idle timeout: " + std::to_string(idle_timeout) + " seconds";
res += indent.Close();
return res;
@@ -471,7 +474,6 @@ int Endpoint::UDP::Send(Packet::Ptr packet) {
// Detach from the Ptr — libuv takes ownership until the callback fires.
Packet* raw = packet.release();
uv_buf_t buf = *raw;
int err = uv_udp_send(raw->req(),
&impl_->handle_,
&buf,
@@ -532,8 +534,6 @@ void Endpoint::InitPerContext(Realm* realm, Local<Object> target) {
ENDPOINT_STATE(V)
#undef V
NODE_DEFINE_CONSTANT(target, DEFAULT_MAX_CONNECTIONS);
NODE_DEFINE_CONSTANT(target, DEFAULT_MAX_CONNECTIONS_PER_HOST);
NODE_DEFINE_CONSTANT(target, DEFAULT_MAX_SOCKETADDRESS_LRU_SIZE);
NODE_DEFINE_CONSTANT(target, DEFAULT_MAX_STATELESS_RESETS);
NODE_DEFINE_CONSTANT(target, DEFAULT_MAX_RETRY_LIMIT);
@@ -593,9 +593,15 @@ Endpoint::Endpoint(Environment* env,
packet_pool_(kDefaultMaxPacketLength,
ArenaPool<Packet>::kDefaultSlotsPerBlock),
udp_(this),
addrLRU_(options_.address_lru_size) {
idle_timer_(env,
[this] {
HandleScope scope(this->env()->isolate());
Destroy();
}),
addr_validation_lru_(options_.address_lru_size) {
MakeWeak();
udp_.Unref();
idle_timer_.Unref();
STAT_RECORD_TIMESTAMP(Stats, created_at);
IF_QUIC_DEBUG(env) {
Debug(this, "Endpoint created. Options %s", options.ToString());
@@ -640,11 +646,12 @@ RegularToken Endpoint::GenerateNewToken(uint32_t version,
return RegularToken(version, remote_address, options_.token_secret);
}
SessionManager& Endpoint::session_manager() const {
return BindingData::Get(env()).session_manager();
}
StatelessResetToken Endpoint::GenerateNewStatelessResetToken(
uint8_t* token, const CID& cid) const {
Debug(const_cast<Endpoint*>(this),
"Generating new stateless reset token for CID %s",
cid);
DCHECK(!is_closed() && !is_closing());
return StatelessResetToken(token, options_.reset_token_secret, cid);
}
@@ -652,9 +659,38 @@ StatelessResetToken Endpoint::GenerateNewStatelessResetToken(
void Endpoint::AddSession(const CID& cid, BaseObjectPtr<Session> session) {
DCHECK(!is_closed() && !is_closing());
Debug(this, "Adding session for CID %s", cid);
IncrementSocketAddressCounter(session->remote_address());
if (state_->max_connections_per_host > 0) {
conn_counts_per_host_[session->remote_address()]++;
}
auto& mgr = session_manager();
// Associate peer-chosen CIDs in the local dcid_to_scid_ map.
AssociateCID(session->config().dcid, session->config().scid);
sessions_[cid] = session;
mgr.AddSession(cid, session);
mgr.SetPrimaryEndpoint(session.get(), this);
// For server sessions, associate the client's original DCID (ocid) so
// that 0-RTT packets arriving in a separate UDP datagram can be routed
// to this session. This must happen after the session is added (so
// FindSession can resolve the mapping) but before EmitNewSession (which
// runs JS and may yield to libuv, allowing the 0-RTT packet to arrive).
if (session->is_server() && session->config().ocid) {
AssociateCID(session->config().ocid, session->config().scid);
}
// After Retry, the client continues to use the Retry SCID as its DCID
// until the handshake completes. Register it so retransmitted Initials
// and subsequent handshake packets can be routed to this session.
if (session->is_server() && session->config().retry_scid) {
AssociateCID(session->config().retry_scid, session->config().scid);
}
// Increment the primary session count and ref the handle BEFORE
// EmitNewSession. EmitNewSession calls into JS, which may close/destroy
// the session synchronously. The session's ~Impl calls RemoveSession
// which decrements the count. If we increment after EmitNewSession,
// RemoveSession would see count=0 and the count would be permanently
// off by one.
if (primary_session_count_++ == 0) {
idle_timer_.Stop();
udp_.Ref();
}
if (session->is_server()) {
STAT_INCREMENT(Stats, server_sessions);
// We only emit the new session event for server sessions.
@@ -664,46 +700,58 @@ void Endpoint::AddSession(const CID& cid, BaseObjectPtr<Session> session) {
} else {
STAT_INCREMENT(Stats, client_sessions);
}
udp_.Ref();
}
void Endpoint::RemoveSession(const CID& cid,
const SocketAddress& remote_address) {
if (is_closed()) return;
Debug(this, "Removing session for CID %s", cid);
if (sessions_.erase(cid)) {
DecrementSocketAddressCounter(remote_address);
auto it = conn_counts_per_host_.find(remote_address);
if (it != conn_counts_per_host_.end()) {
if (--it->second == 0) {
conn_counts_per_host_.erase(it);
}
}
if (sessions_.empty()) {
if (primary_session_count_ > 0 && --primary_session_count_ == 0) {
udp_.Unref();
session_manager().RemoveSession(cid);
// The endpoint may be idle (no sessions, not listening). MaybeDestroy
// handles both closing (immediate destroy) and idle timeout (start
// timer or destroy based on idle_timeout setting).
MaybeDestroy();
return;
}
session_manager().RemoveSession(cid);
if (state_->closing == 1) MaybeDestroy();
}
BaseObjectPtr<Session> Endpoint::FindSession(const CID& cid) {
auto session_it = sessions_.find(cid);
if (session_it == std::end(sessions_)) {
// If our given cid is not a match that doesn't mean we
// give up. A session might be identified by multiple
// CIDs. Let's see if our secondary map has a match!
auto scid_it = dcid_to_scid_.find(cid);
if (scid_it != std::end(dcid_to_scid_)) {
session_it = sessions_.find(scid_it->second);
CHECK_NE(session_it, std::end(sessions_));
return session_it->second;
}
// No match found.
return {};
// First, try the SessionManager's primary sessions_ map directly.
// This handles the common case where the CID is a locally-generated SCID.
auto session = session_manager().FindSession(cid);
if (session) return session;
// If not found, check this endpoint's local dcid_to_scid_ map for a
// secondary CID mapping. This map contains peer-chosen CID values that
// are only meaningful in the context of this endpoint's sessions.
auto scid_it = dcid_to_scid_.find(cid);
if (scid_it != dcid_to_scid_.end()) {
session = session_manager().FindSession(scid_it->second);
if (session) return session;
// Stale mapping — clean up.
dcid_to_scid_.erase(scid_it);
}
// Match found!
return session_it->second;
return {};
}
void Endpoint::AssociateCID(const CID& cid, const CID& scid) {
if (!is_closed() && !is_closing() && cid && scid && cid != scid &&
dcid_to_scid_[cid] != scid) {
Debug(this, "Associating CID %s with SCID %s", cid, scid);
dcid_to_scid_.emplace(cid, scid);
if (!is_closed() && !is_closing() && cid && scid && cid != scid) {
auto it = dcid_to_scid_.find(cid);
if (it == dcid_to_scid_.end() || it->second != scid) {
Debug(this, "Associating CID %s with SCID %s", cid, scid);
dcid_to_scid_[cid] = scid;
}
}
}
@@ -718,14 +766,14 @@ void Endpoint::AssociateStatelessResetToken(const StatelessResetToken& token,
Session* session) {
if (is_closed() || is_closing()) return;
Debug(this, "Associating stateless reset token %s with session", token);
token_map_[token] = session;
session_manager().AssociateStatelessResetToken(token, session);
}
void Endpoint::DisassociateStatelessResetToken(
const StatelessResetToken& token) {
if (!is_closed()) {
Debug(this, "Disassociating stateless reset token %s", token);
token_map_.erase(token);
session_manager().DisassociateStatelessResetToken(token);
}
}
@@ -774,7 +822,7 @@ void Endpoint::SendRetry(const PathDescriptor& options) {
// its own. What this count does not give is the rate of retry, so it is still
// somewhat limited.
Debug(this, "Sending retry on path %s", options);
auto info = addrLRU_.Upsert(options.remote_address);
auto info = addr_validation_lru_.Upsert(options.remote_address);
if (++(info->retry_count) <= options_.max_retries) {
auto packet =
Packet::CreateRetryPacket(*this, options, options_.token_secret);
@@ -821,24 +869,29 @@ bool Endpoint::SendStatelessReset(const PathDescriptor& options,
const auto exceeds_limits = [&] {
SocketAddressInfoTraits::Type* counts =
addrLRU_.Peek(options.remote_address);
addr_validation_lru_.Peek(options.remote_address);
auto count = counts != nullptr ? counts->reset_count : 0;
return count >= options_.max_stateless_resets;
};
// Per the QUIC spec, we need to protect against sending too many stateless
// reset tokens to an endpoint to prevent endless looping.
if (exceeds_limits()) return false;
if (exceeds_limits()) {
Debug(this, "Stateless reset rate limit exceeded");
return false;
}
auto packet = Packet::CreateStatelessResetPacket(
*this, options, options_.reset_token_secret, source_len);
if (packet) {
addrLRU_.Upsert(options.remote_address)->reset_count++;
Debug(this, "Sending stateless reset packet (%zu bytes)", packet->length());
addr_validation_lru_.Upsert(options.remote_address)->reset_count++;
STAT_INCREMENT(Stats, stateless_reset_count);
Send(std::move(packet));
return true;
}
Debug(this, "Failed to create stateless reset packet");
return false;
}
@@ -886,8 +939,9 @@ bool Endpoint::Start() {
return false;
}
BindingData::Get(env()).listening_endpoints[this] =
BaseObjectPtr<Endpoint>(this);
auto& binding = BindingData::Get(env());
binding.listening_endpoints[this] = BaseObjectPtr<Endpoint>(this);
binding.session_manager().RegisterEndpoint(this, udp_.local_address());
state_->receiving = 1;
return true;
}
@@ -929,6 +983,7 @@ void Endpoint::Listen(const Session::Options& options) {
};
if (Start()) {
Debug(this, "Listening with options %s", server_state_->options);
idle_timer_.Stop();
state_->listening = 1;
}
}
@@ -978,13 +1033,25 @@ BaseObjectPtr<Session> Endpoint::Connect(
}
void Endpoint::MaybeDestroy() {
if (!is_closed() && sessions_.empty() && state_->pending_callbacks == 0 &&
state_->listening == 0) {
// Destroy potentially creates v8 handles so let's make sure
// we have a HandleScope on the stack.
HandleScope scope(env()->isolate());
Destroy();
if (is_closed() || primary_session_count_ > 0 ||
state_->pending_callbacks > 0 || state_->listening == 1) {
return;
}
if (options_.idle_timeout > 0) {
// Start the idle timer. If it fires before a new session or listen
// call reactivates this endpoint, the endpoint will be destroyed.
idle_timer_.Update(options_.idle_timeout * 1000);
return;
}
// With idle_timeout == 0, only destroy if the endpoint is actively
// closing (via close() or CloseGracefully). An idle endpoint that
// is not closing stays alive with an unref'd handle so the process
// can still exit.
if (state_->closing != 1) return;
// Destroy potentially creates v8 handles so let's make sure
// we have a HandleScope on the stack.
HandleScope scope(env()->isolate());
Destroy();
}
void Endpoint::Destroy(CloseContext context, int status) {
@@ -1020,14 +1087,9 @@ void Endpoint::Destroy(CloseContext context, int status) {
// If there are open sessions still, shut them down. As those clean themselves
// up, they will remove themselves. The cleanup here will be synchronous and
// no attempt will be made to communicate further with the peer.
// Intentionally copy the sessions map so that we can safely iterate over it
// while those clean themselves up.
auto sessions = sessions_;
for (auto& session : sessions)
session.second->Close(Session::CloseMethod::SILENT);
sessions.clear();
DCHECK(sessions_.empty());
token_map_.clear();
idle_timer_.Close();
session_manager().CloseAllSessionsFor(this);
DCHECK_EQ(primary_session_count_, 0);
dcid_to_scid_.clear();
server_state_.reset();
@@ -1035,7 +1097,9 @@ void Endpoint::Destroy(CloseContext context, int status) {
state_->closing = 0;
state_->bound = 0;
state_->receiving = 0;
BindingData::Get(env()).listening_endpoints.erase(this);
auto& binding = BindingData::Get(env());
binding.listening_endpoints.erase(this);
binding.session_manager().UnregisterEndpoint(this);
STAT_RECORD_TIMESTAMP(Stats, destroyed_at);
EmitClose(close_context_, close_status_);
@@ -1045,7 +1109,6 @@ void Endpoint::CloseGracefully() {
if (is_closed() || is_closing()) return;
Debug(this, "Closing gracefully");
state_->listening = 0;
state_->closing = 1;
@@ -1062,7 +1125,7 @@ void Endpoint::Receive(const uv_buf_t& buf,
const CID& dcid,
const CID& scid) {
DCHECK_NOT_NULL(session);
DCHECK(!session->is_destroyed());
if (session->is_destroyed()) return;
size_t len = store.length();
if (session->Receive(std::move(store), local_address, remote_address)) {
STAT_INCREMENT_N(Stats, bytes_received, len);
@@ -1129,32 +1192,28 @@ void Endpoint::Receive(const uv_buf_t& buf,
return;
}
// If ngtcp2_is_supported_version returns a non-zero value, the version is
// recognized and supported. If it returns 0, we'll go ahead and send a
// version negotiation packet in response.
if (ngtcp2_is_supported_version(hd.version) == 0) {
Debug(this,
"Packet not acceptable because the version (%d) is not supported. "
"Will attempt to send version negotiation",
hd.version);
SendVersionNegotiation(
PathDescriptor{version, dcid, scid, local_address, remote_address});
// The packet was successfully processed, even if we did refuse the
// connection.
STAT_INCREMENT(Stats, packets_received);
return;
}
// Unsupported versions are handled earlier in Receive() via the
// NGTCP2_ERR_VERSION_NEGOTIATION return from ngtcp2_pkt_decode_version_cid.
// If we reach here, the version must be supported.
CHECK_NE(ngtcp2_is_supported_version(hd.version), 0);
// This is the next important condition check... If the server has been
// marked busy or the remote peer has exceeded their maximum number of
// concurrent connections, any new connections will be shut down
// immediately.
const auto limits_exceeded = ([&] {
if (sessions_.size() >= options_.max_connections_total) return true;
SocketAddressInfoTraits::Type* counts = addrLRU_.Peek(remote_address);
auto count = counts != nullptr ? counts->active_connections : 0;
return count >= options_.max_connections_per_host;
if (state_->max_connections_total > 0 &&
primary_session_count_ >= state_->max_connections_total) {
return true;
}
if (state_->max_connections_per_host > 0) {
auto it = conn_counts_per_host_.find(remote_address);
if (it != conn_counts_per_host_.end() &&
it->second >= state_->max_connections_per_host) {
return true;
}
}
return false;
})();
if (state_->busy || limits_exceeded) {
@@ -1168,7 +1227,7 @@ void Endpoint::Receive(const uv_buf_t& buf,
// the same.
if (state_->busy) STAT_INCREMENT(Stats, server_busy_count);
SendImmediateConnectionClose(
PathDescriptor{version, scid, dcid, local_address, remote_address},
PathDescriptor{version, dcid, scid, local_address, remote_address},
QuicError::ForTransport(NGTCP2_CONNECTION_REFUSED));
// The packet was successfully processed, even if we did refuse the
// connection.
@@ -1179,6 +1238,12 @@ void Endpoint::Receive(const uv_buf_t& buf,
Debug(
this, "Accepting initial packet for %s from %s", dcid, remote_address);
// Generate a fresh server SCID rather than reusing the client's original
// DCID. The client's original DCID is typically short (8 bytes) and we
// need a 20-byte SCID to properly match short_dcidlen passed to
// ngtcp2_pkt_decode_version_cid.
auto server_scid = server_state_->options.cid_factory->Generate();
// At this point, we start to set up the configuration for our local
// session. We pass the received scid here as the dcid argument value
// because that is the value *this* session will use as the outbound dcid.
@@ -1189,55 +1254,86 @@ void Endpoint::Receive(const uv_buf_t& buf,
local_address,
remote_address,
scid,
dcid,
server_scid,
dcid);
Debug(this, "Using session config %s", config);
// The this point, the config.scid and config.dcid represent *our* views of
// the CIDs. Specifically, config.dcid identifies the peer and config.scid
// identifies us. config.dcid should equal scid, and config.scid should
// equal dcid.
// identifies us. config.dcid should equal scid (peer's SCID is our DCID),
// and config.ocid should equal dcid (peer's original DCID).
DCHECK(config.dcid == scid);
DCHECK(config.scid == dcid);
DCHECK(config.ocid == dcid);
const auto is_remote_address_validated = ([&] {
auto info = addrLRU_.Peek(remote_address);
auto info = addr_validation_lru_.Peek(remote_address);
return info != nullptr ? info->validated : false;
})();
// QUIC has address validation built in to the handshake but allows for
// an additional explicit validation request using RETRY frames. If we
// are using explicit validation, we check for the existence of a valid
// token in the packet. If one does not exist, we send a retry with
// a new token. If it does exist, and if it is valid, we grab the original
// cid and continue.
if (!is_remote_address_validated) {
// Retry token processing and address validation are two separate
// concerns. A retry token MUST always be parsed when present because
// it carries the original_destination_connection_id (ODCID) that the
// server must echo in its transport parameters. Without it, the peer
// will reject the connection with PROTOCOL_VIOLATION.
//
// The address validation LRU cache determines whether we need to
// *send* a Retry, but must NOT skip *processing* an incoming retry
// token — a concurrent connection may have already validated the
// address (populating the LRU) while this connection's Retry was
// still in flight.
// Step 1: Always process a retry token if present, to extract the
// ODCID regardless of address validation state.
if (hd.type == NGTCP2_PKT_INITIAL && hd.tokenlen > 0 &&
hd.token[0] == RetryToken::kTokenMagic) {
RetryToken token(hd.token, hd.tokenlen);
Debug(this,
"Initial packet from %s has retry token %s",
remote_address,
token);
auto ocid =
token.Validate(version,
remote_address,
dcid,
options_.token_secret,
options_.retry_token_expiration * NGTCP2_SECONDS);
if (!ocid.has_value()) {
Debug(this, "Retry token from %s is invalid.", remote_address);
SendImmediateConnectionClose(
PathDescriptor{version, scid, dcid, local_address, remote_address},
QuicError::ForTransport(NGTCP2_CONNECTION_REFUSED));
STAT_INCREMENT(Stats, packets_received);
return;
}
Debug(this,
"Retry token from %s is valid. Original dcid %s",
remote_address,
ocid.value());
config.ocid = ocid.value();
config.retry_scid = dcid;
config.set_token(token);
// Mark the address as validated since the retry round-trip proves
// reachability.
Debug(this, "Remote address %s is validated", remote_address);
addr_validation_lru_.Upsert(remote_address)->validated = true;
}
// Step 2: Address validation — decide whether to send a Retry or
// accept the packet. This only applies when the address has not
// been validated yet (no LRU hit and no retry token above).
if (!is_remote_address_validated && !config.retry_scid) {
Debug(this, "Remote address %s is not validated", remote_address);
switch (hd.type) {
case NGTCP2_PKT_INITIAL:
// First, let's see if we need to do anything here.
if (options_.validate_address) {
// If there is no token, generate and send one.
if (hd.tokenlen == 0) {
Debug(this,
"Initial packet has no token. Sending retry to %s to start "
"validation",
remote_address);
// In this case we sent a retry to the remote peer and return
// without creating a session. What we expect to happen next is
// that the remote peer will try again with a new initial packet
// that includes the retry token we are sending them. It's
// possible, however, that they just give up and go away or send
// us another initial packet that does not have the token. In that
// case we'll end up right back here asking them to validate
// again.
//
// It is possible that the SendRetry(...) won't actually send a
// retry if the remote address has exceeded the maximum number of
// retry attempts it is allowed as tracked by the addressLRU
// cache. In that case, we'll just drop the packet on the floor.
SendRetry(PathDescriptor{
version,
dcid,
@@ -1245,53 +1341,12 @@ void Endpoint::Receive(const uv_buf_t& buf,
local_address,
remote_address,
});
// We still consider this a successfully handled packet even
// if we send a retry.
STAT_INCREMENT(Stats, packets_received);
return;
}
// We have two kinds of tokens, each prefixed with a different
// magic byte.
// Non-retry tokens (regular tokens).
switch (hd.token[0]) {
case RetryToken::kTokenMagic: {
RetryToken token(hd.token, hd.tokenlen);
Debug(this,
"Initial packet from %s has retry token %s",
remote_address,
token);
auto ocid = token.Validate(
version,
remote_address,
dcid,
options_.token_secret,
options_.retry_token_expiration * NGTCP2_SECONDS);
if (!ocid.has_value()) {
Debug(
this, "Retry token from %s is invalid.", remote_address);
// Invalid retry token was detected. Close the connection.
SendImmediateConnectionClose(
PathDescriptor{
version, scid, dcid, local_address, remote_address},
QuicError::ForTransport(NGTCP2_CONNECTION_REFUSED));
// We still consider this a successfully handled packet even
// if we send a connection close.
STAT_INCREMENT(Stats, packets_received);
return;
}
// The ocid is the original dcid that was encoded into the
// original retry packet sent to the client. We use it for
// validation.
Debug(this,
"Retry token from %s is valid. Original dcid %s",
remote_address,
ocid.value());
config.ocid = ocid.value();
config.retry_scid = dcid;
config.set_token(token);
break;
}
case RegularToken::kTokenMagic: {
RegularToken token(hd.token, hd.tokenlen);
Debug(this,
@@ -1306,10 +1361,6 @@ void Endpoint::Receive(const uv_buf_t& buf,
Debug(this,
"Regular token from %s is invalid.",
remote_address);
// If the regular token is invalid, let's send a retry to be
// lenient. There's a small risk that a malicious peer is
// trying to make us do some work but the risk is fairly low
// here.
SendRetry(PathDescriptor{
version,
dcid,
@@ -1317,8 +1368,6 @@ void Endpoint::Receive(const uv_buf_t& buf,
local_address,
remote_address,
});
// We still consider this to be a successfully handled packet
// if a retry is sent.
STAT_INCREMENT(Stats, packets_received);
return;
}
@@ -1330,13 +1379,6 @@ void Endpoint::Receive(const uv_buf_t& buf,
Debug(this,
"Initial packet from %s has unknown token type",
remote_address);
// If our prefix bit does not match anything we know about,
// let's send a retry to be lenient. There's a small risk that a
// malicious peer is trying to make us do some work but the risk
// is fairly low here. The SendRetry will avoid sending a retry
// if the remote address has exceeded the maximum number of
// retry attempts it is allowed as tracked by the addressLRU
// cache.
SendRetry(PathDescriptor{
version,
dcid,
@@ -1349,33 +1391,16 @@ void Endpoint::Receive(const uv_buf_t& buf,
}
}
// Ok! If we've got this far, our token is valid! Which means our
// path to the remote address is valid (for now). Let's record that
// so we don't have to do this dance again for this endpoint
// instance.
Debug(this, "Remote address %s is validated", remote_address);
addrLRU_.Upsert(remote_address)->validated = true;
addr_validation_lru_.Upsert(remote_address)->validated = true;
} else if (hd.tokenlen > 0) {
Debug(this,
"Ignoring initial packet from %s with unexpected token",
remote_address);
// If validation is turned off and there is a token, that's weird.
// The peer should only have a token if we sent it to them and we
// wouldn't have sent it unless validation was turned on. Let's
// assume the peer is buggy or malicious and drop the packet on the
// floor.
return;
}
break;
case NGTCP2_PKT_0RTT:
// 0-RTT packets are inherently replayable and could be sent
// from a spoofed source address to trigger amplification.
// When address validation is enabled, we send a Retry to
// force the client to prove it can receive at its claimed
// address. This adds a round trip but prevents amplification
// attacks. When address validation is disabled (e.g., on
// trusted networks), we skip the Retry and allow 0-RTT to
// proceed without additional validation.
if (options_.validate_address) {
Debug(
this, "Sending retry to %s due to 0RTT packet", remote_address);
@@ -1434,12 +1459,13 @@ void Endpoint::Receive(const uv_buf_t& buf,
// If a Session has been associated with the token, then it is a valid
// stateless reset token. We need to dispatch it to the session to be
// processed.
auto it = token_map_.find(StatelessResetToken(vec.base));
if (it != token_map_.end()) {
auto* session = session_manager().FindSessionByStatelessResetToken(
StatelessResetToken(vec.base));
if (session != nullptr) {
// If the session happens to have been destroyed already, we'll
// just ignore the packet.
if (!it->second->is_destroyed()) [[likely]] {
receive(it->second,
if (!session->is_destroyed()) [[likely]] {
receive(session,
std::move(store),
local_address,
remote_address,
@@ -1491,10 +1517,29 @@ void Endpoint::Receive(const uv_buf_t& buf,
// cannot be processed; all we can do is ignore it. If it succeeds, we have a
// valid QUIC header but there is still no guarantee that the packet can be
// successfully processed.
if (ngtcp2_pkt_decode_version_cid(
&pversion_cid, vec.base, vec.len, NGTCP2_MAX_CIDLEN) < 0) {
Debug(this, "Failed to decode packet header, ignoring");
return; // Ignore the packet!
switch (ngtcp2_pkt_decode_version_cid(
&pversion_cid, vec.base, vec.len, NGTCP2_MAX_CIDLEN)) {
case 0:
break; // Supported version, continue processing.
case NGTCP2_ERR_VERSION_NEGOTIATION: {
// The packet has an unsupported version but the CIDs were
// successfully decoded. Send a Version Negotiation response
// per RFC 9000 Section 6. The VN packet's DCID is the client's
// SCID and vice versa (mirrored back to the client).
Debug(this,
"Packet version %d is not supported, sending version negotiation",
pversion_cid.version);
CID dcid(pversion_cid.dcid, pversion_cid.dcidlen);
CID scid(pversion_cid.scid, pversion_cid.scidlen);
SendVersionNegotiation(PathDescriptor{
pversion_cid.version, dcid, scid, local_address(), remote_address});
STAT_INCREMENT(Stats, packets_received);
return;
}
default:
// Truly invalid packet — cannot be decoded at all.
Debug(this, "Failed to decode packet header, ignoring");
return;
}
// QUIC currently requires CID lengths of max NGTCP2_MAX_CIDLEN. Ignore any
@@ -1550,11 +1595,26 @@ void Endpoint::Receive(const uv_buf_t& buf,
// stateless reset, the packet will be handled with no additional action
// necessary here. We want to return immediately without committing any
// further resources.
if (!scid && maybeStatelessReset(dcid, scid, store, addr, remote_address)) {
if (pversion_cid.version == 0 &&
maybeStatelessReset(dcid, scid, store, addr, remote_address)) {
Debug(this, "Packet was a stateless reset");
return; // Stateless reset! Don't do any further processing.
}
// If this is a short header packet for an unknown DCID, send a
// stateless reset so the peer knows the session is gone. Short header
// packets are identified by version == 0 (set by ngtcp2_pkt_decode_
// version_cid). We must NOT use !scid here because long header Initial
// packets can have a 0-length SCID (valid per RFC 9000 Section 7.2).
if (pversion_cid.version == 0) {
Debug(this, "Sending stateless reset for unknown short header packet");
SendStatelessReset(
PathDescriptor{
pversion_cid.version, dcid, scid, addr, remote_address},
store.length());
return;
}
// Process the packet as an initial packet...
return acceptInitialPacket(pversion_cid.version,
dcid,
@@ -1587,18 +1647,9 @@ void Endpoint::PacketDone(int status) {
DCHECK_GE(state_->pending_callbacks, 1);
state_->pending_callbacks--;
env()->DecreaseWaitingRequestCounter();
// Can we go ahead and close now?
if (state_->closing == 1) MaybeDestroy();
}
void Endpoint::IncrementSocketAddressCounter(const SocketAddress& addr) {
addrLRU_.Upsert(addr)->active_connections++;
}
void Endpoint::DecrementSocketAddressCounter(const SocketAddress& addr) {
auto* counts = addrLRU_.Peek(addr);
if (counts != nullptr && counts->active_connections > 0)
counts->active_connections--;
// Check if we can close or start the idle timer now that this
// pending callback has completed.
if (state_->closing == 1 || primary_session_count_ == 0) MaybeDestroy();
}
bool Endpoint::is_closed() const {
@@ -1619,10 +1670,7 @@ void Endpoint::MemoryInfo(MemoryTracker* tracker) const {
tracker->TrackField("server_options", server_state_->options);
tracker->TrackField("server_tls_context", server_state_->tls_context);
}
tracker->TrackField("token_map", token_map_);
tracker->TrackField("sessions", sessions_);
tracker->TrackField("cid_map", dcid_to_scid_);
tracker->TrackField("address LRU", addrLRU_);
tracker->TrackField("address LRU", addr_validation_lru_);
}
// ======================================================================================
+61 -25
View File
@@ -6,6 +6,7 @@
#include <async_wrap.h>
#include <env.h>
#include <node_sockaddr.h>
#include <timer_wrap.h>
#include <uv.h>
#include <v8.h>
#include <algorithm>
@@ -14,6 +15,7 @@
#include "bindingdata.h"
#include "packet.h"
#include "session.h"
#include "session_manager.h"
#include "sessionticket.h"
#include "tokens.h"
@@ -24,12 +26,25 @@ namespace node::quic {
// client and server simultaneously.
class Endpoint final : public AsyncWrap, public Packet::Listener {
public:
static constexpr uint64_t DEFAULT_MAX_CONNECTIONS =
std::min<uint64_t>(kMaxSizeT, kMaxSafeJsInteger);
static constexpr uint64_t DEFAULT_MAX_CONNECTIONS_PER_HOST = 100;
static constexpr uint64_t DEFAULT_MAX_SOCKETADDRESS_LRU_SIZE =
(DEFAULT_MAX_CONNECTIONS_PER_HOST * 10);
// The socket address LRU is used for tracking validated remote addresses.
static constexpr uint64_t DEFAULT_MAX_SOCKETADDRESS_LRU_SIZE = 1024;
// The max stateless resets is the maximum number of stateless reset packets
// that the Endpoint will generate for a given remote host within a window of
// time (while tracking that host in the socket address LRU). This is not
// mandated by QUIC, and the limit is arbitrary. We can set it to whatever
// we'd like. The purpose is to prevent a malicious peer from intentionally
// triggering generation of a large number of stateless resets. Once the
// limit is reached, packets that would have otherwise triggered generation
// of a stateless reset will simply be dropped instead.
static constexpr uint64_t DEFAULT_MAX_STATELESS_RESETS = 10;
// Similar to stateless resets, the max retry limit is the maximum number of
// retry packets that the Endpoint will generate for a given remote host
// within a window of time (while tracking that host in the socket address
// LRU). This is not mandated by QUIC, and the limit is arbitrary. We can set
// it to whatever we'd like. The purpose is to prevent a malicious peer from
// intentionally triggering generation of a large number of retries.
static constexpr uint64_t DEFAULT_MAX_RETRY_LIMIT = 10;
// Endpoint configuration options
@@ -50,17 +65,10 @@ class Endpoint final : public AsyncWrap, public Packet::Listener {
RetryToken::QUIC_DEFAULT_RETRYTOKEN_EXPIRATION / NGTCP2_SECONDS;
// Tokens issued using NEW_TOKEN are time-limited. By default, tokens expire
// after DEFAULT_TOKEN_EXPIRATION *seconds*.
// after QUIC_DEFAULT_REGULARTOKEN_EXPIRATION *seconds*.
uint64_t token_expiration =
RegularToken::QUIC_DEFAULT_REGULARTOKEN_EXPIRATION / NGTCP2_SECONDS;
// Each Endpoint places limits on the number of concurrent connections from
// a single host, and the total number of concurrent connections allowed as
// a whole. These are set to fairly modest, and arbitrary defaults. We can
// set these to whatever we'd like.
uint64_t max_connections_per_host = DEFAULT_MAX_CONNECTIONS_PER_HOST;
uint64_t max_connections_total = DEFAULT_MAX_CONNECTIONS;
// A stateless reset in QUIC is a discrete mechanism that one endpoint can
// use to communicate to a peer that it has lost whatever state it
// previously held about a session. Because generating a stateless reset
@@ -134,6 +142,14 @@ class Endpoint final : public AsyncWrap, public Packet::Listener {
// Setting to 0 uses the default.
uint8_t udp_ttl = 0;
// When an endpoint becomes idle (not listening and no primary sessions),
// it will be destroyed after this many seconds. A value of 0 means
// destroy immediately when idle (default, preserves pre-SessionManager
// behavior). A positive value keeps the endpoint alive for potential
// reuse by future connect() or listen() calls.
static constexpr uint64_t DEFAULT_IDLE_TIMEOUT = 0;
uint64_t idle_timeout = DEFAULT_IDLE_TIMEOUT;
void MemoryInfo(MemoryTracker* tracker) const override;
SET_MEMORY_INFO_NAME(Endpoint::Config)
SET_SELF_SIZE(Options)
@@ -324,9 +340,6 @@ class Endpoint final : public AsyncWrap, public Packet::Listener {
void EmitNewSession(const BaseObjectPtr<Session>& session);
void EmitClose(CloseContext context, int status);
void IncrementSocketAddressCounter(const SocketAddress& address);
void DecrementSocketAddressCounter(const SocketAddress& address);
// JavaScript API
// Create a new Endpoint.
@@ -376,6 +389,11 @@ class Endpoint final : public AsyncWrap, public Packet::Listener {
ArenaPool<Packet> packet_pool_;
UDP udp_;
// Idle timer: started when the endpoint becomes idle (not listening,
// no primary sessions). When it fires, the endpoint is destroyed.
// Stopped when a new session is added or listening begins.
TimerWrapHandle idle_timer_;
struct ServerState {
Session::Options options;
std::shared_ptr<TLSContext> tls_context;
@@ -383,18 +401,29 @@ class Endpoint final : public AsyncWrap, public Packet::Listener {
// Set if/when the endpoint is configured to listen.
std::optional<ServerState> server_state_ = std::nullopt;
// A Session is generally identified by one or more CIDs. We use two
// maps for this rather than one to avoid creating a whole bunch of
// BaseObjectPtr references. The primary map (sessions_) just maps
// the original CID to the Session, the second map (dcid_to_scid_)
// maps the additional CIDs to the primary.
CID::Map<BaseObjectPtr<Session>> sessions_;
// Count of sessions for which this endpoint is the primary endpoint.
// Drives ref/unref and idle timer logic. The actual session-to-endpoint
// mapping is maintained by the SessionManager.
size_t primary_session_count_ = 0;
// Per-endpoint CID -> SCID mapping for peer-chosen CIDs from connection
// establishment (config.dcid, config.ocid). These are kept per-endpoint
// because peer-chosen values can collide across endpoints (e.g., a
// client's random outgoing DCID matching an incoming DCID on the server
// endpoint). Locally-generated CIDs that need cross-endpoint routing
// (preferred address, multipath) go in SessionManager::dcid_to_scid_.
//
// Endpoint::FindSession does a three-tier lookup:
// 1. SessionManager::sessions_[cid] (direct SCID match)
// 2. SessionManager::dcid_to_scid_[cid] (cross-endpoint CID)
// 3. Endpoint::dcid_to_scid_[cid] (peer-chosen CID)
// Each tier resolves to an SCID and looks up SessionManager::sessions_.
CID::Map<CID> dcid_to_scid_;
StatelessResetToken::Map<Session*> token_map_;
SessionManager& session_manager() const;
struct SocketAddressInfoTraits final {
struct Type final {
size_t active_connections;
size_t reset_count;
size_t retry_count;
uint64_t timestamp;
@@ -405,7 +434,14 @@ class Endpoint final : public AsyncWrap, public Packet::Listener {
static void Touch(const SocketAddress& address, Type* type);
};
SocketAddressLRU<SocketAddressInfoTraits> addrLRU_;
SocketAddressLRU<SocketAddressInfoTraits> addr_validation_lru_;
// Per-IP connection counts for maxConnectionsPerHost enforcement.
// Only populated when max_connections_per_host > 0. Entries are
// added in AddSession and removed when the count reaches 0 in
// RemoveSession. The map size is bounded by the number of active
// sessions (each entry has count >= 1).
SocketAddress::IpMap<uint16_t> conn_counts_per_host_;
CloseContext close_context_ = CloseContext::CLOSE;
int close_status_ = 0;
+600 -187
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -3,6 +3,8 @@
#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
#include <memory>
#include <optional>
#include "application.h"
#include "session.h"
namespace node::quic {
@@ -13,6 +15,11 @@ namespace node::quic {
std::unique_ptr<Session::Application> CreateHttp3Application(
Session* session, const Session::Application_Options& options);
// Parse HTTP/3 specific session ticket app data. Called from
// Application::ParseTicketData() when the type byte is HTTP3.
// The data includes the type byte prefix.
std::optional<PendingTicketAppData> ParseHttp3TicketData(const uv_buf_t& data);
} // namespace node::quic
#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
-140
View File
@@ -1,140 +0,0 @@
#if HAVE_OPENSSL && HAVE_QUIC
#include "guard.h"
#ifndef OPENSSL_NO_QUIC
#include "logstream.h"
#include <async_wrap-inl.h>
#include <base_object-inl.h>
#include <env-inl.h>
#include <memory_tracker-inl.h>
#include <node_external_reference.h>
#include <stream_base-inl.h>
#include <uv.h>
#include <v8.h>
#include "bindingdata.h"
namespace node {
using v8::FunctionTemplate;
using v8::Local;
using v8::Object;
namespace quic {
JS_CONSTRUCTOR_IMPL(LogStream, logstream_constructor_template, {
tmpl = FunctionTemplate::New(env->isolate());
JS_INHERIT(AsyncWrap);
JS_CLASS_FIELDS(logstream, StreamBase::kInternalFieldCount);
StreamBase::AddMethods(env, tmpl);
})
BaseObjectPtr<LogStream> LogStream::Create(Environment* env) {
JS_NEW_INSTANCE_OR_RETURN(env, obj, nullptr);
return MakeDetachedBaseObject<LogStream>(env, obj);
}
LogStream::LogStream(Environment* env, Local<Object> obj)
: AsyncWrap(env, obj, PROVIDER_QUIC_LOGSTREAM), StreamBase(env) {
MakeWeak();
AttachToObject(GetObject());
}
void LogStream::Emit(const uint8_t* data, size_t len, EmitOption option) {
if (fin_seen_) return;
fin_seen_ = option == EmitOption::FIN;
size_t remaining = len;
// If the len is greater than the size of the buffer returned by
// EmitAlloc then EmitRead will be called multiple times.
while (remaining != 0) {
uv_buf_t buf = EmitAlloc(remaining);
size_t chunk_len = std::min<size_t>(remaining, buf.len);
memcpy(buf.base, data, chunk_len);
remaining -= chunk_len;
data += chunk_len;
// If we are actively reading from the stream, we'll call emit
// read immediately. Otherwise we buffer the chunk and will push
// the chunks out the next time ReadStart() is called.
if (reading_) {
EmitRead(chunk_len, buf);
} else {
// The total measures the total memory used so we always
// increment but buf.len and not chunk len.
ensure_space(buf.len);
total_ += buf.len;
buffer_.push_back(Chunk{chunk_len, buf});
}
}
if (ended_ && reading_) {
EmitRead(UV_EOF);
}
}
void LogStream::Emit(const std::string_view line, EmitOption option) {
Emit(reinterpret_cast<const uint8_t*>(line.data()), line.length(), option);
}
void LogStream::End() {
ended_ = true;
}
int LogStream::ReadStart() {
if (reading_) return 0;
// Flush any chunks that have already been buffered.
for (const auto& chunk : buffer_) EmitRead(chunk.len, chunk.buf);
total_ = 0;
buffer_.clear();
if (fin_seen_) {
// If we've already received the fin, there's nothing else to wait for.
EmitRead(UV_EOF);
return ReadStop();
}
// Otherwise, we're going to wait for more chunks to be written.
reading_ = true;
return 0;
}
int LogStream::ReadStop() {
reading_ = false;
return 0;
}
// We do not use either of these.
int LogStream::DoShutdown(ShutdownWrap* req_wrap) {
UNREACHABLE();
}
int LogStream::DoWrite(WriteWrap* w,
uv_buf_t* bufs,
size_t count,
uv_stream_t* send_handle) {
UNREACHABLE();
}
bool LogStream::IsAlive() {
return !ended_;
}
bool LogStream::IsClosing() {
return ended_;
}
AsyncWrap* LogStream::GetAsyncWrap() {
return this;
}
void LogStream::MemoryInfo(MemoryTracker* tracker) const {
tracker->TrackFieldWithSize("buffer", total_);
}
// The LogStream buffer enforces a maximum size of kMaxLogStreamBuffer.
void LogStream::ensure_space(size_t amt) {
while (total_ + amt > kMaxLogStreamBuffer) {
total_ -= buffer_.front().buf.len;
buffer_.pop_front();
}
}
} // namespace quic
} // namespace node
#endif // OPENSSL_NO_QUIC
#endif // HAVE_OPENSSL && HAVE_QUIC
-84
View File
@@ -1,84 +0,0 @@
#pragma once
#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
#include <async_wrap.h>
#include <base_object.h>
#include <env.h>
#include <stream_base.h>
#include <list>
#include "defs.h"
namespace node::quic {
// The LogStream is a utility that the QUIC impl uses to publish both QLog
// and Keylog diagnostic data (one instance for each).
class LogStream final : public AsyncWrap, public StreamBase {
public:
enum InternalFields {
kInternalFieldCount = std::max<uint32_t>(AsyncWrap::kInternalFieldCount,
StreamBase::kInternalFieldCount),
};
JS_CONSTRUCTOR(LogStream);
static BaseObjectPtr<LogStream> Create(Environment* env);
LogStream(Environment* env, v8::Local<v8::Object> obj);
enum class EmitOption : uint8_t {
NONE,
FIN,
};
void Emit(const uint8_t* data,
size_t len,
EmitOption option = EmitOption::NONE);
void Emit(const std::string_view line, EmitOption option = EmitOption::NONE);
void End();
int ReadStart() override;
int ReadStop() override;
// We do not use either of these.
int DoShutdown(ShutdownWrap* req_wrap) override;
int DoWrite(WriteWrap* w,
uv_buf_t* bufs,
size_t count,
uv_stream_t* send_handle) override;
bool IsAlive() override;
bool IsClosing() override;
AsyncWrap* GetAsyncWrap() override;
void MemoryInfo(MemoryTracker* tracker) const override;
SET_MEMORY_INFO_NAME(LogStream)
SET_SELF_SIZE(LogStream)
private:
struct Chunk {
// len will be <= buf.len
size_t len;
uv_buf_t buf;
};
size_t total_ = 0;
std::list<Chunk> buffer_;
bool fin_seen_ = false;
bool ended_ = false;
bool reading_ = false;
// The value here is fairly arbitrary. Once we get everything
// fully implemented and start working with this, we might
// tune this number further.
static constexpr size_t kMaxLogStreamBuffer = 1024 * 10;
// The LogStream buffer enforces a maximum size of kMaxLogStreamBuffer.
void ensure_space(size_t amt);
};
} // namespace node::quic
#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
+15 -5
View File
@@ -127,11 +127,15 @@ Packet::Ptr Packet::CreateImmediateConnectionClosePacket(
"immediate connection close (endpoint)");
if (!packet) return packet;
ngtcp2_vec vec = *packet;
// ngtcp2_crypto_write_connection_close expects dcid to be the
// client's SCID and scid to be the client's DCID (mirrored).
// PathDescriptor carries the incoming packet's CIDs as-is, so
// we swap here.
ssize_t nwrite = ngtcp2_crypto_write_connection_close(vec.base,
vec.len,
path_descriptor.version,
path_descriptor.dcid,
path_descriptor.scid,
path_descriptor.dcid,
reason.code(),
nullptr,
0);
@@ -160,9 +164,11 @@ Packet::Ptr Packet::CreateStatelessResetPacket(
if (!packet) return packet;
ngtcp2_vec vec = *packet;
ssize_t nwrite = ngtcp2_pkt_write_stateless_reset(
auto nwrite = ngtcp2_pkt_write_stateless_reset2(
vec.base, pktlen, token, random, kRandlen);
if (nwrite <= static_cast<ssize_t>(kMinStatelessResetLen)) return Ptr();
if (nwrite < static_cast<ssize_t>(kMinStatelessResetLen)) {
return Ptr();
}
packet->Truncate(static_cast<size_t>(nwrite));
return packet;
@@ -203,14 +209,18 @@ Packet::Ptr Packet::CreateVersionNegotiationPacket(
if (!packet) return packet;
ngtcp2_vec vec = *packet;
// ngtcp2_pkt_write_version_negotiation expects dcid to be the
// client's SCID and scid to be the client's DCID (mirrored).
// PathDescriptor carries the incoming packet's CIDs as-is, so
// we swap here.
ssize_t nwrite =
ngtcp2_pkt_write_version_negotiation(vec.base,
pktlen,
0,
path_descriptor.dcid,
path_descriptor.dcid.length(),
path_descriptor.scid,
path_descriptor.scid.length(),
path_descriptor.dcid,
path_descriptor.dcid.length(),
sv,
arraysize(sv));
if (nwrite <= 0) return Ptr();
+9
View File
@@ -69,6 +69,15 @@ class Packet final {
size_t capacity() const { return capacity_; }
const SocketAddress& destination() const { return destination_; }
Listener* listener() const { return listener_; }
// Redirect the packet to a different endpoint for cross-endpoint sends
// (e.g., PATH_RESPONSE on a preferred address path). Updates the
// listener (for pending_callbacks accounting) and the destination
// (for uv_udp_send targeting). The packet data is unchanged.
void Redirect(Listener* listener, const SocketAddress& destination) {
listener_ = listener;
destination_ = destination;
}
uv_udp_send_t* req() { return &req_; }
operator uv_buf_t() const {
+978 -371
View File
File diff suppressed because it is too large. Load diff
+76 -9
View File
@@ -16,7 +16,6 @@
#include "cid.h"
#include "data.h"
#include "defs.h"
#include "logstream.h"
#include "packet.h"
#include "preferredaddress.h"
#include "sessionticket.h"
@@ -74,9 +73,9 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
// HTTP/3 specific options.
uint64_t max_field_section_size = 0;
uint64_t qpack_max_dtable_capacity = 0;
uint64_t qpack_encoder_max_dtable_capacity = 0;
uint64_t qpack_blocked_streams = 0;
uint64_t qpack_max_dtable_capacity = 4096;
uint64_t qpack_encoder_max_dtable_capacity = 4096;
uint64_t qpack_blocked_streams = 100;
bool enable_connect_protocol = true;
bool enable_datagrams = true;
@@ -112,6 +111,12 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
// (ALPN negotiated during handshake). Must be called before any
// application data is received.
void SetApplication(std::unique_ptr<Application> app);
// Controls which datagram to drop when the pending datagram queue is full.
enum class DatagramDropPolicy : uint8_t {
DROP_OLDEST = 0, // Drop the oldest queued datagram (default).
DROP_NEWEST = 1, // Drop the incoming datagram.
};
// The options used to configure a session. Most of these deal directly with
// the transport parameters that are exchanged with the remote peer during
// handshake.
@@ -151,6 +156,11 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
// completion of the tls handshake.
uint64_t handshake_timeout = UINT64_MAX;
// The keep-alive timeout in milliseconds. When set to a non-zero value,
// ngtcp2 will automatically send PING frames to keep the connection alive
// before the idle timeout fires. Set to 0 to disable (default).
uint64_t keep_alive_timeout = 0;
// Maximum initial flow control window size for a stream.
uint64_t max_stream_window = 0;
@@ -180,6 +190,21 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
// is the better of the two for our needs.
ngtcp2_cc_algo cc_algorithm = CC_ALGO_CUBIC;
// Controls which datagram to drop when the pending queue is full.
DatagramDropPolicy datagram_drop_policy = DatagramDropPolicy::DROP_OLDEST;
// Maximum number of SendPendingData attempts before a datagram is
// abandoned. When a datagram cannot be sent due to congestion control
// or packet size constraints, it remains in the queue and the counter
// is incremented. Once the limit is reached, the datagram is dropped
// and reported as abandoned. Range: 1-255. Default: 5.
uint8_t max_datagram_send_attempts = 5;
// Multiplier for the Probe Timeout (PTO) used to compute the draining
// period duration after receiving CONNECTION_CLOSE. RFC 9000 Section
// 10.2 requires at least 3x PTO. Range: 3-255. Default: 3.
uint8_t draining_period_multiplier = 3;
// An optional NEW_TOKEN from a previous connection to the same
// server. When set, the token is included in the Initial packet
// to skip address validation. Client-side only.
@@ -312,6 +337,7 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
struct Stats;
void HandleQlog(uint32_t flags, const void* data, size_t len);
void EmitQlog(uint32_t flags, std::string_view data);
private:
struct Impl;
@@ -335,6 +361,18 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
void Send(Packet::Ptr packet, const PathStorage& path);
datagram_id SendDatagram(Store&& data);
// Pending datagram accessors for use by SendPendingData.
struct PendingDatagram {
datagram_id id;
Store data;
uint8_t send_attempts = 0;
};
bool HasPendingDatagrams() const;
PendingDatagram& PeekPendingDatagram();
void PopPendingDatagram();
size_t PendingDatagramCount() const;
void DatagramSent(datagram_id id);
// A non-const variation to allow certain modifications.
Config& config();
@@ -343,6 +381,9 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
DO_NOT_NOTIFY,
};
BaseObjectPtr<Stream> FindStream(stream_id id) const;
// Returns a copy of the streams map (safe for iteration while streams
// are being destroyed).
StreamsMap streams() const;
BaseObjectPtr<Stream> CreateStream(
stream_id id,
CreateStreamOption option = CreateStreamOption::NOTIFY,
@@ -422,6 +463,12 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
bool wants_session_ticket() const;
void SetStreamOpenAllowed();
// Populate state buffer fields from the 0-RTT transport params.
// Called after ngtcp2_conn_decode_and_set_0rtt_transport_params
// succeeds, so that values like maxDatagramSize are available
// before the handshake completes.
void PopulateEarlyTransportParamsState();
// It's a terrible name but "wrapped" here means that the Session has been
// passed out to JavaScript and should be "wrapped" by whatever handler is
// defined there to manage it.
@@ -471,10 +518,17 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
// JavaScript callouts
void EmitClose(const QuicError& error = QuicError());
void EmitGoaway(stream_id last_stream_id);
// Sets the max datagram payload size in the shared state. Used by
// Http3ApplicationImpl to block datagram sends when the peer's
// SETTINGS_H3_DATAGRAM=0 (RFC 9297 §3).
void set_max_datagram_size(uint16_t size);
void EmitDatagram(Store&& datagram, DatagramReceivedFlags flag);
void EmitDatagramStatus(datagram_id id, DatagramStatus status);
void EmitHandshakeComplete();
void EmitKeylog(const char* line);
void EmitOrigins(std::vector<std::string>&& origins);
struct ValidatedPath {
std::shared_ptr<SocketAddress> local;
@@ -487,6 +541,8 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
const std::optional<ValidatedPath>& oldPath);
void EmitSessionTicket(Store&& ticket);
void EmitNewToken(const uint8_t* token, size_t len);
void EmitEarlyDataRejected();
void DestroyAllStreams(const QuicError& error);
void EmitStream(const BaseObjectWeakPtr<Stream>& stream);
void EmitVersionNegotiation(const ngtcp2_pkt_hd& hd,
const uint32_t* sv,
@@ -495,7 +551,9 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
void DatagramReceived(const uint8_t* data,
size_t datalen,
DatagramReceivedFlags flag);
void GenerateNewConnectionId(ngtcp2_cid* cid, size_t len, uint8_t* token);
void GenerateNewConnectionId(ngtcp2_cid* cid,
size_t len,
ngtcp2_stateless_reset_token* token);
bool HandshakeCompleted();
void HandshakeConfirmed();
void SelectPreferredAddress(PreferredAddress* preferredAddress);
@@ -503,17 +561,26 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
QuicConnectionPointer InitConnection();
Side side_;
ngtcp2_mem allocator_;
const ngtcp2_mem* allocator_;
std::unique_ptr<Impl> impl_;
// These flags live on Session (not Impl) so that the NgTcp2CallbackScope
// and NgHttp3CallbackScope destructors can safely clear them even after
// Impl has been destroyed via MakeCallback re-entrancy during a callback.
// The scope is placed at the ngtcp2/nghttp3 entry point (e.g. Receive,
// OnTimeout) rather than on individual callbacks, so the deferred destroy
// only fires after all callbacks for that entry point have completed.
bool in_ngtcp2_callback_scope_ = false;
bool in_nghttp3_callback_scope_ = false;
bool destroy_deferred_ = false;
QuicConnectionPointer connection_;
std::unique_ptr<TLSSession> tls_session_;
BaseObjectPtr<LogStream> qlog_stream_;
BaseObjectPtr<LogStream> keylog_stream_;
friend struct NgTcp2CallbackScope;
friend struct NgHttp3CallbackScope;
friend class Application;
friend class DefaultApplication;
friend class Http3ApplicationImpl;
friend class Endpoint;
friend class SessionManager;
friend class Stream;
friend class PendingStream;
friend class TLSContext;
+170
View File
@@ -0,0 +1,170 @@
#if HAVE_OPENSSL && HAVE_QUIC
#include "guard.h"
#ifndef OPENSSL_NO_QUIC
#include <base_object-inl.h>
#include <node_sockaddr-inl.h>
#include <util-inl.h>
#include "endpoint.h"
#include "session.h"
#include "session_manager.h"
namespace node::quic {
SessionManager::SessionManager(Environment* env) : env_(env) {}
SessionManager::~SessionManager() = default;
BaseObjectPtr<Session> SessionManager::FindSession(const CID& cid) {
// Direct SCID match.
auto it = sessions_.find(cid);
if (it != sessions_.end()) return it->second;
// Cross-endpoint CID mapping (locally-generated CIDs for preferred
// address, multipath, etc.).
auto scid_it = dcid_to_scid_.find(cid);
if (scid_it != dcid_to_scid_.end()) {
it = sessions_.find(scid_it->second);
if (it != sessions_.end()) return it->second;
// Stale mapping — clean up.
dcid_to_scid_.erase(scid_it);
}
return {};
}
void SessionManager::AddSession(const CID& scid,
BaseObjectPtr<Session> session) {
sessions_[scid] = std::move(session);
}
void SessionManager::AssociateCID(const CID& cid, const CID& scid) {
if (cid && scid && cid != scid) {
dcid_to_scid_[cid] = scid;
}
}
void SessionManager::DisassociateCID(const CID& cid) {
if (cid) {
dcid_to_scid_.erase(cid);
}
}
void SessionManager::RemoveSession(const CID& scid) {
auto it = sessions_.find(scid);
if (it != sessions_.end()) {
primary_map_.erase(it->second.get());
sessions_.erase(it);
}
}
void SessionManager::AssociateStatelessResetToken(
const StatelessResetToken& token, Session* session) {
token_map_[token] = session;
}
void SessionManager::DisassociateStatelessResetToken(
const StatelessResetToken& token) {
token_map_.erase(token);
}
Session* SessionManager::FindSessionByStatelessResetToken(
const StatelessResetToken& token) const {
auto it = token_map_.find(token);
if (it != token_map_.end()) return it->second;
return nullptr;
}
void SessionManager::RegisterEndpoint(Endpoint* endpoint,
const SocketAddress& local_address) {
endpoints_.insert(endpoint);
endpoint_addrs_[endpoint] = local_address;
}
void SessionManager::UnregisterEndpoint(Endpoint* endpoint) {
endpoints_.erase(endpoint);
endpoint_addrs_.erase(endpoint);
// If no endpoints remain, destroy all sessions.
if (endpoints_.empty()) {
DestroyAllSessions();
}
}
bool SessionManager::HasEndpoints() const {
return !endpoints_.empty();
}
size_t SessionManager::endpoint_count() const {
return endpoints_.size();
}
Endpoint* SessionManager::FindEndpointForAddress(
const SocketAddress& local_addr) const {
// First pass: exact match.
for (const auto& [endpoint, addr] : endpoint_addrs_) {
if (addr == local_addr) return endpoint;
}
// Second pass: wildcard fallback. An endpoint bound to 0.0.0.0:port
// or [::]:port can serve any address on that port.
int port = SocketAddress::GetPort(local_addr.data());
for (const auto& [endpoint, addr] : endpoint_addrs_) {
if (SocketAddress::GetPort(addr.data()) == port) {
auto host = addr.address();
if (host == "0.0.0.0" || host == "::") {
return endpoint;
}
}
}
return nullptr;
}
void SessionManager::SetPrimaryEndpoint(Session* session, Endpoint* endpoint) {
primary_map_[session] = endpoint;
}
Endpoint* SessionManager::GetPrimaryEndpoint(Session* session) const {
auto it = primary_map_.find(session);
if (it != primary_map_.end()) return it->second;
return nullptr;
}
void SessionManager::CloseAllSessionsFor(Endpoint* endpoint) {
// Collect sessions whose primary is this endpoint, then close them.
// We collect first because closing a session modifies primary_map_.
std::vector<BaseObjectPtr<Session>> to_close;
for (const auto& [session, ep] : primary_map_) {
if (ep == endpoint) {
// Look up the owning reference from sessions_ so the session
// stays alive during close.
for (const auto& [cid, sess_ptr] : sessions_) {
if (sess_ptr.get() == session) {
to_close.push_back(sess_ptr);
break;
}
}
}
}
for (auto& session : to_close) {
session->Close(Session::CloseMethod::SILENT);
}
}
void SessionManager::DestroyAllSessions() {
// Copy the map since closing sessions will modify it.
auto sessions = sessions_;
for (auto& [cid, session] : sessions) {
session->Close(Session::CloseMethod::SILENT);
}
sessions.clear();
token_map_.clear();
dcid_to_scid_.clear();
primary_map_.clear();
}
bool SessionManager::is_empty() const {
return sessions_.empty();
}
} // namespace node::quic
#endif // OPENSSL_NO_QUIC
#endif // HAVE_OPENSSL && HAVE_QUIC
+109
View File
@@ -0,0 +1,109 @@
#pragma once
#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
#include <env.h>
#include <node_sockaddr.h>
#include <unordered_map>
#include <unordered_set>
#include "cid.h"
#include "tokens.h"
namespace node::quic {
class Endpoint;
class Session;
// SessionManager is a per-Realm singleton that centralizes QUIC session
// routing. It holds the authoritative CID -> Session mapping, enabling
// any Endpoint to route packets to any session. This decouples session
// lifetime from individual endpoints, which is required for preferred
// address, connection migration, and multi-path QUIC.
//
// SessionManager is held by BindingData and lazily created on first access.
// It is not exposed to JavaScript.
class SessionManager final {
public:
explicit SessionManager(Environment* env);
~SessionManager();
// Session routing. The sessions_ map holds BaseObjectPtr<Session> (owning
// references). SessionManager is the single authority for session ownership.
BaseObjectPtr<Session> FindSession(const CID& dcid);
void AddSession(const CID& scid, BaseObjectPtr<Session> session);
void RemoveSession(const CID& scid);
// Cross-endpoint CID association. This map holds locally-generated CIDs
// that need to be routable from any endpoint (e.g., preferred address CID,
// multipath NEW_CONNECTION_ID CIDs). Peer-chosen CIDs from connection
// establishment (config.dcid, config.ocid) go in Endpoint::dcid_to_scid_
// instead, because those values can collide across endpoints.
void AssociateCID(const CID& cid, const CID& scid);
void DisassociateCID(const CID& cid);
// Stateless reset token association. The token_map_ holds raw (non-owning)
// pointers. Entries are valid only while the corresponding session exists
// in sessions_. Sessions clean up their tokens during teardown.
void AssociateStatelessResetToken(const StatelessResetToken& token,
Session* session);
void DisassociateStatelessResetToken(const StatelessResetToken& token);
Session* FindSessionByStatelessResetToken(
const StatelessResetToken& token) const;
// Endpoint registry. Endpoints register themselves when they start
// receiving and unregister when they close.
void RegisterEndpoint(Endpoint* endpoint, const SocketAddress& local_address);
void UnregisterEndpoint(Endpoint* endpoint);
bool HasEndpoints() const;
size_t endpoint_count() const;
// Find the endpoint bound to a given local address. Used by the session
// send path to route packets through the correct endpoint based on the
// ngtcp2 packet path. Tries exact match first, then wildcard fallback
// (0.0.0.0 or [::] on the same port).
Endpoint* FindEndpointForAddress(const SocketAddress& local_addr) const;
// Primary endpoint tracking. Each session has one primary endpoint
// responsible for its lifecycle.
void SetPrimaryEndpoint(Session* session, Endpoint* endpoint);
Endpoint* GetPrimaryEndpoint(Session* session) const;
// Close all sessions whose primary endpoint is the given endpoint.
// Used by Endpoint::Destroy().
void CloseAllSessionsFor(Endpoint* endpoint);
// Destroy all sessions. Used when the last endpoint is removed.
void DestroyAllSessions();
bool is_empty() const;
private:
Environment* env_;
// The sessions_ map holds strong owning references keyed by locally-
// generated SCIDs. This is the single source of truth for session
// ownership.
CID::Map<BaseObjectPtr<Session>> sessions_;
// Cross-endpoint CID -> primary SCID mapping. Contains locally-generated
// CIDs that need to be routable from any endpoint. Peer-chosen CIDs
// from connection establishment are in Endpoint::dcid_to_scid_ instead.
CID::Map<CID> dcid_to_scid_;
// Stateless reset token -> Session (non-owning).
StatelessResetToken::Map<Session*> token_map_;
// All registered endpoints.
std::unordered_set<Endpoint*> endpoints_;
// Endpoint -> bound local address, for FindEndpointForAddress lookups.
std::unordered_map<Endpoint*, SocketAddress> endpoint_addrs_;
// Session -> primary Endpoint mapping (non-owning both directions;
// sessions are owned by sessions_, endpoints are externally owned).
std::unordered_map<Session*, Endpoint*> primary_map_;
};
} // namespace node::quic
#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
+17 -8
View File
@@ -136,25 +136,33 @@ SSL_TICKET_RETURN SessionTicket::DecryptedCallback(SSL* ssl,
case SSL_TICKET_NO_DECRYPT:
return SSL_TICKET_RETURN_IGNORE_RENEW;
case SSL_TICKET_SUCCESS_RENEW:
[[fallthrough]];
return static_cast<SSL_TICKET_RETURN>(
AppData::Extract(ssl, session, AppData::Source::Flag::STATUS_RENEW));
case SSL_TICKET_SUCCESS:
return static_cast<SSL_TICKET_RETURN>(AppData::Extract(ssl));
return static_cast<SSL_TICKET_RETURN>(AppData::Extract(ssl, session));
}
}
SessionTicket::AppData::AppData(SSL* ssl) : ssl_(ssl) {}
SessionTicket::AppData::AppData(SSL* ssl, SSL_SESSION* session)
: ssl_(ssl), session_(session) {}
SSL_SESSION* SessionTicket::AppData::GetSession() const {
return session_ != nullptr ? session_ : SSL_get0_session(ssl_);
}
bool SessionTicket::AppData::Set(const uv_buf_t& data) {
if (set_ || data.base == nullptr || data.len == 0) return false;
set_ = true;
SSL_SESSION_set1_ticket_appdata(SSL_get0_session(ssl_), data.base, data.len);
SSL_SESSION_set1_ticket_appdata(GetSession(), data.base, data.len);
return set_;
}
std::optional<const uv_buf_t> SessionTicket::AppData::Get() const {
auto* sess = GetSession();
if (sess == nullptr) return std::nullopt;
uv_buf_t buf;
int ret =
SSL_SESSION_get0_ticket_appdata(SSL_get0_session(ssl_),
SSL_SESSION_get0_ticket_appdata(sess,
reinterpret_cast<void**>(&buf.base),
reinterpret_cast<size_t*>(&buf.len));
if (ret != 1) return std::nullopt;
@@ -168,11 +176,12 @@ void SessionTicket::AppData::Collect(SSL* ssl) {
}
}
SessionTicket::AppData::Status SessionTicket::AppData::Extract(SSL* ssl) {
SessionTicket::AppData::Status SessionTicket::AppData::Extract(
SSL* ssl, SSL_SESSION* session, Source::Flag flag) {
auto source = GetAppDataSource(ssl);
if (source != nullptr) {
AppData app_data(ssl);
return source->ExtractSessionTicketAppData(app_data);
AppData app_data(ssl, session);
return source->ExtractSessionTicketAppData(app_data, flag);
}
return Status::TICKET_IGNORE;
}
+6 -2
View File
@@ -72,7 +72,7 @@ class SessionTicket::AppData final {
TICKET_USE_RENEW = SSL_TICKET_RETURN_USE_RENEW,
};
explicit AppData(SSL* session);
explicit AppData(SSL* ssl, SSL_SESSION* session = nullptr);
DISALLOW_COPY_AND_MOVE(AppData)
bool Set(const uv_buf_t& data);
@@ -94,11 +94,15 @@ class SessionTicket::AppData final {
};
static void Collect(SSL* ssl);
static Status Extract(SSL* ssl);
static Status Extract(SSL* ssl,
SSL_SESSION* session,
Source::Flag flag = Source::Flag::STATUS_NONE);
private:
SSL_SESSION* GetSession() const;
bool set_ = false;
SSL* ssl_;
SSL_SESSION* session_;
};
} // namespace node::quic
+288 -83
View File
@@ -1,7 +1,7 @@
#include "ngtcp2/ngtcp2.h"
#if HAVE_OPENSSL && HAVE_QUIC
#include "guard.h"
#ifndef OPENSSL_NO_QUIC
#include "streams.h"
#include <aliased_struct-inl.h>
#include <async_wrap-inl.h>
#include <base_object-inl.h>
@@ -9,18 +9,22 @@
#include <memory_tracker-inl.h>
#include <node_blob.h>
#include <node_bob-inl.h>
#include <node_file.h>
#include <node_sockaddr-inl.h>
#include "application.h"
#include "bindingdata.h"
#include "defs.h"
#include "session.h"
#include "streams.h"
namespace node {
using v8::Array;
using v8::ArrayBuffer;
using v8::ArrayBufferView;
using v8::BackingStore;
using v8::BigInt;
using v8::FunctionCallbackInfo;
using v8::Global;
using v8::Integer;
using v8::Just;
@@ -30,6 +34,7 @@ using v8::Nothing;
using v8::Object;
using v8::ObjectTemplate;
using v8::SharedArrayBuffer;
using v8::Uint32;
using v8::Uint8Array;
using v8::Value;
@@ -43,6 +48,7 @@ namespace quic {
V(READ_ENDED, read_ended, uint8_t) \
V(WRITE_ENDED, write_ended, uint8_t) \
V(RESET, reset, uint8_t) \
V(RESET_CODE, reset_code, uint64_t) \
V(HAS_OUTBOUND, has_outbound, uint8_t) \
V(HAS_READER, has_reader, uint8_t) \
/* Set when the stream has a block event handler */ \
@@ -52,7 +58,11 @@ namespace quic {
/* Set when the stream has a reset event handler */ \
V(WANTS_RESET, wants_reset, uint8_t) \
/* Set when the stream has a trailers event handler */ \
V(WANTS_TRAILERS, wants_trailers, uint8_t)
V(WANTS_TRAILERS, wants_trailers, uint8_t) \
/* True when 0-RTT early data was received */ \
V(RECEIVED_EARLY_DATA, received_early_data, uint8_t) \
V(WRITE_DESIRED_SIZE, write_desired_size, uint32_t) \
V(HIGH_WATER_MARK, high_water_mark, uint32_t)
#define STREAM_STATS(V) \
/* Marks the timestamp when the stream object was created. */ \
@@ -144,31 +154,23 @@ STAT_STRUCT(Stream, STREAM)
// ============================================================================
namespace {
// Creates an in-memory DataQueue entry from an ArrayBuffer by either
// detaching it (zero-copy) or copying its contents if detach is not
// possible (e.g., SharedArrayBuffer-backed or non-detachable).
// Returns nullptr on failure (error already thrown if allocation failed).
// Creates an in-memory DataQueue entry by copying the requested range of
// the given ArrayBuffer into a fresh BackingStore. The caller's buffer is
// not detached or otherwise modified, so callers can safely reuse or
// mutate it after the call returns. Callers that want to ensure their
// buffer cannot be mutated after handing it off can call
// `ArrayBuffer.prototype.transfer()` themselves before calling into the
// QUIC API.
// Returns nullptr on zero length or allocation failure.
std::unique_ptr<DataQueue::Entry> CreateEntryFromBuffer(
Environment* env, Local<ArrayBuffer> buffer, size_t offset, size_t length) {
if (length == 0) return nullptr;
std::shared_ptr<v8::BackingStore> backing;
if (buffer->IsDetachable()) {
backing = buffer->GetBackingStore();
if (buffer->Detach(Local<Value>()).IsNothing()) {
backing.reset();
}
}
if (!backing) {
// Buffer is not detachable or detach failed. Copy the data.
JS_TRY_ALLOCATE_BACKING_OR_RETURN(env, copy, length, nullptr);
memcpy(copy->Data(),
static_cast<const uint8_t*>(buffer->Data()) + offset,
length);
offset = 0;
backing = std::move(copy);
}
JS_TRY_ALLOCATE_BACKING_OR_RETURN(env, copy, length, nullptr);
memcpy(copy->Data(),
static_cast<const uint8_t*>(buffer->Data()) + offset,
length);
return DataQueue::CreateInMemoryEntryFromBackingStore(
std::move(backing), offset, length);
std::move(copy), 0, length);
}
} // namespace
@@ -226,10 +228,43 @@ Maybe<std::shared_ptr<DataQueue>> Stream::GetDataQueueFromSource(
JS_TRY_ALLOCATE_BACKING_OR_RETURN(
env, backing, str.length(), Nothing<std::shared_ptr<DataQueue>>());
memcpy(backing->Data(), *str, str.length());
auto len = backing->ByteLength();
entries.push_back(DataQueue::CreateInMemoryEntryFromBackingStore(
std::move(backing), 0, backing->ByteLength()));
std::move(backing), 0, len));
return Just(DataQueue::CreateIdempotent(std::move(entries)));
}
// FileHandle — create an fd-backed DataQueue from the file path.
// The JS side validates and locks the FileHandle before passing
// the C++ handle here. We detect FileHandle by checking if the
// object's constructor name is "FileHandle".
if (value->IsObject()) {
auto obj = value.As<Object>();
Local<v8::String> ctor_name;
auto maybe_name = obj->GetConstructorName();
if (!maybe_name.IsEmpty()) {
ctor_name = maybe_name;
Utf8Value name(env->isolate(), ctor_name);
if (strcmp(*name, "FileHandle") == 0) {
fs::FileHandle* file_handle;
ASSIGN_OR_RETURN_UNWRAP(
&file_handle, value, Nothing<std::shared_ptr<DataQueue>>());
Local<Value> path;
if (!v8::String::NewFromUtf8(env->isolate(),
file_handle->original_name().c_str())
.ToLocal(&path)) {
return Nothing<std::shared_ptr<DataQueue>>();
}
auto entry = DataQueue::CreateFdEntry(env, path);
if (!entry) return Nothing<std::shared_ptr<DataQueue>>();
size_t size = entry->size().value_or(0);
auto queue = DataQueue::Create();
if (!queue) return Nothing<std::shared_ptr<DataQueue>>();
queue->append(std::move(entry));
queue->cap(size);
return Just(std::move(queue));
}
}
}
// TODO(jasnell): Add streaming sources...
THROW_ERR_INVALID_ARG_TYPE(env, "Invalid data source type");
return Nothing<std::shared_ptr<DataQueue>>();
@@ -247,6 +282,14 @@ struct Stream::Impl {
std::shared_ptr<DataQueue> dataqueue;
if (GetDataQueueFromSource(env, args[0]).To(&dataqueue)) {
stream->set_outbound(std::move(dataqueue));
// set_outbound does not call ResumeStream because during
// construction the stream is not yet registered with the session.
// When attaching a source after creation (via setBody), the
// stream is already registered and must be resumed to enter the
// send queue.
if (!stream->is_pending()) {
stream->session().ResumeStream(stream->id());
}
}
}
@@ -254,7 +297,7 @@ struct Stream::Impl {
JS_METHOD(Destroy) {
Stream* stream;
ASSIGN_OR_RETURN_UNWRAP(&stream, args.This());
if (args.Length() > 1) {
if (args.Length() >= 1) {
CHECK(args[0]->IsBigInt());
bool lossless = false;
uint64_t code = args[0].As<BigInt>()->Uint64Value(&lossless);
@@ -272,8 +315,7 @@ struct Stream::Impl {
// Sends a block of headers to the peer. If the stream is not yet open,
// the headers will be queued and sent immediately when the stream is
// opened. If the application does not support sending headers on streams,
// they will be ignored and dropped on the floor.
// opened. Returns false if the application does not support headers.
JS_METHOD(SendHeaders) {
Stream* stream;
ASSIGN_OR_RETURN_UNWRAP(&stream, args.This());
@@ -287,8 +329,13 @@ struct Stream::Impl {
// If the stream is pending, the headers will be queued until the
// stream is opened, at which time the queued header block will be
// immediately sent when the stream is opened.
// immediately sent when the stream is opened. If we already know
// that the application does not support headers, return false
// immediately so the JS side can throw an appropriate error.
if (stream->is_pending()) {
if (!stream->session().application().SupportsHeaders()) {
return args.GetReturnValue().Set(false);
}
stream->EnqueuePendingHeaders(kind, headers, flags);
return args.GetReturnValue().Set(true);
}
@@ -355,18 +402,22 @@ struct Stream::Impl {
JS_METHOD(SetPriority) {
Stream* stream;
ASSIGN_OR_RETURN_UNWRAP(&stream, args.This());
CHECK(args[0]->IsUint32()); // Priority
CHECK(args[1]->IsUint32()); // Priority flag
CHECK(args[0]->IsUint32()); // Packed: (urgency << 1) | incremental
StreamPriority priority = FromV8Value<StreamPriority>(args[0]);
StreamPriorityFlags flags = FromV8Value<StreamPriorityFlags>(args[1]);
uint32_t packed = args[0].As<Uint32>()->Value();
StreamPriority priority = static_cast<StreamPriority>(packed >> 1);
StreamPriorityFlags flags = (packed & 1)
? StreamPriorityFlags::INCREMENTAL
: StreamPriorityFlags::NON_INCREMENTAL;
if (stream->is_pending()) {
stream->pending_priority_ = PendingPriority{
.priority = priority,
.flags = flags,
};
} else {
// Always update the stored priority on the stream.
stream->priority_ = StoredPriority{
.priority = priority,
.flags = flags,
.pending = stream->is_pending(),
};
if (!stream->is_pending()) {
stream->session().application().SetStreamPriority(
*stream, priority, flags);
}
@@ -376,13 +427,23 @@ struct Stream::Impl {
Stream* stream;
ASSIGN_OR_RETURN_UNWRAP(&stream, args.This());
if (stream->is_pending()) {
return args.GetReturnValue().Set(
static_cast<uint32_t>(StreamPriority::DEFAULT));
// On the client side, priority is always read from the stream's
// stored value since the client is the one setting it. On the
// server side, we delegate to the application which can read
// the peer's requested priority (e.g., from PRIORITY_UPDATE
// frames in HTTP/3).
if (!stream->session().is_server()) {
auto& pri = stream->priority_;
uint32_t packed = (static_cast<uint32_t>(pri.priority) << 1) |
(pri.flags == StreamPriorityFlags::INCREMENTAL ? 1 : 0);
return args.GetReturnValue().Set(packed);
}
auto priority = stream->session().application().GetStreamPriority(*stream);
args.GetReturnValue().Set(static_cast<uint32_t>(priority));
auto result = stream->session().application().GetStreamPriority(*stream);
uint32_t packed =
(static_cast<uint32_t>(result.priority) << 1) |
(result.flags == StreamPriorityFlags::INCREMENTAL ? 1 : 0);
args.GetReturnValue().Set(packed);
}
// Returns a Blob::Reader that can be used to read data that has been
@@ -391,9 +452,9 @@ struct Stream::Impl {
Stream* stream;
ASSIGN_OR_RETURN_UNWRAP(&stream, args.This());
BaseObjectPtr<Blob::Reader> reader = stream->get_reader();
if (reader) return args.GetReturnValue().Set(reader->object());
THROW_ERR_INVALID_STATE(Environment::GetCurrent(args),
"Unable to get a reader for the stream");
if (reader) args.GetReturnValue().Set(reader->object());
// Returns undefined when the stream is not readable (e.g. a local
// unidirectional stream). The JS side checks for this.
}
JS_METHOD(InitStreamingSource) {
@@ -505,13 +566,24 @@ class Stream::Outbound final : public MemoryRetainer {
bool is_streaming() const { return streaming_; }
size_t total() const { return total_; }
size_t uncommitted() const { return uncommitted_; }
// Total bytes in the pipeline: data appended to the DataQueue that
// hasn't been pulled yet, plus data pulled but not yet acknowledged.
// This is the number to compare against highWaterMark for backpressure.
size_t queued_bytes() const { return queued_ + total_; }
// Appends an entry to the underlying DataQueue. Only valid when
// the Outbound was created in streaming mode.
bool AppendEntry(std::unique_ptr<DataQueue::Entry> entry) {
if (!streaming_ || !queue_) return false;
auto size = entry->size();
auto result = queue_->append(std::move(entry));
return result.has_value() && result.value();
if (result.has_value() && result.value()) {
if (size.has_value()) queued_ += size.value();
return true;
}
return false;
}
int Pull(bob::Next<ngtcp2_vec> next,
@@ -520,6 +592,14 @@ class Stream::Outbound final : public MemoryRetainer {
size_t count,
size_t max_count_hint) {
if (next_pending_) {
// An async read is in flight, but there may be uncommitted bytes
// from a previous read that ngtcp2 didn't accept (nwrite=0 due
// to pacing/congestion). Return those bytes so the send loop can
// retry rather than blocking until the async read completes.
if (uncommitted_ > 0) {
PullUncommitted(std::move(next));
return bob::Status::STATUS_CONTINUE;
}
std::move(next)(bob::Status::STATUS_BLOCK, nullptr, 0, [](int) {});
return bob::Status::STATUS_BLOCK;
}
@@ -557,9 +637,6 @@ class Stream::Outbound final : public MemoryRetainer {
// that the pull is sync but allow for it to be async.
int ret = reader_->Pull(
[this](auto status, auto vecs, auto count, auto done) {
// Always make sure next_pending_ is false when we're done.
auto on_exit = OnScopeLeave([this] { next_pending_ = false; });
// The status should never be wait here.
DCHECK_NE(status, bob::Status::STATUS_WAIT);
@@ -568,6 +645,7 @@ class Stream::Outbound final : public MemoryRetainer {
// being asynchronous, our stream is blocking waiting for the data,
// but we have an error! oh no! We need to error the stream.
if (next_pending_) {
next_pending_ = false;
stream_->Destroy(
QuicError::ForNgtcp2Error(NGTCP2_INTERNAL_ERROR));
// We do not need to worry about calling MarkErrored in this case
@@ -586,7 +664,10 @@ class Stream::Outbound final : public MemoryRetainer {
// Here, there is no more data to read, but we will might have data
// in the uncommitted queue. We'll resume the stream so that the
// session will try to read from it again.
// We must clear next_pending_ before calling ResumeStream because
// ResumeStream can synchronously re-enter Outbound::Pull.
if (next_pending_) {
next_pending_ = false;
stream_->session().ResumeStream(stream_->id());
}
return;
@@ -610,7 +691,10 @@ class Stream::Outbound final : public MemoryRetainer {
// being asynchronous, our stream is blocking waiting for the data.
// Now that we have data, let's resume the stream so the session will
// pull from it again.
// We must clear next_pending_ before calling ResumeStream because
// ResumeStream can synchronously re-enter Outbound::Pull.
if (next_pending_) {
next_pending_ = false;
stream_->session().ResumeStream(stream_->id());
}
},
@@ -667,9 +751,17 @@ class Stream::Outbound final : public MemoryRetainer {
// Reads here are generally expected to be synchronous. If we have a reader
// that insists on providing data asynchronously, then we'll have to block
// until the data is actually available.
// until the data is actually available. However, if there are uncommitted
// bytes already buffered (from a previous async read), return those now
// rather than blocking — the async callback will resume the stream when
// more data arrives.
if (ret == bob::Status::STATUS_WAIT) {
next_pending_ = true;
if (uncommitted_ > 0) {
PullUncommitted(std::move(next));
return bob::Status::STATUS_CONTINUE;
}
std::move(next)(bob::Status::STATUS_BLOCK, nullptr, 0, [](int) {});
return bob::Status::STATUS_BLOCK;
}
@@ -751,6 +843,11 @@ class Stream::Outbound final : public MemoryRetainer {
count_++;
total_ += vectors[n].len;
uncommitted_ += vectors[n].len;
if (queued_ >= vectors[n].len) {
queued_ -= vectors[n].len;
} else {
queued_ = 0;
}
}
}
@@ -797,6 +894,10 @@ class Stream::Outbound final : public MemoryRetainer {
// waiting to be acknowledged. When we receive acknowledgement, we will
// automatically free held bytes from the buffer.
size_t uncommitted_ = 0;
// Bytes appended to the DataQueue that haven't been pulled yet.
// Decremented in Pull() when data moves from the queue to the buffer.
size_t queued_ = 0;
};
// ============================================================================
@@ -865,7 +966,6 @@ void Stream::InitPerContext(Realm* realm, Local<Object> target) {
}
Stream* Stream::From(void* stream_user_data) {
DCHECK_NOT_NULL(stream_user_data);
return static_cast<Stream*>(stream_user_data);
}
@@ -913,6 +1013,7 @@ Stream::Stream(BaseObjectWeakPtr<Session> session,
set_outbound(std::move(source));
STAT_RECORD_TIMESTAMP(Stats, created_at);
auto params = ngtcp2_conn_get_local_transport_params(this->session());
STAT_SET(Stats, max_offset, params->initial_max_data);
STAT_SET(Stats, opened_at, stats_->created_at);
@@ -945,6 +1046,7 @@ Stream::Stream(BaseObjectWeakPtr<Session> session,
set_outbound(std::move(source));
STAT_RECORD_TIMESTAMP(Stats, created_at);
auto params = ngtcp2_conn_get_local_transport_params(this->session());
STAT_SET(Stats, max_offset, params->initial_max_data);
}
@@ -969,27 +1071,36 @@ void Stream::NotifyStreamOpened(stream_id id) {
CHECK_EQ(ngtcp2_conn_set_stream_user_data(this->session(), id, this), 0);
maybe_pending_stream_.reset();
if (pending_priority_) {
auto& priority = pending_priority_.value();
if (priority_.pending) {
session().application().SetStreamPriority(
*this, priority.priority, priority.flags);
pending_priority_ = std::nullopt;
*this, priority_.priority, priority_.flags);
priority_.pending = false;
}
decltype(pending_headers_queue_) queue;
pending_headers_queue_.swap(queue);
for (auto& headers : queue) {
// TODO(@jasnell): What if the application does not support headers?
session().application().SendHeaders(*this,
headers->kind,
headers->headers.Get(env()->isolate()),
headers->flags);
if (!pending_headers_queue_.empty()) {
if (!session().application().SupportsHeaders()) {
// Headers were enqueued while the application was not yet known
// (headers_supported == 0), and the negotiated application does
// not support headers. This is a fatal mismatch.
Destroy(QuicError::ForApplication(0));
return;
}
decltype(pending_headers_queue_) queue;
pending_headers_queue_.swap(queue);
for (auto& headers : queue) {
session().application().SendHeaders(
*this,
headers->kind,
headers->headers.Get(env()->isolate()),
headers->flags);
}
}
// If the stream is not a local undirectional stream and is_readable is
// false, then we should shutdown the streams readable side now.
if (!is_local_unidirectional() && !is_readable()) {
NotifyReadableEnded(pending_close_read_code_);
}
if (!is_remote_unidirectional() && !is_writable()) {
if (!is_remote_unidirectional() && !is_writable() &&
!session_->application().stream_fin_managed_by_application()) {
NotifyWritableEnded(pending_close_write_code_);
}
@@ -1003,7 +1114,7 @@ void Stream::NotifyStreamOpened(stream_id id) {
void Stream::NotifyReadableEnded(error_code code) {
CHECK(!is_pending());
Session::SendPendingDataScope send_scope(&session());
ngtcp2_conn_shutdown_stream_read(session(), 0, id(), code);
CHECK_EQ(ngtcp2_conn_shutdown_stream_read(session(), 0, id(), code), 0);
}
void Stream::NotifyWritableEnded(error_code code) {
@@ -1061,6 +1172,14 @@ bool Stream::is_eos() const {
return state_->fin_sent;
}
bool Stream::wants_trailers() const {
return state_->wants_trailers;
}
void Stream::set_early() {
state_->received_early_data = 1;
}
bool Stream::is_writable() const {
// Remote unidirectional streams are never writable, and remote streams can
// never be pending.
@@ -1071,6 +1190,18 @@ bool Stream::is_writable() const {
return state_->write_ended == 0;
}
bool Stream::has_outbound() const {
return outbound_ != nullptr;
}
bool Stream::has_reader() const {
return reader_ != nullptr;
}
Blob::Reader* Stream::reader() const {
return reader_.get();
}
bool Stream::is_readable() const {
// Local unidirectional streams are never readable, and remote streams can
// never be pending.
@@ -1102,7 +1233,11 @@ void Stream::set_outbound(std::shared_ptr<DataQueue> source) {
DCHECK_NULL(outbound_);
outbound_ = std::make_unique<Outbound>(this, std::move(source));
state_->has_outbound = 1;
if (!is_pending()) session_->ResumeStream(id());
// Note: We intentionally do NOT call ResumeStream here. During
// construction, the stream has not yet been added to the session's
// streams map, so FindStream would fail. The caller (CreateStream /
// AddStream) is responsible for calling ResumeStream after the
// stream is registered.
}
void Stream::InitStreaming() {
@@ -1120,7 +1255,7 @@ void Stream::InitStreaming() {
if (!is_pending()) session_->ResumeStream(id());
}
void Stream::WriteStreamData(const v8::FunctionCallbackInfo<v8::Value>& args) {
void Stream::WriteStreamData(const FunctionCallbackInfo<Value>& args) {
auto env = this->env();
if (outbound_ == nullptr || !outbound_->is_streaming()) {
return THROW_ERR_INVALID_STATE(env, "Streaming source is not initialized");
@@ -1161,6 +1296,7 @@ void Stream::WriteStreamData(const v8::FunctionCallbackInfo<v8::Value>& args) {
if (!is_pending()) session_->ResumeStream(id());
UpdateWriteDesiredSize();
args.GetReturnValue().Set(static_cast<double>(outbound_->total()));
}
@@ -1179,9 +1315,8 @@ void Stream::EndWriting() {
}
void Stream::EntryRead(size_t amount) {
// Tells us that amount bytes we're reading from inbound_
// We use this as a signal to extend the flow control
// window to receive more bytes.
// Called when the JS consumer reads data from the inbound DataQueue.
// Extend the flow control window so the sender can transmit more.
session().ExtendStreamOffset(id(), amount);
session().ExtendOffset(amount);
}
@@ -1250,12 +1385,12 @@ void Stream::Acknowledge(size_t datalen) {
// ngtcp2 guarantees that offset must always be greater than the previously
// received offset.
DCHECK_GE(datalen, STAT_GET(Stats, max_offset_ack));
STAT_SET(Stats, max_offset_ack, datalen);
STAT_INCREMENT_N(Stats, max_offset_ack, datalen);
// Consumes the given number of bytes in the buffer.
outbound_->Acknowledge(datalen);
STAT_RECORD_TIMESTAMP(Stats, acked_at);
UpdateWriteDesiredSize();
}
void Stream::Commit(size_t datalen, bool fin) {
@@ -1280,8 +1415,10 @@ void Stream::EndReadable(std::optional<uint64_t> maybe_final_size) {
state_->read_ended = 1;
set_final_size(maybe_final_size.value_or(STAT_GET(Stats, bytes_received)));
inbound_->cap(STAT_GET(Stats, final_size));
// Notify the JS reader so it can see EOS.
if (reader_) reader_->NotifyPull();
// Notify the JS reader so it can see EOS. Pass fin=true so the
// wakeup promise resolves with a value the iterator can check to
// avoid waiting for another wakeup that will never come.
if (reader_) reader_->NotifyPull(true);
}
void Stream::Destroy(QuicError error) {
@@ -1327,7 +1464,9 @@ void Stream::Destroy(QuicError error) {
auto session = session_;
session_.reset();
session->RemoveStream(id());
// EmitClose above triggers MakeCallback which can destroy the session
// via JS re-entrancy. The weak pointer may now be null.
if (session) session->RemoveStream(id());
// Critically, make sure that the RemoveStream call is the last thing
// trying to use this stream object. Once that call is made, the stream
@@ -1342,14 +1481,13 @@ void Stream::ReceiveData(const uint8_t* data,
ReceiveDataFlags flags) {
// If reading has ended, or there is no data, there's nothing to do but maybe
// end the readable side if this is the last bit of data we've received.
Debug(this, "Receiving %zu bytes of data", len);
if (state_->read_ended == 1 || len == 0) {
if (flags.fin) EndReadable();
return;
}
if (flags.early) state_->received_early_data = 1;
STAT_INCREMENT_N(Stats, bytes_received, len);
STAT_SET(Stats, max_offset_received, STAT_GET(Stats, bytes_received));
STAT_RECORD_TIMESTAMP(Stats, received_at);
@@ -1365,12 +1503,19 @@ void Stream::ReceiveData(const uint8_t* data,
}
void Stream::ReceiveStopSending(QuicError error) {
// Note that this comes from *this* endpoint, not the other side. We handle it
// if we haven't already shutdown our *receiving* side of the stream.
if (state_->read_ended) return;
// STOP_SENDING from the peer asks us to stop sending. Per RFC 9000
// §3.5 the receiver SHOULD respond with RESET_STREAM, which is what
// ngtcp2_conn_shutdown_stream_write below schedules. If our
// writable side has already been shut down (e.g. we already sent
// RESET_STREAM ourselves or finished sending with FIN) there is
// nothing more to do here. The previous guard checked
// `state_->read_ended` which is unrelated to the writable side and
// suppressed STOP_SENDING handling whenever a sibling RESET_STREAM
// frame had been processed first within the same packet.
if (state_->write_ended) return;
Debug(this, "Received stop sending with error %s", error);
ngtcp2_conn_shutdown_stream_read(session(), 0, id(), error.code());
EndReadable();
ngtcp2_conn_shutdown_stream_write(session(), 0, id(), error.code());
EndWritable();
}
void Stream::ReceiveStreamReset(uint64_t final_size, QuicError error) {
@@ -1383,6 +1528,7 @@ void Stream::ReceiveStreamReset(uint64_t final_size, QuicError error) {
"Received stream reset with final size %" PRIu64 " and error %s",
final_size,
error);
state_->reset_code = error.code();
EndReadable(final_size);
EmitReset(error);
}
@@ -1400,6 +1546,58 @@ void Stream::EmitBlocked() {
MakeCallback(BindingData::Get(env()).stream_blocked_callback(), 0, nullptr);
}
void Stream::EmitDrain() {
if (!env()->can_call_into_js()) return;
CallbackScope<Stream> cb_scope(this);
MakeCallback(BindingData::Get(env()).stream_drain_callback(), 0, nullptr);
}
void Stream::UpdateWriteDesiredSize() {
if (!outbound_ || !outbound_->is_streaming()) return;
uint64_t available;
uint64_t hwm = state_->high_water_mark;
if (is_pending()) {
// Pending streams don't have a stream ID yet, so ngtcp2 can't
// report their flow control window. Use the high water mark as
// the available capacity so writes can proceed while pending.
available = hwm > 0 ? hwm : std::numeric_limits<uint32_t>::max();
} else {
// Calculate available capacity based on QUIC flow control.
// The effective limit is the minimum of stream-level and
// connection-level flow control remaining.
ngtcp2_conn* conn = session();
uint64_t stream_left = ngtcp2_conn_get_max_stream_data_left(conn, id());
uint64_t conn_left = ngtcp2_conn_get_max_data_left(conn);
available = std::min(stream_left, conn_left);
// Apply the high water mark as an additional ceiling.
if (hwm > 0) {
available = std::min(available, hwm);
}
}
// Total bytes in the pipeline: data in the DataQueue (not yet pulled by
// ngtcp2) plus data pulled but not yet acknowledged. Using queued_bytes()
// ensures that data appended via writeSync is accounted for in
// backpressure even before ngtcp2 pulls it.
uint64_t buffered = outbound_->queued_bytes();
uint64_t desired = (available > buffered) ? (available - buffered) : 0;
// Clamp to uint32 range since write_desired_size is uint32_t.
uint32_t clamped = static_cast<uint32_t>(
std::min<uint64_t>(desired, std::numeric_limits<uint32_t>::max()));
uint32_t old_size = state_->write_desired_size;
state_->write_desired_size = clamped;
// Fire drain when transitioning from 0 to non-zero
if (old_size == 0 && desired > 0) {
EmitDrain();
}
}
void Stream::EmitClose(const QuicError& error) {
if (!env()->can_call_into_js()) return;
CallbackScope<Stream> cb_scope(this);
@@ -1458,6 +1656,13 @@ void Stream::Schedule(Queue* queue) {
if (outbound_ && stream_queue_.IsEmpty()) queue->PushBack(this);
}
void Stream::Unschedule() {
// Remove this stream from the send queue. Used when the stream becomes
// flow-control blocked so that SendPendingData does not spin retrying it.
Debug(this, "Unscheduled");
stream_queue_.Remove();
}
} // namespace quic
} // namespace node
+31 -4
View File
@@ -218,12 +218,27 @@ class Stream final : public AsyncWrap,
// data to be acknowledged by the remote peer.
bool is_eos() const;
// True if the stream wants to send trailing headers after the body.
bool wants_trailers() const;
// Marks this stream as having received 0-RTT early data.
void set_early();
// True if this stream is still in a readable state.
bool is_readable() const;
// True if this stream is still in a writable state.
bool is_writable() const;
// True if an outbound data source has been configured.
bool has_outbound() const;
// True if a Blob::Reader has been created for the inbound data.
bool has_reader() const;
// Returns the Blob::Reader for the inbound data, or nullptr.
Blob::Reader* reader() const;
// Called by the session/application to indicate that the specified number
// of bytes have been acknowledged by the peer.
void Acknowledge(size_t datalen);
@@ -326,6 +341,14 @@ class Stream final : public AsyncWrap,
// blocked because of flow control restriction.
void EmitBlocked();
// Notifies the JavaScript side that the outbound buffer has capacity
// for more data. Fires when write_desired_size transitions from 0 to > 0.
void EmitDrain();
// Updates the write_desired_size state field based on current flow control
// and outbound buffer state. Emits drain if transitioning from 0 to > 0.
void UpdateWriteDesiredSize();
// Delivers the set of inbound headers that have been collected.
void EmitHeaders();
@@ -355,11 +378,14 @@ class Stream final : public AsyncWrap,
error_code pending_close_read_code_ = 0;
error_code pending_close_write_code_ = 0;
struct PendingPriority {
StreamPriority priority;
StreamPriorityFlags flags;
struct StoredPriority {
StreamPriority priority = StreamPriority::DEFAULT;
StreamPriorityFlags flags = StreamPriorityFlags::NON_INCREMENTAL;
bool pending = false;
};
std::optional<PendingPriority> pending_priority_ = std::nullopt;
StoredPriority priority_;
const StoredPriority& stored_priority() const { return priority_; }
// The headers_ field holds a block of headers that have been received and
// are being buffered for delivery to the JavaScript side.
@@ -393,6 +419,7 @@ class Stream final : public AsyncWrap,
using Queue = ListHead<Stream, &Stream::stream_queue_>;
void Schedule(Queue* queue);
void Unschedule();
};
} // namespace node::quic
+28 -11
View File
@@ -631,8 +631,16 @@ int TLSContext::OnSNI(SSL* ssl, int* ad, void* arg) {
auto it = default_ctx->sni_contexts_.find(servername);
if (it != default_ctx->sni_contexts_.end()) {
SSL_set_SSL_CTX(ssl, it->second->ctx_.get());
return SSL_TLSEXT_ERR_OK;
}
}
// No matching hostname found. If the default context has a certificate
// (from the sni['*'] wildcard identity), fall through to use it.
// Otherwise, reject the connection with an unrecognized_name alert.
if (SSL_CTX_get0_certificate(default_ctx->ctx_.get()) == nullptr) {
*ad = SSL_AD_UNRECOGNIZED_NAME;
return SSL_TLSEXT_ERR_ALERT_FATAL;
}
return SSL_TLSEXT_ERR_OK;
}
@@ -697,9 +705,10 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
if (!SET(verify_client) || !SET(reject_unauthorized) ||
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
!SET(servername) || !SET(ciphers) || !SET(groups) ||
!SET(verify_private_key) || !SET(keylog) ||
!SET_VECTOR(crypto::KeyObjectData, keys) || !SET_VECTOR(Store, certs) ||
!SET_VECTOR(Store, ca) || !SET_VECTOR(Store, crl)) {
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
!SET_VECTOR(Store, certs) || !SET_VECTOR(Store, ca) ||
!SET_VECTOR(Store, crl)) {
return Nothing<Options>();
}
@@ -840,15 +849,23 @@ void TLSSession::Initialize(
// The early data will just be ignored if it's invalid.
if (ossl_context_.set_session_ticket(ticket)) {
ngtcp2_vec rtp = sessionTicket.transport_params();
if (ngtcp2_conn_decode_and_set_0rtt_transport_params(
*session_, rtp.base, rtp.len) == 0) {
if (!ossl_context_.set_early_data_enabled()) {
validation_error_ = "Failed to enable early data";
ossl_context_.reset();
return;
// Only enable 0-RTT if the option allows it. The session
// ticket is still used for TLS resumption (1-RTT) either way.
if (options.enable_early_data) {
ngtcp2_vec rtp = sessionTicket.transport_params();
if (ngtcp2_conn_decode_and_set_0rtt_transport_params(
*session_, rtp.base, rtp.len) == 0) {
if (!ossl_context_.set_early_data_enabled()) {
validation_error_ = "Failed to enable early data";
ossl_context_.reset();
return;
}
session_->SetStreamOpenAllowed();
// Populate the state buffer from the 0-RTT transport
// params so that maxDatagramSize and other values are
// available before the handshake completes.
session_->PopulateEarlyTransportParamsState();
}
session_->SetStreamOpenAllowed();
}
}
}
+9
View File
@@ -241,6 +241,15 @@ class TLSContext final : public MemoryRetainer,
// JavaScript option name "crl"
std::vector<Store> crl;
// The port to advertise in ORIGIN frames for this hostname.
// Defaults to 443 (the standard HTTPS port). Only relevant for
// server-side SNI entries used with HTTP/3.
uint16_t port = 443;
// Whether this hostname should be included in ORIGIN frames.
// Only relevant for server-side SNI entries.
bool authoritative = true;
void MemoryInfo(MemoryTracker* tracker) const override;
SET_MEMORY_INFO_NAME(TLSContext::Options)
SET_SELF_SIZE(Options)
+30 -13
View File
@@ -61,42 +61,59 @@ std::string TokenSecret::ToString() const {
// ============================================================================
// StatelessResetToken
StatelessResetToken::StatelessResetToken() : ptr_(nullptr), buf_() {}
StatelessResetToken::StatelessResetToken()
: ngtcp2_stateless_reset_token(), ptr_(nullptr) {}
StatelessResetToken::StatelessResetToken(const uint8_t* token) : ptr_(token) {}
StatelessResetToken::StatelessResetToken(const uint8_t* token)
: ptr_(reinterpret_cast<const ngtcp2_stateless_reset_token*>(token)) {}
StatelessResetToken::StatelessResetToken(
const ngtcp2_stateless_reset_token* token)
: ptr_(token) {}
StatelessResetToken::StatelessResetToken(const TokenSecret& secret,
const CID& cid)
: ptr_(buf_) {
: ptr_(this) {
CHECK_EQ(ngtcp2_crypto_generate_stateless_reset_token(
buf_, secret, kStatelessTokenLen, cid),
data, secret, kStatelessTokenLen, cid),
0);
}
StatelessResetToken::StatelessResetToken(uint8_t* token,
const TokenSecret& secret,
const CID& cid)
: ptr_(token) {
: ptr_(reinterpret_cast<const ngtcp2_stateless_reset_token*>(token)) {
CHECK_EQ(ngtcp2_crypto_generate_stateless_reset_token(
token, secret, kStatelessTokenLen, cid),
0);
}
StatelessResetToken::StatelessResetToken(ngtcp2_stateless_reset_token* token,
const TokenSecret& secret,
const CID& cid)
: ptr_(token) {
CHECK_EQ(ngtcp2_crypto_generate_stateless_reset_token(
token->data, secret, kStatelessTokenLen, cid),
0);
}
StatelessResetToken::StatelessResetToken(const StatelessResetToken& other)
: ptr_(buf_) {
: ngtcp2_stateless_reset_token(), ptr_(other ? this : nullptr) {
if (other) {
memcpy(buf_, other.ptr_, kStatelessTokenLen);
} else {
ptr_ = nullptr;
memcpy(data, other.ptr_->data, kStatelessTokenLen);
}
}
StatelessResetToken::operator const uint8_t*() const {
return ptr_ != nullptr ? ptr_ : buf_;
return ptr_ != nullptr ? ptr_->data : data;
}
StatelessResetToken::operator const ngtcp2_stateless_reset_token*() const {
return ptr_;
}
StatelessResetToken::operator const char*() const {
return reinterpret_cast<const char*>(ptr_ != nullptr ? ptr_ : buf_);
return reinterpret_cast<const char*>(ptr_ != nullptr ? ptr_->data : data);
}
StatelessResetToken::operator bool() const {
@@ -109,7 +126,7 @@ bool StatelessResetToken::operator==(const StatelessResetToken& other) const {
(ptr_ != nullptr && other.ptr_ == nullptr)) {
return false;
}
return CRYPTO_memcmp(ptr_, other.ptr_, kStatelessTokenLen) == 0;
return CRYPTO_memcmp(ptr_->data, other.ptr_->data, kStatelessTokenLen) == 0;
}
bool StatelessResetToken::operator!=(const StatelessResetToken& other) const {
@@ -128,7 +145,7 @@ std::string StatelessResetToken::ToString() const {
size_t StatelessResetToken::Hash::operator()(
const StatelessResetToken& token) const {
if (token.ptr_ == nullptr) return 0;
return HashBytes(token.ptr_, kStatelessTokenLen);
return HashBytes(token.ptr_->data, kStatelessTokenLen);
}
StatelessResetToken StatelessResetToken::kInvalid;
+16 -11
View File
@@ -70,7 +70,8 @@ class TokenSecret final : public MemoryRetainer {
//
// StatlessResetTokens are always kStatelessTokenLen bytes,
// as are the secrets used to generate the token.
class StatelessResetToken final : public MemoryRetainer {
class StatelessResetToken final : public ngtcp2_stateless_reset_token,
public MemoryRetainer {
public:
static constexpr int kStatelessTokenLen = NGTCP2_STATELESS_RESET_TOKENLEN;
@@ -78,30 +79,35 @@ class StatelessResetToken final : public MemoryRetainer {
// Generates a stateless reset token using HKDF with the cid and token secret
// as input. The token secret is either provided by user code when an Endpoint
// is created or is generated randomly.
// is created or is generated randomly. The token is stored in the inherited
// ngtcp2_stateless_reset_token::data and ptr_ is set to this.
StatelessResetToken(const TokenSecret& secret, const CID& cid);
// Generates a stateless reset token using the given token storage.
// Generates a stateless reset token into the given external storage.
// The StatelessResetToken wraps the token and does not take ownership.
// The token storage must be at least kStatelessTokenLen bytes in length.
// The length is not verified so care must be taken when using this
// constructor.
StatelessResetToken(uint8_t* token,
const TokenSecret& secret,
const CID& cid);
// Generates a stateless reset token into the given external storage.
// The StatelessResetToken wraps the token and does not take ownership.
StatelessResetToken(ngtcp2_stateless_reset_token* token,
const TokenSecret& secret,
const CID& cid);
// Wraps the given token. Does not take over ownership of the token storage.
// The token must be at least kStatelessTokenLen bytes in length.
// The length is not verified so care must be taken when using this
// constructor.
explicit StatelessResetToken(const uint8_t* token);
// Wraps the given token. Does not take over ownership of the token storage.
explicit StatelessResetToken(const ngtcp2_stateless_reset_token* token);
StatelessResetToken(const StatelessResetToken& other);
DISALLOW_MOVE(StatelessResetToken)
std::string ToString() const;
operator const uint8_t*() const;
operator const ngtcp2_stateless_reset_token*() const;
operator bool() const;
bool operator==(const StatelessResetToken& other) const;
@@ -124,8 +130,7 @@ class StatelessResetToken final : public MemoryRetainer {
private:
operator const char*() const;
const uint8_t* ptr_;
uint8_t buf_[NGTCP2_STATELESS_RESET_TOKENLEN];
const ngtcp2_stateless_reset_token* ptr_;
};
// A RETRY packet communicates a retry token to the client. Retry tokens are
+58 -10
View File
@@ -1,6 +1,8 @@
#if HAVE_OPENSSL && HAVE_QUIC
#include "guard.h"
#ifndef OPENSSL_NO_QUIC
#include <async_wrap-inl.h>
#include <base_object-inl.h>
#include <env-inl.h>
#include <memory_tracker-inl.h>
#include <node_sockaddr-inl.h>
@@ -10,6 +12,7 @@
#include "defs.h"
#include "endpoint.h"
#include "session.h"
#include "session_manager.h"
#include "tokens.h"
#include "transportparams.h"
@@ -69,10 +72,49 @@ Maybe<TransportParams::Options> TransportParams::Options::From(
#undef SET
// TODO(@jasnell): We are not yet exposing the ability to set the preferred
// adddress via the options, tho the underlying support is here in the class.
options.preferred_address_ipv4 = std::nullopt;
options.preferred_address_ipv6 = std::nullopt;
// Parse the preferred address options. These are SocketAddress objects
// (or undefined to skip). Only meaningful for server sessions.
Local<Value> preferred_ipv4;
if (!params->Get(env->context(), state.preferred_address_ipv4_string())
.ToLocal(&preferred_ipv4)) {
return Nothing<Options>();
}
if (!preferred_ipv4->IsUndefined()) {
if (!SocketAddressBase::HasInstance(env, preferred_ipv4)) {
THROW_ERR_INVALID_ARG_TYPE(
env, "transportParams.preferredAddressIpv4 must be a SocketAddress");
return Nothing<Options>();
}
auto* addr = BaseObject::FromJSObject<SocketAddressBase>(
preferred_ipv4.As<Object>());
if (addr->address()->family() != AF_INET) {
THROW_ERR_INVALID_ARG_VALUE(
env, "transportParams.preferredAddressIpv4 must be an IPv4 address");
return Nothing<Options>();
}
options.preferred_address_ipv4 = *addr->address();
}
Local<Value> preferred_ipv6;
if (!params->Get(env->context(), state.preferred_address_ipv6_string())
.ToLocal(&preferred_ipv6)) {
return Nothing<Options>();
}
if (!preferred_ipv6->IsUndefined()) {
if (!SocketAddressBase::HasInstance(env, preferred_ipv6)) {
THROW_ERR_INVALID_ARG_TYPE(
env, "transportParams.preferredAddressIpv6 must be a SocketAddress");
return Nothing<Options>();
}
auto* addr = BaseObject::FromJSObject<SocketAddressBase>(
preferred_ipv6.As<Object>());
if (addr->address()->family() != AF_INET6) {
THROW_ERR_INVALID_ARG_VALUE(
env, "transportParams.preferredAddressIpv6 must be an IPv6 address");
return Nothing<Options>();
}
options.preferred_address_ipv6 = *addr->address();
}
return Just<Options>(options);
}
@@ -113,8 +155,6 @@ std::string TransportParams::Options::ToString() const {
res += prefix + "max ack delay: " + std::to_string(max_ack_delay);
res += prefix +
"max datagram frame size: " + std::to_string(max_datagram_frame_size);
res += prefix + "disable active migration: " +
(disable_active_migration ? std::string("yes") : std::string("no"));
res += indent.Close();
return res;
}
@@ -151,8 +191,8 @@ TransportParams::TransportParams(const Config& config, const Options& options)
SET_PARAM(ack_delay_exponent);
SET_PARAM(max_datagram_frame_size);
SET_PARAM_V(max_idle_timeout, options.max_idle_timeout * NGTCP2_SECONDS);
SET_PARAM_V(disable_active_migration,
options.disable_active_migration ? 1 : 0);
SET_PARAM_V(disable_active_migration, 0);
SET_PARAM_V(grease_quic_bit, 1);
SET_PARAM_V(preferred_addr_present, 0);
SET_PARAM_V(stateless_reset_token_present, 0);
SET_PARAM_V(retry_scid_present, 0);
@@ -172,11 +212,13 @@ TransportParams::TransportParams(const Config& config, const Options& options)
#undef SET_PARAM
#undef SET_PARAM_V
if (options.preferred_address_ipv4.has_value())
if (options.preferred_address_ipv4.has_value()) {
SetPreferredAddress(options.preferred_address_ipv4.value());
}
if (options.preferred_address_ipv6.has_value())
if (options.preferred_address_ipv6.has_value()) {
SetPreferredAddress(options.preferred_address_ipv6.value());
}
}
TransportParams::TransportParams(const ngtcp2_vec& vec, Version version)
@@ -288,6 +330,12 @@ void TransportParams::GeneratePreferredAddressToken(Session* session) {
params_.preferred_addr.stateless_reset_token,
config.preferred_address_cid),
session);
// Register the preferred address CID with SessionManager for
// cross-endpoint routing. This is a locally-generated CID that needs
// to be routable from the preferred address endpoint (which may be
// different from the primary endpoint).
auto& mgr = BindingData::Get(session->env()).session_manager();
mgr.AssociateCID(config.preferred_address_cid, config.scid);
}
}
+1 -8
View File
@@ -114,16 +114,9 @@ class TransportParams final {
// The maximum size of DATAGRAM frames that the endpoint will accept.
// Setting the value to 0 will disable DATAGRAM support.
// https://datatracker.ietf.org/doc/html/rfc9221#section-3
uint64_t max_datagram_frame_size = kDefaultMaxPacketLength;
uint16_t max_datagram_frame_size = kDefaultMaxPacketLength;
// When true, communicates that the Session does not support active
// connection migration. See the QUIC specification for more details on
// connection migration.
// https://www.rfc-editor.org/rfc/rfc9000.html#section-18.2-4.30.1
// TODO(@jasnell): Active connection migration is not yet implemented.
// This will be revisited in a future update.
bool disable_active_migration = true;
static const Options kDefault;
void MemoryInfo(MemoryTracker* tracker) const override;
+6 -18
View File
@@ -495,25 +495,10 @@ TEST(DataQueue, NonIdempotentDataQueue) {
CHECK(!waitingForPull);
CHECK_EQ(status, node::bob::STATUS_CONTINUE);
// We can read the expected data from reader1. Because the entries are
// InMemoryEntry instances, reads will be fully synchronous here.
// The next read produces buffer2. When the first entry's reader returns
// EOS, the NonIdempotentDataQueueReader immediately pulls from the next
// entry (recursive Pull), so the transition is seamless.
waitingForPull = true;
status = reader->Pull(
[&](int status, const DataQueue::Vec* vecs, size_t count, auto done) {
waitingForPull = false;
CHECK_EQ(status, node::bob::STATUS_CONTINUE);
CHECK_EQ(count, 0);
},
node::bob::OPTIONS_SYNC,
nullptr,
0,
node::bob::kMaxCountHint);
CHECK(!waitingForPull);
CHECK_EQ(status, node::bob::STATUS_CONTINUE);
// The next read produces buffer2, and should be the end.
status = reader->Pull(
[&](int status, const DataQueue::Vec* vecs, size_t count, auto done) {
waitingForPull = false;
@@ -628,6 +613,9 @@ TEST(DataQueue, DataQueueEntry) {
CHECK(!pullIsPending);
CHECK_EQ(status, node::bob::STATUS_CONTINUE);
// Cap the queue so the reader can reach EOS after draining all entries.
data_queue2->cap();
// Read to completion...
while (status != node::bob::STATUS_EOS) {
status = reader->Pull(
+78 -1
View File
@@ -56,7 +56,7 @@ TEST(StatelessResetToken, Basic) {
CHECK_EQ(token, token2);
// Let's pretend out secret is also a token just for the sake
// Let's pretend our secret is also a token just for the sake
// of the test. That's ok because they're the same length.
StatelessResetToken token3(secret);
@@ -85,6 +85,83 @@ TEST(StatelessResetToken, Basic) {
CHECK_EQ(found->second, token);
}
TEST(StatelessResetToken, Ngtcp2StructIntegration) {
uint8_t secret[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6};
uint8_t cid_data[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
ngtcp2_cid cid_;
ngtcp2_cid_init(&cid_, cid_data, 10);
TokenSecret fixed_secret(secret);
CID cid(cid_);
// Owning token — generated into the inherited ngtcp2_stateless_reset_token
StatelessResetToken owning(fixed_secret, cid);
CHECK(owning);
// The ngtcp2_stateless_reset_token* conversion operator should return
// a valid pointer to the token data.
const ngtcp2_stateless_reset_token* as_struct = owning;
CHECK_NE(as_struct, nullptr);
// The struct's data should match the uint8_t* conversion.
const uint8_t* as_bytes = owning;
CHECK_EQ(
memcmp(
as_struct->data, as_bytes, StatelessResetToken::kStatelessTokenLen),
0);
// Non-owning from const ngtcp2_stateless_reset_token* — wraps an
// existing struct without copying.
StatelessResetToken from_struct(as_struct);
CHECK(from_struct);
CHECK_EQ(from_struct, owning);
// The pointer should be the same (non-owning wraps, doesn't copy).
const ngtcp2_stateless_reset_token* from_struct_ptr = from_struct;
CHECK_EQ(from_struct_ptr, as_struct);
// Owning into external ngtcp2_stateless_reset_token — generates the
// token into a caller-provided struct.
ngtcp2_stateless_reset_token external_struct{};
StatelessResetToken into_struct(&external_struct, fixed_secret, cid);
CHECK(into_struct);
CHECK_EQ(into_struct, owning);
// The external struct should now contain the generated token.
CHECK_EQ(memcmp(external_struct.data,
as_bytes,
StatelessResetToken::kStatelessTokenLen),
0);
// The conversion operator should return a pointer to the external struct.
const ngtcp2_stateless_reset_token* into_struct_ptr = into_struct;
CHECK_EQ(into_struct_ptr, &external_struct);
// Copy of an owning token should itself be owning (independent copy).
StatelessResetToken copy_of_owning = owning;
CHECK_EQ(copy_of_owning, owning);
const ngtcp2_stateless_reset_token* copy_ptr = copy_of_owning;
// Should NOT point to the same memory as the original.
CHECK_NE(copy_ptr, as_struct);
// But data should match.
CHECK_EQ(
memcmp(copy_ptr->data, as_bytes, StatelessResetToken::kStatelessTokenLen),
0);
// Copy of a non-owning token should become owning (copies data).
StatelessResetToken copy_of_non_owning = from_struct;
CHECK_EQ(copy_of_non_owning, from_struct);
const ngtcp2_stateless_reset_token* copy_no_ptr = copy_of_non_owning;
// Should NOT point to the original non-owning source.
CHECK_NE(copy_no_ptr, from_struct_ptr);
// kInvalid conversions.
const ngtcp2_stateless_reset_token* invalid_ptr =
StatelessResetToken::kInvalid;
CHECK_EQ(invalid_ptr, nullptr);
const uint8_t* invalid_bytes = StatelessResetToken::kInvalid;
// When ptr_ is null, falls back to inherited data (zeroed).
uint8_t zeroed[StatelessResetToken::kStatelessTokenLen]{};
CHECK_EQ(
memcmp(invalid_bytes, zeroed, StatelessResetToken::kStatelessTokenLen),
0);
}
TEST(RetryToken, Basic) {
auto& random = CID::Factory::random();
TokenSecret secret;
+84 -8
View File
@@ -1,5 +1,5 @@
#include "node_sockaddr-inl.h"
#include "gtest/gtest.h"
#include "node_sockaddr-inl.h"
using node::SocketAddress;
using node::SocketAddressBlockList;
@@ -43,6 +43,85 @@ TEST(SocketAddress, SocketAddress) {
CHECK_EQ(map[addr], 2);
}
TEST(SocketAddress, IpHashAndIpEqual) {
sockaddr_storage s1, s2, s3, s4;
// Same IP, different ports.
SocketAddress::ToSockAddr(AF_INET, "10.0.0.1", 443, &s1);
SocketAddress::ToSockAddr(AF_INET, "10.0.0.1", 8080, &s2);
// Different IP.
SocketAddress::ToSockAddr(AF_INET, "10.0.0.2", 443, &s3);
SocketAddress addr1(reinterpret_cast<const sockaddr*>(&s1));
SocketAddress addr2(reinterpret_cast<const sockaddr*>(&s2));
SocketAddress addr3(reinterpret_cast<const sockaddr*>(&s3));
SocketAddress::IpHash ip_hash;
SocketAddress::IpEqual ip_equal;
// Same IP, different port: should hash equal and compare equal.
CHECK_EQ(ip_hash(addr1), ip_hash(addr2));
CHECK(ip_equal(addr1, addr2));
// Different IP: should not compare equal.
CHECK(!ip_equal(addr1, addr3));
// Full Hash (includes port) should differ for same IP, different port.
CHECK_NE(SocketAddress::Hash()(addr1), SocketAddress::Hash()(addr2));
// IpMap should treat same-IP-different-port as the same key.
SocketAddress::IpMap<uint16_t> map;
map[addr1] = 1;
map[addr2]++; // Same IP as addr1, should increment the same entry.
CHECK_EQ(map[addr1], 2);
CHECK_EQ(map.size(), 1);
map[addr3] = 10;
CHECK_EQ(map.size(), 2);
CHECK_EQ(map[addr3], 10);
}
TEST(SocketAddress, IpHashIPv6) {
sockaddr_storage s1, s2, s3;
SocketAddress::ToSockAddr(AF_INET6, "::1", 443, &s1);
SocketAddress::ToSockAddr(AF_INET6, "::1", 8080, &s2);
SocketAddress::ToSockAddr(AF_INET6, "::2", 443, &s3);
SocketAddress addr1(reinterpret_cast<const sockaddr*>(&s1));
SocketAddress addr2(reinterpret_cast<const sockaddr*>(&s2));
SocketAddress addr3(reinterpret_cast<const sockaddr*>(&s3));
SocketAddress::IpHash ip_hash;
SocketAddress::IpEqual ip_equal;
// Same IPv6, different port: equal.
CHECK_EQ(ip_hash(addr1), ip_hash(addr2));
CHECK(ip_equal(addr1, addr2));
// Different IPv6: not equal.
CHECK(!ip_equal(addr1, addr3));
// IpMap with IPv6 keys.
SocketAddress::IpMap<uint16_t> map;
map[addr1] = 5;
map[addr2]++;
CHECK_EQ(map[addr1], 6);
CHECK_EQ(map.size(), 1);
}
TEST(SocketAddress, IpEqualCrossFamily) {
sockaddr_storage s1, s2;
SocketAddress::ToSockAddr(AF_INET, "127.0.0.1", 443, &s1);
SocketAddress::ToSockAddr(AF_INET6, "::1", 443, &s2);
SocketAddress addr1(reinterpret_cast<const sockaddr*>(&s1));
SocketAddress addr2(reinterpret_cast<const sockaddr*>(&s2));
SocketAddress::IpEqual ip_equal;
// Different address families should never be equal.
CHECK(!ip_equal(addr1, addr2));
}
TEST(SocketAddress, SocketAddressIPv6) {
sockaddr_storage storage;
SocketAddress::ToSockAddr(AF_INET6, "::1", 443, &storage);
@@ -85,7 +164,6 @@ TEST(SocketAddressLRU, SocketAddressLRU) {
SocketAddress::ToSockAddr(AF_INET, "123.123.123.125", 443, &storage[2]);
SocketAddress::ToSockAddr(AF_INET, "123.123.123.123", 443, &storage[3]);
SocketAddress addr1(reinterpret_cast<const sockaddr*>(&storage[0]));
SocketAddress addr2(reinterpret_cast<const sockaddr*>(&storage[1]));
SocketAddress addr3(reinterpret_cast<const sockaddr*>(&storage[2]));
@@ -197,12 +275,10 @@ TEST(SocketAddressBlockList, Simple) {
sockaddr_storage storage[2];
SocketAddress::ToSockAddr(AF_INET, "10.0.0.1", 0, &storage[0]);
SocketAddress::ToSockAddr(AF_INET, "10.0.0.2", 0, &storage[1]);
std::shared_ptr<SocketAddress> addr1 =
std::make_shared<SocketAddress>(
reinterpret_cast<const sockaddr*>(&storage[0]));
std::shared_ptr<SocketAddress> addr2 =
std::make_shared<SocketAddress>(
reinterpret_cast<const sockaddr*>(&storage[1]));
std::shared_ptr<SocketAddress> addr1 = std::make_shared<SocketAddress>(
reinterpret_cast<const sockaddr*>(&storage[0]));
std::shared_ptr<SocketAddress> addr2 = std::make_shared<SocketAddress>(
reinterpret_cast<const sockaddr*>(&storage[1]));
bl.AddSocketAddress(addr1);
bl.AddSocketAddress(addr2);
+57
View File
@@ -0,0 +1,57 @@
// Shared helpers for QUIC tests.
//
// Usage:
// import { key, cert, listen, connect } from '../common/quic.mjs';
//
// Provides pre-loaded TLS credentials and thin wrappers around node:quic
// listen/connect that apply default options suitable for most tests.
import * as fixtures from '../common/fixtures.mjs';
const { createPrivateKey } = await import('node:crypto');
const quic = await import('node:quic');
// Pre-loaded TLS credentials from the standard agent1 fixture pair.
const key = createPrivateKey(fixtures.readKey('agent1-key.pem'));
const cert = fixtures.readKey('agent1-cert.pem');
/**
* Start a QUIC server with sensible test defaults.
* @param {Function} callback The session callback (receives QuicSession).
* @param {object} [options] Options forwarded to quic.listen(). The
* following defaults are applied when not specified:
* - sni: { '*': { keys: [key], certs: [cert] } }
* - alpn: ['quic-test']
* @returns {Promise<QuicEndpoint>}
*/
async function listen(callback, options = {}) {
const {
sni = { '*': { keys: [key], certs: [cert] } },
alpn = ['quic-test'],
...rest
} = options;
return quic.listen(callback, { sni, alpn, ...rest });
}
/**
* Connect a QUIC client with sensible test defaults.
* @param {SocketAddress|string} address The server address.
* @param {object} [options] Options forwarded to quic.connect(). The
* following defaults are applied when not specified:
* - alpn: 'quic-test'
* @returns {Promise<QuicSession>}
*/
async function connect(address, options = {}) {
const {
alpn = 'quic-test',
...rest
} = options;
return quic.connect(address, { alpn, ...rest });
}
export {
key,
cert,
listen,
connect,
};
+24 -1
View File
@@ -2,6 +2,7 @@ all: \
ca1-cert.pem \
ca2-cert.pem \
ca2-crl.pem \
ca2-crl-agent3.pem \
ca3-cert.pem \
ca4-cert.pem \
ca5-cert.pem \
@@ -529,6 +530,28 @@ ca2-crl.pem: ca2-key.pem ca2-cert.pem ca2.cnf agent4-cert.pem
-out ca2-crl.pem \
-passin 'pass:password'
#
# Make CRL with agent3 being rejected
# Uses a separate temporary database so the ca2-crl.pem revocation of agent4
# does not contaminate this CRL.
#
ca2-crl-agent3.pem: ca2-key.pem ca2-cert.pem ca2.cnf agent3-cert.pem
@> ca2-crl-agent3-database.txt
@sed 's/ca2-database/ca2-crl-agent3-database/' ca2.cnf > ca2-crl-agent3.cnf
openssl ca -revoke agent3-cert.pem \
-keyfile ca2-key.pem \
-cert ca2-cert.pem \
-config ca2-crl-agent3.cnf \
-passin 'pass:password'
openssl ca \
-keyfile ca2-key.pem \
-cert ca2-cert.pem \
-config ca2-crl-agent3.cnf \
-gencrl \
-out ca2-crl-agent3.pem \
-passin 'pass:password'
@rm -f ca2-crl-agent3.cnf ca2-crl-agent3-database.txt*
#
# agent5 is signed by ca2 (client cert)
#
@@ -1161,7 +1184,7 @@ irrelevant_san_correct_subject-key.pem:
openssl ecparam -name prime256v1 -genkey -noout -out irrelevant_san_correct_subject-key.pem
clean:
rm -f *.pfx *.pem *.srl ca2-database.txt ca2-serial fake-startcom-root-serial *.print *.old fake-startcom-root-issued-certs/*.pem
rm -f *.pfx *.pem *.srl ca2-database.txt ca2-crl-agent3-database.txt* ca2-crl-agent3.cnf ca2-serial fake-startcom-root-serial *.print *.old fake-startcom-root-issued-certs/*.pem
@> fake-startcom-root-database.txt
test: agent1-verify agent2-verify agent3-verify agent4-verify agent5-verify agent6-verify agent7-verify agent8-verify agent10-verify ec10-verify
+13
View File
@@ -0,0 +1,13 @@
-----BEGIN X509 CRL-----
MIIB/jCB5wIBATANBgkqhkiG9w0BAQ0FADB6MQswCQYDVQQGEwJVUzELMAkGA1UE
CAwCQ0ExCzAJBgNVBAcMAlNGMQ8wDQYDVQQKDAZKb3llbnQxEDAOBgNVBAsMB05v
ZGUuanMxDDAKBgNVBAMMA2NhMjEgMB4GCSqGSIb3DQEJARYRcnlAdGlueWNsb3Vk
cy5vcmcXDTI2MDQxNjA0MDM1MloYDzIwNTMwOTAxMDQwMzUyWjAnMCUCFHtnB1Iw
05rTKjL+Xc+x+pXi6jGdFw0yNjA0MTYwNDAzNTJaoA4wDDAKBgNVHRQEAwIBATAN
BgkqhkiG9w0BAQ0FAAOCAQEAS7PnQxPHv+VXvmCOcTQOYWns16+G5cmaY8/fYjwM
6zOQPTItJTH+S2EJ3JvqES3Xm3KH+2Qh/8gAiiGNL9zdBpuNcJyUlJpIPuvWPd0P
Bup7u2YEvc9NjuP8thslf267A8tieFf4mF+AO1lvFp+CGoyRSwtNGOWCMkFDGgGn
ZOVXw5Q782PhUwThozGjR40zDkNjW/uFPJjMkz/RZFEmWshGf9t3VzahRs8PUApr
XTdatufBUPrWiTWyQAuME50ajzq/tfuj2kokqfOvy1mkoNwtySVxKSlwGjejd5Xj
yV/v4a5FDjXw4AwqEe+Cul9J2eyBb1jHkc+R9rutHTKEZA==
-----END X509 CRL-----
@@ -0,0 +1,48 @@
// Flags: --experimental-quic --no-warnings
// Test: validateAddress triggers Retry flow.
// When the server endpoint has validateAddress: true, it should send
// a Retry packet before accepting the connection. The handshake still
// completes successfully.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import * as fixtures from '../common/fixtures.mjs';
const { strictEqual } = assert;
const { readKey } = fixtures;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect, QuicEndpoint } = await import('node:quic');
const { createPrivateKey } = await import('node:crypto');
const key = createPrivateKey(readKey('agent1-key.pem'));
const cert = readKey('agent1-cert.pem');
const endpoint = new QuicEndpoint({ validateAddress: true });
const serverEndpoint = await listen(mustCall(async (serverSession) => {
const info = await serverSession.opened;
// The handshake should complete despite the Retry flow.
strictEqual(info.protocol, 'quic-test');
serverSession.close();
}), {
endpoint,
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['quic-test'],
});
const clientSession = await connect(serverEndpoint.address, {
alpn: 'quic-test',
servername: 'localhost',
});
const info = await clientSession.opened;
strictEqual(info.protocol, 'quic-test');
// The serverEndpoint must be closed after we wait for the clientSession to close.
await clientSession.closed;
await serverEndpoint.close();
+17 -15
View File
@@ -4,6 +4,9 @@ import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import * as fixtures from '../common/fixtures.mjs';
const { strictEqual, notStrictEqual } = assert;
const { readKey } = fixtures;
if (!hasQuic) {
skip('QUIC is not enabled');
}
@@ -11,34 +14,33 @@ if (!hasQuic) {
const { listen, connect } = await import('node:quic');
const { createPrivateKey } = await import('node:crypto');
const key = createPrivateKey(fixtures.readKey('agent1-key.pem'));
const cert = fixtures.readKey('agent1-cert.pem');
const key = createPrivateKey(readKey('agent1-key.pem'));
const cert = readKey('agent1-cert.pem');
// Test h3 ALPN negotiation with Http3ApplicationImpl.
// Both server and client use the default ALPN (h3).
const serverOpened = Promise.withResolvers();
const clientOpened = Promise.withResolvers();
const serverEndpoint = await listen(mustCall((serverSession) => {
serverSession.opened.then(mustCall((info) => {
assert.strictEqual(info.protocol, 'h3');
serverOpened.resolve();
serverSession.close();
}));
const serverEndpoint = await listen(mustCall(async (serverSession) => {
const info = await serverSession.opened;
strictEqual(info.protocol, 'h3');
serverOpened.resolve();
serverSession.close();
}), {
sni: { '*': { keys: [key], certs: [cert] } },
});
assert.ok(serverEndpoint.address !== undefined);
notStrictEqual(serverEndpoint.address, undefined);
const clientSession = await connect(serverEndpoint.address, {
servername: 'localhost',
});
clientSession.opened.then(mustCall((info) => {
assert.strictEqual(info.protocol, 'h3');
clientOpened.resolve();
}));
await Promise.all([serverOpened.promise, clientOpened.promise]);
async function checkClient() {
const info = await clientSession.opened;
strictEqual(info.protocol, 'h3');
}
await Promise.all([serverOpened.promise, checkClient()]);
clientSession.close();
+50
View File
@@ -0,0 +1,50 @@
// Flags: --experimental-quic --no-warnings
// Test: ALPN mismatch causes connection failure.
// The server offers 'quic-test' but the client requests 'nonexistent'.
// The handshake should fail.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { rejects, strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const onerror = mustCall((err) => {
strictEqual(err.code, 'ERR_QUIC_TRANSPORT_ERROR');
}, 2);
const transportParams = { maxIdleTimeout: 1 };
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await rejects(serverSession.opened, {
code: 'ERR_QUIC_TRANSPORT_ERROR',
});
await rejects(serverSession.closed, {
code: 'ERR_QUIC_TRANSPORT_ERROR',
});
}), {
transportParams,
onerror,
});
// Client requests an ALPN the server doesn't offer.
const clientSession = await connect(serverEndpoint.address, {
alpn: 'nonexistent-protocol',
transportParams,
onerror,
});
await rejects(clientSession.opened, {
code: 'ERR_QUIC_TRANSPORT_ERROR',
});
// The handshake should fail — opened may reject or never resolve.
// The session should close with an error.
await rejects(clientSession.closed, {
code: 'ERR_QUIC_TRANSPORT_ERROR',
});
+17 -17
View File
@@ -4,6 +4,9 @@ import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import * as fixtures from '../common/fixtures.mjs';
const { notStrictEqual, strictEqual } = assert;
const { readKey } = fixtures;
if (!hasQuic) {
skip('QUIC is not enabled');
}
@@ -11,37 +14,34 @@ if (!hasQuic) {
const { listen, connect } = await import('node:quic');
const { createPrivateKey } = await import('node:crypto');
const key = createPrivateKey(fixtures.readKey('agent1-key.pem'));
const cert = fixtures.readKey('agent1-cert.pem');
const key = createPrivateKey(readKey('agent1-key.pem'));
const cert = readKey('agent1-cert.pem');
// Server offers multiple ALPNs. Client requests one that the server supports.
// Verify the negotiated protocol matches on both sides.
const serverOpened = Promise.withResolvers();
const clientOpened = Promise.withResolvers();
const serverEndpoint = await listen(mustCall((serverSession) => {
serverSession.opened.then(mustCall((info) => {
// The server should negotiate proto-b (client's choice from server's list)
assert.strictEqual(info.protocol, 'proto-b');
serverOpened.resolve();
serverSession.close();
}));
async function checkSession(session) {
const info = await session.opened;
// The client should negotiate proto-b (the only protocol it requested)
strictEqual(info.protocol, 'proto-b');
}
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await checkSession(serverSession);
serverOpened.resolve();
}), {
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['proto-a', 'proto-b', 'proto-c'],
});
assert.ok(serverEndpoint.address !== undefined);
notStrictEqual(serverEndpoint.address, undefined);
const clientSession = await connect(serverEndpoint.address, {
alpn: 'proto-b',
servername: 'localhost',
});
clientSession.opened.then(mustCall((info) => {
assert.strictEqual(info.protocol, 'proto-b');
clientOpened.resolve();
}));
await Promise.all([serverOpened.promise, clientOpened.promise]);
clientSession.close();
await Promise.all([serverOpened.promise, checkSession(clientSession)]);
await clientSession.close();
@@ -0,0 +1,45 @@
// Flags: --experimental-quic --no-warnings
// Test: onblocked callback error handling.
// A sync throw in stream.onblocked destroys the stream via
// safeCallbackInvoke. The stream.closed promise rejects with the error.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { rejects } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const testError = new Error('onblocked throw');
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverSession.closed;
}), {
// Small stream window to trigger flow control blocking.
transportParams: {
maxIdleTimeout: 1,
initialMaxStreamDataBidiRemote: 256,
},
});
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxIdleTimeout: 1 },
});
await clientSession.opened;
const stream = await clientSession.createBidirectionalStream();
stream.onblocked = mustCall(() => {
throw testError;
});
// Body larger than the 256-byte flow control window triggers onblocked.
stream.setBody(new Uint8Array(4096));
// The stream's closed promise should reject with the error from the throw.
await rejects(stream.closed, testError);
@@ -0,0 +1,46 @@
// Flags: --experimental-quic --no-warnings
// Test: async rejection in ondatagram destroys session.
// safeCallbackInvoke detects the returned promise and attaches a
// rejection handler that calls session.destroy(err). The error is
// delivered to the onerror callback.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const testError = new Error('async ondatagram rejection');
const serverDone = Promise.withResolvers();
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await assert.rejects(serverSession.closed, testError);
serverDone.resolve();
}), {
transportParams: { maxDatagramFrameSize: 1200 },
ondatagram: mustCall(async () => {
throw testError;
}),
onerror: mustCall((err) => {
assert.strictEqual(err, testError);
}),
});
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxIdleTimeout: 1, maxDatagramFrameSize: 1200 },
});
await clientSession.opened;
await clientSession.sendDatagram(new Uint8Array([1, 2, 3]));
await serverDone.promise;
// The server session was destroyed abruptly (no CONNECTION_CLOSE sent).
// The client may receive a stateless reset if it sends any packet
// before its idle timeout fires, so closed may reject.
await assert.rejects(clientSession.closed, { code: 'ERR_QUIC_TRANSPORT_ERROR' });
serverEndpoint.close();
await serverEndpoint.closed;
@@ -0,0 +1,48 @@
// Flags: --experimental-quic --no-warnings
// Test: ondatagram callback error handling.
// A sync throw in ondatagram destroys the session via safeCallbackInvoke.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { rejects, strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const testError = new Error('ondatagram throw');
const serverDone = Promise.withResolvers();
const serverEndpoint = await listen(mustCall(async (serverSession) => {
// The session is destroyed by the ondatagram throw. The closed promise
// rejects with testError. Verify that and signal completion.
await rejects(serverSession.closed, testError);
serverDone.resolve();
}), {
transportParams: { maxDatagramFrameSize: 1200 },
ondatagram() {
throw testError;
},
onerror: mustCall((err) => {
strictEqual(err, testError);
}),
});
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxIdleTimeout: 1, maxDatagramFrameSize: 1200 },
});
await clientSession.opened;
// Send a datagram to trigger the server's ondatagram callback.
await clientSession.sendDatagram(new Uint8Array([1, 2, 3]));
await serverDone.promise;
// The server session was destroyed abruptly (no CONNECTION_CLOSE sent).
// The client may receive a stateless reset if it sends any packet
// before its idle timeout fires, so closed may reject.
await rejects(clientSession.closed, { code: 'ERR_QUIC_TRANSPORT_ERROR' });
await serverEndpoint.close();
@@ -0,0 +1,40 @@
// Flags: --experimental-quic --no-warnings
// Test: ondatagramstatus callback error handling.
// A sync throw in ondatagramstatus destroys the session via safeCallbackInvoke.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { rejects, strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const testError = new Error('ondatagramstatus throw');
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverSession.closed;
}), {
transportParams: { maxIdleTimeout: 1, maxDatagramFrameSize: 1200 },
});
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxIdleTimeout: 1, maxDatagramFrameSize: 1200 },
ondatagramstatus() {
throw testError;
},
onerror: mustCall((err) => {
strictEqual(err, testError);
}),
});
await clientSession.opened;
// Send a datagram. The status callback fires when the peer ACKs it.
await clientSession.sendDatagram(new Uint8Array([1, 2, 3]));
// The session's closed should reject with the error from the throw.
await rejects(clientSession.closed, testError);
@@ -0,0 +1,36 @@
// Flags: --experimental-quic --no-warnings
// Test: onerror set via connect() options.
// The onerror callback can be provided in the options object at
// session creation time to avoid race conditions with errors that
// occur during or immediately after the handshake.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { rejects, strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const transportParams = { maxIdleTimeout: 1 };
const testError = new Error('destroy with error');
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverSession.closed;
}), { transportParams });
const clientSession = await connect(serverEndpoint.address, {
transportParams,
onerror: mustCall((err) => {
strictEqual(err, testError);
}),
});
await clientSession.opened;
clientSession.destroy(testError);
await rejects(clientSession.closed, testError);
@@ -0,0 +1,62 @@
// Flags: --experimental-quic --no-warnings
// Test: onerror setter validation.
// Setting onerror to a non-function (including null) throws.
// Setting to undefined clears it. Setting to a function works.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { strictEqual, throws } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const errorCheck = {
code: 'ERR_INVALID_ARG_TYPE',
};
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverSession.opened;
// Session onerror validation: non-functions throw.
throws(() => { serverSession.onerror = 'not a function'; }, errorCheck);
throws(() => { serverSession.onerror = 42; }, errorCheck);
throws(() => { serverSession.onerror = null; }, errorCheck);
// Setting to a function works.
const fn = () => {};
serverSession.onerror = fn;
// The getter returns the bound version, not the original.
strictEqual(typeof serverSession.onerror, 'function');
// Setting to undefined clears it.
serverSession.onerror = undefined;
strictEqual(serverSession.onerror, undefined);
serverSession.close();
}));
const clientSession = await connect(serverEndpoint.address);
await clientSession.opened;
// Client-side stream onerror validation.
const stream = await clientSession.createBidirectionalStream({
body: new TextEncoder().encode('x'),
});
throws(() => { stream.onerror = 'not a function'; }, errorCheck);
throws(() => { stream.onerror = 42; }, errorCheck);
throws(() => { stream.onerror = null; }, errorCheck);
// Setting to a function works.
stream.onerror = () => {};
strictEqual(typeof stream.onerror, 'function');
// Setting to undefined clears it.
stream.onerror = undefined;
strictEqual(stream.onerror, undefined);
await clientSession.closed;
@@ -0,0 +1,76 @@
// Flags: --experimental-quic --no-warnings
// Test: onerror callback behavior
// session.onerror fires when session is destroyed with error.
// session.onerror receives the original error as argument.
// session.closed rejects with the original error after onerror.
// session.onerror not called when destroy() has no error.
import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs';
import assert from 'node:assert';
import dc from 'node:diagnostics_channel';
const { ok, rejects, strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
// quic.session.error fires when a session is destroyed with an error.
// It should fire once for the first client session (destroyed with error)
// and not for the second (destroyed without error).
dc.subscribe('quic.session.error', mustCall((msg) => {
ok(msg.session, 'session.error should include session');
ok(msg.error, 'session.error should include error');
}));
const transportParams = { maxIdleTimeout: 1 };
// All tested using a single endpoint with two client sessions.
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverSession.closed;
}, 2), { transportParams });
// First client: destroy WITH error — onerror fires.
{
const testError = new Error('destroy with error');
const clientSession = await connect(serverEndpoint.address, {
transportParams,
});
await clientSession.opened;
let onerrorCalled = false;
clientSession.onerror = mustCall((err) => {
// Receives the original error.
strictEqual(err, testError);
onerrorCalled = true;
});
clientSession.destroy(testError);
// Onerror was called synchronously during destroy.
strictEqual(onerrorCalled, true);
// Closed rejects with the original error.
await rejects(clientSession.closed, testError);
}
// Second client: destroy WITHOUT error — onerror should NOT fire.
{
const clientSession = await connect(serverEndpoint.address, {
transportParams,
});
await clientSession.opened;
clientSession.onerror = mustNotCall('onerror should not be called');
clientSession.destroy();
// Closed resolves (no error).
await clientSession.closed;
}
serverEndpoint.close();
await serverEndpoint.closed;
@@ -0,0 +1,36 @@
// Flags: --experimental-quic --no-warnings
// Test: onhandshake callback error handling.
// A sync throw in onhandshake destroys the session via safeCallbackInvoke.
// The error is delivered to the onerror callback and the session's
// closed promise rejects with the error.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const testError = new Error('onhandshake throw');
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverSession.closed;
}), { transportParams: { maxIdleTimeout: 1 } });
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxIdleTimeout: 1 },
onhandshake() {
throw testError;
},
onerror: mustCall((err) => {
assert.strictEqual(err, testError);
}),
});
// The session's closed should reject with the error from the throw.
await assert.rejects(clientSession.closed, testError);
await serverEndpoint.close();
@@ -0,0 +1,42 @@
// Flags: --experimental-quic --no-warnings
// Test: onnewtoken callback error handling.
// A sync throw in onnewtoken destroys the session via safeCallbackInvoke.
// The server submits a NEW_TOKEN after handshake completes; the client
// receives it via the onnewtoken callback. Since the session ticket and
// NEW_TOKEN both arrive after the handshake, session.opened is already
// resolved and there is no unhandled rejection / uncaught exception.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { rejects, strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const testError = new Error('onnewtoken throw');
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverSession.closed;
}), { transportParams: { maxIdleTimeout: 1 } });
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxIdleTimeout: 1 },
onnewtoken() {
throw testError;
},
onerror: mustCall((err) => {
strictEqual(err, testError);
}),
});
await clientSession.opened;
// The session's closed should reject with the error from the throw.
await rejects(clientSession.closed, testError);
await serverEndpoint.close();
@@ -0,0 +1,53 @@
// Flags: --experimental-quic --no-warnings
// Test: onpathvalidation callback error handling.
// A sync throw in onpathvalidation destroys the session via
// safeCallbackInvoke. The error is delivered to the onerror
// callback and the session's closed promise rejects.
import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs';
import assert from 'node:assert';
const { rejects, strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const testError = new Error('onpathvalidation throw');
// The preferred endpoint never receives a new session — it only
// routes PATH_CHALLENGE packets via SessionManager.
const preferredEndpoint = await listen(mustNotCall(), {});
const serverEndpoint = await listen(mustCall(async (serverSession) => {
// The server session closes with a transport error when the
// client is destroyed by the throw. That's expected.
await rejects(serverSession.closed, {
code: 'ERR_QUIC_TRANSPORT_ERROR',
});
}), {
transportParams: {
preferredAddressIpv4: preferredEndpoint.address,
},
});
const clientSession = await connect(serverEndpoint.address, {
reuseEndpoint: false,
onpathvalidation() {
throw testError;
},
onerror: mustCall((err) => {
// The error from the throw should be delivered here.
strictEqual(err, testError);
}),
});
await clientSession.opened;
// The session's closed should reject with the thrown error.
await rejects(clientSession.closed, testError);
await serverEndpoint.close();
await preferredEndpoint.close();
@@ -0,0 +1,66 @@
// Flags: --experimental-quic --no-warnings
// Test: onreset callback error handling.
// A sync throw in stream.onreset destroys the STREAM (not the session)
// via safeCallbackInvoke. The stream.onerror fires with the original
// error, and stream.closed rejects. The session remains alive.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { rejects, strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const encoder = new TextEncoder();
const testError = new Error('onreset throw');
const serverReady = Promise.withResolvers();
const serverStreamDone = Promise.withResolvers();
const serverEndpoint = await listen(mustCall((serverSession) => {
serverSession.onstream = mustCall(async (stream) => {
// The stream's onerror should fire with the throw from onreset.
stream.onerror = mustCall((err) => {
strictEqual(err, testError);
});
stream.onreset = () => {
throw testError;
};
serverReady.resolve();
// Stream closed rejects because the onreset throw destroyed it.
await rejects(stream.closed, testError);
serverStreamDone.resolve();
});
}));
const clientSession = await connect(serverEndpoint.address);
await clientSession.opened;
const stream = await clientSession.createBidirectionalStream({
body: encoder.encode('trigger onstream'),
});
// Wait for the server to have the stream before resetting.
await serverReady.promise;
stream.resetStream(1n);
// Wait for the server stream to be destroyed by the onreset throw.
await serverStreamDone.promise;
// The client stream was reset. Destroy it explicitly to clean up
// (resetStream only shuts the write side; the read side is still open
// waiting for the server which won't send anything now).
stream.destroy();
await stream.closed;
// Close both sides.
await clientSession.close();
await serverEndpoint.close();
@@ -0,0 +1,41 @@
// Flags: --experimental-quic --no-warnings
// Test: onsessionticket callback error handling.
// A sync throw in onsessionticket destroys the session via safeCallbackInvoke.
// Unlike onhandshake throws, no uncaughtException is produced because the
// session ticket arrives after the handshake completes (session.opened is
// already resolved so there is no unhandled rejection).
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { rejects, strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const testError = new Error('onsessionticket throw');
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverSession.closed;
}), { transportParams: { maxIdleTimeout: 1 } });
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxIdleTimeout: 1 },
onsessionticket() {
throw testError;
},
onerror: mustCall((err) => {
strictEqual(err, testError);
}),
});
await clientSession.opened;
// The session's closed should reject with the error from the throw.
await rejects(clientSession.closed, testError);
await serverEndpoint.close();
@@ -0,0 +1,46 @@
// Flags: --experimental-quic --experimental-stream-iter --no-warnings
// Test: async rejection in onstream destroys session.
// safeCallbackInvoke detects the returned promise and attaches a
// rejection handler that calls session.destroy(err). The error is
// delivered to the onerror callback.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { strictEqual, rejects } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const testError = new Error('async onstream rejection');
const serverEndpoint = await listen(mustCall(async (serverSession) => {
serverSession.onerror = mustCall((err) => {
strictEqual(err, testError);
});
serverSession.onstream = async () => {
throw testError;
};
// Session closed rejects with the error from the async rejection.
await rejects(serverSession.closed, testError);
}), { transportParams: { maxIdleTimeout: 1 } });
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxIdleTimeout: 1 },
});
await clientSession.opened;
const stream = await clientSession.createBidirectionalStream({
body: new TextEncoder().encode('trigger onstream'),
});
// The client session closes via CONNECTION_CLOSE or idle timeout
// after the server session is destroyed by the async rejection.
await Promise.all([stream.closed, clientSession.closed]);
await serverEndpoint.close();
@@ -0,0 +1,49 @@
// Flags: --experimental-quic --experimental-stream-iter --no-warnings
// Test: callback error handling for onstream.
// Sync throw in onstream destroys the session.
// safeCallbackInvoke catches the throw and calls session.destroy(error).
// The error is delivered to the onerror callback.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { rejects, strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const encoder = new TextEncoder();
const testError = new Error('sync onstream throw');
const serverEndpoint = await listen(mustCall(async (serverSession) => {
serverSession.onerror = mustCall((err) => {
strictEqual(err, testError);
});
serverSession.onstream = () => {
throw testError;
};
// The session's closed rejects with the error from destroy().
await rejects(serverSession.closed, testError);
}), { transportParams: { maxIdleTimeout: 1 } });
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxIdleTimeout: 1 },
});
await clientSession.opened;
// Send data to trigger onstream on the server.
const stream = await clientSession.createBidirectionalStream({
body: encoder.encode('trigger onstream'),
});
// The client session will close via CONNECTION_CLOSE or idle timeout
// after the server session is destroyed.
await Promise.all([stream.closed, clientSession.closed]);
await serverEndpoint.close();
@@ -0,0 +1,83 @@
// Flags: --experimental-quic --experimental-stream-iter --no-warnings
// Test: stream.onerror callback behavior.
// * stream.onerror fires when stream is destroyed with error.
// * stream.onerror receives the original error as argument.
// * stream.closed rejects with the original error after onerror.
// * stream.onerror not called when destroy() has no error.
import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs';
import assert from 'node:assert';
const { rejects, strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const encoder = new TextEncoder();
const serverDone = Promise.withResolvers();
const serverEndpoint = await listen(mustCall((serverSession) => {
serverSession.onstream = mustCall(async (stream) => {
stream.writer.endSync();
await stream.closed;
serverSession.close();
serverDone.resolve();
});
}));
const clientSession = await connect(serverEndpoint.address);
await clientSession.opened;
{
const stream = await clientSession.createBidirectionalStream({
body: encoder.encode('will error'),
});
const testError = new Error('stream destroy error');
let onerrorCalled = false;
stream.onerror = mustCall((err) => {
// Receives the original error.
strictEqual(err, testError);
onerrorCalled = true;
});
stream.destroy(testError);
// The onerror was called synchronously during destroy.
strictEqual(onerrorCalled, true);
// The stream.closed rejects with the original error.
await rejects(stream.closed, testError);
}
// The stream.onerror not called when destroy() has no error.
// Create a stream with no body — use the writer API so the server sees
// it and can close cleanly.
{
const stream = await clientSession.createBidirectionalStream();
const w = stream.writer;
stream.onerror = mustNotCall('stream.onerror should not be called');
// Send data so the server's onstream fires, then end.
w.writeSync('no error');
w.endSync();
// Wait for the server to process and close its side.
await serverDone.promise;
// Now destroy without error.
stream.destroy();
// Closed should resolve (not reject).
await stream.closed;
}
await clientSession.close();
await serverEndpoint.close();
@@ -0,0 +1,53 @@
// Flags: --experimental-quic --no-warnings
// Test: SuppressedError when async onerror rejects.
// When session.onerror returns a Promise that rejects, a SuppressedError
// wrapping both the rejection reason and the original error is thrown
// via process.nextTick as an uncaught exception.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { ok, rejects, strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const originalError = new Error('original destroy error');
const onerrorRejection = new Error('async onerror rejected');
const transportParams = { maxIdleTimeout: 1 };
// The SuppressedError is thrown via process.nextTick after the
// onerror promise rejects, so it appears as an uncaught exception.
process.on('uncaughtException', mustCall((err) => {
ok(err instanceof SuppressedError);
// .error is the onerror rejection reason
strictEqual(err.error, onerrorRejection);
// .suppressed is the original error that triggered destroy
strictEqual(err.suppressed, originalError);
}));
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverSession.closed;
}), { transportParams });
const clientSession = await connect(serverEndpoint.address, {
transportParams,
});
await clientSession.opened;
// Async onerror: returns a promise that rejects.
clientSession.onerror = mustCall(async () => {
throw onerrorRejection;
});
clientSession.destroy(originalError);
// Closed rejects with the original error (not the SuppressedError).
await rejects(clientSession.closed, originalError);
await serverEndpoint.close();
@@ -0,0 +1,52 @@
// Flags: --experimental-quic --no-warnings
// Test: SuppressedError when onerror throws.
// If session.onerror throws synchronously, a SuppressedError
// wrapping both the onerror error and the original error is
// thrown via process.nextTick as an uncaught exception.
// The SuppressedError's .error is the onerror failure and
// .suppressed is the original error.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { ok, rejects, strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const originalError = new Error('original destroy error');
const onerrorError = new Error('onerror itself threw');
const transportParams = { maxIdleTimeout: 1 };
// The SuppressedError is thrown via process.nextTick, so it appears
// as an uncaught exception.
process.on('uncaughtException', mustCall((err) => {
ok(err instanceof SuppressedError);
// .error is the onerror failure
strictEqual(err.error, onerrorError);
// .suppressed is the original error that triggered destroy
strictEqual(err.suppressed, originalError);
}));
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverSession.closed;
}), { transportParams });
const clientSession = await connect(serverEndpoint.address, {
transportParams,
});
await clientSession.opened;
clientSession.onerror = mustCall(() => {
throw onerrorError;
});
clientSession.destroy(originalError);
// Closed rejects with the original error (not the SuppressedError).
await rejects(clientSession.closed, originalError);
+52
View File
@@ -0,0 +1,52 @@
// Flags: --experimental-quic --experimental-stream-iter --no-warnings
// Test: congestion control algorithm selection.
// Verify that each CC algorithm (reno, cubic, bbr) can be selected
// and that a session completes a data transfer successfully with each.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { ok, strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const { bytes } = await import('stream/iter');
const encoder = new TextEncoder();
const payload = encoder.encode('congestion control test');
const payloadLength = payload.byteLength;
for (const cc of ['reno', 'cubic', 'bbr']) {
const serverDone = Promise.withResolvers();
const serverEndpoint = await listen(mustCall((serverSession) => {
serverSession.onstream = mustCall(async (stream) => {
const data = await bytes(stream);
strictEqual(data.byteLength, payloadLength);
stream.writer.endSync();
await stream.closed;
serverSession.close();
serverDone.resolve();
});
}), { cc });
const clientSession = await connect(serverEndpoint.address, { cc });
await clientSession.opened;
const stream = await clientSession.createBidirectionalStream({
body: encoder.encode('congestion control test'),
});
for await (const _ of stream) { /* drain */ } // eslint-disable-line no-unused-vars
await Promise.all([stream.closed, serverDone.promise]);
// Verify the session stats show congestion control was active.
ok(clientSession.stats.cwnd > 0n, `${cc}: cwnd should be > 0`);
await clientSession.closed;
await serverEndpoint.close();
}
@@ -0,0 +1,56 @@
// Flags: --experimental-quic --experimental-stream-iter --no-warnings
// Test: concurrent connections from multiple clients.
// Multiple clients connect to the same server simultaneously and each
// exchanges data successfully.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const { bytes } = await import('stream/iter');
const encoder = new TextEncoder();
const numClients = 5;
let serverStreamCount = 0;
const allDone = Promise.withResolvers();
const serverEndpoint = await listen(mustCall((serverSession) => {
serverSession.onstream = mustCall(async (stream) => {
// Echo back the data.
const w = stream.writer;
w.writeSync(await bytes(stream));
w.endSync();
await stream.closed;
if (++serverStreamCount === numClients) {
allDone.resolve();
}
});
}, numClients));
// Connect all clients concurrently.
const clientPromises = [];
for (let i = 0; i < numClients; i++) {
clientPromises.push((async () => {
const cs = await connect(serverEndpoint.address, { reuseEndpoint: false });
await cs.opened;
const message = `client ${i}`;
const stream = await cs.createBidirectionalStream({
body: encoder.encode(message),
});
const received = await bytes(stream);
strictEqual(new TextDecoder().decode(received), message);
await stream.closed;
cs.close();
await cs.closed;
})());
}
await Promise.all([...clientPromises, allDone.promise]);
await serverEndpoint.close();
@@ -0,0 +1,76 @@
// Flags: --experimental-quic --no-warnings
// Test: connection total limit enforcement.
// maxConnectionsTotal limits total concurrent connections.
// When the limit is exceeded, the server sends CONNECTION_REFUSED
// and the client's session is destroyed with ERR_QUIC_TRANSPORT_ERROR.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import * as fixtures from '../common/fixtures.mjs';
const { rejects, strictEqual } = assert;
const { readKey } = fixtures;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect, QuicEndpoint } = await import('node:quic');
const { createPrivateKey } = await import('node:crypto');
const key = createPrivateKey(readKey('agent1-key.pem'));
const cert = readKey('agent1-cert.pem');
// Create endpoint with maxConnectionsTotal = 1.
const endpoint = new QuicEndpoint({ maxConnectionsTotal: 1 });
// Verify the limits are readable and mutable.
strictEqual(endpoint.maxConnectionsTotal, 1);
strictEqual(endpoint.maxConnectionsPerHost, 0);
endpoint.maxConnectionsPerHost = 100;
strictEqual(endpoint.maxConnectionsPerHost, 100);
endpoint.maxConnectionsPerHost = 0;
let sessionCount = 0;
const serverEndpoint = await listen(mustCall(async (serverSession) => {
sessionCount++;
await Promise.all([serverSession.opened, serverSession.closed]);
}), {
endpoint,
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['quic-test'],
transportParams: { maxIdleTimeout: 2 },
});
// First connection should succeed.
const cs1 = await connect(serverEndpoint.address, {
alpn: 'quic-test',
transportParams: { maxIdleTimeout: 2 },
});
await cs1.opened;
// Second connection — server rejects with CONNECTION_REFUSED.
const cs2 = await connect(serverEndpoint.address, {
alpn: 'quic-test',
transportParams: { maxIdleTimeout: 1 },
onerror: mustCall((err) => {
strictEqual(err.code, 'ERR_QUIC_TRANSPORT_ERROR');
}),
});
await Promise.all([
rejects(cs2.opened, {
code: 'ERR_QUIC_TRANSPORT_ERROR',
}),
rejects(cs2.closed, {
code: 'ERR_QUIC_TRANSPORT_ERROR',
}),
]);
// Only 1 session should have been accepted by the server.
strictEqual(sessionCount, 1);
await cs1.close();
await serverEndpoint.close();
@@ -0,0 +1,64 @@
// Flags: --experimental-quic --no-warnings
// Test: datagram abandoned status for queue overflow.
// When the datagram pending queue is full and a new datagram is sent,
// the drop policy causes a datagram to be dropped. The dropped datagram
// should be reported with status 'abandoned' (not 'lost'), indicating
// it was never actually sent on the wire.
import { hasQuic, skip, mustCall, mustCallAtLeast } from '../common/index.mjs';
import assert from 'node:assert';
const { notStrictEqual, strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
let serverSession;
const serverEndpoint = await listen(mustCall((session) => {
serverSession = session;
}), {
transportParams: { maxDatagramFrameSize: 1200 },
});
const ids = [0n, 0n, 0n];
let abandoned = false;
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxDatagramFrameSize: 1200 },
ondatagramstatus: mustCallAtLeast((id, status) => {
if (status === 'abandoned') {
strictEqual(id, ids[0]);
abandoned = true;
}
// We'll likely only get status for one other datagram.
}),
});
await clientSession.opened;
// Set a very small queue so overflow happens immediately.
clientSession.maxPendingDatagrams = 2;
// Send 3 datagrams with a queue size of 2. The first datagram should
// be abandoned when the third is sent (drop-oldest policy is default).
ids[0] = await clientSession.sendDatagram(new Uint8Array([1]));
ids[1] = await clientSession.sendDatagram(new Uint8Array([2]));
ids[2] = await clientSession.sendDatagram(new Uint8Array([3]));
notStrictEqual(ids[0], 0n);
notStrictEqual(ids[1], 0n);
notStrictEqual(ids[2], 0n);
// The abandoned status fires synchronously during sendDatagram when the
// queue overflows. It should already be set
strictEqual(abandoned, true);
await Promise.all([
serverSession.close(),
clientSession.close(),
]);
await serverEndpoint.close();
@@ -0,0 +1,82 @@
// Flags: --experimental-quic --no-warnings
// Test: datagram drop-newest policy.
// With maxPendingDatagrams=2 and drop-newest, sending 5 datagrams
// rapidly should reject the newest when the queue is full. The
// server should receive the oldest datagrams.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import * as fixtures from '../common/fixtures.mjs';
const { ok, strictEqual } = assert;
const { readKey } = fixtures;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('node:quic');
const { createPrivateKey } = await import('node:crypto');
const key = createPrivateKey(readKey('agent1-key.pem'));
const cert = readKey('agent1-cert.pem');
const allReceived = Promise.withResolvers();
const allStatusReceived = Promise.withResolvers();
let serverCounter = 0;
let clientAbandonCounter = 0;
let clientAckCounter = 0;
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await Promise.all([serverSession.opened, allStatusReceived.promise]);
serverSession.close();
await serverSession.closed;
}), {
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['quic-test'],
transportParams: { maxDatagramFrameSize: 1200 },
ondatagram: mustCall(function(data, early) {
// We whould only receive datagrams 1 and 2
strictEqual(data.length, 1);
ok(data[0] === 0 || data[0] === 1);
ok(!early);
if (++serverCounter === 2) allReceived.resolve();
}, 2),
});
const clientSession = await connect(serverEndpoint.address, {
alpn: 'quic-test',
transportParams: { maxDatagramFrameSize: 1200 },
datagramDropPolicy: 'drop-newest',
ondatagramstatus: mustCall((_, status) => {
if (status === 'abandoned') {
clientAbandonCounter++;
} else if (status === 'acknowledged') {
clientAckCounter++;
}
if (clientAbandonCounter + clientAckCounter === 5) {
allStatusReceived.resolve();
}
}, 5),
});
await clientSession.opened;
clientSession.maxPendingDatagrams = 2;
// Send 5 datagrams. With drop-newest, the 3rd/4th/5th are rejected
// (the queue holds the 1st and 2nd).
for (let i = 0; i < 5; i++) {
await clientSession.sendDatagram(new Uint8Array([i]));
}
await Promise.all([allReceived.promise, allStatusReceived.promise]);
// 3 abandoned (datagrams 1, 2, 3) and 2 acknowledged (datagrams 4, 5).
strictEqual(clientAbandonCounter, 3);
strictEqual(clientAckCounter, 2);
await clientSession.closed;
await serverEndpoint.close();
@@ -0,0 +1,83 @@
// Flags: --experimental-quic
// Test: datagram drop-oldest policy.
// With maxPendingDatagrams=2 and drop-oldest, sending 5 datagrams
// rapidly should drop the oldest when the queue overflows. The
// server should receive the most recent datagrams (4th and 5th).
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import * as fixtures from '../common/fixtures.mjs';
const { ok, strictEqual } = assert;
const { readKey } = fixtures;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('node:quic');
const { createPrivateKey } = await import('node:crypto');
const key = createPrivateKey(readKey('agent1-key.pem'));
const cert = readKey('agent1-cert.pem');
const allReceived = Promise.withResolvers();
const allStatusReceived = Promise.withResolvers();
let serverCounter = 0;
let clientAbandonCounter = 0;
let clientAckCounter = 0;
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await Promise.all([serverSession.opened, allStatusReceived.promise]);
await serverSession.close();
}), {
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['quic-test'],
transportParams: { maxDatagramFrameSize: 1200 },
ondatagram: mustCall(function(data, early) {
// With drop-oldest, the queue keeps the newest. After 5 sends with
// queue size 2, only datagrams 4 and 5 (values 3 and 4) remain.
strictEqual(data.length, 1);
ok(data[0] === 3 || data[0] === 4);
ok(!early);
if (++serverCounter === 2) allReceived.resolve();
}, 2),
});
const clientSession = await connect(serverEndpoint.address, {
alpn: 'quic-test',
transportParams: { maxDatagramFrameSize: 1200 },
datagramDropPolicy: 'drop-oldest',
ondatagramstatus: mustCall((_, status) => {
if (status === 'abandoned') {
clientAbandonCounter++;
} else if (status === 'acknowledged') {
clientAckCounter++;
}
if (clientAbandonCounter + clientAckCounter === 5) {
allStatusReceived.resolve();
}
}, 5),
});
await clientSession.opened;
clientSession.maxPendingDatagrams = 2;
// Send 5 datagrams. With drop-oldest and queue size 2:
// 1 queued, 2 queued, 3 arrives → 1 dropped, 4 arrives → 2 dropped,
// 5 arrives → 3 dropped. Queue ends with [4, 5].
for (let i = 0; i < 5; i++) {
await clientSession.sendDatagram(new Uint8Array([i]));
}
await Promise.all([allReceived.promise, allStatusReceived.promise]);
// 3 abandoned (datagrams 1, 2, 3) and 2 acknowledged (datagrams 4, 5).
strictEqual(clientAbandonCounter, 3);
strictEqual(clientAckCounter, 2);
await clientSession.closed;
await serverEndpoint.close();
+70
View File
@@ -0,0 +1,70 @@
// Flags: --experimental-quic --no-warnings
// Test: datagram server-to-client and echo round-trip
// Server sends datagram, client receives via ondatagram.
// Datagram echo — client sends, server echoes back in
// its ondatagram callback (queued, flushed via SendPendingData).
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import * as fixtures from '../common/fixtures.mjs';
const { setTimeout } = await import('node:timers/promises');
const { ok, strictEqual } = assert;
const { readKey } = fixtures;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('node:quic');
const { createPrivateKey } = await import('node:crypto');
const key = createPrivateKey(readKey('agent1-key.pem'));
const cert = readKey('agent1-cert.pem');
const serverGot = Promise.withResolvers();
const clientGot = Promise.withResolvers();
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await Promise.all([serverSession.opened, serverGot.promise]);
// Give time for the echo to be sent before closing.
await setTimeout(100);
await serverSession.close();
}), {
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['quic-test'],
transportParams: { maxDatagramFrameSize: 10 },
// Server echoes received datagram data back to client.
// The sendDatagram call happens inside ondatagram (ngtcp2 callback
// scope). The datagram is queued and flushed by SendPendingData.
ondatagram: mustCall((data, early, session) => {
ok(data instanceof Uint8Array);
ok(!early);
session.sendDatagram(data);
serverGot.resolve();
}),
});
const clientSession = await connect(serverEndpoint.address, {
alpn: 'quic-test',
transportParams: { maxDatagramFrameSize: 10 },
// Client receives datagram from server.
ondatagram: mustCall(function(data) {
ok(data instanceof Uint8Array);
strictEqual(data.byteLength, 3);
strictEqual(data[0], 10);
strictEqual(data[1], 20);
strictEqual(data[2], 30);
clientGot.resolve();
}),
});
await clientSession.opened;
// Client sends datagram to trigger the echo.
await clientSession.sendDatagram(new Uint8Array([10, 20, 30]));
await Promise.all([serverGot.promise, clientGot.promise, clientSession.closed]);
await serverEndpoint.close();
@@ -0,0 +1,93 @@
// Flags: --experimental-quic --no-warnings
// Test: datagram edge cases.
// DGRAM-08 / DGIMP-08: Zero-length datagram returns 0n (not sent).
// DGC-01 / DGIMP-09: maxDatagramFrameSize: 0 disables datagrams entirely.
// Datagram arrives with no ondatagram callback — no crash.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import { setTimeout } from 'node:timers/promises';
const { strictEqual, notStrictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
// --- DGRAM-08 / DGIMP-08: Zero-length datagram returns 0n ---
{
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverSession.closed;
}), {
transportParams: { maxIdleTimeout: 1, maxDatagramFrameSize: 1200 },
});
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxIdleTimeout: 1, maxDatagramFrameSize: 1200 },
});
await clientSession.opened;
// Zero-length ArrayBufferView
const zeroId = await clientSession.sendDatagram(new Uint8Array(0));
strictEqual(zeroId, 0n);
// Zero-length string
const emptyStringId = await clientSession.sendDatagram('');
strictEqual(emptyStringId, 0n);
await clientSession.close();
await serverEndpoint.close();
}
// --- DGC-01 / DGIMP-09: maxDatagramFrameSize: 0 disables datagrams ---
{
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverSession.closed;
}), {
// Server advertises 0 — no datagrams accepted.
transportParams: { maxIdleTimeout: 1, maxDatagramFrameSize: 0 },
});
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxIdleTimeout: 1, maxDatagramFrameSize: 10 },
});
await clientSession.opened;
// maxDatagramSize reflects the peer's (server's) transport param.
strictEqual(clientSession.maxDatagramSize, 0);
// Sending returns 0n immediately — datagram not sent.
const id = await clientSession.sendDatagram(new Uint8Array([1, 2, 3]));
strictEqual(id, 0n);
await clientSession.close();
await serverEndpoint.close();
}
// --- DGRAM-11: No ondatagram callback — no crash ---
{
const serverEndpoint = await listen(mustCall(async (serverSession) => {
// No ondatagram set — datagrams arrive but are silently discarded.
await serverSession.opened;
// Give time for the datagram to arrive and be processed without crash.
await setTimeout(200);
await serverSession.close();
}), {
transportParams: { maxDatagramFrameSize: 1200 },
});
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxDatagramFrameSize: 1200 },
});
await clientSession.opened;
// Send a datagram even though the server has no ondatagram handler.
const id = await clientSession.sendDatagram(new Uint8Array([1, 2, 3]));
notStrictEqual(id, 0n);
await clientSession.closed;
await serverEndpoint.close();
}
@@ -0,0 +1,58 @@
// Flags: --experimental-quic --no-warnings
// Test: maxDatagramFrameSize transport param validation.
// The maxDatagramFrameSize transport parameter must be a uint16
// (0-65535). Values outside this range or of the wrong type should
// be rejected.
import { hasQuic, skip, mustNotCall } from '../common/index.mjs';
import assert from 'node:assert';
import * as fixtures from '../common/fixtures.mjs';
const { rejects } = assert;
const { readKey } = fixtures;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen } = await import('node:quic');
const { createPrivateKey } = await import('node:crypto');
const key = createPrivateKey(readKey('agent1-key.pem'));
const cert = readKey('agent1-cert.pem');
const sni = { '*': { keys: [key], certs: [cert] } };
const alpn = ['quic-test'];
// Invalid values for maxDatagramFrameSize — must be rejected.
const invalid = [
-1,
65536,
1.5,
'a',
null,
false,
true,
{},
[],
() => {},
];
for (const maxDatagramFrameSize of invalid) {
const transportParams = { maxDatagramFrameSize };
await rejects(
listen(mustNotCall(), { sni, alpn, transportParams }),
{ code: 'ERR_INVALID_ARG_VALUE' },
`listen should reject maxDatagramFrameSize: ${maxDatagramFrameSize}`,
);
}
// Valid values — should not throw.
const valid = [0, 1, 100, 1200, 65535];
for (const maxDatagramFrameSize of valid) {
const transportParams = { maxDatagramFrameSize };
const ep = await listen(mustNotCall(), { sni, alpn, transportParams });
ep.close();
await ep.closed;
}
@@ -0,0 +1,84 @@
// Flags: --experimental-quic --experimental-stream-iter --no-warnings
// Test: multiple datagrams and datagrams alongside streams
// Client sends multiple datagrams.
// Datagrams sent alongside an active bidi stream.
// Datagrams are unreliable — we verify at least some arrive.
// The stream is opened first to establish bidirectional traffic,
// keeping the congestion window healthy for datagram sends.
import { hasQuic, skip, mustCall, mustCallAtLeast } from '../common/index.mjs';
import assert from 'node:assert';
import * as fixtures from '../common/fixtures.mjs';
const { ok } = assert;
const { readKey } = fixtures;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('node:quic');
const { createPrivateKey } = await import('node:crypto');
const { bytes } = await import('stream/iter');
const key = createPrivateKey(readKey('agent1-key.pem'));
const cert = readKey('agent1-cert.pem');
const numDatagrams = 5;
let serverDatagramCount = 0;
const gotSomeDg = Promise.withResolvers();
const streamDone = Promise.withResolvers();
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverSession.opened;
// Server receives stream data alongside datagrams.
serverSession.onstream = mustCall(async (stream) => {
stream.writer.endSync();
await bytes(stream);
await stream.closed;
streamDone.resolve();
});
await Promise.all([gotSomeDg.promise, streamDone.promise]);
await serverSession.close();
}), {
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['quic-test'],
transportParams: { maxDatagramFrameSize: 1200 },
ondatagram: mustCallAtLeast((data) => {
ok(data instanceof Uint8Array);
serverDatagramCount++;
gotSomeDg.resolve();
}),
});
const clientSession = await connect(serverEndpoint.address, {
alpn: 'quic-test',
transportParams: { maxDatagramFrameSize: 1200 },
});
await clientSession.opened;
// Open a stream FIRST to establish bidirectional traffic.
// This ensures ACKs flow back from the server, keeping the
// congestion window open for subsequent datagram sends.
const stream = await clientSession.createBidirectionalStream({
body: new TextEncoder().encode('hello'),
});
// Send multiple datagrams alongside the active stream.
for (let i = 0; i < numDatagrams; i++) {
await clientSession.sendDatagram(new Uint8Array([i]));
}
// Complete the stream.
for await (const _ of stream) { /* drain */ } // eslint-disable-line no-unused-vars
await stream.closed;
// At least some datagrams should have arrived.
ok(serverDatagramCount > 0, 'Server should have received at least one datagram');
await clientSession.closed;
await serverEndpoint.close();
@@ -0,0 +1,72 @@
// Flags: --experimental-quic --no-warnings
// session.sendDatagram() must not detach the caller's underlying
// ArrayBuffer. The bytes are copied into an internal buffer on every
// send, so the caller's source ArrayBuffer remains live and may be
// reused for further sends, mutated, or sliced into additional views.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { strictEqual, deepStrictEqual, notStrictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const received = [];
const allReceived = Promise.withResolvers();
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await allReceived.promise;
await serverSession.close();
}), {
transportParams: { maxDatagramFrameSize: 1200 },
ondatagram: mustCall((data) => {
received.push(Buffer.from(data));
if (received.length === 3) allReceived.resolve();
}, 3),
});
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxDatagramFrameSize: 1200 },
});
await clientSession.opened;
// A full-buffer Uint8Array. The same source must be reusable across
// multiple sendDatagram calls.
const source = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
const firstId = await clientSession.sendDatagram(source);
notStrictEqual(firstId, 0n);
strictEqual(source.buffer.detached, false,
'source ArrayBuffer must not be detached after sendDatagram');
const secondId = await clientSession.sendDatagram(source);
notStrictEqual(secondId, 0n);
strictEqual(source.buffer.detached, false,
'source ArrayBuffer must remain live after second sendDatagram');
// Mutating the source after the previous sendDatagram returned must
// not affect what the peer ultimately receives — the bytes have
// already been copied into the QUIC layer's internal buffer.
source[0] = 99;
const thirdId = await clientSession.sendDatagram(source);
notStrictEqual(thirdId, 0n);
await allReceived.promise;
// Datagrams are unreliable and may be reordered, but the test fixture
// does not exercise loss or reordering — assert by sorting.
const sorted = received.map((b) => b.toString('hex')).sort();
const expected = [
Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]).toString('hex'),
Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]).toString('hex'),
Buffer.from([99, 2, 3, 4, 5, 6, 7, 8]).toString('hex'),
].sort();
deepStrictEqual(sorted, expected);
await clientSession.closed;
await serverEndpoint.close();
@@ -0,0 +1,64 @@
// Flags: --experimental-quic --no-warnings
// Test: datagram size limit enforcement.
// Datagram larger than maxDatagramSize returns 0n (not sent).
// Datagram at exactly maxDatagramSize is accepted and delivered.
// Same as DGRAM-03 via sendDatagram return value.
// maxDatagramSize reflects the maximum datagram payload the peer can
// receive, accounting for DATAGRAM frame overhead (type byte + varint
// length encoding). It is derived from the peer's maxDatagramFrameSize
// transport parameter minus the frame overhead.
// We use maxDatagramFrameSize: 200 so that the exact-max datagram fits
// comfortably within a QUIC packet (which has its own header + AEAD
// overhead on top of the DATAGRAM frame).
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { ok, strictEqual, notStrictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const serverGot = Promise.withResolvers();
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverGot.promise;
serverSession.close();
await serverSession.closed;
}), {
transportParams: { maxDatagramFrameSize: 200 },
ondatagram: mustCall((data) => {
ok(data instanceof Uint8Array);
serverGot.resolve();
}),
});
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxDatagramFrameSize: 200 },
});
await clientSession.opened;
const maxSize = clientSession.maxDatagramSize;
// maxDatagramSize should be less than maxDatagramFrameSize due to
// the DATAGRAM frame overhead (1 byte type + varint length encoding).
ok(maxSize > 0);
ok(maxSize < 200);
// DGRAM-03 / DGIMP-10: Datagram too large — returns 0n.
const oversized = new Uint8Array(maxSize + 1);
const tooLargeId = await clientSession.sendDatagram(oversized);
strictEqual(tooLargeId, 0n);
// Datagram at exactly maxDatagramSize — accepted and delivered.
const exactMax = new Uint8Array(maxSize);
exactMax[0] = 42;
const exactId = await clientSession.sendDatagram(exactMax);
notStrictEqual(exactId, 0n);
await Promise.all([serverGot.promise, clientSession.closed]);
await serverEndpoint.close();
@@ -0,0 +1,220 @@
// Flags: --experimental-quic --no-warnings
// Test: sendDatagram with various input source types.
// String with custom encoding (e.g., 'hex').
// Promise input — resolves then sends.
// Promise input — session closes during await, returns 0n.
// SharedArrayBuffer copies instead of transfers.
// Pooled Buffer (partial view) copies correctly.
// DataView input.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { deepStrictEqual, notStrictEqual, strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
// --- DGIMP-01: String with custom encoding ---
{
const received = [];
const allReceived = Promise.withResolvers();
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await allReceived.promise;
await serverSession.close();
}), {
transportParams: { maxDatagramFrameSize: 1200 },
ondatagram: mustCall((data) => {
received.push(Buffer.from(data));
if (received.length === 2) allReceived.resolve();
}, 2),
});
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxDatagramFrameSize: 1200 },
});
await clientSession.opened;
// Send hex-encoded string — '48656c6c6f' is 'Hello' in hex.
const hexId = await clientSession.sendDatagram('48656c6c6f', 'hex');
notStrictEqual(hexId, 0n);
// Send base64-encoded string — 'V29ybGQ=' is 'World' in base64.
const b64Id = await clientSession.sendDatagram('V29ybGQ=', 'base64');
notStrictEqual(b64Id, 0n);
await allReceived.promise;
deepStrictEqual(received[0], Buffer.from('Hello'));
deepStrictEqual(received[1], Buffer.from('World'));
await clientSession.closed;
await serverEndpoint.close();
}
// --- DGIMP-02: Promise input ---
{
const serverGot = Promise.withResolvers();
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverGot.promise;
await serverSession.close();
}), {
transportParams: { maxDatagramFrameSize: 1200 },
ondatagram: mustCall((data) => {
deepStrictEqual(Buffer.from(data), Buffer.from([42]));
serverGot.resolve();
}),
});
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxDatagramFrameSize: 1200 },
});
await clientSession.opened;
// Send a Promise that resolves to a Uint8Array.
const promiseId = await clientSession.sendDatagram(
Promise.resolve(new Uint8Array([42])),
);
notStrictEqual(promiseId, 0n);
await Promise.all([serverGot.promise, clientSession.closed]);
await serverEndpoint.close();
}
// --- DGIMP-03: Promise input, session closes during await ---
{
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverSession.closed;
}), {
transportParams: { maxIdleTimeout: 1, maxDatagramFrameSize: 1200 },
onerror() {},
});
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxIdleTimeout: 1, maxDatagramFrameSize: 1200 },
});
await clientSession.opened;
// Create a promise that resolves after the session starts closing.
// sendDatagram passes the initial checks, then awaits the promise.
// While awaiting, the session closes. When the promise resolves,
// sendDatagram finds the session closed and returns 0n.
const slowPromise = new Promise((resolve) => {
setImmediate(mustCall(async () => {
await clientSession.close();
resolve(new Uint8Array([1]));
}));
});
const id = await clientSession.sendDatagram(slowPromise);
strictEqual(id, 0n);
await serverEndpoint.close();
}
// --- DGIMP-04: SharedArrayBuffer ---
{
const serverGot = Promise.withResolvers();
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverGot.promise;
await serverSession.close();
}), {
transportParams: { maxDatagramFrameSize: 1200 },
ondatagram: mustCall((data) => {
deepStrictEqual(Buffer.from(data), Buffer.from([10, 20, 30]));
serverGot.resolve();
}),
});
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxDatagramFrameSize: 1200 },
});
await clientSession.opened;
// Create a SharedArrayBuffer-backed view.
const sab = new SharedArrayBuffer(3);
const view = new Uint8Array(sab);
view[0] = 10;
view[1] = 20;
view[2] = 30;
const id = await clientSession.sendDatagram(view);
notStrictEqual(id, 0n);
// The SharedArrayBuffer should still be usable (copied, not transferred).
strictEqual(view[0], 10);
await Promise.all([serverGot.promise, clientSession.closed]);
await serverEndpoint.close();
}
// --- DGIMP-05: Pooled Buffer (partial view) ---
{
const serverGot = Promise.withResolvers();
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverGot.promise;
await serverSession.close();
}), {
transportParams: { maxDatagramFrameSize: 1200 },
ondatagram: mustCall((data) => {
// The received data should match the slice content.
deepStrictEqual(Buffer.from(data), Buffer.from('hello'));
serverGot.resolve();
}),
});
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxDatagramFrameSize: 1200 },
});
await clientSession.opened;
// Buffer.from('hello') creates a pooled buffer — its backing
// ArrayBuffer is larger and the view has a non-zero offset.
const pooledBuf = Buffer.from('hello');
const id = await clientSession.sendDatagram(pooledBuf);
notStrictEqual(id, 0n);
await Promise.all([serverGot.promise, clientSession.closed]);
await serverEndpoint.close();
}
// --- DGIMP-06: DataView ---
{
const serverGot = Promise.withResolvers();
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverGot.promise;
await serverSession.close();
}), {
transportParams: { maxDatagramFrameSize: 1200 },
ondatagram: mustCall((data) => {
deepStrictEqual(Buffer.from(data), Buffer.from([0xCA, 0xFE]));
serverGot.resolve();
}),
});
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxDatagramFrameSize: 1200 },
});
await clientSession.opened;
const ab = new ArrayBuffer(4);
const fullView = new Uint8Array(ab);
fullView.set([0xDE, 0xAD, 0xCA, 0xFE]);
// DataView over bytes [2, 3] of the buffer.
const dv = new DataView(ab, 2, 2);
const id = await clientSession.sendDatagram(dv);
notStrictEqual(id, 0n);
await Promise.all([serverGot.promise, clientSession.closed]);
await serverEndpoint.close();
}
@@ -0,0 +1,76 @@
// Flags: --experimental-quic --no-warnings
// Test: ondatagramstatus callback.
// After sending a datagram, the ondatagramstatus callback fires
// with the datagram ID and either 'acknowledged' or 'lost'.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import * as fixtures from '../common/fixtures.mjs';
const { setTimeout } = await import('node:timers/promises');
const { ok, strictEqual } = assert;
const { readKey } = fixtures;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('node:quic');
const { createPrivateKey } = await import('node:crypto');
const key = createPrivateKey(readKey('agent1-key.pem'));
const cert = readKey('agent1-cert.pem');
const serverGot = Promise.withResolvers();
const statusReceived = Promise.withResolvers();
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await Promise.all([serverSession.opened, serverGot.promise]);
// Give a moment for the ACK to propagate to the client so the
// ondatagramstatus callback fires before the session closes.
await setTimeout(100);
await serverSession.close();
}), {
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['quic-test'],
transportParams: { maxDatagramFrameSize: 1200 },
ondatagram: mustCall(function() {
serverGot.resolve();
}),
});
let statusId;
let statusValue;
const clientSession = await connect(serverEndpoint.address, {
alpn: 'quic-test',
transportParams: { maxDatagramFrameSize: 1200 },
ondatagramstatus: mustCall((id, status) => {
strictEqual(typeof id, 'bigint');
strictEqual(typeof status, 'string');
ok(
status === 'acknowledged' || status === 'lost' || status === 'abandoned',
`status should be 'acknowledged', 'lost', or 'abandoned', got '${status}'`,
);
statusId = id;
statusValue = status;
statusReceived.resolve();
}),
});
await clientSession.opened;
const id = await clientSession.sendDatagram(new Uint8Array([1, 2, 3]));
// Wait for the server to receive and the status callback to fire.
await Promise.all([serverGot.promise, statusReceived.promise]);
// The status callback should have been called with the same ID.
strictEqual(statusId, id);
// On localhost the datagram should be acknowledged, not lost.
strictEqual(statusValue, 'acknowledged');
await clientSession.closed;
await serverEndpoint.close();
+46
View File
@@ -0,0 +1,46 @@
// Flags: --experimental-quic --no-warnings
// Test: string datagram with multi-byte UTF-8 characters.
// Verifies that sendDatagram with a string containing multi-byte UTF-8
// characters (CJK, emoji, etc.) encodes correctly and the receiver
// gets the exact bytes.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { ok, deepStrictEqual, strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const message = '\u4f60\u597d\u4e16\u754c'; // "Hello World" in Chinese
const expected = Buffer.from(message, 'utf8');
const serverGot = Promise.withResolvers();
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverGot.promise;
await serverSession.close();
}), {
transportParams: { maxDatagramFrameSize: 1200 },
ondatagram: mustCall((data) => {
ok(data instanceof Uint8Array);
// Verify the received bytes match the UTF-8 encoding.
deepStrictEqual(Buffer.from(data), expected);
serverGot.resolve();
}),
});
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxDatagramFrameSize: 1200 },
});
await clientSession.opened;
const id = await clientSession.sendDatagram(message);
strictEqual(id, 1n);
await Promise.all([serverGot.promise, clientSession.closed]);
await serverEndpoint.close();
+62
View File
@@ -0,0 +1,62 @@
// Flags: --experimental-quic --no-warnings
// Test: basic datagram send and receive.
// Client sends datagram, server receives via ondatagram.
// maxDatagramSize reflects peer's transport param.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import * as fixtures from '../common/fixtures.mjs';
const { ok, strictEqual } = assert;
const { readKey } = fixtures;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('node:quic');
const { createPrivateKey } = await import('node:crypto');
const key = createPrivateKey(readKey('agent1-key.pem'));
const cert = readKey('agent1-cert.pem');
const serverGot = Promise.withResolvers();
const serverEndpoint = await listen(mustCall(async (serverSession) => {
await serverSession.opened;
// maxDatagramSize reflects peer's max payload (frame size
// minus DATAGRAM frame overhead of type byte + varint length).
ok(serverSession.maxDatagramSize > 0);
ok(serverSession.maxDatagramSize < 1200);
// Wait for the datagram before closing.
await serverGot.promise;
await serverSession.close();
}), {
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['quic-test'],
transportParams: { maxDatagramFrameSize: 1200 },
ondatagram: mustCall((data) => {
ok(data instanceof Uint8Array);
strictEqual(data.byteLength, 3);
serverGot.resolve();
}),
});
const clientSession = await connect(serverEndpoint.address, {
alpn: 'quic-test',
transportParams: { maxDatagramFrameSize: 1200 },
});
await clientSession.opened;
// Client maxDatagramSize reflects actual payload max.
ok(clientSession.maxDatagramSize > 0);
ok(clientSession.maxDatagramSize < 1200);
// Client sends datagram.
const id = await clientSession.sendDatagram(new Uint8Array([1, 2, 3]));
assert.strictEqual(id, 1n);
await Promise.all([serverGot.promise, clientSession.closed]);
await serverEndpoint.close();
@@ -0,0 +1,55 @@
// Flags: --experimental-quic --no-warnings
// Test: default transport parameter limits are reasonable.
// Verify that the default transport parameters have sane values:
// not zero (which would prevent streams), and not excessively large
// (which could waste resources).
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
const { ok } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const serverEndpoint = await listen(mustCall(async (serverSession) => {
const info = await serverSession.opened;
// The handshake info should be available.
ok(info, 'handshake info should be available');
await serverSession.closed;
}));
const clientSession = await connect(serverEndpoint.address, {
reuseEndpoint: false,
});
const info = await clientSession.opened;
// Verify the handshake completed and we can inspect the session.
ok(info, 'handshake info should be available');
// Check that the session has reasonable default stream limits by
// verifying we can create at least one bidirectional and one
// unidirectional stream.
const bidiStream = await clientSession.createBidirectionalStream();
ok(bidiStream, 'should be able to create a bidi stream');
bidiStream.destroy();
const uniStream = await clientSession.createUnidirectionalStream();
ok(uniStream, 'should be able to create a uni stream');
uniStream.destroy();
// Check the endpoint's maxConnectionsPerHost and maxConnectionsTotal
// defaults are either 0 (unlimited) or a reasonable positive number.
const maxPerHost = serverEndpoint.maxConnectionsPerHost;
const maxTotal = serverEndpoint.maxConnectionsTotal;
ok(maxPerHost >= 0, 'maxConnectionsPerHost should be non-negative');
ok(maxTotal >= 0, 'maxConnectionsTotal should be non-negative');
await clientSession.close();
await serverEndpoint.close();
@@ -0,0 +1,44 @@
// Flags: --experimental-quic --no-warnings
// Test: diagnostics_channel endpoint busy change.
// quic.endpoint.busy.change fires on endpoint.busy toggle.
import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs';
import assert from 'node:assert';
import dc from 'node:diagnostics_channel';
import * as fixtures from '../common/fixtures.mjs';
const { ok, strictEqual } = assert;
const { readKey } = fixtures;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, QuicEndpoint } = await import('node:quic');
const { createPrivateKey } = await import('node:crypto');
const key = createPrivateKey(readKey('agent1-key.pem'));
const cert = readKey('agent1-cert.pem');
let busyChangeCount = 0;
dc.subscribe('quic.endpoint.busy.change', mustCall((msg) => {
busyChangeCount++;
ok(msg.endpoint);
strictEqual(typeof msg.busy, 'boolean');
}, 2));
const endpoint = new QuicEndpoint();
const serverEndpoint = await listen(mustNotCall(), {
endpoint,
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['quic-test'],
});
// Toggle busy on and off.
endpoint.busy = true;
endpoint.busy = false;
strictEqual(busyChangeCount, 2);
await serverEndpoint.close();
@@ -0,0 +1,48 @@
// Flags: --experimental-quic --no-warnings
// Test: diagnostics_channel datagram status event.
// quic.session.receive.datagram.status fires with ack/lost status.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import dc from 'node:diagnostics_channel';
const { ok, strictEqual } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const statusDone = Promise.withResolvers();
// quic.session.receive.datagram.status fires with status.
dc.subscribe('quic.session.receive.datagram.status', mustCall((msg) => {
ok(msg.session);
ok(msg.id);
strictEqual(msg?.status, 'acknowledged');
statusDone.resolve();
}));
const serverEndpoint = await listen(async (serverSession) => {
// Server stays alive until the client closes so the ACK
// has time to propagate back to the client.
await serverSession.closed;
}, {
transportParams: { maxDatagramFrameSize: 1200 },
ondatagram() {},
});
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxDatagramFrameSize: 1200 },
ondatagramstatus() {},
});
await clientSession.opened;
await clientSession.sendDatagram(new Uint8Array([1, 2, 3]));
// Wait for the status event before closing.
await statusDone.promise;
await clientSession.close();
await serverEndpoint.close();
@@ -0,0 +1,52 @@
// Flags: --experimental-quic --no-warnings
// Test: diagnostics_channel datagram events.
// quic.session.receive.datagram fires on datagram receipt.
// quic.session.send.datagram fires on datagram send.
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import dc from 'node:diagnostics_channel';
const { ok } = assert;
if (!hasQuic) {
skip('QUIC is not enabled');
}
const { listen, connect } = await import('../common/quic.mjs');
const serverGot = Promise.withResolvers();
// quic.session.send.datagram fires on send.
dc.subscribe('quic.session.send.datagram', mustCall((msg) => {
ok(msg.session);
ok(msg.id);
ok(msg.length > 0);
}));
// quic.session.receive.datagram fires on receipt.
dc.subscribe('quic.session.receive.datagram', mustCall((msg) => {
ok(msg.session);
ok(msg.length > 0);
}));
const serverEndpoint = await listen(async (serverSession) => {
await serverSession.closed;
}, {
transportParams: { maxDatagramFrameSize: 1200 },
ondatagram() {
serverGot.resolve();
},
});
const clientSession = await connect(serverEndpoint.address, {
transportParams: { maxDatagramFrameSize: 1200 },
});
await clientSession.opened;
await clientSession.sendDatagram(new Uint8Array([1, 2, 3]));
await serverGot.promise;
await clientSession.close();
await serverEndpoint.close();
Loaded 100 of 287 files, more files were not shown because too many files have changed in this diff. Show more