Skip to content

Commit f612f03

Browse files
committed
Make QUIC stream truncation behaviour configurable
1 parent 7639fa7 commit f612f03

14 files changed

Lines changed: 425 additions & 233 deletions

‎doc/api/quic.md‎

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

3236+
#### `sessionOptions.truncatedReads`
3237+
3238+
* Type: {string} One of `'error'` or `'allow'`.
3239+
* **Default:** `'error'`
3240+
3241+
Controls how reading a stream reports a truncated read. A stream's read side
3242+
can end without receiving a QUIC FIN, meaning the peer never signalled that
3243+
the whole stream had been sent and the data received may be incomplete. This
3244+
selects how the stream's async iterator reports this:
3245+
3246+
* `'error'` - The default. Peers are expected to always send a FIN to end
3247+
their data explicitly, and so any truncation is an error. The iterator yields
3248+
the data that did arrive and then throws, so an incomplete stream can never
3249+
be mistaken for a complete one. Incomplete streams will either throw a
3250+
`ERR_QUIC_STREAM_RESET` carrying the peer's error code, a connection error,
3251+
or `ERR_QUIC_STREAM_ABORTED` for other cases.
3252+
3253+
* `'allow'` - Truncated reads are allowed: only a stream or connection error
3254+
is reported, and any clean abort/cancellation or similar simply ends the
3255+
stream. A non-zero peer reset still throws `ERR_QUIC_STREAM_RESET` and a
3256+
connection error still throws its real error, but a truncation that carried
3257+
no error (an idle timeout, a graceful close, a local `stopSending()`) ends
3258+
the read cleanly with the data received. This matches `stream.closed`,
3259+
which rejects only on an error.
3260+
32363261
#### `sessionOptions.verifyPeer` (client only)
32373262

