Skip to content

Commit 745a7aa

Browse files
committed
Make QUIC stream truncation behaviour configurable
1 parent a63d381 commit 745a7aa

14 files changed

Lines changed: 431 additions & 237 deletions

doc/api/quic.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3174,6 +3174,31 @@ value, PING frames will be sent automatically to keep the connection alive
31743174
before the idle timeout fires. The value should be less than the effective
31753175
idle timeout (`maxIdleTimeout` transport parameter) to be useful.
31763176

3177+
#### `sessionOptions.truncatedReads`
3178+
3179+
* Type: {string} One of `'error'` or `'allow'`.
3180+
* **Default:** `'error'`
3181+
3182+
Controls how reading a stream reports a truncated read. A stream's read side
3183+
can end without receiving a QUIC FIN, meaning the peer never signalled that
3184+
the whole stream had been sent and the data received may be incomplete. This
3185+
selects how the stream's async iterator reports this:
3186+
3187+
* `'error'` - The default. Peers are expected to always send a FIN to end
3188+
their data explicitly, and so any truncation is an error. The iterator yields
3189+
the data that did arrive and then throws, so an incomplete stream can never
3190+
be mistaken for a complete one. Incomplete streams will either throw a
3191+
`ERR_QUIC_STREAM_RESET` carrying the peer's error code, a connection error,
3192+
or `ERR_QUIC_STREAM_ABORTED` for other cases.
3193+
3194+
* `'allow'` - Truncated reads are allowed: only a stream or connection error
3195+
is reported, and any clean abort/cancellation or similar simply ends the
3196+
stream. A non-zero peer reset still throws `ERR_QUIC_STREAM_RESET` and a
3197+
connection error still throws its real error, but a truncation that carried
3198+
no error (an idle timeout, a graceful close, a local `stopSending()`) ends
3199+
the read cleanly with the data received. This matches `stream.closed`,
3200+
which rejects only on an error.
3201+
31773202
#### `sessionOptions.verifyPeer` (client only)
31783203

31793204
* Type: {string} One of `'strict'`, `'auto'`, or `'manual'`.

