Skip to content

stream: preserve mutable chunks in Web Stream adapters - #64579

Open
seungwoo505 wants to merge 12 commits into
nodejs:mainfrom
seungwoo505:fix-writable-to-web-write
Open

seungwoo505 wants to merge 12 commits into
nodejs:mainfrom
seungwoo505:fix-writable-to-web-write

Conversation

@seungwoo505

@seungwoo505 seungwoo505 commented Jul 18, 2026 •

Copy link
Copy Markdown

When a Node.js writable is converted with Writable.toWeb(), the Web Streams
write() promise can currently settle as soon as the native write() call
returns true. That return value only represents backpressure, so the native
stream may still retain the supplied mutable BufferSource. Reusing the buffer
after awaiting the Web write can therefore change bytes that have not yet been
consumed.

This change:

  • waits for the native per-write callback when the stream is an uncorked,
    unmodified Writable;
  • coordinates callback completion with drain, aborts, and native stream
    errors;
  • passes a private same-brand BufferSource copy for Duplex, corked, overridden,
    and legacy write paths where waiting for a callback would change existing
    completion behavior;
  • preserves native HTTP validation errors when fallback copying fails; and
  • preserves SharedArrayBuffer backing for cloned views.

The original native Writable.prototype.write and
OutgoingMessage.prototype.write methods are captured so patched methods and
accessors are classified and invoked consistently.

Validation included:

  • make -j4 test (full test suite passed)
  • the changed Web Streams adapter, Duplex, and compression tests
  • the existing Writable, Duplex, and Web Streams adapter test set
  • CompressionStream WPT tests
  • repeated async regression tests
  • ESLint and git diff --check

Fixes: #64549

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/http
  • @nodejs/net
  • @nodejs/streams

