Skip to content

Commit 92459cd

Browse files
webstreams: rewrite ReadableStream, WritableStream, and TransformStream in C++ (zero JS builtins) (oven-sh#33193)
## What Rewrites the WHATWG Streams implementation (`ReadableStream`, `WritableStream`, `TransformStream`, all readers/writers/controllers, `pipeTo`/`pipeThrough`/`tee`, and the async iterator) as pure C++ in `src/jsc/bindings/webcore/streams/`, and deletes the old implementation (19 stream JS builtins + 51 old webcore stream C++ files). There are **zero JS builtins left in the streams path**: no `@`-private JS state, no builtin closures, no JS-side state machine. All stream state lives as C++ members on the JS cells themselves (`WriteBarrier` + `visitChildren`), and all spec "upon fulfillment/rejection" reactions go through two shared mechanisms (per-global native reaction handlers + bound functions) instead of per-instance closures. Bun's extensions are preserved: `type: "direct"` streams, `type: "bytes"`/BYOB, lazily-materialized native controllers (file/socket/spawn bodies), `Bun.readableStreamTo*`, `readMany`, and the generated JSSink classes. String chunks keep working everywhere they used to (verified old-vs-new on 15 text-chunk scenarios). ## Why - **Memory** (100k live instances each, RSS/instance, release builds): | | old | new | | |---|---|---|---| | `new ReadableStream({pull(){}})` | 1283 B | **582 B** | −55% | | RS + `getReader()` | 1709 B | **666 B** | −61% | | `new WritableStream({write(){}})` | 1718 B | **1036 B** | −40% | | `new TransformStream()` | 3628 B | **1626 B** | −55% | The old implementation allocated ~3.1M JS `Function` objects for 400k streams (the builtins' per-stream algorithm closures); the new one allocates ~300k — only the user's own callbacks. - **Throughput** (64 KiB chunks, 32 MiB/pass, best of 5, release vs release on the same machine; `bench/snippets/webstreams-throughput.mjs`): **Stream machinery cost** — the shared-chunk scenarios enqueue the same 64 KiB view every time; default streams pass chunks by reference in every runtime (nothing is copied), so these rows are reported as chunks/second (an earlier revision reported them as "MB/s" of nominal payload — that unit was misleading and is superseded by this table). Median of 3 isolated processes per cell, measured at the head that includes the async-iterator/`reader.read()` inlining. | scenario | node 26 | deno 2.9-canary | bun 1.3.14 | old (canary) | new | peak RSS node / deno / 1.3.14 / old / new (MB) | |---|---|---|---|---|---|---| | `reader.read()` loop | 0.93 M/s | 1.01 M/s | 0.85 M/s | 1.09 M/s | **1.77 M chunks/s** (565 ns/chunk) | 49 / 59 / 43 / 42 / **38** | | `for await` | 0.87 M/s | 0.71 M/s | 0.45 M/s | 0.40 M/s | **1.59 M chunks/s** | 50 / 57 / 47 / 47 / **38** | | `pipeTo(WritableStream)` | 0.38 M/s | 0.57 M/s | 0.32 M/s | 0.34 M/s | **0.64 M chunks/s** | 57 / 61 / 49 / 50 / **39** | | `pipeThrough(TransformStream)` | 0.19 M/s | 0.31 M/s | 0.23 M/s | 0.21 M/s | **0.48 M chunks/s** | 58 / 63 / 56 / 54 / **40** | | `tee()` + drain both | 0.20 M/s | 0.40 M/s | 0.39 M/s | 0.33 M/s | **0.75 M chunks/s** | 54 / 60 / 49 / 48 / **38** | **Real throughput** — the fresh-buffers scenarios allocate and write a new 64 KiB chunk per enqueue (what socket/file sources produce), so MB/s is bounded by real memory work and every runtime converges toward allocator + memcpy bandwidth: | scenario | node 26 | deno 2.9-canary | bun 1.3.14 | old (canary) | new | |---|---|---|---|---|---| | `reader.read()` loop (fresh buffers) | 6,509 | **6,851 MB/s** | 5,852 | 6,418 | 6,201 | | `for await` (fresh buffers) | **8,317** | 7,230 | 5,231 | 5,274 | 6,345 | | `pipeTo` (fresh buffers) | 4,897 | 5,298 | 5,805 | 5,033 | **5,911** | | `pipeThrough` (fresh buffers) | 4,664 | 5,394 | 5,099 | 4,556 | **5,400** | | `tee()` + drain both (fresh buffers) | 4,394 | 5,097 | 5,332 | 4,745 | **5,982** | **Consumers and byte sources** (materialize real output; MB/s): | scenario | node 26 | deno 2.9-canary | bun 1.3.14 | old (canary) | new | peak RSS 1.3.14 / old / new (MB) | |---|---|---|---|---|---|---| | `new Response(stream).arrayBuffer()` | 746 | 1,818 | 8,132 | 7,237 | **8,127** | 173 / 170 / 199 | | text chunks → `Response.text()` (Bun extension) | — | — | 8,131 | 8,187 | **8,415** | 204 / 203 / 198 | | `Bun.readableStreamToBytes(stream)` (Bun extension) | — | — | 8,445 | 6,947 | **8,208** | 173 / 171 / 198 | | byte source, default reader | 5,578 | 5,935 | 22,997 † | 5,997 | **7,955** | 43 / 71 / 57 | | byte source, BYOB reader | 17,835 | 15,912 | 38,373 † | 24,709 † | 15,396 | 45 / 44 / 39 | | direct stream → `readableStreamToBytes` (Bun extension) | — | — | 2,233 | 3,773 | 3,644 | 218 / 194 / 229 | **Transform streams** (`bench/snippets/webstreams-transform.mjs`; 16 MiB per pass in 64 KiB chunks, best of 5, median of 3 isolated processes; MB/s of decoded/uncompressed payload): | scenario | node 26 | deno 2.9-canary | bun 1.3.14 | old (canary) | new | |---|---|---|---|---|---| | `TextEncoderStream` | 46 | 389 | 2,429 | 2,442 | **2,717** | | `TextDecoderStream` | 1,233 | 588 | 1,395 | 1,198 | **1,581** | | `CompressionStream` (gzip) | 270 | 439 | 447 | 361 | **390** | | `DecompressionStream` (gzip) | 689 | **1,086** | 926 | 853 | 1,057 | `TextDecoderStream` is the row the streams machinery dominates; the compression rows are mostly zlib-bound so the rewrite moves them less (both are still ahead of the pre-rewrite canary). Note `CompressionStream` on 1.3.14 (447) vs today's `main` (361) — that ~20% predates this PR and is unrelated to the streams rewrite. Methodology: `bench/snippets/webstreams-throughput.mjs` (64 KiB chunks, 32 MiB per pass, best of 5), every scenario in its own process via `--scenario=`, median of 3 processes per cell on an idle machine; RSS is the OS-reported peak process RSS (`/usr/bin/time -v`). node v26.3.0, deno 2.9.1 canary, "old (canary)" = `1.4.0-canary` @ `4f2932980` (main just before this branch's merge base). † pre-rewrite Bun skipped the (spec-required) buffer transfers on byte sources — against the runtimes that also transfer (Node/Deno) the new implementation is faster on the default-reader row and behind on BYOB (an optimization target; see follow-ups). BYOB note: an earlier revision of this table was ~30% behind Node/Deno on the BYOB row. That gap is closed (parity with Deno, ~97% of Node, medians of noisy runs) by holding byte-stream buffers as `ArrayBuffer` impls instead of `JSArrayBuffer` wrapper cells: the spec's two per-chunk transfers are now contents moves with no new GC cells and no extra-memory re-reporting; the only cell per chunk is the view handed to the user. (Old Bun's larger BYOB number is not a valid baseline — it skipped the spec-required buffer transfers.) Peak-RSS note (investigated to root cause): three accumulate-then-assemble consumer rows peak about one payload (~25–35 MB here) higher than the previous implementation. This is not retention or a leak: at any instant the new implementation holds exactly one more *dead* result awaiting collection, because it allocates ~half as many JS objects per consume (measured: ~3.8k vs ~7.6k for a 512-chunk body), so JSC's allocation-driven GC runs less often between back-to-back large consumes. It is bounded at one payload, does not grow with iteration count, and post-GC RSS is equal or lower than before. Left as-is deliberately: papering over it with GC hints in the consumer path would trade real-application throughput for a benchmark's transient peak. - **Consumers × chunk shape** (8 MiB/pass, MB/s, old → new; `bench/snippets/webstreams-consumers.mjs`): | shape | `toText` | `toArrayBuffer` | `toBytes` | `toArray` | `Response.text` | `Response.arrayBuffer` | `for await` | |---|---|---|---|---|---|---|---| | binary 64 KiB ×128 | 2,593 → 3,690 | 6,831 → 7,843 | 6,998 → 8,232 | 63,273 → 81,372 | 2,614 → 3,248 | 7,724 → 7,455 | 32,908 → 115,214 | | text 64 KiB ×128 | 7,926 → 8,080 | 3,227 → 3,386 | 1,800 → 3,846 | 77,247 → 89,450 | 7,223 → 7,808 | 3,971 → 3,636 | 42,027 → 124,338 | | mixed 64 KiB ×128 | 2,257 → 3,652 | 4,473 → 4,118 | 4,194 → 4,084 | 84,268 → 109,658 | 2,444 → 3,324 | 4,743 → 4,398 | 49,439 → 107,137 | | binary 1 KiB ×8192 | 1,041 → 954 | 1,023 → 1,523 | 1,115 → 1,712 | 1,455 → 2,483 | 1,117 → 1,130 | 1,147 → 1,485 | 867 → 2,057 | | text 1 KiB ×8192 | 1,414 → 1,588 | 970 → 1,375 | 952 → 1,206 | 1,319 → 2,121 | 1,449 → 1,905 | 721 → 1,348 | 889 → 2,524 | | one 8 MiB chunk | 1,959 → 5,084 | ≈2 TB/s (identity) | ≈2 TB/s | ≈2 TB/s | 1,955 → 5,213 | ≈2 TB/s | ≈2 TB/s | The buffered consumers are driven by a persistent pump operation (one reaction registration per pending read; bulk queue drain per hop), which is what recovered the many-small-chunk rows. Remaining cells within run-to-run noise of the previous implementation: binary-1 KiB `toText` and a few 64 KiB `arrayBuffer` cells (±10%). - **Memory** (`bench/snippets/webstreams-memory.mjs`, release builds): | retained RSS per live instance (100k) | old | new | |---|---|---| | `new ReadableStream({pull(){}})` | 1,307 B | **566 B** | | RS + `getReader()` | 1,680 B | **676 B** | | `new WritableStream({write(){}})` | 1,722 B | **1,034 B** | | `new TransformStream()` | 3,635 B | **1,596 B** | | workload RSS | old peak | new peak | |---|---|---| | `pipeTo` 512 MiB (64 KiB chunks) | 48.8 MB | **17.0 MB** | | `for await` 512 MiB | 2.5 MB | **0.5 MB** | | `Response(stream 256 MiB).arrayBuffer()` | 254.5 MB | 253.1 MB | | `Response(stream 256 MiB of text).text()` | 256.5 MB | 249.4 MB | The implementation allocates no closures per stream (~3.1M `Function` objects → ~300k for 400k streams: only the user's own callbacks remain). - **`tee()` / `clone()`** (`bench/snippets/webstreams-tee.mjs`; 128 MiB tee payload, 64 MiB fetched body, 32 MiB uploaded body): | scenario | old | new | |---|---|---| | `tee()`: both branches drained concurrently | 57,731 MB/s | **206,635 MB/s** | | `tee()`: branch B read only after A finishes | 84,543 MB/s | **151,754 MB/s** | | `tee()`: read A, cancel B | 48,141 MB/s | **90,148 MB/s** | | `fetch().clone()`: read both bodies | 747 MB/s / 210 MB peak | **1,237 MB/s / 49 MB peak** | | `fetch().clone()`: read one, cancel clone | 643 MB/s | 710 MB/s | | `Bun.serve`: `req.clone()`, read both bodies | 1,423 MB/s / 32 MB peak | **1,466 MB/s / 1.5 MB peak** | The one remaining row below the previous implementation anywhere in these tables is direct streams → `readableStreamToBytes` (−20%), tracked in the checklist. - **Spec conformance**, measured with the WPT streams suite vendored in this PR (69 `.any.js` files including `idlharness.any.js`, 1402 subtests, run in CI): - old implementation (behavior files only): **971/1174 passing (82.7%)**, 2 process-aborting crashes, 10 timeouts - this PR: **1402/1402 passing (100%)**, 0 crashes, 0 timeouts, and `expectations.json` is empty — no expected-failure list. This includes WPT's `idlharness.any.js` (228 WebIDL surface subtests: interface-object descriptors, prototype layout, method `length`/`name`, brand checks), the same file Node runs, vendored with `idlharness.js` + the webidl2 parser + the `.idl` definitions from the same WPT commit. idlharness found one real bug, fixed here: the streams interface objects were installed as *enumerable* globals (Web IDL: `{writable: true, enumerable: false, configurable: true}`; Node and the browsers comply) — they are now `DontEnum` like `URL` already was. The other non-streams constructors (`Response`, `Blob`, …) have the same pre-existing issue and are a separate follow-up. - The last formerly-pinned behavior subtest turned out to be a harness-shim divergence, not a stream bug: upstream `testharness.js`'s `assert_object_equals` recurses into any non-null object on the *actual* side, so its `{value: <empty Uint8Array>}` vs `{value: undefined}` comparison is vacuous — the semantics every browser and Node run under. Our shim was stricter; it now ports upstream byte-for-byte. Bun's behavior for that case (cancel then `read(view)` resolving with a zero-length view) matches the WHATWG algorithm text, the reference implementation, Node, and Deno. - Scope vs Node/Deno: the same upstream `streams/` test files both run in their WPT CI, minus the `.tentative` `owning-type` proposal (Node expected-fails it) and `transferable/**` (postMessage stream transfer, a feature Bun does not implement). Node and Deno each still carry expected-failure lists on this suite; this PR's list is empty. - **Maintainability**: the implementation is a file-per-class transcription of the spec, so every function maps to a named spec operation. ## Issues this closes Each one verified by running the issue's own reproduction on the pre-rewrite build (bug reproduces) and on this branch (fixed): - oven-sh#3700 / oven-sh#32529 — `ReadableStream.from()` (global and `node:stream/web`) - oven-sh#6860 — reusing a consumed `ReadableStream` now always rejects (`Bun.readableStreamTo*` on a disturbed, unlocked stream rejects with "ReadableStream has already been used"; the spec'd `new Response(stream)` path already threw) - oven-sh#7091 — `ReadableStreamBYOBReader.read(view, { min })` - oven-sh#10431 — `values({ preventCancel: true })` - oven-sh#17081 — `ReadableStreamBYOBReader.releaseLock()` with pending reads (rejects them per spec instead of throwing) - oven-sh#26392 — `pipeTo` honors `AbortSignal` (sink `abort()` and source `cancel()` both run) - oven-sh#31156 — `WritableStreamDefaultController.signal` - oven-sh#32402 — BYOB `read(view)` detaches the supplied buffer - oven-sh#17837 — `ReadableStreamDefaultController` methods failing brand checks ("`this` is not a ReadableStreamDefaultController"): no deterministic repro exists in the issue, but the mechanism that produced it (the JS-builtin controllers) no longer exists; every controller is a real C++ class now. (oven-sh#19006 from the same triage list is not claimed: its repro already passes on current `main`.) Fixes oven-sh#3700 Fixes oven-sh#6860 Fixes oven-sh#7091 Fixes oven-sh#10431 Fixes oven-sh#17081 Fixes oven-sh#17837 Fixes oven-sh#26392 Fixes oven-sh#31156 Fixes oven-sh#32402 Fixes oven-sh#32529 ## Review feedback round (2026-07-03) All applied on top of the rewrite, each with tests where behavior changed: 1. **Identifier caching**: every `Identifier::fromString` in the streams sources (71 sites, several per-chunk) now uses `vm.propertyNames` / `BunBuiltinNames`; the three helpers that took a method-name literal take `const Identifier&`. 2. **Replacement-character audit**: no defects — WTF's default lenient UTF-8 conversion is the FFFD-replacing conversion (`StringImpl.cpp`'s conversion modes share the same case), and the mixed text path byte-concatenates before decoding once, so multi-byte sequences split across chunks decode correctly. The two equivalent encoders were unified on the simdutf sizer/writer pair. 3. **Accumulator retention**: the text accumulator (including the one on the long-lived direct-stream controller), the array sink's result array, and the internal chunk arrays held by conversion reactions are all released the moment the result is materialized. 4. **String limits**: text assembly past the string limit used to abort the process (StringBuilder overflow / `fromUTF8ReplacingInvalidSequences`'s RELEASE_ASSERT); every materialization site now throws a catchable out-of-memory error using the same predicate Bun's string constructors use, with subprocess regression tests via the `bun:internal-for-testing` synthetic allocation limit. 5. **InternalFieldTuples**: the one nested (3-field) case is a dedicated `JSReadableStreamIntoArrayOperation` cell; the one-shot sink's close-function tuple and pipeTo's wait-for-all latch tuple are fields on their existing cells. The remaining tuples are genuine 2-field pairs. 6. **Single-pass iteration**: the chunk-array converters read the array exactly once (elements held in a `MarkedArgumentBuffer`, strings materialized and sized once); no user-provided iterable was ever iterated twice (`ReadableStream.from` builds one iterator record). 7. **`VM&` threading**: internal helpers across the streams sources take `JSC::VM&` from their callers instead of re-deriving it from the global object; entry points derive it once. ## Behavior changes (intentional) - Reusing an already-consumed `ReadableStream` with `Bun.readableStreamTo*` now rejects (`ERR_INVALID_STATE`, "ReadableStream has already been used") instead of resolving with an empty result (oven-sh#6860). The previous implementation only errored while the stream was still locked. - `new TextDecoderStream(label, undefined | null)` treats the options as an empty dictionary per Web IDL (previously threw). - The streams interface objects on `globalThis` are non-enumerable, per Web IDL (matches Node and the browsers; found by WPT's `idlharness`). - Releasing a reader/writer lock now follows the current spec **and Node.js**: pending reads, `reader.closed`, `writer.closed`/`ready`, and post-release method calls reject with the same `TypeError` (+ `code: "ERR_INVALID_STATE"`, same messages) that Node 26 produces — verified case-by-case against Node. The old Bun-specific `AbortError` with `code: "ERR_STREAM_RELEASE_LOCK"` is no longer produced (nothing in the tree produced or consumed it after the old builtins were removed; `process.stdin`'s internals were updated accordingly). - `Bun.readableStreamTo*` are now regular native functions on `Bun` (previously configurable JS builtins). ## Tests - `test/js/third_party/wpt-streams/`: the vendored WPT streams suite + expectations (re-recorded against this implementation; expected-failure bodies still execute, so both regressions and silent graduations turn the suite red). - New regression tests for byte-source `pipeTo`/`pipeThrough` (not covered by any WPT subtest) in `test/js/web/streams/streams.test.js`; the `releaseLock` test there was updated from the old implementation-specific error shape to the Node/spec shape (see behavior changes). - `bench/snippets/webstreams.mjs`, `webstreams-throughput.mjs`, `webstreams-memory.mjs`: the benchmarks behind the numbers above. - The wider Bun test-suite sweep is in progress on this branch. ## Status - [x] implementation + integration, old implementation deleted - [x] WPT streams suite vendored (including `idlharness.any.js`): 1402/1402 subtests passing, `expectations.json` empty - [x] node-compat pass: reader/writer release, locked-acquisition, brand-check, byte- and default-controller and BYOB-request error shapes match Node 26 (codes + messages); promise semantics match Node observably - [x] Bun extension semantics preserved and tested: async-iterable/async-generator bodies (direct controller via `yield`, sink backpressure, iterator `throw`/`return`), `reader.readMany()`, direct streams, `AsyncLocalStorage` propagation into source callbacks - [x] buffered consumers ≥ the previous implementation on every chunk shape (persistent pump op) - [x] exception-check discipline: suites run clean under `BUN_JSC_validateExceptionChecks=1` - [x] `tee()` / `Response.clone()` / `Request.clone()` faster with lower peak RSS - [x] release-build crash + hang fixed for a direct stream whose reader is released mid-pull - [ ] CI: full matrix on the latest push --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 5187e27 commit 92459cd

276 files changed

Lines changed: 54660 additions & 14571 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,7 @@ src/runtime/bake/generated.ts
198198
# them on disk; keep ignored so they don't show as untracked).
199199
src/jsc/bindings/GeneratedJS2Native.zig
200200
src/jsc/bindings/GeneratedBindings.zig
201+
202+
# Web Streams rewrite working notes (design docs, spec transcription, review logs).
203+
# Kept locally for the ongoing work; not part of the source tree.
204+
/specs/
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Consumer x chunk-shape matrix for JS-sourced ReadableStreams (MB/s, best of RUNS).
2+
// Every shape totals ~8 MiB so numbers are comparable across rows.
3+
const RUNS = 5;
4+
const MB = 1024 * 1024;
5+
const TOTAL = 8 * MB;
6+
7+
const binary = size => {
8+
const chunk = new Uint8Array(size).fill(120);
9+
const count = TOTAL / size;
10+
return () => {
11+
let i = 0;
12+
return new ReadableStream({
13+
pull(c) {
14+
if (i++ < count) c.enqueue(chunk);
15+
else c.close();
16+
},
17+
});
18+
};
19+
};
20+
const text = size => {
21+
const chunk = "x".repeat(size);
22+
const count = TOTAL / size;
23+
return () => {
24+
let i = 0;
25+
return new ReadableStream({
26+
pull(c) {
27+
if (i++ < count) c.enqueue(chunk);
28+
else c.close();
29+
},
30+
});
31+
};
32+
};
33+
const mixed = size => {
34+
const textChunk = "y".repeat(size);
35+
const binaryChunk = new Uint8Array(size).fill(121);
36+
const count = TOTAL / size;
37+
return () => {
38+
let i = 0;
39+
return new ReadableStream({
40+
pull(c) {
41+
if (i < count) (c.enqueue(i % 2 ? textChunk : binaryChunk), i++);
42+
else c.close();
43+
},
44+
});
45+
};
46+
};
47+
48+
const shapes = {
49+
"binary 64KiB x128": binary(64 * 1024),
50+
"binary 1KiB x8192": binary(1024),
51+
"text 64KiB x128": text(64 * 1024),
52+
"text 1KiB x8192": text(1024),
53+
"mixed text/bytes 64KiB x128": mixed(64 * 1024),
54+
"one 8MiB chunk": (() => {
55+
const chunk = new Uint8Array(TOTAL).fill(122);
56+
return () =>
57+
new ReadableStream({
58+
start(c) {
59+
c.enqueue(chunk);
60+
c.close();
61+
},
62+
});
63+
})(),
64+
};
65+
66+
const consumers = {
67+
"toText": s => Bun.readableStreamToText(s),
68+
"toArrayBuffer": s => Bun.readableStreamToArrayBuffer(s),
69+
"toBytes": s => Bun.readableStreamToBytes(s),
70+
"toArray": s => Bun.readableStreamToArray(s),
71+
"toBlob": async s => (await Bun.readableStreamToBlob(s)).size,
72+
"Response.text": s => new Response(s).text(),
73+
"Response.arrayBuffer": s => new Response(s).arrayBuffer(),
74+
"for await": async s => {
75+
let n = 0;
76+
for await (const c of s) n += c.length;
77+
return n;
78+
},
79+
};
80+
81+
const table = {};
82+
for (const [shapeName, make] of Object.entries(shapes)) {
83+
const row = (table[shapeName] = {});
84+
for (const [consumerName, consume] of Object.entries(consumers)) {
85+
await consume(make()); // warmup + validity
86+
let best = Infinity;
87+
for (let i = 0; i < RUNS; i++) {
88+
const t0 = performance.now();
89+
await consume(make());
90+
best = Math.min(best, performance.now() - t0);
91+
}
92+
row[consumerName] = Math.round(TOTAL / MB / (best / 1000));
93+
}
94+
}
95+
const consumerNames = Object.keys(consumers);
96+
const version = typeof Bun !== "undefined" ? `bun ${Bun.revision.slice(0, 9)}` : `node ${process.version}`;
97+
console.log(`# webstreams consumers (MB/s) — ${version}${TOTAL / MB} MiB per pass, best of ${RUNS}`);
98+
console.log(["shape".padEnd(28), ...consumerNames.map(n => n.padStart(14))].join(""));
99+
for (const [shapeName, row] of Object.entries(table))
100+
console.log([shapeName.padEnd(28), ...consumerNames.map(n => String(row[n]).padStart(14))].join(""));
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Web Streams memory: (1) retained RSS per live instance, (2) peak/settled RSS for
2+
// streaming workloads. Run with any JS runtime; extra heap counts print under Bun.
3+
const N = 100_000;
4+
const gc = globalThis.Bun?.gc ?? globalThis.gc;
5+
if (!gc) throw new Error("This benchmark needs a GC hook: run with Bun, or node --expose-gc.");
6+
const rss = () => process.memoryUsage.rss();
7+
const MB = 1024 * 1024;
8+
const fmt = n => (n / MB).toFixed(1).padStart(8) + " MB";
9+
10+
console.log(`# per-instance retained RSS (n=${N} live instances)`);
11+
const keep = [];
12+
function perInstance(label, make) {
13+
gc(true);
14+
const before = rss();
15+
const held = new Array(N);
16+
for (let i = 0; i < N; i++) held[i] = make();
17+
gc(true);
18+
const perObject = (rss() - before) / N;
19+
console.log(`${label.padEnd(40)} ${perObject.toFixed(0).padStart(6)} bytes/instance`);
20+
keep.push(held);
21+
}
22+
perInstance("new ReadableStream({pull(){}})", () => new ReadableStream({ pull() {} }));
23+
perInstance("new ReadableStream() + getReader()", () => new ReadableStream({ pull() {} }).getReader());
24+
perInstance("new WritableStream({write(){}})", () => new WritableStream({ write() {} }));
25+
perInstance("new TransformStream()", () => new TransformStream());
26+
keep.length = 0;
27+
gc(true);
28+
29+
console.log(`\n# workload RSS (peak over baseline during the run, settled after gc)`);
30+
const CHUNK = new Uint8Array(64 * 1024).fill(120);
31+
async function workload(label, fn) {
32+
gc(true);
33+
const before = rss();
34+
let peak = before;
35+
const timer = setInterval(() => {
36+
peak = Math.max(peak, rss());
37+
}, 5);
38+
// Whatever `fn` returns is kept alive until after the settled measurement, so
39+
// "N live objects" workloads measure retention rather than post-return garbage.
40+
const keepAlive = await fn();
41+
clearInterval(timer);
42+
peak = Math.max(peak, rss());
43+
gc(true);
44+
const settled = rss();
45+
console.log(`${label.padEnd(46)} peak ${fmt(peak - before)} settled ${fmt(settled - before)}`);
46+
return keepAlive;
47+
}
48+
const source = n => {
49+
let i = 0;
50+
return new ReadableStream({
51+
pull(c) {
52+
if (i++ < n) c.enqueue(CHUNK);
53+
else c.close();
54+
},
55+
});
56+
};
57+
await workload("pipeTo 512 MiB (64 KiB chunks)", async () => {
58+
let n = 0;
59+
await source(8192).pipeTo(
60+
new WritableStream({
61+
write(c) {
62+
n += c.length;
63+
},
64+
}),
65+
);
66+
});
67+
await workload("for await 512 MiB", async () => {
68+
let n = 0;
69+
for await (const c of source(8192)) n += c.length;
70+
});
71+
await workload("Response(stream 256 MiB).arrayBuffer()", async () => {
72+
(await new Response(source(4096)).arrayBuffer()).byteLength;
73+
});
74+
await workload("Response(stream 256 MiB of text).text()", async () => {
75+
let i = 0;
76+
const text = "x".repeat(64 * 1024);
77+
const rs = new ReadableStream({
78+
pull(c) {
79+
if (i++ < 4096) c.enqueue(text);
80+
else c.close();
81+
},
82+
});
83+
(await new Response(rs).text()).length;
84+
});
85+
{
86+
const held = await workload("10k live TransformStream chains (held)", async () => {
87+
const chains = new Array(10_000);
88+
for (let i = 0; i < chains.length; i++) {
89+
const ts = new TransformStream();
90+
chains[i] = [ts, ts.readable.getReader(), ts.writable.getWriter()];
91+
}
92+
gc(true);
93+
return chains;
94+
});
95+
held.length = 0;
96+
}
97+
await workload("2k concurrent pipeThrough pipes (1 MiB each)", async () => {
98+
const pipes = [];
99+
for (let i = 0; i < 2000; i++) {
100+
let k = 0;
101+
const rs = new ReadableStream({
102+
pull(c) {
103+
if (k++ < 16) c.enqueue(CHUNK);
104+
else c.close();
105+
},
106+
});
107+
pipes.push(rs.pipeThrough(new TransformStream()).pipeTo(new WritableStream({ write() {} })));
108+
}
109+
await Promise.all(pipes);
110+
});
111+
112+
if (typeof Bun !== "undefined") {
113+
gc(true);
114+
const { heapStats } = await import("bun:jsc");
115+
const counts = heapStats().objectTypeCounts;
116+
const interesting = Object.entries(counts)
117+
.filter(([k]) => /Stream|Reader|Writer|Controller|Request|Promise|Function/i.test(k))
118+
.sort((a, b) => b[1] - a[1])
119+
.slice(0, 16);
120+
console.log("\n# heapStats().objectTypeCounts after the workloads (top stream-related):");
121+
for (const [k, v] of interesting) console.log(` ${k}: ${v}`);
122+
}

bench/snippets/webstreams-tee.mjs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// tee()/clone() throughput and memory: plain ReadableStream.tee, fetch Response.clone,
2+
// and Bun.serve Request.clone. Reports MB/s over the total bytes moved plus peak/settled
3+
// RSS growth for each scenario (best-of-RUNS for time; max for memory).
4+
const RUNS = 3;
5+
const MB = 1024 * 1024;
6+
const CHUNK = new Uint8Array(64 * 1024).fill(120);
7+
const gc = globalThis.Bun?.gc ?? globalThis.gc;
8+
if (!gc) throw new Error("This benchmark needs a GC hook: run with Bun, or node --expose-gc.");
9+
const rss = () => process.memoryUsage.rss();
10+
const fmt = n => (n / MB).toFixed(1).padStart(7) + " MB";
11+
12+
const source = totalBytes => {
13+
const count = Math.ceil(totalBytes / CHUNK.length);
14+
let i = 0;
15+
return new ReadableStream({
16+
pull(c) {
17+
if (i++ < count) c.enqueue(CHUNK);
18+
else c.close();
19+
},
20+
});
21+
};
22+
const drain = async rs => {
23+
const r = rs.getReader();
24+
let n = 0;
25+
while (true) {
26+
const { done, value } = await r.read();
27+
if (done) return n;
28+
n += value.length;
29+
}
30+
};
31+
32+
async function bench(label, totalBytes, fn) {
33+
await fn(); // warmup
34+
let best = Infinity;
35+
let peak = 0;
36+
for (let i = 0; i < RUNS; i++) {
37+
gc(true);
38+
const before = rss();
39+
let localPeak = before;
40+
const timer = setInterval(() => {
41+
localPeak = Math.max(localPeak, rss());
42+
}, 5);
43+
const t0 = performance.now();
44+
await fn();
45+
const elapsed = performance.now() - t0;
46+
clearInterval(timer);
47+
localPeak = Math.max(localPeak, rss());
48+
best = Math.min(best, elapsed);
49+
peak = Math.max(peak, localPeak - before);
50+
}
51+
const mbps = totalBytes / MB / (best / 1000);
52+
console.log(`${label.padEnd(46)} ${mbps.toFixed(0).padStart(7)} MB/s peak RSS +${fmt(peak)}`);
53+
}
54+
55+
const TOTAL = 128 * MB;
56+
await bench("tee(): both branches drained concurrently", TOTAL * 2, async () => {
57+
const [a, b] = source(TOTAL).tee();
58+
await Promise.all([drain(a), drain(b)]);
59+
});
60+
await bench("tee(): branch B read only after A finishes", TOTAL * 2, async () => {
61+
const [a, b] = source(TOTAL).tee();
62+
await drain(a);
63+
await drain(b);
64+
});
65+
await bench("tee(): read A, cancel B", TOTAL, async () => {
66+
const [a, b] = source(TOTAL).tee();
67+
const done = drain(a);
68+
await b.cancel();
69+
await done;
70+
});
71+
72+
if (typeof Bun !== "undefined") {
73+
const BODY_BYTES = 64 * MB;
74+
await using server = Bun.serve({
75+
port: 0,
76+
async fetch(req) {
77+
const url = new URL(req.url);
78+
if (url.pathname === "/stream") return new Response(source(BODY_BYTES));
79+
if (url.pathname === "/clone-echo") {
80+
// Request.clone(): consume the body twice server-side.
81+
const clone = req.clone();
82+
const [a, b] = await Promise.all([req.arrayBuffer(), clone.arrayBuffer()]);
83+
return new Response(String(a.byteLength + b.byteLength));
84+
}
85+
return new Response("nope", { status: 404 });
86+
},
87+
});
88+
const base = `http://localhost:${server.port}`;
89+
90+
await bench("fetch(stream).clone(): read both bodies", BODY_BYTES * 2, async () => {
91+
const response = await fetch(`${base}/stream`);
92+
const clone = response.clone();
93+
await Promise.all([response.arrayBuffer(), clone.arrayBuffer()]);
94+
});
95+
await bench("fetch(stream).clone(): read one, cancel clone", BODY_BYTES, async () => {
96+
const response = await fetch(`${base}/stream`);
97+
const clone = response.clone();
98+
const read = response.arrayBuffer();
99+
await clone.body.cancel();
100+
await read;
101+
});
102+
const upload = new Uint8Array(32 * MB).fill(7);
103+
await bench("Bun.serve: req.clone(), read both bodies", upload.length * 2, async () => {
104+
const res = await fetch(`${base}/clone-echo`, { method: "POST", body: upload });
105+
if ((await res.text()) !== String(upload.length * 2)) throw new Error("bad echo");
106+
});
107+
}

0 commit comments

Comments
 (0)