lib/internal/blob.js

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -574,13 +574,7 @@ function createBlobReaderStream(reader) {
574574
// unbounded memory growth when the DataQueue has a large burst of data.
575575
const kMaxBatchChunks = 16;
576576

577-
async function* createBlobReaderIterable(reader, options = kEmptyObject) {
578-
// getEndError(status) lets the caller map a read ending to an error, or null.
579-
// It is consulted both on a read failure (status < 0, always an error - we
580-
// fall back to a generic one if the caller returns null) and on a clean EOS
581-
// (status 0, an error only if the caller returns one, e.g. a stream that
582-
// ended before its FIN).
583-
const { getEndError } = options;
577+
async function* createBlobReaderIterable(reader) {
584578
let wakeup = PromiseWithResolvers();
585579
reader.setWakeup(wakeup.resolve);
586580

@@ -606,9 +600,7 @@ async function* createBlobReaderIterable(reader, options = kEmptyObject) {
606600
break;
607601
}
608602
if (pullResult.status < 0) {
609-
error = (typeof getEndError === 'function' &&
610-
getEndError(pullResult.status)) ||
611-
new ERR_INVALID_STATE('The reader is not readable');
603+
error = new ERR_INVALID_STATE('The reader is not readable');
612604
break;
613605
}
614606
if (pullResult.status === 2) {
@@ -623,11 +615,7 @@ async function* createBlobReaderIterable(reader, options = kEmptyObject) {
623615
yield batch;
624616
}
625617

626-
if (eos) {
627-
const eosError = typeof getEndError === 'function' ? getEndError(0) : null;
628-
if (eosError) throw eosError;
629-
return;
630-
}
618+
if (eos) return;
631619
if (error) throw error;
632620

633621
if (blocked) {

lib/internal/quic/quic.js

Lines changed: 71 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ const {
1212
DataViewPrototypeGetByteLength,
1313
ErrorCaptureStackTrace,
1414
FunctionPrototypeBind,
15-
Number,
1615
ObjectDefineProperties,
1716
ObjectKeys,
1817
PromisePrototypeThen,
@@ -113,8 +112,6 @@ const {
113112
ERR_QUIC_CONNECTION_FAILED,
114113
ERR_QUIC_ENDPOINT_CLOSED,
115114
ERR_QUIC_OPEN_STREAM_FAILED,
116-
ERR_QUIC_STREAM_ABORTED,
117-
ERR_QUIC_STREAM_RESET,
118115
ERR_QUIC_VERSION_NEGOTIATION_ERROR,
119116
},
120117
} = require('internal/errors');
@@ -396,6 +393,7 @@ const endpointRegistry = new SafeSet();
396393
* @property {number} [minVersion] The minimum acceptable QUIC version
397394
* @property {'use'|'ignore'|'default'} [preferredAddressPolicy] The preferred address policy
398395
* @property {'strict'|'auto'|'manual'} [verifyPeer='auto'] Peer certificate verification policy (client only)
396+
* @property {'error'|'allow'} [truncatedReads] Truncated read policy
399397
* @property {ApplicationOptions} [application] The application options
400398
* @property {TransportParams} [transportParams] The transport parameters
401399
* @property {string} [servername] The server name identifier (client only)
@@ -1560,6 +1558,8 @@ class QuicStream {
15601558
state: undefined,
15611559
stats: undefined,
15621560
pendingClose: undefined,
1561+
destroyError: undefined,
1562+
truncatedReads: undefined,
15631563
reader: undefined,
15641564
destroying: false,
15651565
iteratorLocked: false,
@@ -1606,9 +1606,10 @@ class QuicStream {
16061606
* @param {object} handle
16071607
* @param {QuicSession} session
16081608
* @param {number} direction
1609-
* @param {boolean} [isLocal]
1609+
* @param {boolean} isLocal
1610+
* @param {'error'|'allow'} truncatedReads
16101611
*/
1611-
constructor(privateSymbol, handle, session, direction, isLocal) {
1612+
constructor(privateSymbol, handle, session, direction, isLocal, truncatedReads) {
16121613
assertPrivateSymbol(privateSymbol);
16131614

16141615
this.#handle = handle;
@@ -1617,6 +1618,7 @@ class QuicStream {
16171618
inner.session = session;
16181619
inner.direction = direction;
16191620
inner.isLocal = isLocal;
1621+
inner.truncatedReads = truncatedReads;
16201622
inner.state = new QuicStreamState(
16211623
kPrivateConstructor, handle.state, handle.stateByteOffset);
16221624

@@ -1644,39 +1646,41 @@ class QuicStream {
16441646
inner.iteratorLocked = true;
16451647

16461648
inner.reader ??= this.#handle?.getReader();
1647-
// Non-readable stream (outbound-only unidirectional, or closed)
1648-
if (!inner.reader) return;
1649-
1650-
// Maps the read side's end state to the error to surface, or null if it
1651-
// ended cleanly. state.finReceived is set only when the peer explicitly
1652-
// sent a FIN, confirming we got the whole stream - the one and only clean
1653-
// ending. Returns null in that case. Without a FIN the read side is
1654-
// truncated, for one of these reasons:
1655-
// * Peer sent us a RESET_STREAM. The C++ side records the
1656-
// code in state.resetCode regardless of whether the JS
1657-
// onreset handler was attached. state.finReceived stays
1658-
// false because no FIN was seen.
1659-
// * We aborted locally via stream.stopSending(). This runs EndReadable
1660-
// in C++, setting state.readEnded but not state.finReceived. There
1661-
// is no peer code to surface.
1662-
// * The session was torn down before a FIN arrived (a local or peer
1663-
// connection close, an idle timeout, or any error), again leaving
1664-
// state.finReceived false.
1665-
const readTruncationError = () => {
1666-
if (inner.state.readEnded && !inner.state.finReceived) {
1667-
const peerResetCode = inner.state.resetCode;
1668-
if (peerResetCode !== undefined && peerResetCode > 0n) {
1669-
return new ERR_QUIC_STREAM_RESET(Number(peerResetCode));
1670-
}
1671-
return new ERR_QUIC_STREAM_ABORTED(
1672-
'Stream aborted before FIN was received');
1649+
// No reader means either a outbound-only unidirectional stream, or a
1650+
// stream already destroyed (data gone, but truncation must still be
1651+
// checked below).
1652+
if (inner.reader) {
1653+
yield* createBlobReaderIterable(inner.reader);
1654+
}
1655+
1656+
if (inner.state.readEnded && !inner.state.finReceived) {
1657+
// The readable has been truncated - ended with no clean FIN. We expose
1658+
// this in different ways depending on the truncatedReads option.
1659+
1660+
// Non-zero reset is always an error:
1661+
const peerResetCode = inner.state.resetCode;
1662+
if (peerResetCode > 0n) {
1663+
throw new QuicError(
1664+
`The QUIC stream was reset by the peer with error code ${peerResetCode}`,
1665+
{ __proto__: null,
1666+
code: 'ERR_QUIC_STREAM_RESET',
1667+
errorCode: peerResetCode });
16731668
}
1674-
return null;
1675-
};
16761669

1677-
yield* createBlobReaderIterable(inner.reader, {
1678-
getEndError: readTruncationError,
1679-
});
1670+
// If stream teardown has started (stats.destroyedAt is set) then a
1671+
// close event confirming a final error/clean close will settle
1672+
// imminently (might be settled already). We await here to rethrow
1673+
// any errors if the connection has failed.
1674+
if (this.destroyed || this.stats.destroyedAt !== 0n) {
1675+
await this.closed;
1676+
}
1677+
1678+
// Clean abort is truncation, but not necessarily an error:
1679+
if (inner.truncatedReads === 'error') {
1680+
throw new QuicError('Stream aborted before FIN was received',
1681+
{ __proto__: null, errorCode: peerResetCode ?? 0n });
1682+
}
1683+
}
16801684
}
16811685

16821686
/**
@@ -2028,9 +2032,10 @@ class QuicStream {
20282032
if (error !== undefined && typeof inner.onerror === 'function') {
20292033
invokeOnerror(inner.onerror, error);
20302034
}
2031-
const handle = this.#handle;
2032-
this[kFinishClose](error);
2033-
handle.destroy();
2035+
// handle.destroy() kicks off all the cleanup internals, eventually
2036+
// including [kFinishClose] which needs this original destroy error:
2037+
inner.destroyError = error;
2038+
this.#handle.destroy();
20342039
}
20352040

20362041
/**
@@ -2511,6 +2516,9 @@ class QuicStream {
25112516
if (this.destroyed) {
25122517
return inner.pendingClose.promise;
25132518
}
2519+
// Prefer an error staged by destroy() (the original object the caller
2520+
// passed) over the error delivered by the native close callback.
2521+
error = inner.destroyError ?? error;
25142522
if (error !== undefined) {
25152523
inner.pendingClose.reject(error);
25162524
} else {
@@ -2726,6 +2734,7 @@ class QuicSession {
27262734
// because server-side cert validation is handled by rejectUnauthorized
27272735
// at the C++ level.
27282736
verifyPeer: 'manual',
2737+
truncatedReads: 'error',
27292738
handshakeInfo: undefined,
27302739
/** @type {QuicSessionPath|undefined} */
27312740
path: undefined,
@@ -2757,8 +2766,9 @@ class QuicSession {
27572766
* @param {symbol} privateSymbol
27582767
* @param {object} handle
27592768
* @param {QuicEndpoint} endpoint
2769+
* @param {{ truncatedReads?: 'error'|'allow' }} [options]
27602770
*/
2761-
constructor(privateSymbol, handle, endpoint) {
2771+
constructor(privateSymbol, handle, endpoint, options = kEmptyObject) {
27622772
// Instances of QuicSession can only be created internally.
27632773
assertPrivateSymbol(privateSymbol);
27642774

@@ -2767,6 +2777,8 @@ class QuicSession {
27672777

27682778
const inner = this.#inner;
27692779
inner.endpoint = endpoint;
2780+
const { truncatedReads } = options;
2781+
if (truncatedReads !== undefined) inner.truncatedReads = truncatedReads;
27702782
// Move any qlog entries that arrived before the wrapper existed.
27712783
if (handle._pendingQlog !== undefined) {
27722784
inner.pendingQlog = handle._pendingQlog;
@@ -3280,7 +3292,8 @@ class QuicSession {
32803292
}
32813293

32823294
const stream = new QuicStream(
3283-
kPrivateConstructor, handle, this, direction, true /* isLocal */);
3295+
kPrivateConstructor, handle, this, direction, true /* isLocal */,
3296+
inner.truncatedReads);
32843297
inner.streams.add(stream);
32853298
if (typeof this.#inner.onerror === 'function') {
32863299
markPromiseAsHandled(stream.closed);
@@ -4047,7 +4060,7 @@ class QuicSession {
40474060
[kNewStream](handle, direction) {
40484061
const inner = this.#inner;
40494062
const stream = new QuicStream(kPrivateConstructor, handle, this, direction,
4050-
false /* isLocal */);
4063+
false /* isLocal */, inner.truncatedReads);
40514064

40524065
// Set the default high water mark for received streams.
40534066
stream.highWaterMark = kDefaultHighWaterMark;
@@ -4145,6 +4158,7 @@ class QuicEndpoint {
41454158
sessions: new SafeSet(),
41464159
stat: undefined,
41474160
stats: undefined,
4161+
truncatedReads: undefined,
41484162
onsession: undefined,
41494163
sessionCallbacks: undefined,
41504164
};
@@ -4277,8 +4291,8 @@ class QuicEndpoint {
42774291
};
42784292
}
42794293

4280-
#newSession(handle) {
4281-
const session = new QuicSession(kPrivateConstructor, handle, this);
4294+
#newSession(handle, options) {
4295+
const session = new QuicSession(kPrivateConstructor, handle, this, options);
42824296
this.#inner.sessions.add(session);
42834297
// Set default pending datagram queue size.
42844298
session.maxPendingDatagrams = kDefaultMaxPendingDatagrams;
@@ -4452,9 +4466,13 @@ class QuicEndpoint {
44524466
ontrailers,
44534467
oninfo,
44544468
onwanttrailers,
4469+
// Stored on the endpoint and applied to each incoming session.
4470+
truncatedReads,
44554471
...rest
44564472
} = options;
44574473

4474+
inner.truncatedReads = truncatedReads;
4475+
44584476
// Store session and stream callbacks to apply to each new incoming session.
44594477
inner.sessionCallbacks = {
44604478
__proto__: null,
@@ -4496,6 +4514,7 @@ class QuicEndpoint {
44964514
validateObject(options, 'options');
44974515
const {
44984516
sessionTicket,
4517+
truncatedReads,
44994518
...rest
45004519
} = options;
45014520

@@ -4504,7 +4523,7 @@ class QuicEndpoint {
45044523
if (handle === undefined) {
45054524
throw new ERR_QUIC_CONNECTION_FAILED();
45064525
}
4507-
const session = this.#newSession(handle);
4526+
const session = this.#newSession(handle, { __proto__: null, truncatedReads });
45084527
// Set callbacks before any async work to avoid missing events
45094528
// that fire during or immediately after the handshake.
45104529
applyCallbacks(session, options);
@@ -4738,7 +4757,8 @@ class QuicEndpoint {
47384757
const inner = this.#inner;
47394758
assert(typeof inner.onsession === 'function',
47404759
'onsession callback not specified');
4741-
const session = this.#newSession(handle);
4760+
const session = this.#newSession(handle,
4761+
{ __proto__: null, truncatedReads: inner.truncatedReads });
47424762
// Apply session callbacks stored at listen time before notifying
47434763
// the onsession callback, to avoid missing events that fire
47444764
// during or immediately after the handshake.
@@ -5185,6 +5205,7 @@ function processSessionOptions(options, config = kEmptyObject) {
51855205
maxDatagramSendAttempts = 5,
51865206
streamIdleTimeout,
51875207
verifyPeer = 'auto',
5208+
truncatedReads = 'error',
51885209
// HTTP/3 application-specific options. Nested under `application`
51895210
// to separate protocol-specific settings from transport-level ones.
51905211
application = kEmptyObject,
@@ -5236,6 +5257,9 @@ function processSessionOptions(options, config = kEmptyObject) {
52365257
validateOneOf(verifyPeer, 'options.verifyPeer',
52375258
['strict', 'auto', 'manual']);
52385259

5260+
validateOneOf(truncatedReads, 'options.truncatedReads',
5261+
['error', 'allow']);
5262+
52395263
validateInteger(drainingPeriodMultiplier, 'options.drainingPeriodMultiplier',
52405264
3, 255);
52415265

@@ -5298,6 +5322,7 @@ function processSessionOptions(options, config = kEmptyObject) {
52985322
verifyHostname: verifyPeer !== 'manual',
52995323
},
53005324
verifyPeer,
5325+
truncatedReads,
53015326
qlog,
53025327
maxPayloadSize,
53035328
unacknowledgedPacketThreshold,

0 commit comments

Comments
 (0)