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