Title: Readable.toWeb(): uncaughtException (ERR_INVALID_STATE "Controller is already closed") when the stream is canceled during backpressure resume · Issue #64529 · nodejs/node · GitHub
Open Graph Title: Readable.toWeb(): uncaughtException (ERR_INVALID_STATE "Controller is already closed") when the stream is canceled during backpressure resume · Issue #64529 · nodejs/node
X Title: Readable.toWeb(): uncaughtException (ERR_INVALID_STATE "Controller is already closed") when the stream is canceled during backpressure resume · Issue #64529 · nodejs/node
Description: Version v22.23.1 (also affects main — the unguarded code is still present) Platform Linux (Docker, x86_64) — also reproduced on Darwin 25.5.0 arm64 Subsystem stream, webstreams What steps will reproduce the bug? Convert a Node Readable (...
Open Graph Description: Version v22.23.1 (also affects main — the unguarded code is still present) Platform Linux (Docker, x86_64) — also reproduced on Darwin 25.5.0 arm64 Subsystem stream, webstreams What steps will repr...
X Description: Version v22.23.1 (also affects main — the unguarded code is still present) Platform Linux (Docker, x86_64) — also reproduced on Darwin 25.5.0 arm64 Subsystem stream, webstreams What steps will repr...
Opengraph URL: https://github.com/nodejs/node/issues/64529
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"Readable.toWeb(): uncaughtException (ERR_INVALID_STATE \"Controller is already closed\") when the stream is canceled during backpressure resume","articleBody":"### Version\n\nv22.23.1 (also affects `main` — the unguarded code is still present)\n\n### Platform\n\n```text\nLinux (Docker, x86_64) — also reproduced on Darwin 25.5.0 arm64\n```\n\n### Subsystem\n\nstream, webstreams\n\n### What steps will reproduce the bug?\n\nConvert a Node `Readable` (e.g. a `PassThrough`) to a web `ReadableStream` with `Readable.toWeb()`, pipe it to a slow sink so backpressure pause/resume cycles occur, and cancel/abort mid-transfer (as happens whenever an HTTP client disconnects while a server streams a response built from `new Response(Readable.toWeb(nodeStream))`):\n\n```js\n// repro.mjs — node repro.mjs\nimport { PassThrough, Readable, pipeline as pump } from \"node:stream\";\n\nlet uncaught = 0;\nprocess.on(\"uncaughtExceptionMonitor\", (e) => { if (++uncaught === 1) console.log(e.stack); });\nprocess.on(\"uncaughtException\", () => {});\n\nasync function one() {\n const src = new Readable({\n read() {\n this.push(Buffer.alloc(16 * 1024, 1));\n if ((this.bytes = (this.bytes || 0) + 16384) > 512 * 1024) this.push(null);\n },\n });\n const pt = new PassThrough({ highWaterMark: 16384 });\n pump(src, pt, () => {});\n const web = Readable.toWeb(pt);\n\n const ac = new AbortController();\n const writer = new WritableStream(\n { async write() { await new Promise((r) => setTimeout(r, 1)); } }, // slow sink → backpressure\n { highWaterMark: 1 },\n );\n setTimeout(() => ac.abort(), Math.floor(Math.random() * 12)); // client disconnect\n try { await web.pipeTo(writer, { signal: ac.signal }); } catch {}\n await new Promise((r) => setImmediate(r));\n}\n\nfor (let i = 0; i \u003c 400; i++) await one();\nconsole.log(`uncaught=${uncaught}/400 node=${process.version}`);\n```\n\n### How often does it reproduce? Is there a required condition?\n\n400/400 iterations on v22.23.1 with the harness above. Required conditions: the source is flowing under backpressure (pause → `pull()` → `resume()` cycles) and the web stream is canceled (directly or via `pipeTo` abort) while a resume tick is already scheduled.\n\n### What is the expected behavior? Why is that the expected behavior?\n\nCanceling a `ReadableStream` produced by `Readable.toWeb()` should tear the pipeline down quietly. A consumer abort is a normal event; it should never escalate to an `uncaughtException` the user cannot catch.\n\n### What do you see instead?\n\n```\nTypeError [ERR_INVALID_STATE]: Invalid state: Controller is already closed\n at ReadableStreamDefaultController.enqueue (node:internal/webstreams/readablestream:1077:13)\n at PassThrough.onData (node:internal/webstreams/adapters:468:16)\n at PassThrough.emit (node:events:519:28)\n at Readable.read (node:internal/streams/readable:782:10)\n at flow (node:internal/streams/readable:1283:53)\n at resume_ (node:internal/streams/readable:1262:3)\n at process.processTicksAndRejections (node:internal/process/task_queues:89:21)\n```\n\nThe throw happens synchronously inside an EventEmitter `'data'` callback, so it surfaces as an **uncaughtException** — unreachable by any user-level try/catch.\n\n### Additional information\n\nRoot cause in `lib/internal/webstreams/adapters.js` (`newReadableStreamFromStreamReadable`):\n\n```js\nfunction onData(chunk) {\n // Copy the Buffer to detach it from the pool.\n if (Buffer.isBuffer(chunk) && !objectMode)\n chunk = new Uint8Array(chunk);\n controller.enqueue(chunk); // ← no closed/canceled guard\n if (controller.desiredSize \u003c= 0)\n streamReadable.pause();\n}\n```\n\nPer spec, `ReadableStreamCancel` sets the stream state to `\"closed\"` **before** invoking the underlying source's `cancel()` (which destroys the Node stream), and the `'data'` listener is never removed on cancel. A `resume_`-scheduled flow tick can therefore deliver one more `'data'` event after the controller is closed but before `destroy()` takes effect, and `enqueue` throws.\n\nSuggested fix: guard `onData` with `wasCanceled` (and/or wrap the `enqueue` in try/catch → destroy the source), or remove the `'data'` listener in `cancel()`.\n\nReal-world impact: any server doing `new Response(Readable.toWeb(nodeStream))` (e.g. a Next.js route handler proxying object-storage bytes) hits this whenever a browser aborts mid-download — `\u003cvideo>` seek/probe aborts made this fire on every deploy cutover for us. Note the same unguarded pattern is reachable from Next.js's own `action-handler.ts` (`Readable.toWeb(sizeLimitedBody)`).\n\nWorkaround: use pull-based `ReadableStream.from(nodeReadable)` instead of `Readable.toWeb()` — 0/400 with the same harness, and cancel still propagates destroy to the source via the async iterator's `return()`.\n","author":{"url":"https://github.com/moonjiho-bigtablet","@type":"Person","name":"moonjiho-bigtablet"},"datePublished":"2026-07-16T04:21:42Z","url":"https://github.com/nodejs/node/issues/64529"}
| 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:84975b38-3dc7-0ea1-1b43-96580fb69661 |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | CE54:1085A1:621431:7EC576:6AED2AC7 |
| html-safe-nonce | 567e9bc45aecedc2ad837eff4f91b950302350dbec19d54efbab9ec9a957c260 |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJDRTU0OjEwODVBMTo2MjE0MzE6N0VDNTc2OjZBRUQyQUM3IiwidmlzaXRvcl9pZCI6Ijc3MTc0NTA2ODUyNjY4NjQ4MzkiLCJyZWdpb25fZWRnZSI6ImlhZCIsInJlZ2lvbl9yZW5kZXIiOiJpYWQifQ== |
| visitor-hmac | 39262329c5dbdbeab66b22fdbfbd63e049d612778a3c6d13a379e38a2c173115 |
| hovercard-subject-tag | issue:4898658389 |
| 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/64529/issue_layout |
| twitter:image | https://opengraph.githubassets.com/73ddc3fb960d0994501e8e16896f6097efb5cc7a04878f801ddd8390628cf9b8/nodejs/node/issues/64529 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/73ddc3fb960d0994501e8e16896f6097efb5cc7a04878f801ddd8390628cf9b8/nodejs/node/issues/64529 |
| og:image:alt | Version v22.23.1 (also affects main — the unguarded code is still present) Platform Linux (Docker, x86_64) — also reproduced on Darwin 25.5.0 arm64 Subsystem stream, webstreams What steps will repr... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | moonjiho-bigtablet |
| hostname | github.com |
| expected-hostname | github.com |
| None | 9d95365dda0576efe9b71f45807595e396bd0ca9286fba5367a05048e6ddf8f2 |
| 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 | 98b41fa4b5635ceb45a4c54320fced73817ff290 |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
| react-app-name | issues |
Links:
Viewport: width=device-width