@@ -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