Skip to content

Commit e397072

Browse files
committed
stream: keep writer async context for parked transform writes
When a TransformStream write parks on backpressure, the reader that clears the backpressure delivers the cached write continuation. The continuation was registered in the reader's async context. Thus transform() for the parked chunk, and for every later queued chunk, ran in the reader's context. In the spec, a parked write is a reaction to [[backpressureChangePromise]], and the writable controller reacts to the sink write promise. Both reactions are registered at write time, so they run in the writer's context. Store the async context frame when the write parks, and register the continuation in that frame. This gives the v24.20.0 behavior for AsyncLocalStorage again. If the reader runs in the same frame, the frame does not change. Fixes: #66906 Refs: #65143 Refs: #65625 Signed-off-by: Timer <timer@vercel.com>
1 parent 14ed05f commit e397072

2 files changed

Lines changed: 90 additions & 1 deletion

File tree

‎lib/internal/webstreams/transformstream.js‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ const {
8181
} = require('internal/webstreams/writablestream');
8282

8383
const assert = require('internal/assert');
84+
const AsyncContextFrame = require('internal/async_context_frame');
8485

8586
const kSkipThrow = Symbol('kSkipThrow');
8687

@@ -264,6 +265,7 @@ class TransformStreamState {
264265
pullPending = false;
265266
pendingWriteParked = false;
266267
pendingWriteChunk = undefined;
268+
pendingWriteFrame = undefined;
267269
writeContinuation = undefined;
268270
}
269271
ObjectSetPrototypeOf(TransformStreamState.prototype, null);
@@ -505,7 +507,18 @@ function transformStreamSetBackpressure(stream, backpressure) {
505507
state.readable[kState].controller[kState].pullFulfilled);
506508
}
507509
} else if (state.pendingWriteParked) {
508-
PromisePrototypeThen(kResolvedPromise, state.writeContinuation);
510+
// The spec's reaction to [[backpressureChangePromise]] is registered
511+
// when the write parks, so it runs in the writer's async context, not
512+
// in the context of the reader that clears the backpressure.
513+
const frame = state.pendingWriteFrame;
514+
const priorFrame = AsyncContextFrame.current();
515+
if (frame === priorFrame) {
516+
PromisePrototypeThen(kResolvedPromise, state.writeContinuation);
517+
} else {
518+
AsyncContextFrame.set(frame);
519+
PromisePrototypeThen(kResolvedPromise, state.writeContinuation);
520+
AsyncContextFrame.set(priorFrame);
521+
}
509522
}
510523
}
511524

@@ -662,10 +675,12 @@ function transformStreamDefaultSinkWriteAlgorithm(chunk, writableController) {
662675
assert(!state.pendingWriteParked);
663676
state.pendingWriteParked = true;
664677
state.pendingWriteChunk = chunk;
678+
state.pendingWriteFrame = AsyncContextFrame.current();
665679
state.writeContinuation ??= () => {
666680
const pendingChunk = state.pendingWriteChunk;
667681
state.pendingWriteParked = false;
668682
state.pendingWriteChunk = undefined;
683+
state.pendingWriteFrame = undefined;
669684
const writableState = state.writable[kState];
670685
const writableControllerState = writableState.controller[kState];
671686
if (writableState.state === 'erroring') {
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use strict';
2+
3+
require('../common');
4+
const assert = require('node:assert');
5+
const { AsyncLocalStorage } = require('node:async_hooks');
6+
const { test } = require('node:test');
7+
const { setImmediate } = require('node:timers/promises');
8+
9+
// In the spec, a transform sink write that arrives while the stream has
10+
// backpressure is a reaction to [[backpressureChangePromise]], and the
11+
// writable controller reacts to the sink write promise. Both reactions are
12+
// registered at write time, so transform() runs in the writer's async
13+
// context, also when the reader that clears the backpressure runs in a
14+
// different context.
15+
16+
function recordingTransform(als, seen) {
17+
return new TransformStream({
18+
transform(chunk, controller) {
19+
seen.push(`${chunk}:${als.getStore()}`);
20+
controller.enqueue(chunk);
21+
},
22+
flush() {
23+
seen.push(`flush:${als.getStore()}`);
24+
},
25+
});
26+
}
27+
28+
test('pipeThrough() keeps the writer context after a parked write', async () => {
29+
const als = new AsyncLocalStorage();
30+
const seen = [];
31+
const readable = als.run('writer', () =>
32+
new ReadableStream({
33+
start(controller) {
34+
for (const chunk of 'abcde') controller.enqueue(chunk);
35+
controller.close();
36+
},
37+
}).pipeThrough(recordingTransform(als, seen)));
38+
39+
// Let the internal pipe write the first chunk, which parks.
40+
await setImmediate();
41+
42+
// Read outside als.run().
43+
await Array.fromAsync(readable);
44+
45+
assert.deepStrictEqual(seen, [
46+
'a:writer', 'b:writer', 'c:writer', 'd:writer', 'e:writer', 'flush:writer',
47+
]);
48+
});
49+
50+
test('writer.write() keeps the writer context after a parked write', async () => {
51+
const als = new AsyncLocalStorage();
52+
const seen = [];
53+
const { readable, writable } = recordingTransform(als, seen);
54+
55+
// Let start() settle, so that the first write parks on backpressure.
56+
await setImmediate();
57+
const done = als.run('writer', () => {
58+
const writer = writable.getWriter();
59+
return Promise.all([
60+
writer.write('a'),
61+
writer.write('b'),
62+
writer.write('c'),
63+
writer.close(),
64+
]);
65+
});
66+
67+
// Read outside als.run().
68+
await Array.fromAsync(readable);
69+
await done;
70+
71+
assert.deepStrictEqual(seen, [
72+
'a:writer', 'b:writer', 'c:writer', 'flush:writer',
73+
]);
74+
});

0 commit comments

Comments
 (0)