@nodejs-github-bot nodejs-github-bot added lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Jul 18, 2026
@seungwoo505
seungwoo505 marked this pull request as ready for review July 18, 2026 16:14
Comment thread lib/_http_outgoing.js Outdated
@@ -1260,4 +1262,5 @@ module.exports = {
validateHeaderName,
validateHeaderValue,
OutgoingMessage,
outgoingMessagePrototypeWrite,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why expose this?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Web Streams adapter uses this export to compare the active write() method with the original OutgoingMessage.prototype.write.
This distinction preserves the native validation behavior without applying it to overridden write() implementations.
The reference is captured here so a later prototype monkey-patch is not mistaken for the original method.

@seungwoo505
seungwoo505 force-pushed the fix-writable-to-web-write branch from 97f5318 to c08321a Compare July 27, 2026 12:18
@seungwoo505

seungwoo505 commented Jul 27, 2026 •

Copy link
Copy Markdown
Author

Hi @bjohansebas, just a friendly follow-up on this.
I’ve answered the question above and rebased the PR onto the latest main, resolving the merge conflict.
The build, relevant Web Streams tests, and ESLint all pass locally.
When you have time, could you please take another look?
Thank you!

@bjohansebas bjohansebas added stream Issues and PRs related to Node.js streams. web streams Issues and PRs related to the Web Streams API. labels Jul 27, 2026
@mcollina

Copy link
Copy Markdown
Member

Sorry for the radio silence. Can you rebase again?

@seungwoo505

Copy link
Copy Markdown
Author

Sorry, I just saw your message.
I’ll rebase it by the end of today

@seungwoo505
seungwoo505 force-pushed the fix-writable-to-web-write branch from c08321a to 062b9a0 Compare September 28, 2026 10:50
@seungwoo505

seungwoo505 commented Sep 28, 2026 •

Copy link
Copy Markdown
Author

Hi @mcollina, I’ve rebased the PR onto the latest main and resolved the conflicts.
git diff --check, make -j4, make test, the relevant Web Streams tests, and the targeted JavaScript lint checks all pass locally.
Could you please take a look when you have a chance?
Thanks!

@mcollina mcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@mcollina mcollina added the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Sep 29, 2026
@mcollina
mcollina requested a review from panva September 29, 2026 10:22
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark GHA (webstreams / adapters): https://github.com/nodejs/node/actions/runs/36556500893

Results

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

Benchmark results:

                                                         confidence improvement accuracy (*)    (**)   (***)
webstreams/adapters.js kind='readable-from-web' n=100000                 1.94 %       ±8.32% ±10.97% ±14.07%
webstreams/adapters.js kind='readable-to-web' n=100000                   1.36 %       ±8.69% ±11.46% ±14.70%
webstreams/adapters.js kind='writable-from-web' n=100000                -0.54 %       ±7.68% ±10.13% ±13.00%
webstreams/adapters.js kind='writable-to-web' n=100000          ***    -81.09 %       ±6.21%  ±8.21% ±10.57%

Be aware that when doing many comparisons the risk of a false-positive
result increases. In this case, there are 4 comparisons, you can thus
expect the following amount of false-positive results:
  0.20 false positives, when considering a   5% risk acceptance (*, **, ***),
  0.04 false positives, when considering a   1% risk acceptance (**, ***),
  0.00 false positives, when considering a 0.1% risk acceptance (***)

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Sep 29, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@panva

panva commented Sep 29, 2026

Copy link
Copy Markdown
Member

The benchmark shows a significant drop for writable-to-web. I think this needs some work before landing.

@seungwoo505

Copy link
Copy Markdown
Author

I’ve confirmed the performance regression shown in the benchmark.
I’ll work on addressing it as soon as possible.

@codecov

codecov Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.32739% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.43%. Comparing base (cede7e6) to head (cc9b0db).
⚠️ Report is 31 commits behind head on main.

Files with missing lines Patch % Lines
lib/internal/webstreams/adapters.js 96.83% 12 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #64579      +/-   ##
==========================================
+ Coverage   90.39%   90.43%   +0.03%     
==========================================
  Files         792      791       -1     
  Lines      275697   276022     +325     
  Branches    52868    52970     +102     
==========================================
+ Hits       249208   249610     +402     
+ Misses      16892    16818      -74     
+ Partials     9597     9594       -3     
Files with missing lines Coverage Δ
lib/_http_outgoing.js 97.96% <100.00%> (+<0.01%) ⬆️
lib/internal/streams/writable.js 96.44% <100.00%> (+0.02%) ⬆️
lib/internal/webstreams/writablestream.js 99.54% <100.00%> (+0.01%) ⬆️
lib/internal/webstreams/adapters.js 90.22% <96.83%> (+1.98%) ⬆️

... and 33 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Wait for native write callbacks when they can safely represent chunk
consumption. For Duplex streams, corked writes, and custom or legacy
write methods, pass private BufferSource copies to preserve completion
timing.

Coordinate callback completion with backpressure, aborts, and stream
errors so a settled Web Streams write no longer exposes mutable bytes
still retained by the native stream.

Preserve native HTTP validation and SharedArrayBuffer backing when
fallback copies are required.

Signed-off-by: seungwoo <zoozoo1302@gmail.com>
Cover native callback completion, fallback copies, aborts, and error
propagation for mutable BufferSource chunks passed to Node.js Web
Streams adapters.

Verify HTTP validation, SharedArrayBuffer backing, and Duplex and
compression paths.

Signed-off-by: seungwoo <zoozoo1302@gmail.com>
Rely on native stream validation after copying array buffer views.
Keep object-mode writes on their existing completion timing, and remove
adapter-only HTTP detection and prototype capture.

Signed-off-by: seungwoo <zoozoo1302@gmail.com>
Remove overlapping override coverage. Verify object-mode writes settle
independently of native callbacks. Exercise the Writable.toWeb(Duplex)
path and keep native HTTP validation coverage.

Assisted-by: Codex
Signed-off-by: seungwoo <zoozoo1302@gmail.com>
Use the Float16Array constructor captured during Node.js initialization
instead of reading it from globalThis when the adapter is loaded.
This prevents changes to the global constructor from affecting chunk
cloning.

Signed-off-by: seungwoo <zoozoo1302@gmail.com>
Use private Buffer storage for ArrayBuffer-backed copies and reuse
it for Buffer inputs. Read view metadata through intrinsic getters
and avoid redundant type checks.

Preserve view types, raw bytes, and independent backing. Cover
initialized backing without requiring an exact allocation size.

Signed-off-by: seungwoo <zoozoo1302@gmail.com>
Observe synchronous native write completion without waiting for a
public write callback tick. Resume pending writes without allocating
a promise capability when async hooks and context propagation allow
it.

Coordinate callback completion, drain, and abort while preserving
async context, Promise reaction order, and error delivery. Cover
mixed completion modes, lifecycle changes, and modified Promise
properties.

Signed-off-by: seungwoo <zoozoo1302@gmail.com>
Preserve the original HTTP write type error when cloning an invalid
DataView fails. Only restore validation for the unmodified native
write method, leaving overridden methods on the copy fallback.

Cover resizing before and after enqueue and changes to HTTP write
methods.

Signed-off-by: seungwoo <zoozoo1302@gmail.com>
Reject close() when the native stream was destroyed before finishing,
so end() cannot mask a premature close before eos reports it.

Preserve the native error when available and cover synchronous and
asynchronous destruction, legacy streams, and normal autoDestroy.

Signed-off-by: seungwoo <zoozoo1302@gmail.com>
Remove cases now covered by the dedicated copy and lifecycle tests.

Keep compression coverage focused on completing a write before
reading output and preserving bytes after the input is reused.

Signed-off-by: seungwoo <zoozoo1302@gmail.com>
Document when a mutable chunk can be reused after writer.write()
fulfills, and prohibit mutation while the write is pending.

Explain copying in byte mode, reference passing in object mode, and
preservation of bytes retained by a Duplex readable side.

Signed-off-by: seungwoo <zoozoo1302@gmail.com>
@seungwoo505
seungwoo505 force-pushed the fix-writable-to-web-write branch from 3c2a3c3 to 620db54 Compare October 1, 2026 11:49
@seungwoo505

seungwoo505 commented Oct 1, 2026 •

Copy link
Copy Markdown
Author

Hi @panva, I've pushed updates to reduce the extra scheduling and Promise allocations in write completion, and to optimize BufferSource copying.
These changes preserve safe buffer reuse after await writer.write().
Could you take another look when you have a chance?

@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark GHA (webstreams / adapters): https://github.com/nodejs/node/actions/runs/36878990003

Results

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

Benchmark results:

                                                         confidence improvement accuracy (*)    (**)   (***)
webstreams/adapters.js kind='readable-from-web' n=100000                -0.49 %      ±11.53% ±15.20% ±19.50%
webstreams/adapters.js kind='readable-to-web' n=100000                   0.39 %      ±13.70% ±18.05% ±23.16%
webstreams/adapters.js kind='writable-from-web' n=100000                -0.08 %      ±11.83% ±15.59% ±20.00%
webstreams/adapters.js kind='writable-to-web' n=100000          ***     29.51 %      ±12.85% ±16.94% ±21.75%

Be aware that when doing many comparisons the risk of a false-positive
result increases. In this case, there are 4 comparisons, you can thus
expect the following amount of false-positive results:
  0.20 false positives, when considering a   5% risk acceptance (*, **, ***),
  0.04 false positives, when considering a   1% risk acceptance (**, ***),
  0.00 false positives, when considering a 0.1% risk acceptance (***)

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

@panva

panva commented Oct 1, 2026

Copy link
Copy Markdown
Member

I wonder if we should also check an asynchronous Writable with _writev. The existing benchmark completes writes synchronously, so it doesn't exercise native batching.

The case below defers both callbacks with setImmediate.

diff --git a/benchmark/webstreams/adapters.js b/benchmark/webstreams/adapters.js
--- a/benchmark/webstreams/adapters.js
+++ b/benchmark/webstreams/adapters.js
@@ -15,6 +15,7 @@ const bench = common.createBenchmark(main, {
     'readable-to-web',
     'readable-from-web',
     'writable-to-web',
+    'writable-to-web-async-writev',
     'writable-from-web',
   ],
 });
