Title: webstreams: transform() loses AsyncLocalStorage context after a parked write (regression in v24.22.0) · Issue #66906 · nodejs/node · GitHub
Open Graph Title: webstreams: transform() loses AsyncLocalStorage context after a parked write (regression in v24.22.0) · Issue #66906 · nodejs/node
X Title: webstreams: transform() loses AsyncLocalStorage context after a parked write (regression in v24.22.0) · Issue #66906 · nodejs/node
Description: Version v24.22.0, v24.22.1, v26.10.0, v26.12.1, and main since v27.0.0-nightly20260913dc3b46f749. v24.20.0 and v22.23.4 do not have the problem. v24.21.0 and v26.9.0 have a smaller form of it (see "What do you see instead?"). Platform Da...
Open Graph Description: Version v24.22.0, v24.22.1, v26.10.0, v26.12.1, and main since v27.0.0-nightly20260913dc3b46f749. v24.20.0 and v22.23.4 do not have the problem. v24.21.0 and v26.9.0 have a smaller form of it (see ...
X Description: Version v24.22.0, v24.22.1, v26.10.0, v26.12.1, and main since v27.0.0-nightly20260913dc3b46f749. v24.20.0 and v22.23.4 do not have the problem. v24.21.0 and v26.9.0 have a smaller form of it (see ...
Opengraph URL: https://github.com/nodejs/node/issues/66906
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"webstreams: transform() loses AsyncLocalStorage context after a parked write (regression in v24.22.0)","articleBody":"### Version\n\nv24.22.0, v24.22.1, v26.10.0, v26.12.1, and `main` since `v27.0.0-nightly20260913dc3b46f749`.\n\nv24.20.0 and v22.23.4 do not have the problem. v24.21.0 and v26.9.0 have a smaller form of it (see \"What do you see instead?\").\n\n### Platform\n\n```text\nDarwin 25.6.0 arm64\nLinux x86_64 (official node:24.21.0-slim and node:24.22.1-slim images)\n```\n\n### Subsystem\n\nwebstreams, async_hooks (`AsyncLocalStorage`)\n\n### What steps will reproduce the bug?\n\nSave this file as `repro.mjs` and run `node repro.mjs`:\n\n```js\nimport { AsyncLocalStorage } from 'node:async_hooks';\n\nconst als = new AsyncLocalStorage();\nconst seen = [];\n\n// Build the pipeline inside als.run().\nconst readable = als.run('writer', () =>\n new ReadableStream({\n start(controller) {\n for (const chunk of 'abcde') controller.enqueue(chunk);\n controller.close();\n },\n }).pipeThrough(new TransformStream({\n transform(chunk, controller) {\n seen.push(`${chunk}:${als.getStore() ?? 'none'}`);\n controller.enqueue(chunk);\n },\n flush() {\n seen.push(`flush:${als.getStore() ?? 'none'}`);\n },\n })));\n\n// Let the internal pipe write the first chunk, which parks on backpressure.\nawait new Promise((resolve) => setImmediate(resolve));\n\n// Read outside als.run().\nfor await (const _ of readable);\n\nconsole.log(process.version, seen.join(' '));\n```\n\n### How often does it reproduce? Is there a required condition?\n\nAlways. The result is the same on each run.\n\nRequired condition: the first write of the internal pipe must park on backpressure before the reader starts. In the repro, the `setImmediate()` wait does this. If you remove the wait, every chunk keeps the store on all versions that I tested.\n\n### What is the expected behavior? Why is that the expected behavior?\n\nExpected output (v24.20.0 and v22.23.4):\n\n```text\nv24.20.0 a:writer b:writer c:writer d:writer e:writer flush:writer\n```\n\nEvery `transform()` call runs in the async context of the writer. Here, the writer is the internal pipe that `pipeThrough()` starts inside `als.run()`.\n\nWhy this is the expected behavior:\n\n1. In the Streams spec, a parked transform write is a promise reaction. [TransformStreamDefaultSinkWriteAlgorithm](https://streams.spec.whatwg.org/#transform-stream-default-sink-write-algorithm), step 3: \"Return the result of reacting to backpressureChangePromise with the following fulfillment steps: … Return ! TransformStreamDefaultControllerPerformTransform(controller, chunk).\"\n2. [WritableStreamDefaultControllerProcessWrite](https://streams.spec.whatwg.org/#writable-stream-default-controller-process-write) also reacts to the sink write promise. Its fulfillment steps call `WritableStreamDefaultControllerAdvanceQueueIfNeeded()`, which processes the next queued write.\n3. Both reactions are registered when the write is processed, in the writer's context. The reader's pull ([TransformStreamDefaultSourcePullAlgorithm](https://streams.spec.whatwg.org/#transform-stream-default-source-pull)) only resolves `[[backpressureChangePromise]]`.\n4. A promise reaction runs in the async context that was current when the reaction was registered. `AsyncLocalStorage` works this way. The TC39 [AsyncContext proposal](https://tc39.es/proposal-async-context/) (Stage 2) specifies the same rule: [PerformPromiseThen](https://tc39.es/proposal-async-context/#sec-performpromisethen) stores an `AsyncContextSnapshot()` in each reaction.\n\nThus, an implementation that follows the promise structure of the spec runs every `transform()` in the writer's context. Node did this up to v24.20.0.\n\nSpec status: the Streams spec does not define async context propagation. In my tests, the chunk order does not change, and the source comments say that the continuation runs at the same microtask position as the old reaction. Thus, as far as I can tell, this does not break a normative requirement of the Streams spec. It changes the async context that user callbacks see: the context no longer follows the promise reactions of the spec. The non-normative AsyncContext [web integration notes](https://github.com/tc39/proposal-async-context/blob/master/WEB-INTEGRATION.md#stream-underlying-apis) say that sink and transformer callbacks use the context of the data flow, and that only `pull()` uses the context of the `read()` caller. The notes also say that the propagation for piping is not defined yet.\n\n### What do you see instead?\n\n```text\nv24.21.0 a:none b:writer c:writer d:writer e:writer flush:writer\nv24.22.0 a:none b:none c:none d:none e:none flush:writer\nv24.22.1 a:none b:none c:none d:none e:none flush:writer\nv26.9.0 a:none b:writer c:writer d:writer e:writer flush:writer\nv26.10.0 a:none b:none c:none d:none e:none flush:writer\nv26.12.1 a:none b:none c:none d:none e:none flush:none\n```\n\nOn v24.22.x and v26.10.0+, `transform()` runs without the store for every chunk. `--no-async-context-frame` gives the same output on v24.22.1.\n\nImpact: a server often runs a request handler inside `AsyncLocalStorage.run()`, and then pipes the response body to the socket after the handler returns. A `TransformStream` in the response body then loses request-scoped state. For example, in a Next.js route handler, `after()` that is called from `transform()` for the last chunk of the response throws \"`after` was called outside a request scope\" on v24.22.1. It works on v24.21.0.\n\n### Additional information\n\nI found two changes by reading the source at each tag:\n\n1. v24.21.0 (#65143, 86d1196ebf; also v26.8.0): a parked write no longer reacts to `[[backpressureChangePromise]]`. `transformStreamSetBackpressure(stream, false)` queues `writeContinuation` from the reader's pull, so `transform()` for the parked chunk runs in the reader's context. The sink still returns `pendingWrite.promise`. Thus the write reactions of the writable controller are still registered at write time, and later chunks keep the writer's context.\n2. v24.22.0 (#65625, 8aee69b912 on `main`, backport bbb00558c5; also v26.10.0): the sink returns `kParkedAlgorithmResult`, and `writeContinuation` attaches `writeFulfilled` and `writeRejected` to the perform-transform promise when the reader unparks the write. `writeFulfilled` calls `writableStreamDefaultControllerAdvanceQueueIfNeeded()`. Thus all later queued writes, and the internal pipe loop that continues from them, run in the reader's context.\n\nNightly bisect, with a check on chunk `c`: the last good build is `v27.0.0-nightly20260912565f69f986`, and the first bad build is `v27.0.0-nightly20260913dc3b46f749`. The range has 28 commits. Five of them have the `stream:` prefix: 1278496599, 50b355ec04, 77f95b5f59, 8aee69b912, and 1c179c99e7. From the diffs, 8aee69b912 is the commit that moves the write reactions.\n\nA possible fix: when a write parks, capture the current async context, and run `writeContinuation` (the perform-transform call and the write reactions) in that context. This gives the v24.20.0 output for every chunk, also for the parked chunk.\n\nOn v26.12.1, `flush()` also runs without the store. I did not bisect that change.\n\ncc @mcollina (author of #65143 and #65625)\n","author":{"url":"https://github.com/Timer","@type":"Person","name":"Timer"},"datePublished":"2026-10-21T20:10:19Z","url":"https://github.com/nodejs/node/issues/66906"}
| 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:65e16a29-f33b-7e59-1df3-f0d8f7ed7012 |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | C8A2:32386D:BA04F8:F24241:6AED4608 |
| html-safe-nonce | d8ffa6e2329c072aa3afb29b15292ebd90c64b55d3432d1048e5c8137940947b |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJDOEEyOjMyMzg2RDpCQTA0Rjg6RjI0MjQxOjZBRUQ0NjA4IiwidmlzaXRvcl9pZCI6IjE3NTMyOTE5Mzc5MTQxMTE0OTYiLCJyZWdpb25fZWRnZSI6ImlhZCIsInJlZ2lvbl9yZW5kZXIiOiJpYWQifQ== |
| visitor-hmac | 0807ac58d441fa465385e1109a0916eab85fe7f54a2b329a95cdaf9c0938b1f1 |
| hovercard-subject-tag | issue:5967742462 |
| 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/66906/issue_layout |
| twitter:image | https://opengraph.githubassets.com/2ba5dd541544dd46bd7880e06d77947ede69dd35a2b1e8a0a395247a6005e405/nodejs/node/issues/66906 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/2ba5dd541544dd46bd7880e06d77947ede69dd35a2b1e8a0a395247a6005e405/nodejs/node/issues/66906 |
| og:image:alt | Version v24.22.0, v24.22.1, v26.10.0, v26.12.1, and main since v27.0.0-nightly20260913dc3b46f749. v24.20.0 and v22.23.4 do not have the problem. v24.21.0 and v26.9.0 have a smaller form of it (see ... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | Timer |
| hostname | github.com |
| expected-hostname | github.com |
| None | 56fb42677ae87ed3e64410323d67980aa65d0485568a7858f874e7edfc6d1c11 |
| 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 | 9c9a37e2423ed382cf39f70b5aa4b4858b40a3b0 |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
| react-app-name | issues |
Links:
Viewport: width=device-width