Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
4681e36
webstreams: add rewrite design docs, spec transcription, and recon un…
Jarred-Sumner Jul 1, 2026
39aeeea
webstreams: design v2, adversarial reviews, WPT baseline, and slot ta…
Jarred-Sumner Jul 1, 2026
6498ffd
test: vendor the WPT streams suite and record an honest baseline agai…
Jarred-Sumner Jul 1, 2026
a4a0df2
test: share one WPT testharness shim between the h2 and streams suites
Jarred-Sumner Jul 1, 2026
7a05fde
webstreams: add the frozen C++ header set for the pure-C++ Web Stream…
Jarred-Sumner Jul 1, 2026
e0dd389
webstreams: implement ReadableStream, WritableStream, and TransformSt…
Jarred-Sumner Jul 1, 2026
c704a47
webstreams: switch the runtime to the C++ implementation, delete the …
Jarred-Sumner Jul 1, 2026
1b99401
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 1, 2026
5f29a9f
webstreams: WPT conformance fixes, Node-compatible release errors, by…
Jarred-Sumner Jul 1, 2026
f928f1a
chore: keep the streams rewrite working notes out of the tree
Jarred-Sumner Jul 1, 2026
b21fca5
test: fix an infinite byte-source pipeTo test
Jarred-Sumner Jul 1, 2026
d31cb18
webstreams: use the shared buffer concatenation for stream consumers
Jarred-Sumner Jul 1, 2026
472b917
webstreams: exception-check discipline pass
Jarred-Sumner Jul 1, 2026
a9d9834
webstreams: route text consumers through the array pump
Jarred-Sumner Jul 1, 2026
71b4e40
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 1, 2026
d523ba1
webstreams: match Node's errors for locked reader/writer acquisition
Jarred-Sumner Jul 1, 2026
7c5384b
webstreams: Node-shaped errors; fulfill internal promises directly
Jarred-Sumner Jul 1, 2026
0441cad
webstreams: drive buffered consumers with a persistent pump operation
Jarred-Sumner Jul 1, 2026
8e3f27f
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 1, 2026
9c303eb
bench: collect between webstreams throughput scenarios
Jarred-Sumner Jul 1, 2026
37c2c21
webstreams: apply review feedback
Jarred-Sumner Jul 1, 2026
7bee0db
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 1, 2026
ca4675f
webstreams: delete the dead standalone-sink pump protocol
Jarred-Sumner Jul 1, 2026
c035029
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 1, 2026
c35022d
fetch: reject a disturbed or locked ReadableStream body with a TypeError
Jarred-Sumner Jul 1, 2026
5c28008
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 1, 2026
a912f7e
webstreams: fix a crash and a hang when a direct stream's reader is r…
Jarred-Sumner Jul 1, 2026
ab7b6a1
webstreams: restore Bun's async-iterable body semantics
Jarred-Sumner Jul 1, 2026
8fe80b7
webstreams: propagate the async context into cancel; Node-shaped cont…
Jarred-Sumner Jul 1, 2026
8b486ae
webstreams: never assume a locked stream has a reader
Jarred-Sumner Jul 1, 2026
4850b90
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 1, 2026
7207b26
jsc: attach async stacks to natively created stream errors
Jarred-Sumner Jul 2, 2026
a5e60b0
webstreams: never return to native callers with a pending exception; …
Jarred-Sumner Jul 2, 2026
2a7ed66
fetch: reject keepalive with a ReadableStream body like Node
Jarred-Sumner Jul 2, 2026
21f65c4
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 2, 2026
2985769
webstreams: materialize lazy streams in the reader constructors
Jarred-Sumner Jul 2, 2026
7155cde
webstreams: never assume the reader survives read-result resolution
Jarred-Sumner Jul 2, 2026
9a4d5f1
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 2, 2026
52d6da5
webstreams: settle a direct stream's pending read on cancel
Jarred-Sumner Jul 2, 2026
8d9f34b
webstreams: flush a direct stream's buffered writes at the end of the…
Jarred-Sumner Jul 2, 2026
c880593
webstreams: keep GC cells out of the deferred task queue
Jarred-Sumner Jul 2, 2026
4efef20
webstreams: implement the async-iterable stream source natively
Jarred-Sumner Jul 2, 2026
a3cb9cf
test: await the rejects assertions for the async-iterable regression …
Jarred-Sumner Jul 2, 2026
1a232a4
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 2, 2026
f09e72d
test: assert erroring async-iterable bodies in a subprocess
Jarred-Sumner Jul 2, 2026
e58efcb
webstreams: detach the sink controller when the sink closes under a pump
Jarred-Sumner Jul 3, 2026
f86e1e1
bench: report per-scenario peak and settled RSS in the streams throug…
Jarred-Sumner Jul 3, 2026
ad04af0
bench: isolate stream throughput scenarios behind --scenario and labe…
Jarred-Sumner Jul 3, 2026
e5a1ad9
webstreams: detach the sink controller before the fallible cancel in …
Jarred-Sumner Jul 3, 2026
4901aa8
webstreams: attach async stack frames for every kind of awaiting stre…
Jarred-Sumner Jul 3, 2026
bdad734
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 3, 2026
bcb8696
wpt-streams: port upstream testharness.js assert_object_equals semant…
Jarred-Sumner Jul 3, 2026
f73f79d
webstreams: release the parked direct source in the async-stack regre…
Jarred-Sumner Jul 3, 2026
89c9552
webstreams: run WPT's idlharness and make the streams interface objec…
Jarred-Sumner Jul 3, 2026
42d0ae8
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 3, 2026
1952a92
webstreams: cache identifiers, make string limits catchable, release …
Jarred-Sumner Jul 3, 2026
d205f92
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 3, 2026
5cadd8e
webstreams: single-pass converters, dedicated pump cells, VM& threadi…
Jarred-Sumner Jul 3, 2026
3326eea
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 3, 2026
d82db94
webstreams: hold byte-stream buffers as ArrayBuffer impls, not JSArra…
Jarred-Sumner Jul 3, 2026
65cbc63
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 3, 2026
991980f
serve: free the response sink when a client aborts a stream suspended…
Jarred-Sumner Jul 4, 2026
c7d4261
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 4, 2026
a407a4d
bench: TextDecoderStream / CompressionStream / DecompressionStream th…
Jarred-Sumner Jul 4, 2026
8afcd9d
bench: include TextEncoderStream in the transform-stream benchmark
Jarred-Sumner Jul 4, 2026
5dfd207
webstreams: delete the unused wrapper-level ArrayBuffer transfer helpers
Jarred-Sumner Jul 4, 2026
2d0e6e5
webstreams: inline the async iterator's read path and reader.read()'s…
Jarred-Sumner Jul 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,7 @@ src/runtime/bake/generated.ts
# them on disk; keep ignored so they don't show as untracked).
src/jsc/bindings/GeneratedJS2Native.zig
src/jsc/bindings/GeneratedBindings.zig