32383263
* 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)
@@ -1565,6 +1563,8 @@ class QuicStream {
15651563
state: undefined,
15661564
stats: undefined,
15671565
pendingClose: undefined,
1566+
destroyError: undefined,
1567+
truncatedReads: undefined,
15681568
reader: undefined,
15691569
destroying: false,
15701570
iteratorLocked: false,
@@ -1611,9 +1611,10 @@ class QuicStream {
16111611
* @param {object} handle
16121612
* @param {QuicSession} session
16131613
* @param {number} direction
1614-
* @param {boolean} [isLocal]
1614+
* @param {boolean} isLocal
1615+
* @param {'error'|'allow'} truncatedReads
16151616
*/
1616-
constructor(privateSymbol, handle, session, direction, isLocal) {
1617+
constructor(privateSymbol, handle, session, direction, isLocal, truncatedReads) {
16171618
assertPrivateSymbol(privateSymbol);
16181619

16191620
this.#handle = handle;
@@ -1622,6 +1623,7 @@ class QuicStream {
16221623
inner.session = session;
16231624
inner.direction = direction;
16241625
inner.isLocal = isLocal;
1626+
inner.truncatedReads = truncatedReads;
16251627
inner.state = new QuicStreamState(
16261628
kPrivateConstructor, handle.state, handle.stateByteOffset);
16271629

@@ -1649,39 +1651,41 @@ class QuicStream {
16491651
inner.iteratorLocked = true;
16501652

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

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

16871691
/**
@@ -2033,9 +2037,10 @@ class QuicStream {
20332037
if (error !== undefined && typeof inner.onerror === 'function') {
20342038
invokeOnerror(inner.onerror, error);
20352039
}
2036-
const handle = this.#handle;
2037-
this[kFinishClose](error);
2038-
handle.destroy();
2040+
// handle.destroy() kicks off all the cleanup internals, eventually
2041+
// including [kFinishClose] which needs this original destroy error:
2042+
inner.destroyError = error;
2043+
this.#handle.destroy();
20392044
}
20402045

20412046
/**
@@ -2516,6 +2521,9 @@ class QuicStream {
25162521
if (this.destroyed) {
25172522
return inner.pendingClose.promise;
25182523
}
2524+
// Prefer an error staged by destroy() (the original object the caller
2525+
// passed) over the error delivered by the native close callback.
2526+
error = inner.destroyError ?? error;
25192527
if (error !== undefined) {
25202528
inner.pendingClose.reject(error);
25212529
} else {
@@ -2731,6 +2739,7 @@ class QuicSession {
27312739
// because server-side cert validation is handled by rejectUnauthorized
27322740
// at the C++ level.
27332741
verifyPeer: 'manual',
2742+
truncatedReads: 'error',
27342743
handshakeInfo: undefined,
27352744
/** @type {QuicSessionPath|undefined} */
27362745
path: undefined,
@@ -2764,8 +2773,9 @@ class QuicSession {
27642773
* @param {symbol} privateSymbol
27652774
* @param {object} handle
27662775
* @param {QuicEndpoint} endpoint
2776+
* @param {{ truncatedReads?: 'error'|'allow' }} [options]
27672777
*/
2768-
constructor(privateSymbol, handle, endpoint) {
2778+
constructor(privateSymbol, handle, endpoint, options = kEmptyObject) {
27692779
// Instances of QuicSession can only be created internally.
27702780
assertPrivateSymbol(privateSymbol);
27712781

@@ -2774,6 +2784,8 @@ class QuicSession {
27742784

27752785
const inner = this.#inner;
27762786
inner.endpoint = endpoint;
2787+
const { truncatedReads } = options;
2788+
if (truncatedReads !== undefined) inner.truncatedReads = truncatedReads;
27772789
// Move any qlog entries that arrived before the wrapper existed.
27782790
if (handle._pendingQlog !== undefined) {
27792791
inner.pendingQlog = handle._pendingQlog;
@@ -3319,7 +3331,8 @@ class QuicSession {
33193331
}
33203332

33213333
const stream = new QuicStream(
3322-
kPrivateConstructor, handle, this, direction, true /* isLocal */);
3334+
kPrivateConstructor, handle, this, direction, true /* isLocal */,
3335+
inner.truncatedReads);
33233336
inner.streams.add(stream);
33243337
if (typeof this.#inner.onerror === 'function') {
33253338
markPromiseAsHandled(stream.closed);
@@ -4086,7 +4099,7 @@ class QuicSession {
40864099
[kNewStream](handle, direction) {
40874100
const inner = this.#inner;
40884101
const stream = new QuicStream(kPrivateConstructor, handle, this, direction,
4089-
false /* isLocal */);
4102+
false /* isLocal */, inner.truncatedReads);
40904103

40914104
// Set the default byte budget for received streams.
40924105
stream.budget = kDefaultBudget;
@@ -4184,6 +4197,7 @@ class QuicEndpoint {
41844197
sessions: new SafeSet(),
41854198
stat: undefined,
41864199
stats: undefined,
4200+
truncatedReads: undefined,
41874201
onsession: undefined,
41884202
sessionCallbacks: undefined,
41894203
};
@@ -4316,8 +4330,8 @@ class QuicEndpoint {
43164330
};
43174331
}
43184332

4319-
#newSession(handle) {
4320-
const session = new QuicSession(kPrivateConstructor, handle, this);
4333+
#newSession(handle, options) {
4334+
const session = new QuicSession(kPrivateConstructor, handle, this, options);
43214335
this.#inner.sessions.add(session);
43224336
// Set default pending datagram queue size.
43234337
session.maxPendingDatagrams = kDefaultMaxPendingDatagrams;
@@ -4491,9 +4505,13 @@ class QuicEndpoint {
44914505
ontrailers,
44924506
oninfo,
44934507
onwanttrailers,
4508+
// Stored on the endpoint and applied to each incoming session.
4509+
truncatedReads,
44944510
...rest
44954511
} = options;
44964512

4513+
inner.truncatedReads = truncatedReads;
4514+
44974515
// Store session and stream callbacks to apply to each new incoming session.
44984516
inner.sessionCallbacks = {
44994517
__proto__: null,
@@ -4535,6 +4553,7 @@ class QuicEndpoint {
45354553
validateObject(options, 'options');
45364554
const {
45374555
sessionTicket,
4556+
truncatedReads,
45384557
...rest
45394558
} = options;
45404559

@@ -4543,7 +4562,7 @@ class QuicEndpoint {
45434562
if (handle === undefined) {
45444563
throw new ERR_QUIC_CONNECTION_FAILED();
45454564
}
4546-
const session = this.#newSession(handle);
4565+
const session = this.#newSession(handle, { __proto__: null, truncatedReads });
45474566
// Set callbacks before any async work to avoid missing events
45484567
// that fire during or immediately after the handshake.
45494568
applyCallbacks(session, options);
@@ -4777,7 +4796,8 @@ class QuicEndpoint {
47774796
const inner = this.#inner;
47784797
assert(typeof inner.onsession === 'function',
47794798
'onsession callback not specified');
4780-
const session = this.#newSession(handle);
4799+
const session = this.#newSession(handle,
4800+
{ __proto__: null, truncatedReads: inner.truncatedReads });
47814801
// Apply session callbacks stored at listen time before notifying
47824802
// the onsession callback, to avoid missing events that fire
47834803
// during or immediately after the handshake.
@@ -5263,6 +5283,7 @@ function processSessionOptions(options, config = kEmptyObject) {
52635283
maxDatagramSendAttempts = 5,
52645284
streamIdleTimeout,
52655285
verifyPeer = 'auto',
5286+
truncatedReads = 'error',
52665287
// HTTP/3 application-specific options. Nested under `application`
52675288
// to separate protocol-specific settings from transport-level ones.
52685289
application = kEmptyObject,
@@ -5314,6 +5335,9 @@ function processSessionOptions(options, config = kEmptyObject) {
53145335
validateOneOf(verifyPeer, 'options.verifyPeer',
53155336
['strict', 'auto', 'manual']);
53165337

5338+
validateOneOf(truncatedReads, 'options.truncatedReads',
5339+
['error', 'allow']);
5340+
53175341
validateInteger(drainingPeriodMultiplier, 'options.drainingPeriodMultiplier',
53185342
3, 255);
53195343

@@ -5376,6 +5400,7 @@ function processSessionOptions(options, config = kEmptyObject) {
53765400
verifyHostname: verifyPeer !== 'manual',
53775401
},
53785402
verifyPeer,
5403+
truncatedReads,
53795404
qlog,
53805405
maxPayloadSize,
53815406
unacknowledgedPacketThreshold,

0 commit comments

Comments
 (0)