Version
v26.4.0, v26.5.0, v27.0.0-nightly202607157a11a9b2db
Platform
Darwin 25.5.0 arm64 (pure JS internals — unlikely to be platform-specific)
Subsystem
webstreams
What steps will reproduce the bug?
When a TransformStream in a pipeThrough() chain throws while an upstream chunk is still in flight, the process crashes with an uncaught exception — even though the pipeTo() promise rejects as expected and user code catches the error.
// repro.mjs
const source = new ReadableStream({
start(controller) {
controller.enqueue("a");
controller.enqueue("b");
controller.close();
}
});
const asyncPassThrough = new TransformStream({
async transform(chunk, controller) {
controller.enqueue(chunk);
}
});
const identity = new TransformStream();
const failingTransform = new TransformStream({
transform() {
throw new Error("boom");
}
});
try {
await source
.pipeThrough(asyncPassThrough)
.pipeThrough(identity)
.pipeThrough(failingTransform)
.pipeTo(new WritableStream());
} catch (error) {
console.log("caught: " + error.message);
}
Execute with: node repro.mjs
The shape is timing-sensitive: the second chunk must reach the failing stream after it has errored. At least two chunks are required, and removing either intermediate stage (or making asyncPassThrough's transform synchronous) changes the interleaving enough that the crash disappears.
How often does it reproduce? Is there a required condition?
Deterministic with the reproduction above.
Behavior across runtimes and versions:
| Runtime |
Result |
| Node.js v24.13.0, v25.9.0, v26.0.0, v26.2.0, v26.3.1 |
caught: boom, exit 0 |
| Node.js v26.4.0, v26.5.0, v27 nightly |
caught: boom, then uncaught exception, exit 1 |
| Deno 2.9.3, Bun 1.3.14 |
caught: boom, exit 0 |
What is the expected behavior? Why is that the expected behavior?
The error thrown by the transform should surface exactly once, as the rejection of the pipeTo() promise. Since that rejection is caught, the process should exit cleanly. This is the behavior of Node.js ≤ v26.3.1, Deno, Bun, and browsers.
What do you see instead?
The rejection is delivered and caught (caught: boom is printed), and then the process crashes anyway with a duplicate of the same error:
caught: boom
node:internal/process/promises:324
triggerUncaughtException(err, true /* fromPromise */);
^
Error: boom
at Object.transform (file:///…/repro.mjs:19:9)
at node:internal/webstreams/util:195:32
at transformStreamDefaultControllerPerformTransform (node:internal/webstreams/transformstream:531:18)
at transformStreamDefaultSinkWriteAlgorithm (node:internal/webstreams/transformstream:577:10)
at node:internal/webstreams/transformstream:367:16
at writableStreamDefaultControllerProcessWrite (node:internal/webstreams/writablestream:1138:5)
at writableStreamDefaultControllerAdvanceQueueIfNeeded (node:internal/webstreams/writablestream:1245:5)
at writableStreamDefaultControllerWrite (node:internal/webstreams/writablestream:1100:3)
at writableStreamDefaultWriterWrite (node:internal/webstreams/writablestream:991:3)
at node:internal/webstreams/readablestream:1733:33
Node.js v26.5.0
Additional information
Root cause. Bisecting releases points to v26.4.0, and specifically to 6af9026 (#63572, "stream: drop per-chunk Promise alloc in pipeTo"), which changed both pipeTo write sites from setPromiseHandled(state.currentWrite) — which attaches real reaction handlers — to the native markPromiseAsHandled(state.currentWrite) binding, which only sets the promise's [[PromiseIsHandled]] bit:
// lib/internal/webstreams/readablestream.js (PipeToReadableStreamReadRequest[kChunk])
queueMicrotask(() => {
this.state.currentWrite = writableStreamDefaultWriterWrite(this.writer, chunk);
markPromiseAsHandled(this.state.currentWrite);
this.promise.resolve(false);
});
When the chunk arrives after the destination has already errored, writableStreamDefaultWriterWrite takes an early-return path and returns PromiseReject(storedError) — a promise that is already rejected at creation. V8 enqueues the kPromiseRejectWithNoHandler notification at rejection time. Setting the handled bit afterwards does not emit a kPromiseHandlerAddedAfterReject event (no handler is actually attached), so nothing cancels the pending notification, and Node promotes it to an uncaught exception at the end of the tick. The previous setPromiseHandled attached a real .then reaction, which does emit the cancellation event, so marking "too late" was harmless.
The ordering dependency is easy to demonstrate with the binding directly:
$ node --expose-internals -e "
const { internalBinding } = require('internal/test/binding');
const { markPromiseAsHandled } = internalBinding('util');
const p = Promise.reject(new Error('rejected, then marked'));
markPromiseAsHandled(p);
" # crashes
$ node --expose-internals -e "
const { internalBinding } = require('internal/test/binding');
const { markPromiseAsHandled } = internalBinding('util');
const { promise, reject } = Promise.withResolvers();
markPromiseAsHandled(promise);
reject(new Error('marked, then rejected'));
" # exits cleanly
Both call sites changed by #63572 have the same exposure (the batched fast-path write in readableStreamPipeTo and the PipeToReadableStreamReadRequest[kChunk] slow path; the reproduction above hits the slow path). A fix could restore real handlers at these two sites, or keep the optimization for the common pending-promise case and fall back to attaching a handler when the returned write promise is already rejected.
Impact. Found via zip.js, where reading a ZipCrypto-encrypted entry with a wrong password now kills the whole process: the Invalid password error thrown inside a decryption TransformStream crashes Node even though the library's pipeTo rejection handling and the application's try/catch both work as intended. Any pipeThrough chain whose transform can throw mid-stream (validation, decryption, parsing) is affected, and there is no userland workaround short of a process-level unhandledRejection handler.
Version
v26.4.0, v26.5.0, v27.0.0-nightly202607157a11a9b2db
Platform
Subsystem
webstreams
What steps will reproduce the bug?
When a
TransformStreamin apipeThrough()chain throws while an upstream chunk is still in flight, the process crashes with an uncaught exception — even though thepipeTo()promise rejects as expected and user code catches the error.Execute with:
node repro.mjsThe shape is timing-sensitive: the second chunk must reach the failing stream after it has errored. At least two chunks are required, and removing either intermediate stage (or making
asyncPassThrough'stransformsynchronous) changes the interleaving enough that the crash disappears.How often does it reproduce? Is there a required condition?
Deterministic with the reproduction above.
Behavior across runtimes and versions:
caught: boom, exit 0caught: boom, then uncaught exception, exit 1caught: boom, exit 0What is the expected behavior? Why is that the expected behavior?
The error thrown by the transform should surface exactly once, as the rejection of the
pipeTo()promise. Since that rejection is caught, the process should exit cleanly. This is the behavior of Node.js ≤ v26.3.1, Deno, Bun, and browsers.What do you see instead?
The rejection is delivered and caught (
caught: boomis printed), and then the process crashes anyway with a duplicate of the same error:Additional information
Root cause. Bisecting releases points to v26.4.0, and specifically to 6af9026 (#63572, "stream: drop per-chunk Promise alloc in pipeTo"), which changed both pipeTo write sites from
setPromiseHandled(state.currentWrite)— which attaches real reaction handlers — to the nativemarkPromiseAsHandled(state.currentWrite)binding, which only sets the promise's[[PromiseIsHandled]]bit:When the chunk arrives after the destination has already errored,
writableStreamDefaultWriterWritetakes an early-return path and returnsPromiseReject(storedError)— a promise that is already rejected at creation. V8 enqueues thekPromiseRejectWithNoHandlernotification at rejection time. Setting the handled bit afterwards does not emit akPromiseHandlerAddedAfterRejectevent (no handler is actually attached), so nothing cancels the pending notification, and Node promotes it to an uncaught exception at the end of the tick. The previoussetPromiseHandledattached a real.thenreaction, which does emit the cancellation event, so marking "too late" was harmless.The ordering dependency is easy to demonstrate with the binding directly:
Both call sites changed by #63572 have the same exposure (the batched fast-path write in
readableStreamPipeToand thePipeToReadableStreamReadRequest[kChunk]slow path; the reproduction above hits the slow path). A fix could restore real handlers at these two sites, or keep the optimization for the common pending-promise case and fall back to attaching a handler when the returned write promise is already rejected.Impact. Found via zip.js, where reading a ZipCrypto-encrypted entry with a wrong password now kills the whole process: the
Invalid passworderror thrown inside a decryptionTransformStreamcrashes Node even though the library'spipeTorejection handling and the application'stry/catchboth work as intended. AnypipeThroughchain whose transform can throw mid-stream (validation, decryption, parsing) is affected, and there is no userland workaround short of a process-levelunhandledRejectionhandler.