From 456484bb23446a8461713fccf7373e72a6830ed8 Mon Sep 17 00:00:00 2001 From: Qingyu Wang Date: Wed, 3 Jun 2026 19:15:01 +0800 Subject: [PATCH 1/2] stream: fix pipeTo assert when writer is released during microtask pipeTo defers each slow-path write to a microtask so it does not run synchronously during enqueue(). A race allows the pipe to shut down and release its writer after the write is scheduled but before it runs. The deferred write then hits an ERR_INTERNAL_ASSERTION because writer[[stream]] is undefined. Skip the deferred write when shutdown has already started. Fixes: https://github.com/nodejs/node/issues/63732 Signed-off-by: Qingyu Wang Assisted-by: Codex --- lib/internal/webstreams/readablestream.js | 1 + ...-webstreams-pipeto-writer-released-race.js | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 test/parallel/test-webstreams-pipeto-writer-released-race.js diff --git a/lib/internal/webstreams/readablestream.js b/lib/internal/webstreams/readablestream.js index 78313bd03c1a..d15789aa9fd0 100644 --- a/lib/internal/webstreams/readablestream.js +++ b/lib/internal/webstreams/readablestream.js @@ -1760,6 +1760,7 @@ function readableStreamPipeTo( function forwardChunk() { const chunk = pendingChunk; pendingChunk = undefined; + if (shuttingDown) return; writableStreamDefaultWriterWriteWithRequest(writer, chunk, writeTracker); pump(); } diff --git a/test/parallel/test-webstreams-pipeto-writer-released-race.js b/test/parallel/test-webstreams-pipeto-writer-released-race.js new file mode 100644 index 000000000000..96ec885e2c3f --- /dev/null +++ b/test/parallel/test-webstreams-pipeto-writer-released-race.js @@ -0,0 +1,33 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { ReadableStream, WritableStream } = require('stream/web'); + +{ + let sourceController; + let destController; + + const source = new ReadableStream({ + start(controller) { + sourceController = controller; + }, + }); + + const dest = new WritableStream({ + start(controller) { + destController = controller; + }, + write() {}, + }); + + assert.rejects( + source.pipeTo(dest, { preventCancel: true }), + { message: 'destination errored' }, + ).then(common.mustCall()); + + setImmediate(common.mustCall(() => { + destController.error(new Error('destination errored')); + sourceController.enqueue('chunk'); + })); +} From de4d49d5a4e846131487afc59d23bf33f4e8007d Mon Sep 17 00:00:00 2001 From: Qingyu Wang Date: Wed, 3 Jun 2026 20:51:46 +0800 Subject: [PATCH 2/2] stream: narrow pipeTo guard to writer release check The shuttingDown guard added by the previous commit is too broad: it also skips an already-read chunk while the destination is still writable. The WHATWG Streams spec requires pipeTo to write such chunks during shutdown. Check writer[kState].stream instead. It becomes undefined only after finalize() releases the writer, which is precisely the assertion case. Add a regression test that aborts after enqueue() and verifies that the already-read chunk is written. Signed-off-by: Qingyu Wang Assisted-by: Codex --- lib/internal/webstreams/readablestream.js | 2 +- ...-webstreams-pipeto-writer-released-race.js | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/internal/webstreams/readablestream.js b/lib/internal/webstreams/readablestream.js index d15789aa9fd0..160e91a60bce 100644 --- a/lib/internal/webstreams/readablestream.js +++ b/lib/internal/webstreams/readablestream.js @@ -1760,7 +1760,7 @@ function readableStreamPipeTo( function forwardChunk() { const chunk = pendingChunk; pendingChunk = undefined; - if (shuttingDown) return; + if (writer[kState].stream === undefined) return; writableStreamDefaultWriterWriteWithRequest(writer, chunk, writeTracker); pump(); } diff --git a/test/parallel/test-webstreams-pipeto-writer-released-race.js b/test/parallel/test-webstreams-pipeto-writer-released-race.js index 96ec885e2c3f..20e0933bd60b 100644 --- a/test/parallel/test-webstreams-pipeto-writer-released-race.js +++ b/test/parallel/test-webstreams-pipeto-writer-released-race.js @@ -31,3 +31,33 @@ const { ReadableStream, WritableStream } = require('stream/web'); sourceController.enqueue('chunk'); })); } + +{ + const ac = new AbortController(); + let sourceController; + const chunks = []; + + const source = new ReadableStream({ + start(controller) { + sourceController = controller; + }, + }, { highWaterMark: 0 }); + + const dest = new WritableStream({ + write: common.mustCall((chunk) => { + chunks.push(chunk); + }), + }, { highWaterMark: 1 }); + + assert.rejects( + source.pipeTo(dest, { signal: ac.signal }), + { name: 'AbortError' }, + ).then(common.mustCall(() => { + assert.deepStrictEqual(chunks, ['chunk']); + })); + + setImmediate(common.mustCall(() => { + sourceController.enqueue('chunk'); + ac.abort(); + })); +}