# Web Streams rewrite working notes (design docs, spec transcription, review logs).
# Kept locally for the ongoing work; not part of the source tree.
/specs/
Comment thread
claude[bot] marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Actual location: test/js/third_party/wpt-streams/wpt-streams.test.ts:22-23 (anchored here because that new file's diff is not addressable). The regeneration instructions use the author-local path /root/wpt-fix-scratch/j.jsonl, which won't exist for most contributors — and RESULTS.md:22 in the same directory already documents the same command with the portable /tmp/wpt-streams-journal.jsonl, so the two docs disagree. Suggest matching RESULTS.md's path here. Documentation only; no runtime effect.

Extended reasoning...

What the issue is

The header comment in test/js/third_party/wpt-streams/wpt-streams.test.ts (a new file added by this PR) documents how to regenerate expectations.json. Lines 22-23 read verbatim:

//   rm -f /root/wpt-fix-scratch/j.jsonl
//   WPT_STREAMS_RECORD=/root/wpt-fix-scratch/j.jsonl bun bd test test/js/third_party/wpt-streams/wpt-streams.test.ts

/root/wpt-fix-scratch/j.jsonl is an author-local development path — it assumes the contributor is running as root and has a wpt-fix-scratch directory under /root. Neither is true for most contributors; a non-root user copying this command verbatim gets Permission denied on the rm, and the record run fails to open the journal file for writing.

Internal inconsistency

test/js/third_party/wpt-streams/RESULTS.md:22 — also added by this PR, documenting the same regeneration workflow — already uses a portable path:

WPT_STREAMS_RECORD=/tmp/wpt-streams-journal.jsonl bun bd test test/js/third_party/wpt-streams/wpt-streams.test.ts

So the two checked-in docs for the same procedure disagree with each other. RESULTS.md is the version that works out of the box; wpt-streams.test.ts's header is the one contributors are more likely to read (since the file's own header says "see the header of wpt-streams.test.ts").

Why nothing prevents it

