Repository navigation
webstreams: transform() loses AsyncLocalStorage context after a parked write (regression in v24.22.0) #66906
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
Darwin 25.6.0 arm64
Linux x86_64 (official node:24.21.0-slim and node:24.22.1-slim images)
Subsystem
webstreams, async_hooks (AsyncLocalStorage)
What steps will reproduce the bug?
Save this file as repro.mjs and run node repro.mjs:
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();
const seen = [];
// Build the pipeline inside als.run().
const readable = als.run('writer', () =>
new ReadableStream({
start(controller) {
for (const chunk of 'abcde') controller.enqueue(chunk);
controller.close();
},
}).pipeThrough(new TransformStream({
transform(chunk, controller) {
seen.push(`${chunk}:${als.getStore() ?? 'none'}`);
controller.enqueue(chunk);
},
flush() {
seen.push(`flush:${als.getStore() ?? 'none'}`);
},
})));
// Let the internal pipe write the first chunk, which parks on backpressure.
await new Promise((resolve) => setImmediate(resolve));
// Read outside als.run().
for await (const _ of readable);
console.log(process.version, seen.join(' '));How often does it reproduce? Is there a required condition?
Always. The result is the same on each run.
Required 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.
What is the expected behavior? Why is that the expected behavior?
Expected output (v24.20.0 and v22.23.4):
v24.20.0 a:writer b:writer c:writer d:writer e:writer flush:writer
Every transform() call runs in the async context of the writer. Here, the writer is the internal pipe that pipeThrough() starts inside als.run().
Why this is the expected behavior:
- In the Streams spec, a parked transform write is a promise reaction. TransformStreamDefaultSinkWriteAlgorithm, step 3: "Return the result of reacting to backpressureChangePromise with the following fulfillment steps: … Return ! TransformStreamDefaultControllerPerformTransform(controller, chunk)."
- WritableStreamDefaultControllerProcessWrite also reacts to the sink write promise. Its fulfillment steps call
WritableStreamDefaultControllerAdvanceQueueIfNeeded(), which processes the next queued write. - Both reactions are registered when the write is processed, in the writer's context. The reader's pull (TransformStreamDefaultSourcePullAlgorithm) only resolves
[[backpressureChangePromise]]. - A promise reaction runs in the async context that was current when the reaction was registered.
AsyncLocalStorageworks this way. The TC39 AsyncContext proposal (Stage 2) specifies the same rule: PerformPromiseThen stores anAsyncContextSnapshot()in each reaction.
Thus, 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.
Spec 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 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.
What do you see instead?
v24.21.0 a:none b:writer c:writer d:writer e:writer flush:writer
v24.22.0 a:none b:none c:none d:none e:none flush:writer
v24.22.1 a:none b:none c:none d:none e:none flush:writer
v26.9.0 a:none b:writer c:writer d:writer e:writer flush:writer
v26.10.0 a:none b:none c:none d:none e:none flush:writer
v26.12.1 a:none b:none c:none d:none e:none flush:none
On 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.
Impact: 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.
Additional information
I found two changes by reading the source at each tag:
- v24.21.0 (stream: decouple transform backpressure changes #65143, 86d1196; also v26.8.0): a parked write no longer reacts to
[[backpressureChangePromise]].transformStreamSetBackpressure(stream, false)queueswriteContinuationfrom the reader's pull, sotransform()for the parked chunk runs in the reader's context. The sink still returnspendingWrite.promise. Thus the write reactions of the writable controller are still registered at write time, and later chunks keep the writer's context. - v24.22.0 (stream: keep webstream stream states in fast-mode objects #65625, 8aee69b on
main, backport bbb0055; also v26.10.0): the sink returnskParkedAlgorithmResult, andwriteContinuationattacheswriteFulfilledandwriteRejectedto the perform-transform promise when the reader unparks the write.writeFulfilledcallswritableStreamDefaultControllerAdvanceQueueIfNeeded(). Thus all later queued writes, and the internal pipe loop that continues from them, run in the reader's context.
Nightly 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: 1278496, 50b355e, 77f95b5, 8aee69b, and 1c179c9. From the diffs, 8aee69b is the commit that moves the write reactions.
A 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.
On v26.12.1, flush() also runs without the store. I did not bisect that change.
Activity
stream: keep writer async context for parked transform writes
stream: keep writer async context for parked transform writes
Revert "stream: avoid promise allocation for parked transform writes"
Revert "stream: decouple transform backpressure changes"
Revert "stream: avoid promise allocation for parked transform writes"
Revert "stream: decouple transform backpressure changes"
stream: keep async context of parked writes
stream: keep async context of parked writes