Commit 12065411c9a for nodejs
commit 12065411c9a4b0c8a8674d749ed940e3203d3a77
Author: Matteo Collina <hello@matteocollina.com>
Date: Mon Nov 2 09:24:14 2026 +0100
stream: keep async context of fromWeb writes
Writable.fromWeb() and Duplex.fromWeb() dispatch writes to the
WritableStream directly and complete them through a shared write
tracker. A write issued before the WritableStream had started was
queued and processed from its start step, so the sink ran in the async
context of the code that created the WritableStream, and the tracker
invoked the Node.js write callback synchronously from whatever context
completed the sink write, so the writes and final() it released ran
there too. The promise-based adapter waited for writer.ready and
received the result through a reaction, both in the context of the
Node.js write.
Dispatch writes issued before the start from a writer.ready reaction,
failing them if the stream was closed or errored meanwhile, and deliver
the write callback in the context of the Node.js write, from a reaction
when it completes in another context.
Refs: https://github.com/nodejs/node/issues/66906
Refs: https://github.com/nodejs/node/pull/65548
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/67055
Refs: https://github.com/nodejs/node/pull/67052
Refs: https://github.com/nodejs/node/pull/67054
Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
diff --git a/lib/internal/webstreams/adapters.js b/lib/internal/webstreams/adapters.js
index a41b668f014..fc5ada12be3 100644
--- a/lib/internal/webstreams/adapters.js
+++ b/lib/internal/webstreams/adapters.js
@@ -32,6 +32,8 @@ const {
const {
WritableStream,
isWritableStream,
+ writableStreamDefaultWriterIsStarted,
+ writableStreamDefaultWriterWriteFailure,
writableStreamDefaultWriterWriteWithRequest,
} = require('internal/webstreams/writablestream');
@@ -62,6 +64,11 @@ const {
kResolvedPromise,
} = require('internal/webstreams/util');
+const {
+ getContinuationPreservedEmbedderData: currentContextFrame,
+ setContinuationPreservedEmbedderData: setContextFrame,
+} = internalBinding('async_context_frame');
+
const {
isAnyArrayBuffer,
} = require('internal/util/types');
@@ -157,7 +164,9 @@ const noop = () => {};
// batch is in flight at a time, so one callback slot is enough.
// `promise` is non-undefined so writable-stream queue entries are
// discriminated from kNilRequest by `promise === undefined`;
-// `failed`/`failure` latch a write that could not proceed.
+// `failed`/`failure` latch a write that could not proceed. The callback
+// runs in the async context of the Node.js write that set it, as the
+// reaction to writer.write() did (see settle()).
function createWriteTracker(getDestination) {
return {
promise: null,
@@ -165,6 +174,7 @@ function createWriteTracker(getDestination) {
failed: false,
failure: undefined,
callback: undefined,
+ contextFrame: undefined,
error: undefined,
hasError: false,
dispatching: false,
@@ -187,6 +197,22 @@ function createWriteTracker(getDestination) {
}
},
settle() {
+ const contextFrame = this.contextFrame;
+ this.contextFrame = undefined;
+ const prior = currentContextFrame();
+ if (prior === contextFrame) {
+ this.settleInContext();
+ return;
+ }
+ // Completed from another async context (e.g. a write queued while
+ // the destination was starting): deliver from a reaction registered
+ // in the context of the write, once the destination has finished
+ // processing it.
+ setContextFrame(contextFrame);
+ PromisePrototypeThen(kResolvedPromise, () => this.settleInContext());
+ setContextFrame(prior);
+ },
+ settleInContext() {
const callback = this.callback;
this.callback = undefined;
let error;
@@ -224,7 +250,37 @@ function createWriteTracker(getDestination) {
};
}
+// A write issued before the destination has started would be queued and
+// processed from the start step, in the async context of the code that
+// created the WritableStream. Dispatch it from a writer.ready reaction
+// instead, as the promise-based adapter did, so the sink runs in the
+// context of the Node.js write. The stream may have been closed or
+// errored (e.g. by destroy()) by then.
+function deferDispatch(writer, tracker, dispatchNow) {
+ const dispatch = () => {
+ const failure = writableStreamDefaultWriterWriteFailure(writer);
+ if (failure !== undefined) {
+ tracker.failed = true;
+ tracker.failure = failure;
+ tracker.settle();
+ return;
+ }
+ dispatchNow();
+ };
+ PromisePrototypeThen(writer.ready, dispatch, dispatch);
+}
+
function dispatchWrite(writer, chunk, tracker) {
+ tracker.contextFrame = currentContextFrame();
+ if (!writableStreamDefaultWriterIsStarted(writer)) {
+ deferDispatch(writer, tracker,
+ () => dispatchWriteNow(writer, chunk, tracker));
+ return;
+ }
+ dispatchWriteNow(writer, chunk, tracker);
+}
+
+function dispatchWriteNow(writer, chunk, tracker) {
tracker.dispatching = true;
writableStreamDefaultWriterWriteWithRequest(writer, chunk, tracker);
tracker.dispatching = false;
@@ -233,6 +289,16 @@ function dispatchWrite(writer, chunk, tracker) {
}
function dispatchWritev(writer, chunks, tracker) {
+ tracker.contextFrame = currentContextFrame();
+ if (!writableStreamDefaultWriterIsStarted(writer)) {
+ deferDispatch(writer, tracker,
+ () => dispatchWritevNow(writer, chunks, tracker));
+ return;
+ }
+ dispatchWritevNow(writer, chunks, tracker);
+}
+
+function dispatchWritevNow(writer, chunks, tracker) {
tracker.dispatching = true;
for (let i = 0; i < chunks.length; i++)
writableStreamDefaultWriterWriteWithRequest(writer, chunks[i].chunk, tracker);
diff --git a/lib/internal/webstreams/writablestream.js b/lib/internal/webstreams/writablestream.js
index 7b6a7327944..dfde280e2da 100644
--- a/lib/internal/webstreams/writablestream.js
+++ b/lib/internal/webstreams/writablestream.js
@@ -1007,6 +1007,23 @@ function writableStreamDefaultWriterWrite(writer, chunk) {
return promise;
}
+function writableStreamDefaultWriterIsStarted(writer) {
+ const stream = writer[kState].stream;
+ return stream === undefined ||
+ stream[kState].controller[kState].started;
+}
+
+// Returns the error a write through the writer would fail with, or
+// undefined if the stream still accepts writes.
+function writableStreamDefaultWriterWriteFailure(writer) {
+ const streamState = writer[kState].stream[kState];
+ const { state } = streamState;
+ if (state === 'errored' || state === 'erroring')
+ return streamState.storedError;
+ if (streamState.closeQueuedOrInFlight || state === 'closed')
+ return new ERR_INVALID_STATE.TypeError('WritableStream is closed');
+}
+
// Variant of writableStreamDefaultWriterWrite for pipeTo: the caller
// provides a shared request object instead of a per-write promise record.
// `pending` is incremented before the controller write, which can settle
@@ -1458,6 +1475,8 @@ module.exports = {
writableStreamCloseQueuedOrInFlight,
writableStreamAddWriteRequest,
writableStreamDefaultWriterWrite,
+ writableStreamDefaultWriterIsStarted,
+ writableStreamDefaultWriterWriteFailure,
writableStreamDefaultWriterWriteWithRequest,
writableStreamDefaultWriterRelease,
writableStreamDefaultWriterGetDesiredSize,
diff --git a/test/parallel/test-whatwg-webstreams-adapters-async-context.js b/test/parallel/test-whatwg-webstreams-adapters-async-context.js
new file mode 100644
index 00000000000..94240018e45
--- /dev/null
+++ b/test/parallel/test-whatwg-webstreams-adapters-async-context.js
@@ -0,0 +1,105 @@
+'use strict';
+
+// Writes through Writable.fromWeb() and Duplex.fromWeb() must reach the
+// WritableStream sink in the async context of the Node.js write, also when
+// they are issued before the WritableStream has started.
+
+const common = require('../common');
+const assert = require('assert');
+const { AsyncLocalStorage } = require('async_hooks');
+const { Duplex, Writable } = require('stream');
+const { setImmediate: tick } = require('timers/promises');
+
+const als = new AsyncLocalStorage();
+
+function store() {
+ return als.getStore() ?? 'none';
+}
+
+function createSink(seen, start) {
+ return new WritableStream({
+ start,
+ write(chunk) { seen.push(`write-${chunk}:${store()}`); },
+ close() { seen.push(`close:${store()}`); },
+ });
+}
+
+const tests = [];
+function test(name, expected, fn) {
+ tests.push({ name, expected, fn });
+}
+
+test('Writable.fromWeb() written before start', [
+ 'write-a:writer', 'write-b:writer', 'close:writer',
+], async (seen) => {
+ const ws = als.run('creator', () => createSink(seen));
+ const writable = Writable.fromWeb(ws, { decodeStrings: false });
+ await als.run('writer', () => new Promise((resolve) => {
+ writable.write('a');
+ writable.write('b');
+ writable.end(resolve);
+ }));
+});
+
+test('Writable.fromWeb() writev before start', [
+ 'write-a:writer', 'write-b:writer', 'close:writer',
+], async (seen) => {
+ const ws = als.run('creator', () => createSink(seen));
+ const writable = Writable.fromWeb(ws, { decodeStrings: false });
+ await als.run('writer', () => new Promise((resolve) => {
+ writable.cork();
+ writable.write('a');
+ writable.write('b');
+ writable.uncork();
+ writable.end(resolve);
+ }));
+});
+
+test('Writable.fromWeb() written during an async start', [
+ // The WritableStream processes the chunk queued while it was starting
+ // from its start step. The Node.js write callback still runs in the
+ // writer's context, so the writes it releases do too.
+ 'write-a:creator', 'write-b:writer', 'close:writer',
+], async (seen) => {
+ const ws = als.run('creator', () => createSink(seen, () => tick()));
+ const writable = Writable.fromWeb(ws, { decodeStrings: false });
+ await als.run('writer', () => new Promise((resolve) => {
+ writable.write('a');
+ writable.write('b');
+ writable.end(resolve);
+ }));
+});
+
+test('Writable.fromWeb() written after start', [
+ 'write-a:w1', 'write-b:w2', 'close:ender',
+], async (seen) => {
+ const ws = als.run('creator', () => createSink(seen));
+ const writable = Writable.fromWeb(ws, { decodeStrings: false });
+ await tick();
+ await als.run('w1', () => new Promise((resolve) => writable.write('a', resolve)));
+ await als.run('w2', () => new Promise((resolve) => writable.write('b', resolve)));
+ await als.run('ender', () => new Promise((resolve) => writable.end(resolve)));
+});
+
+test('Duplex.fromWeb() written before start', [
+ 'write-a:writer', 'close:writer',
+], async (seen) => {
+ const pair = als.run('creator', () => ({
+ readable: new ReadableStream({ start(c) { c.close(); } }),
+ writable: createSink(seen),
+ }));
+ const duplex = Duplex.fromWeb(pair, { decodeStrings: false });
+ duplex.resume();
+ await als.run('writer', () => new Promise((resolve) => {
+ duplex.write('a');
+ duplex.end(resolve);
+ }));
+});
+
+(async () => {
+ for (const { name, expected, fn } of tests) {
+ const seen = [];
+ await fn(seen);
+ assert.deepStrictEqual(seen, expected, name);
+ }
+})().then(common.mustCall());