The WPT_STREAMS_RECORD env var is read at runtime (line ~56: process.env.WPT_STREAMS_RECORD) and accepts any writable path — the mechanism itself is fully portable. Only the documented example leaks the author's local scratch directory. This is the same 'author-local scratch path leaked into checked-in docs' class as the resolved specs/ and RESULTS.md → specs/PHASE-D-NOTES.md findings earlier on this PR; this instance was missed when those were cleaned up.

Step-by-step proof

  1. A contributor needs to re-record expectations after a streams change and reads the header of wpt-streams.test.ts (which RESULTS.md:21 explicitly points them to).
  2. They copy line 22: rm -f /root/wpt-fix-scratch/j.jsonlrm: cannot remove '/root/wpt-fix-scratch/j.jsonl': Permission denied (non-root user), or No such file or directory for the parent (root user without that directory).
  3. They copy line 23: WPT_STREAMS_RECORD=/root/wpt-fix-scratch/j.jsonl bun bd test ... → the test file's record-mode fs.appendFileSync fails with EACCES or ENOENT on the first subtest.
  4. Meanwhile RESULTS.md two directories over shows the working /tmp/... version — so the docs are internally inconsistent within the same PR.

Impact and fix

Documentation only — no runtime effect, no test behavior change, hence nit. This does not justify blocking merge. The fix is a two-line change to match RESULTS.md:

//   rm -f /tmp/wpt-streams-journal.jsonl
//   WPT_STREAMS_RECORD=/tmp/wpt-streams-journal.jsonl bun bd test test/js/third_party/wpt-streams/wpt-streams.test.ts

(Anchored at .gitignore:204 because GitHub does not surface an addressable diff line for the collapsed new-file wpt-streams.test.ts.)

100 changes: 100 additions & 0 deletions bench/snippets/webstreams-consumers.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Consumer x chunk-shape matrix for JS-sourced ReadableStreams (MB/s, best of RUNS).
// Every shape totals ~8 MiB so numbers are comparable across rows.
const RUNS = 5;
const MB = 1024 * 1024;
const TOTAL = 8 * MB;

const binary = size => {
const chunk = new Uint8Array(size).fill(120);
const count = TOTAL / size;
return () => {
let i = 0;
return new ReadableStream({
pull(c) {
if (i++ < count) c.enqueue(chunk);
else c.close();
},
});
};
};
const text = size => {
const chunk = "x".repeat(size);
const count = TOTAL / size;
return () => {
let i = 0;
return new ReadableStream({
pull(c) {
if (i++ < count) c.enqueue(chunk);
else c.close();
},
});
};
};
const mixed = size => {
const textChunk = "y".repeat(size);
const binaryChunk = new Uint8Array(size).fill(121);
const count = TOTAL / size;
return () => {
let i = 0;
return new ReadableStream({
pull(c) {
if (i < count) (c.enqueue(i % 2 ? textChunk : binaryChunk), i++);
else c.close();
},
});
};
};

const shapes = {
"binary 64KiB x128": binary(64 * 1024),
"binary 1KiB x8192": binary(1024),
"text 64KiB x128": text(64 * 1024),
"text 1KiB x8192": text(1024),
"mixed text/bytes 64KiB x128": mixed(64 * 1024),
"one 8MiB chunk": (() => {
const chunk = new Uint8Array(TOTAL).fill(122);
return () =>
new ReadableStream({
start(c) {
c.enqueue(chunk);
c.close();
},
});
})(),
};

const consumers = {
"toText": s => Bun.readableStreamToText(s),
"toArrayBuffer": s => Bun.readableStreamToArrayBuffer(s),
"toBytes": s => Bun.readableStreamToBytes(s),
"toArray": s => Bun.readableStreamToArray(s),
"toBlob": async s => (await Bun.readableStreamToBlob(s)).size,
"Response.text": s => new Response(s).text(),
"Response.arrayBuffer": s => new Response(s).arrayBuffer(),
"for await": async s => {
let n = 0;
for await (const c of s) n += c.length;
return n;
},
};