@@ -68,6 +69,26 @@ async function writableToWeb(n) {
   bench.end(n);
 }
 
+async function writableToWebAsyncWritev(n) {
+  const chunk = Buffer.alloc(1024);
+  const streamWritable = new Writable({
+    highWaterMark: 64 * 1024,
+    // Defer completion so subsequent writes can be batched in writev().
+    write(chunk, encoding, callback) {
+      setImmediate(callback);
+    },
+    writev(chunks, callback) {
+      setImmediate(callback);
+    },
+  });
+  const writer = Writable.toWeb(streamWritable).getWriter();
+  bench.start();
+  for (let i = 0; i < n; i++)
+    await writer.write(chunk);
+  await writer.close();
+  bench.end(n);
+}
+
 function writableFromWeb(n) {
   const chunk = Buffer.alloc(1024);
   const writableStream = new WritableStream({
@@ -99,6 +120,9 @@ function main({ n, kind }) {
     case 'writable-to-web':
       writableToWeb(n);
       break;
+    case 'writable-to-web-async-writev':
+      writableToWebAsyncWritev(n);
+      break;
     case 'writable-from-web':
       writableFromWeb(n);
       break;

Add an asynchronous Writable.toWeb() benchmark with write and writev
callbacks deferred by setImmediate. Use 1 KiB chunks and a 64 KiB
highWaterMark to measure changes in native batching.

Signed-off-by: seungwoo <zoozoo1302@gmail.com>
@seungwoo505

Copy link
Copy Markdown
Author

I've added the writable-to-web-async-writev benchmark you suggested.
A local comparison showed a 55.1% drop in throughput compared with the PR's base revision on main.

@seungwoo505

seungwoo505 commented Oct 2, 2026 •

Copy link
Copy Markdown
Author

I've investigated ways to reduce the regression while preserving the existing completion guarantees and error propagation, but haven't found an effective alternative so far.
Since this benchmark awaits writes sequentially, waiting for each native write callback prevents _writev batching.
I therefore consider the loss of batching a performance trade-off of preserving the current completion behavior.

@panva
panva requested a review from mcollina October 2, 2026 11:14
@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark GHA (webstreams / adapters.js): https://github.com/nodejs/node/actions/runs/37113468541

Results

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

Benchmark results:

                                                                    confidence improvement accuracy (*)    (**)   (***)
webstreams/adapters.js kind='readable-from-web' n=100000                           -0.51 %      ±10.51% ±13.85% ±17.78%
webstreams/adapters.js kind='readable-to-web' n=100000                              1.38 %      ±14.61% ±19.26% ±24.71%
webstreams/adapters.js kind='writable-from-web' n=100000                           -0.69 %      ±10.48% ±13.81% ±17.72%
webstreams/adapters.js kind='writable-to-web-async-writev' n=100000        ***    -86.09 %      ±11.00% ±14.54% ±18.72%
webstreams/adapters.js kind='writable-to-web' n=100000                     ***     29.26 %      ±12.77% ±16.84% ±21.62%

Be aware that when doing many comparisons the risk of a false-positive
result increases. In this case, there are 5 comparisons, you can thus
expect the following amount of false-positive results:
  0.25 false positives, when considering a   5% risk acceptance (*, **, ***),
  0.05 false positives, when considering a   1% risk acceptance (**, ***),
  0.01 false positives, when considering a 0.1% risk acceptance (***)

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. stream Issues and PRs related to Node.js streams. web streams Issues and PRs related to the Web Streams API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stream: Writable.toWeb()/Duplex.toWeb() settles write() before a mutable chunk is consumed

5 participants