Commit 1389475f66a for nodejs
commit 1389475f66a722eab83547797491c3e879de3f7f
Author: Dustin <85157729+GiHoon1123@users.noreply.github.com>
Date: Mon Nov 2 21:22:47 2026 +0900
quic: retain readers for readable streams
Create readers when readable QuicStream instances are constructed so
data received before the first async iterator pull survives native
stream cleanup. Add a regression test for a peer-initiated
unidirectional stream that sends data and FIN before the reader is
pulled.
Assisted-by: Codex
Signed-off-by: GiHoon1123 <rlaejrqo465@naver.com>
PR-URL: https://github.com/nodejs/node/pull/66407
Fixes: https://github.com/nodejs/node/issues/66347
Reviewed-By: Tim Perry <pimterry@gmail.com>
diff --git a/doc/api/quic.md b/doc/api/quic.md
index 6df3f62da17..dad86a14468 100644
--- a/doc/api/quic.md
+++ b/doc/api/quic.md
@@ -1993,6 +1993,11 @@ Immediately and abruptly destroys the stream. If `error` is provided and
[`stream.onerror`][] is set, the `onerror` callback is invoked before
destruction. The `stream.closed` promise rejects with the error.
+Buffered incoming data remains readable after destruction, even if iteration
+has not started. If the readable side ended without a FIN, the iterator yields
+that data before reporting the truncation or error according to
+[`sessionOptions.truncatedReads`][].
+
When the stream is destroyed with an `error` (or with an explicit
`options.code`), the QUIC stack signals the abort to the peer:
@@ -4888,6 +4893,7 @@ throughput issues caused by flow control.
[`sessionOptions.sessionTicket`]: #sessionoptionssessionticket
[`sessionOptions.sni`]: #sessionoptionssni-server-only
[`sessionOptions.token`]: #sessionoptionstoken-client-only
+[`sessionOptions.truncatedReads`]: #sessionoptionstruncatedreads
[`stream.destroy()`]: #streamdestroyerror-options
[`stream.headers`]: #streamheaders
[`stream.onerror`]: #streamonerror
diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js
index 52f7fe63fa2..683eba6925e 100644
--- a/lib/internal/quic/quic.js
+++ b/lib/internal/quic/quic.js
@@ -1679,6 +1679,12 @@ class QuicStream {
this.#handle = handle;
handle[kOwner] = this;
const inner = this.#inner;
+ // Keep a reader alive for readable streams before the native handle can
+ // be destroyed. A peer may send data and FIN before the consumer asks
+ // for its async iterator.
+ if (!isLocal || direction === kStreamDirectionBidirectional) {
+ inner.reader = handle.getReader();
+ }
inner.session = session;
inner.direction = direction;
inner.isLocal = isLocal;
@@ -2062,8 +2068,8 @@ class QuicStream {
}
/**
- * Immediately destroys the stream. Any queued data is discarded. If
- * an error is given, the closed promise will be rejected with that
+ * Immediately destroys the stream. Buffered incoming data remains readable.
+ * If an error is given, the closed promise will be rejected with that
* error. If no error is given, the closed promise will be resolved.
* When destroying with an error, RESET_STREAM and/or STOP_SENDING
* are emitted to the peer for any still-open writable / readable
diff --git a/test/parallel/test-quic-stream-truncated-reads-destroy.mjs b/test/parallel/test-quic-stream-truncated-reads-destroy.mjs
index 01eddd3b0f0..f7b7ae981ba 100644
--- a/test/parallel/test-quic-stream-truncated-reads-destroy.mjs
+++ b/test/parallel/test-quic-stream-truncated-reads-destroy.mjs
@@ -43,17 +43,16 @@ const serve = (stream) => { stream.setBody(stallingBody(1000)); };
assert.strictEqual(threw.errorCode, 0n);
}
-// The session is destroyed before iteration even starts: the buffered data
-// is gone, but the truncation is still reported rather than a clean empty
-// end.
+// Destroying the session before iteration preserves the buffered data,
+// but still reports truncation after it has been read.
{
const { received, threw } = await readStream(serve, {
beforeIterate: async ({ stream, session }) => {
- while (stream.stats.bytesReceived === 0n) await setTimeout(5);
+ while (stream.stats.bytesReceived < 1000n) await setTimeout(5);
session.destroy();
},
});
- assert.strictEqual(received, 0);
+ assert.strictEqual(received, 1000);
assert.strictEqual(threw?.code, 'ERR_QUIC_STREAM_ABORTED');
}
@@ -62,11 +61,40 @@ const serve = (stream) => { stream.setBody(stallingBody(1000)); };
{
const { received, threw, closedError } = await readStream(serve, {
beforeIterate: async ({ stream }) => {
- while (stream.stats.bytesReceived === 0n) await setTimeout(5);
+ while (stream.stats.bytesReceived < 1000n) await setTimeout(5);
stream.destroy();
},
});
- assert.strictEqual(received, 0);
+ assert.strictEqual(received, 1000);
assert.strictEqual(threw?.code, 'ERR_QUIC_STREAM_ABORTED');
assert.strictEqual(closedError, undefined);
}
+
+// An explicit error must survive draining the buffered data, even when
+// truncation itself is ignored. Use more than one reader batch of data.
+for (const target of ['session', 'stream']) {
+ for (const truncatedReads of ['error', 'ignore']) {
+ const size = 128 * 1024;
+ const boom = new Error(`${target}: ${truncatedReads}`);
+ const { received, threw, closedError } = await readStream((stream) => {
+ stream.setBody(stallingBody(size));
+ }, {
+ clientOptions: {
+ truncatedReads,
+ transportParams: {
+ initialMaxStreamDataBidiLocal: size * 2,
+ initialMaxData: size * 2,
+ },
+ },
+ beforeIterate: async (context) => {
+ while (context.stream.stats.bytesReceived < BigInt(size)) {
+ await setTimeout(5);
+ }
+ context[target].destroy(boom);
+ },
+ });
+ assert.strictEqual(received, size);
+ assert.strictEqual(threw, boom);
+ assert.strictEqual(closedError, boom);
+ }
+}
diff --git a/test/parallel/test-quic-stream-uni-delayed-reader.mjs b/test/parallel/test-quic-stream-uni-delayed-reader.mjs
new file mode 100644
index 00000000000..45a23361716
--- /dev/null
+++ b/test/parallel/test-quic-stream-uni-delayed-reader.mjs
@@ -0,0 +1,47 @@
+// Flags: --experimental-quic --experimental-stream-iter --no-warnings
+
+// Test: data received before the async iterator is pulled remains readable
+// after a peer-initiated unidirectional stream closes.
+
+import { hasQuic, skip, mustCall } from '../common/index.mjs';
+import assert from 'node:assert';
+
+if (!hasQuic) {
+ skip('QUIC is not enabled');
+}
+
+const { listen, connect } = await import('../common/quic.mjs');
+const { bytes } = await import('stream/iter');
+
+const encoder = new TextEncoder();
+const expected = encoder.encode('data before the first pull');
+const done = Promise.withResolvers();
+
+const serverEndpoint = await listen(mustCall(async (serverSession) => {
+ await serverSession.opened;
+ const stream = await serverSession.createUnidirectionalStream({
+ body: expected,
+ });
+ await stream.closed;
+ serverSession.close();
+}));
+
+const clientSession = await connect(serverEndpoint.address);
+await clientSession.opened;
+
+clientSession.onstream = mustCall(async (stream) => {
+ const iterator = stream[Symbol.asyncIterator]();
+
+ // Delay the first pull until after the peer has sent FIN and the native
+ // stream handle has been closed.
+ await stream.closed;
+
+ const received = await bytes(iterator);
+ assert.deepStrictEqual(received, expected);
+ clientSession.close();
+ done.resolve();
+});
+
+await done.promise;
+await clientSession.closed;
+await serverEndpoint.close();