const table = {};
for (const [shapeName, make] of Object.entries(shapes)) {
const row = (table[shapeName] = {});
for (const [consumerName, consume] of Object.entries(consumers)) {
await consume(make()); // warmup + validity
let best = Infinity;
for (let i = 0; i < RUNS; i++) {
const t0 = performance.now();
await consume(make());
best = Math.min(best, performance.now() - t0);
}
row[consumerName] = Math.round(TOTAL / MB / (best / 1000));
}
}
const consumerNames = Object.keys(consumers);
const version = typeof Bun !== "undefined" ? `bun ${Bun.revision.slice(0, 9)}` : `node ${process.version}`;
console.log(`# webstreams consumers (MB/s) — ${version} — ${TOTAL / MB} MiB per pass, best of ${RUNS}`);
console.log(["shape".padEnd(28), ...consumerNames.map(n => n.padStart(14))].join(""));
for (const [shapeName, row] of Object.entries(table))
console.log([shapeName.padEnd(28), ...consumerNames.map(n => String(row[n]).padStart(14))].join(""));
122 changes: 122 additions & 0 deletions bench/snippets/webstreams-memory.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Web Streams memory: (1) retained RSS per live instance, (2) peak/settled RSS for
// streaming workloads. Run with any JS runtime; extra heap counts print under Bun.
const N = 100_000;
const gc = globalThis.Bun?.gc ?? globalThis.gc;
if (!gc) throw new Error("This benchmark needs a GC hook: run with Bun, or node --expose-gc.");
const rss = () => process.memoryUsage.rss();
const MB = 1024 * 1024;
const fmt = n => (n / MB).toFixed(1).padStart(8) + " MB";

console.log(`# per-instance retained RSS (n=${N} live instances)`);
const keep = [];
function perInstance(label, make) {
gc(true);
const before = rss();
const held = new Array(N);
for (let i = 0; i < N; i++) held[i] = make();
gc(true);
const perObject = (rss() - before) / N;
console.log(`${label.padEnd(40)} ${perObject.toFixed(0).padStart(6)} bytes/instance`);
keep.push(held);
}
perInstance("new ReadableStream({pull(){}})", () => new ReadableStream({ pull() {} }));
perInstance("new ReadableStream() + getReader()", () => new ReadableStream({ pull() {} }).getReader());
perInstance("new WritableStream({write(){}})", () => new WritableStream({ write() {} }));
perInstance("new TransformStream()", () => new TransformStream());
keep.length = 0;
gc(true);

console.log(`\n# workload RSS (peak over baseline during the run, settled after gc)`);
const CHUNK = new Uint8Array(64 * 1024).fill(120);
async function workload(label, fn) {
gc(true);
const before = rss();
let peak = before;
const timer = setInterval(() => {
peak = Math.max(peak, rss());
}, 5);
// Whatever `fn` returns is kept alive until after the settled measurement, so
// "N live objects" workloads measure retention rather than post-return garbage.
const keepAlive = await fn();
clearInterval(timer);
peak = Math.max(peak, rss());
gc(true);
const settled = rss();
console.log(`${label.padEnd(46)} peak ${fmt(peak - before)} settled ${fmt(settled - before)}`);
return keepAlive;
}
const source = n => {
let i = 0;
return new ReadableStream({
pull(c) {
if (i++ < n) c.enqueue(CHUNK);
else c.close();
},
});
};
await workload("pipeTo 512 MiB (64 KiB chunks)", async () => {
let n = 0;
await source(8192).pipeTo(
new WritableStream({
write(c) {
n += c.length;
},
}),
);
});
await workload("for await 512 MiB", async () => {
let n = 0;
for await (const c of source(8192)) n += c.length;
});
await workload("Response(stream 256 MiB).arrayBuffer()", async () => {
(await new Response(source(4096)).arrayBuffer()).byteLength;
});
await workload("Response(stream 256 MiB of text).text()", async () => {
let i = 0;
const text = "x".repeat(64 * 1024);
const rs = new ReadableStream({
pull(c) {
if (i++ < 4096) c.enqueue(text);
else c.close();
},
});
(await new Response(rs).text()).length;
});
{
const held = await workload("10k live TransformStream chains (held)", async () => {
const chains = new Array(10_000);
for (let i = 0; i < chains.length; i++) {
const ts = new TransformStream();
chains[i] = [ts, ts.readable.getReader(), ts.writable.getWriter()];
}
gc(true);
return chains;
});
held.length = 0;
}
await workload("2k concurrent pipeThrough pipes (1 MiB each)", async () => {
const pipes = [];
for (let i = 0; i < 2000; i++) {
let k = 0;
const rs = new ReadableStream({
pull(c) {
if (k++ < 16) c.enqueue(CHUNK);
else c.close();
},
});
pipes.push(rs.pipeThrough(new TransformStream()).pipeTo(new WritableStream({ write() {} })));
}
await Promise.all(pipes);
});

