webstreams: transform() loses AsyncLocalStorage context after a parked write (regression in v24.22.0) · Issue #66906 · nodejs/node · GitHub
Skip to content

webstreams: transform() loses AsyncLocalStorage context after a parked write (regression in v24.22.0) #66906

Description

@Timer

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:

  1. 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)."
  2. WritableStreamDefaultControllerProcessWrite also reacts to the sink write promise. Its fulfillment steps call WritableStreamDefaultControllerAdvanceQueueIfNeeded(), which processes the next queued write.
  3. Both reactions are registered when the write is processed, in the writer's context. The reader's pull (TransformStreamDefaultSourcePullAlgorithm) only resolves [[backpressureChangePromise]].
  4. A promise reaction runs in the async context that was current when the reaction was registered. AsyncLocalStorage works this way. The TC39 AsyncContext proposal (Stage 2) specifies the same rule: PerformPromiseThen stores an AsyncContextSnapshot() 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:

  1. 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) 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.
  2. v24.22.0 (stream: keep webstream stream states in fast-mode objects #65625, 8aee69b on main, backport bbb0055; 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.

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.

cc @mcollina (author of #65143 and #65625)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions