Title: TransformStream error during pipeTo crashes the process despite being caught (v26.4.0 regression) · Issue #64561 · nodejs/node · GitHub
Open Graph Title: TransformStream error during pipeTo crashes the process despite being caught (v26.4.0 regression) · Issue #64561 · nodejs/node
X Title: TransformStream error during pipeTo crashes the process despite being caught (v26.4.0 regression) · Issue #64561 · nodejs/node
Description: 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 pipeThrou...
Open Graph Description: 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 bu...
X Description: 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 bu...
Opengraph URL: https://github.com/nodejs/node/issues/64561
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"TransformStream error during pipeTo crashes the process despite being caught (v26.4.0 regression)","articleBody":"### Version\n\nv26.4.0, v26.5.0, v27.0.0-nightly202607157a11a9b2db\n\n### Platform\n\n```text\nDarwin 25.5.0 arm64 (pure JS internals — unlikely to be platform-specific)\n```\n\n### Subsystem\n\nwebstreams\n\n### What steps will reproduce the bug?\n\nWhen 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.\n\n```js\n// repro.mjs\nconst source = new ReadableStream({\n\tstart(controller) {\n\t\tcontroller.enqueue(\"a\");\n\t\tcontroller.enqueue(\"b\");\n\t\tcontroller.close();\n\t}\n});\n\nconst asyncPassThrough = new TransformStream({\n\tasync transform(chunk, controller) {\n\t\tcontroller.enqueue(chunk);\n\t}\n});\n\nconst identity = new TransformStream();\n\nconst failingTransform = new TransformStream({\n\ttransform() {\n\t\tthrow new Error(\"boom\");\n\t}\n});\n\ntry {\n\tawait source\n\t\t.pipeThrough(asyncPassThrough)\n\t\t.pipeThrough(identity)\n\t\t.pipeThrough(failingTransform)\n\t\t.pipeTo(new WritableStream());\n} catch (error) {\n\tconsole.log(\"caught: \" + error.message);\n}\n```\n\nExecute with: `node repro.mjs`\n\nThe 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.\n\n### How often does it reproduce? Is there a required condition?\n\nDeterministic with the reproduction above.\n\nBehavior across runtimes and versions:\n\n| Runtime | Result |\n| --- | --- |\n| Node.js v24.13.0, v25.9.0, v26.0.0, v26.2.0, v26.3.1 | `caught: boom`, exit 0 |\n| Node.js v26.4.0, v26.5.0, v27 nightly | `caught: boom`, then **uncaught exception, exit 1** |\n| Deno 2.9.3, Bun 1.3.14 | `caught: boom`, exit 0 |\n\n### What is the expected behavior? Why is that the expected behavior?\n\nThe 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.\n\n### What do you see instead?\n\nThe rejection is delivered and caught (`caught: boom` is printed), and then the process crashes anyway with a duplicate of the same error:\n\n```text\ncaught: boom\nnode:internal/process/promises:324\n triggerUncaughtException(err, true /* fromPromise */);\n ^\n\nError: boom\n at Object.transform (file:///…/repro.mjs:19:9)\n at node:internal/webstreams/util:195:32\n at transformStreamDefaultControllerPerformTransform (node:internal/webstreams/transformstream:531:18)\n at transformStreamDefaultSinkWriteAlgorithm (node:internal/webstreams/transformstream:577:10)\n at node:internal/webstreams/transformstream:367:16\n at writableStreamDefaultControllerProcessWrite (node:internal/webstreams/writablestream:1138:5)\n at writableStreamDefaultControllerAdvanceQueueIfNeeded (node:internal/webstreams/writablestream:1245:5)\n at writableStreamDefaultControllerWrite (node:internal/webstreams/writablestream:1100:3)\n at writableStreamDefaultWriterWrite (node:internal/webstreams/writablestream:991:3)\n at node:internal/webstreams/readablestream:1733:33\n\nNode.js v26.5.0\n```\n\n### Additional information\n\n**Root cause.** Bisecting releases points to v26.4.0, and specifically to 6af90267de (#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:\n\n```js\n// lib/internal/webstreams/readablestream.js (PipeToReadableStreamReadRequest[kChunk])\nqueueMicrotask(() => {\n this.state.currentWrite = writableStreamDefaultWriterWrite(this.writer, chunk);\n markPromiseAsHandled(this.state.currentWrite);\n this.promise.resolve(false);\n});\n```\n\nWhen 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.\n\nThe ordering dependency is easy to demonstrate with the binding directly:\n\n```console\n$ node --expose-internals -e \"\nconst { internalBinding } = require('internal/test/binding');\nconst { markPromiseAsHandled } = internalBinding('util');\nconst p = Promise.reject(new Error('rejected, then marked'));\nmarkPromiseAsHandled(p);\n\" # crashes\n\n$ node --expose-internals -e \"\nconst { internalBinding } = require('internal/test/binding');\nconst { markPromiseAsHandled } = internalBinding('util');\nconst { promise, reject } = Promise.withResolvers();\nmarkPromiseAsHandled(promise);\nreject(new Error('marked, then rejected'));\n\" # exits cleanly\n```\n\nBoth 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.\n\n**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.\n","author":{"url":"https://github.com/gildas-lormeau","@type":"Person","name":"gildas-lormeau"},"datePublished":"2026-07-17T16:21:23Z","url":"https://github.com/nodejs/node/issues/64561"}
| react-profiling | 0 |
| react-import-map | react,react-dom,react-dom/client,react-dom/profiling,react-is,react-reconciler,react/compiler-runtime,react/jsx-dev-runtime,react/jsx-runtime,scheduler@d5750093facf |
| route-pattern | /_view_fragments/issues/show/:user_id/:repository/:id/issue_layout(.:format) |
| route-controller | voltron_issues_fragments |
| route-action | issue_layout |
| fetch-nonce | v2:14c7e115-40ba-6872-5a6a-440a8acc544c |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | DC08:2C47BC:449246:596183:6AED237E |
| html-safe-nonce | 57ebef739b0a2b5843a2512ff29473b0efabf2586a7c026c7fe827f3d6e7df85 |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJEQzA4OjJDNDdCQzo0NDkyNDY6NTk2MTgzOjZBRUQyMzdFIiwidmlzaXRvcl9pZCI6IjM2MzE4MzAxNjI2NzcyNDQ3OTgiLCJyZWdpb25fZWRnZSI6ImlhZCIsInJlZ2lvbl9yZW5kZXIiOiJpYWQifQ== |
| visitor-hmac | a0dd673508a1490debf931a8aba13abc0ae4bbe669d945a6b12daa7786f68d9f |
| hovercard-subject-tag | issue:4913024490 |
| github-keyboard-shortcuts | repository,issues,copilot |
| google-site-verification | Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I |
| octolytics-url | https://collector.github.com/github/collect |
| analytics-location | / |
| fb:app_id | 1401488693436528 |
| apple-itunes-app | app-id=1477376905, app-argument=https://github.com/_view_fragments/issues/show/nodejs/node/64561/issue_layout |
| twitter:image | https://opengraph.githubassets.com/34b15da3c8fc7b8db2d0ed45c3efc86fd0d9fe80135188df254f0c56492aff91/nodejs/node/issues/64561 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/34b15da3c8fc7b8db2d0ed45c3efc86fd0d9fe80135188df254f0c56492aff91/nodejs/node/issues/64561 |
| og:image:alt | 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 bu... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | gildas-lormeau |
| hostname | github.com |
| expected-hostname | github.com |
| None | e3e0f7f63e50c6fe879198a93a0a023a648ecc0a5e9bc3b5e033ac57172237e6 |
| turbo-cache-control | no-preview |
| go-import | github.com/nodejs/node git https://github.com/nodejs/node.git |
| octolytics-dimension-user_id | 9950313 |
| octolytics-dimension-user_login | nodejs |
| octolytics-dimension-repository_id | 27193779 |
| octolytics-dimension-repository_nwo | nodejs/node |
| octolytics-dimension-repository_public | true |
| octolytics-dimension-repository_is_fork | false |
| octolytics-dimension-repository_network_root_id | 27193779 |
| octolytics-dimension-repository_network_root_nwo | nodejs/node |
| turbo-body-classes | logged-out env-production page-responsive |
| disable-turbo | false |
| browser-stats-url | https://api.github.com/_private/browser/stats |
| browser-errors-url | https://api.github.com/_private/browser/errors |
| release | 707a24b346ac8a82878b9c084efaa38a997660cb |
| ui-target | canary-2 |
| theme-color | #1e2327 |
| color-scheme | light dark |
| react-app-name | issues |
Links:
Viewport: width=device-width