if (typeof Bun !== "undefined") {
gc(true);
const { heapStats } = await import("bun:jsc");
const counts = heapStats().objectTypeCounts;
const interesting = Object.entries(counts)
.filter(([k]) => /Stream|Reader|Writer|Controller|Request|Promise|Function/i.test(k))
.sort((a, b) => b[1] - a[1])
.slice(0, 16);
console.log("\n# heapStats().objectTypeCounts after the workloads (top stream-related):");
for (const [k, v] of interesting) console.log(` ${k}: ${v}`);
}
107 changes: 107 additions & 0 deletions bench/snippets/webstreams-tee.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// tee()/clone() throughput and memory: plain ReadableStream.tee, fetch Response.clone,
// and Bun.serve Request.clone. Reports MB/s over the total bytes moved plus peak/settled
// RSS growth for each scenario (best-of-RUNS for time; max for memory).
const RUNS = 3;
const MB = 1024 * 1024;
const CHUNK = new Uint8Array(64 * 1024).fill(120);
const gc = globalThis.Bun?.gc ?? globalThis.gc;
if (!gc) throw new Error("This benchmark needs a GC hook: run with Bun, or node --expose-gc.");
const rss = () => process.memoryUsage.rss();
const fmt = n => (n / MB).toFixed(1).padStart(7) + " MB";

const source = totalBytes => {
const count = Math.ceil(totalBytes / CHUNK.length);
let i = 0;
return new ReadableStream({
pull(c) {
if (i++ < count) c.enqueue(CHUNK);
else c.close();
},
});
};
const drain = async rs => {
const r = rs.getReader();
let n = 0;
while (true) {
const { done, value } = await r.read();
if (done) return n;
n += value.length;
}
};

async function bench(label, totalBytes, fn) {
await fn(); // warmup
let best = Infinity;
let peak = 0;
for (let i = 0; i < RUNS; i++) {
gc(true);
const before = rss();
let localPeak = before;
const timer = setInterval(() => {
localPeak = Math.max(localPeak, rss());
}, 5);
const t0 = performance.now();
await fn();
const elapsed = performance.now() - t0;
clearInterval(timer);
localPeak = Math.max(localPeak, rss());
best = Math.min(best, elapsed);
peak = Math.max(peak, localPeak - before);
}
const mbps = totalBytes / MB / (best / 1000);
console.log(`${label.padEnd(46)} ${mbps.toFixed(0).padStart(7)} MB/s peak RSS +${fmt(peak)}`);
}

const TOTAL = 128 * MB;
await bench("tee(): both branches drained concurrently", TOTAL * 2, async () => {
const [a, b] = source(TOTAL).tee();
await Promise.all([drain(a), drain(b)]);
});
await bench("tee(): branch B read only after A finishes", TOTAL * 2, async () => {
const [a, b] = source(TOTAL).tee();
await drain(a);
await drain(b);
});
await bench("tee(): read A, cancel B", TOTAL, async () => {
const [a, b] = source(TOTAL).tee();
const done = drain(a);
await b.cancel();
await done;
});

if (typeof Bun !== "undefined") {
const BODY_BYTES = 64 * MB;
await using server = Bun.serve({
port: 0,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/stream") return new Response(source(BODY_BYTES));
if (url.pathname === "/clone-echo") {
// Request.clone(): consume the body twice server-side.
const clone = req.clone();
const [a, b] = await Promise.all([req.arrayBuffer(), clone.arrayBuffer()]);
return new Response(String(a.byteLength + b.byteLength));
}
return new Response("nope", { status: 404 });
},
});
const base = `http://localhost:${server.port}`;

await bench("fetch(stream).clone(): read both bodies", BODY_BYTES * 2, async () => {
const response = await fetch(`${base}/stream`);
const clone = response.clone();
await Promise.all([response.arrayBuffer(), clone.arrayBuffer()]);
});
await bench("fetch(stream).clone(): read one, cancel clone", BODY_BYTES, async () => {
const response = await fetch(`${base}/stream`);
const clone = response.clone();
const read = response.arrayBuffer();
await clone.body.cancel();
await read;
});
const upload = new Uint8Array(32 * MB).fill(7);
await bench("Bun.serve: req.clone(), read both bodies", upload.length * 2, async () => {
const res = await fetch(`${base}/clone-echo`, { method: "POST", body: upload });
if ((await res.text()) !== String(upload.length * 2)) throw new Error("bad echo");
});
}
Loading
Loading