Skip to content

Commit 635a58c

Browse files
committed
Ensure local stop-sending is handled correctly
1 parent 59fae6b commit 635a58c

3 files changed

Lines changed: 38 additions & 19 deletions

File tree

‎doc/api/quic.md‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3444,11 +3444,10 @@ selects how the stream's async iterator reports this:
34443444

34453445
* `'allow'` - Truncated reads are allowed: only a stream or connection error
34463446
is reported, and any clean abort/cancellation or similar simply ends the
3447-
stream. A non-zero peer reset still throws `ERR_QUIC_STREAM_RESET` and a
3448-
connection error still throws its real error, but a truncation that carried
3449-
no error (an idle timeout, a graceful close, a local `stopSending()`) ends
3450-
the read cleanly with the data received. This matches `stream.closed`,
3451-
which rejects only on an error.
3447+
stream. A non-zero peer reset, non-zero local stop-sending or connection
3448+
error still fails, but a truncation with no error at all (an idle timeout,
3449+
a graceful close, or a plain `stopSending()`) ends the read cleanly with the
3450+
data received. This matches `stream.closed`, which rejects only on an error.
34523451

34533452
#### `sessionOptions.verifyPeer` (client only)
34543453

‎lib/internal/quic/quic.js‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1579,6 +1579,7 @@ class QuicStream {
15791579
stats: undefined,
15801580
pendingClose: undefined,
15811581
destroyError: undefined,
1582+
stopSendingCode: undefined,
15821583
truncatedReads: undefined,
15831584
reader: undefined,
15841585
destroying: false,
@@ -1678,6 +1679,14 @@ class QuicStream {
16781679
// The readable has been truncated - ended with no clean FIN. We expose
16791680
// this in different ways depending on the truncatedReads option.
16801681

1682+
// If we cancelled ourselves, check our own stop-sending code (not the
1683+
// result mirrored by the remote peer)
1684+
if (inner.stopSendingCode > 0n) {
1685+
throw new QuicError('Stream aborted before FIN was received',
1686+
{ __proto__: null,
1687+
errorCode: inner.stopSendingCode });
1688+
}
1689+
16811690
// Non-zero reset is always an error:
16821691
const peerResetCode = inner.state.resetCode;
16831692
if (peerResetCode > 0n) {
@@ -2479,7 +2488,9 @@ class QuicStream {
24792488
stopSending(code = 0n) {
24802489
assertIsQuicStream(this);
24812490
if (this.destroyed) return;
2482-
this.#handle.stopSending(BigInt(code));
2491+
const abortCode = BigInt(code);
2492+
this.#inner.stopSendingCode = abortCode;
2493+
this.#handle.stopSending(abortCode);
24832494
}
24842495

24852496
/**

‎test/parallel/test-quic-stream-truncated-reads.mjs‎

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -104,20 +104,29 @@ const stall = (stream) => { stream.setBody(stallingBody(1000)); };
104104
assert.strictEqual(threw, undefined);
105105
}
106106

107-
// Same with a nonzero stopSending code. The peer answers our STOP_SENDING with
108-
// a RESET_STREAM carrying it, which rejects closed - but the read is still our
109-
// own abort, so it is reported by policy rather than as that reset. This is
110-
// the one case where the read and closed facets deliberately disagree.
107+
// A nonzero stopSending code is an error this end raised, so it is reported
108+
// under either policy and carries that code. The peer answers our
109+
// STOP_SENDING with a RESET_STREAM echoing it, which is what rejects closed -
110+
// but the read reports our own abort rather than attributing it to the peer,
111+
// and does so whether or not that answer has arrived yet.
111112
for (const truncatedReads of ['error', 'allow']) {
112-
const { received, threw, closedError } = await readStream(stall, {
113-
clientOptions: { truncatedReads },
114-
onFirstChunk: ({ stream }) => stream.stopSending(7n),
115-
});
116-
assert.ok(received > 0);
117-
assert.strictEqual(threw?.code,
118-
truncatedReads === 'error' ? 'ERR_QUIC_STREAM_ABORTED' : undefined);
119-
assert.strictEqual(closedError?.code, 'ERR_QUIC_APPLICATION_ERROR');
120-
assert.strictEqual(closedError.errorCode, 7n);
113+
for (const awaitEcho of [false, true]) {
114+
const peerReset = Promise.withResolvers();
115+
const { received, threw, closedError } = await readStream(stall, {
116+
clientOptions: { truncatedReads },
117+
beforeIterate: ({ stream }) => { stream.onreset = () => peerReset.resolve(); },
118+
onFirstChunk: async ({ stream }) => {
119+
stream.stopSending(7n);
120+
// For the 2nd pass, wait until we receive the corresponding reset:
121+
if (awaitEcho) await peerReset.promise;
122+
},
123+
});
124+
assert.ok(received > 0);
125+
assert.strictEqual(threw?.code, 'ERR_QUIC_STREAM_ABORTED');
126+
assert.strictEqual(threw.errorCode, 7n);
127+
assert.strictEqual(closedError?.code, 'ERR_QUIC_APPLICATION_ERROR');
128+
assert.strictEqual(closedError.errorCode, 7n);
129+
}
121130
}
122131

123132
// A peer abruptly destroying its session truncates the read too. That

0 commit comments

Comments
 (0)