Skip to content

webstreams: fix five crash/hang/data-loss bugs in the C++ streams implementation - #33849

Merged
Jarred-Sumner merged 8 commits into
mainfrom
claude/webstreams-audit-fixes
Jul 10, 2026
Merged

webstreams: fix five crash/hang/data-loss bugs in the C++ streams implementation#33849
Jarred-Sumner merged 8 commits into
mainfrom
claude/webstreams-audit-fixes

Conversation

@sosukesuzuki

@sosukesuzuki sosukesuzuki commented Jul 9, 2026

Copy link
Copy Markdown
Member

What

Five self-contained fixes (one commit each) for bugs in the C++ WebStreams implementation introduced by #33193, found while auditing the rewrite. Two are memory-safety issues reachable from a few lines of user JS in release builds; three are hangs/data loss via reentrancy. Each commit ships regression tests verified to fail before the fix and pass after.


1. Foreign-realm newTarget type-confuses the global object (all 10 constructors)

Every stream constructor's subclass slow path did uncheckedDowncast<JSDOMGlobalObject>(newTargetGlobalObject). A node:vm context's global is a sibling class, so the downcast is an invalid static_cast in release builds — getDOMStructure then reads and writes structure caches past the end of the smaller allocation.

const vm = require("node:vm");
const foreignFn = vm.runInContext("(function F(){})", vm.createContext({}));
Reflect.construct(ReadableStream, [], foreignFn); // asserts in debug; heap type confusion in release

The ten copy-pasted structureForNewTarget statics are replaced with one shared template in StreamConstructor.h that dynamicDowncasts and falls back to the constructor's own realm's cached Structure (per-VM correct, unlike a process-global fallback).

2. TransferArrayBuffer left transferred buffers resizable

The spec defines TransferArrayBuffer as ArrayBufferCopyAndDetach(O, undefined, fixed-length), but transferArrayBufferImpl used ArrayBuffer::transferTo, which carries maxByteLength across the transfer. User JS reaching the stream-internal buffer through byobRequest.view.buffer could resize() it, invalidating every byte length the controller recorded:

const rs = new ReadableStream({
  type: "bytes",
  pull(c) {
    c.byobRequest.view.buffer.resize(0); // succeeded; must throw TypeError
    c.enqueue(new Uint8Array(10));       // RELEASE_ASSERT → process abort, release builds included
  },
});
await rs.getReader({ mode: "byob" }).read(
  new Uint8Array(new ArrayBuffer(64, { maxByteLength: 1024 })),
);

A second variant (resize(2) + respond() with a remainder) made the remainder-clone path subspan past the live length — an out-of-bounds heap read whose bytes were delivered to a subsequent read(). Fix mirrors JSC's own arrayBufferCopyAndDetach FixedLength slow path: resizable sources are copied into a fixed-length block, then detached; non-resizable buffers keep the zero-copy transfer. (The WPT streams suite has no resizable-ArrayBuffer coverage, so tests are added.)

3. Bulk drain ran the user pull() before ResetQueue

drainQueueEntriesInto — behind reader.readMany() and the buffered consumers (text(), bytes(), Bun.readableStreamTo*) — removed every queue entry, ran the close/pull step, and only then reset the queue. A chunk enqueued synchronously by that pull landed in the still-live queue and was wiped by the reset; a close() in the same pull saw a momentarily non-empty queue and never re-evaluated:

let pulls = 0;
const rs = new ReadableStream({
  start(c) { c.enqueue("A"); },
  pull(c) { if (++pulls >= 2) { c.enqueue("B"); c.close(); } },
}, { highWaterMark: 2 });
await Bun.sleep(0);
await rs.text(); // hung forever (and "B" was silently destroyed); now resolves "AB"

The queue is now reset before the close/pull step. The pull decision still runs against the pre-drain [[queueTotalSize]], preserving the existing readMany batching cadence (the readMany batches the pipelined pull's chunk test still passes byte-for-byte).

4. Async iterator: reentrant next()/return() from a synchronous pull()

The iterator published m_ongoingPromise only after running steps that invoke the user pull() synchronously. A return() called from inside that pull saw a stale non-pending ongoing promise, skipped the chaining path, and released the reader under an in-flight read (ASSERT(reader->m_readRequests.isEmpty()) in debug):

let it, phase = 0;
const rs = new ReadableStream({
  pull(c) {
    if (++phase === 2) { it.return("bye"); return new Promise(() => {}); }
  },
}, { highWaterMark: 1 });
it = rs.values();
await null; await null; await null;
it.next(); // pull #2 fires synchronously and reenters via it.return() → assert/double release

The result promise is now published before any user JS can run — but only when the current ongoing promise is not pending, so ongoing-settled reactions never rewind the chain tail (queued next() calls still resolve in call order; tests cover both properties).

5. TextEncoderStream/TextDecoderStream let transform failures escape synchronously

The codec transform/flush algorithms wrapped only the encode/decode call in the completion-record catch. Both run user JS (ToString of the chunk; the patchable TextDecoder.prototype.decode), which can cancel the readable mid-transform — the subsequent enqueue's TypeError then escaped synchronously out of writer.write()/writer.close(), and the in-flight operation never settled:

const tes = new TextEncoderStream();
const reader = tes.readable.getReader();
const writer = tes.writable.getWriter();
reader.read();
await null; await null; await null;
try {
  writer.write({ toString() { reader.cancel(); return "x"; } }); // threw synchronously (spec: never throws)
} catch {}
await writer.abort("bye"); // never settled — wedged forever

The catch now covers the enqueue at all sites (encoder transform+flush unified into one helper, mirroring the decoder), converting abrupt completions into rejected promises that flow through transformStreamError — write rejects, abort settles, matching Node.


Test notes

  • All 9 regression tests fail on the unfixed implementation (crash / 5s-timeout hang / sync throw) and pass with the fixes; verified by reverting src/jsc/bindings/webcore/streams/ to main and re-running.
  • Full test/js/web/streams/ + WPT streams + encoding suites: 1,421 tests, no regressions (the one pre-existing failure, streams-leak absolute-RSS floor under ASAN, fails identically on main with a negative RSS delta).

no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/streams/streams.test.js

@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator
Updated 6:16 PM PT - Jul 9th, 2026

@Jarred-Sumner, your commit 452e023 has 3 failures in Build #71217 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33849

That installs a local version of the PR into your bun-33849 executable, so you can run:

bun-33849 --bun

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2021779b-be58-4fba-a82f-ab5268444540

📥 Commits

Reviewing files that changed from the base of the PR and between a8aff7f and 452e023.

📒 Files selected for processing (2)
  • src/jsc/bindings/webcore/streams/JSTransformStream.cpp
  • src/jsc/bindings/webcore/streams/JSWritableStreamDefaultWriter.cpp

Walkthrough

This PR centralizes stream constructor structure resolution, fixes reentrancy in async iterators and bulk reader draining, updates text stream abrupt-completion handling, implements fixed-length transfer for resizable buffers, and adds regression tests.

Changes

Stream construction and structure resolution

Layer / File(s) Summary
Shared newTarget structure resolution
src/jsc/bindings/webcore/streams/StreamConstructor.h, src/jsc/bindings/webcore/streams/*.cpp
Adds a shared structureForNewTarget implementation, removes duplicated local helpers, updates the default-reader call signature, and initializes JSTransformStream with its DOM structure.

Reentrancy and completion handling

Layer / File(s) Summary
Iterator and reader reentrancy
src/jsc/bindings/webcore/streams/JSReadableStreamAsyncIterator.cpp, src/jsc/bindings/webcore/streams/JSReadableStreamDefaultReader.cpp
Publishes iterator result promises before reentrant work and resets the reader queue before close/pull decisions.
Text stream completion paths
src/jsc/bindings/webcore/streams/JSTextDecoderStream.cpp, src/jsc/bindings/webcore/streams/JSTextEncoderStream.cpp
Captures abrupt completions and returns explicit fulfilled or rejected promises around decode, encode, and enqueue operations.
Resizable buffer transfer
src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp
Copies resizable non-shared buffers into fixed-length buffers before detaching the originals.

Regression coverage

Layer / File(s) Summary
Stream behavior tests
test/js/web/encoding/text-decoder-stream.test.ts, test/js/web/encoding/text-encoder-stream.test.ts, test/js/web/streams/streams.test.js
Tests cancellation during text transforms, foreign-realm construction, resizable BYOB transfer, bulk-drain reentrancy, and async-iterator ordering.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixes to WebStreams bugs in the C++ implementation.
Description check ✅ Passed The PR description covers what changed and how it was verified, though it uses alternate section headings.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/web/streams/streams.test.js`:
- Around line 2348-2388: Mark the independent bunExe() subprocess test in
streams.test.js as test.concurrent so it can run in parallel with the other
subprocess cases, and keep the in-process iterator/assertion tests sequential.
Update the streams constructors test around the test("streams constructors
survive a foreign-realm (node:vm) newTarget", ...) block to use the concurrent
test wrapper only for the isolated subprocess case, without changing the shared
logic inside the script or the existing prototype assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4d4a0acf-aa00-4c4a-8d37-404b4528c63e

📥 Commits

Reviewing files that changed from the base of the PR and between fc865b3 and 36747eb.

📒 Files selected for processing (16)
  • src/jsc/bindings/webcore/streams/JSByteLengthQueuingStrategy.cpp
  • src/jsc/bindings/webcore/streams/JSCountQueuingStrategy.cpp
  • src/jsc/bindings/webcore/streams/JSReadableStream.cpp
  • src/jsc/bindings/webcore/streams/JSReadableStreamAsyncIterator.cpp
  • src/jsc/bindings/webcore/streams/JSReadableStreamBYOBReader.cpp
  • src/jsc/bindings/webcore/streams/JSReadableStreamDefaultReader.cpp
  • src/jsc/bindings/webcore/streams/JSTextDecoderStream.cpp
  • src/jsc/bindings/webcore/streams/JSTextEncoderStream.cpp
  • src/jsc/bindings/webcore/streams/JSTransformStream.cpp
  • src/jsc/bindings/webcore/streams/JSWritableStream.cpp
  • src/jsc/bindings/webcore/streams/JSWritableStreamDefaultWriter.cpp
  • src/jsc/bindings/webcore/streams/StreamConstructor.h
  • src/jsc/bindings/webcore/streams/WebStreamsMisc.cpp
  • test/js/web/encoding/text-decoder-stream.test.ts
  • test/js/web/encoding/text-encoder-stream.test.ts
  • test/js/web/streams/streams.test.js
💤 Files with no reviewable changes (7)
  • src/jsc/bindings/webcore/streams/JSByteLengthQueuingStrategy.cpp
  • src/jsc/bindings/webcore/streams/JSWritableStreamDefaultWriter.cpp
  • src/jsc/bindings/webcore/streams/JSCountQueuingStrategy.cpp
  • src/jsc/bindings/webcore/streams/JSReadableStreamBYOBReader.cpp
  • src/jsc/bindings/webcore/streams/JSReadableStream.cpp
  • src/jsc/bindings/webcore/streams/JSTransformStream.cpp
  • src/jsc/bindings/webcore/streams/JSWritableStream.cpp

Comment thread test/js/web/streams/streams.test.js

@claude claude Bot left a comment

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.

No correctness issues found beyond the stale-comment nit, but five separate fixes across JSC bindings — two memory-safety, three reentrancy/ordering — is well past what I'd auto-approve; the async-iterator promise-publication change and the new hwm - size > 0 gate in drainQueueEntriesInto in particular deserve human eyes.

Extended reasoning...

Overview

This PR touches 12 C++ files in src/jsc/bindings/webcore/streams/ plus 3 test files, delivering five independent bug fixes to the WebStreams C++ rewrite from #33193:

  1. structureForNewTarget — replaces 10 copy-pasted static functions (each doing an uncheckedDowncast<JSDOMGlobalObject> on a foreign-realm global) with one shared template in StreamConstructor.h that dynamicDowncasts and falls back to the constructor's own cached Structure.
  2. transferArrayBufferImpl — adds a resizable-buffer slow path that copies into a fixed-length block before detaching, so byobRequest.view.buffer.resize() can no longer invalidate recorded byte lengths.
  3. drainQueueEntriesInto — reorders resetQueue() to run before the close/pull step, and adds an explicit hwm - size > 0 gate around CallPullIfNeeded to preserve the readMany batching cadence now that the queue is empty when the decision runs.
  4. JSReadableStreamAsyncIterator — publishes the result promise into m_ongoingPromise before running user JS, guarded by a "don't rewind the chain tail" check, and adds a clearOngoingIfOurs cleanup lambda for exception paths.
  5. decodeAndEnqueue / new encodeAndEnqueue — widens the catch scope to cover transformStreamDefaultControllerEnqueue, and unifies the encoder's transform/flush into one helper.

Security risks

Fixes #1 and #2 are themselves security-relevant: #1 closes a heap type-confusion (invalid static_cast on a sibling global-object class → getDOMStructure reading/writing past allocation bounds), and #2 closes an OOB heap read reachable via buffer.resize() on a stream-internal buffer. Both are reachable from a few lines of untrusted user JS in release builds. The fixes look correct on inspection (dynamicDowncast + fallback; tryCreate(span) + detach), but this is exactly the code class where a subtle mistake becomes an exploitable primitive, so it warrants a second reviewer.

Level of scrutiny

High. This is hand-written C++ in JSC bindings — the CLAUDE.md "most-blocked category" — with exception-scope discipline (DECLARE_TOP_EXCEPTION_SCOPE, takeAbruptCompletion, RETURN_IF_EXCEPTION on new paths), GC-visited WriteBarrier fields (m_ongoingPromise publication timing), and reentrancy-sensitive state-machine ordering. Each of the five fixes individually is small and well-argued in the description, but together they change control flow at ~15 distinct sites.

Other factors

  • The PR ships 9 targeted regression tests (subprocess-isolated for the crashers, in-process for the hangs) and the description states they were verified to fail on main. Test construction follows repo conventions (bunExe/bunEnv, concurrent pipe drains, {stdout, exitCode, signalCode} object assertions).
  • Fix #3's new hwm - size > 0 gate is a behavioral approximation of the old "CallPullIfNeeded against pre-drain totalSize" — the description says the existing readMany batches the pipelined pull's chunk test still passes byte-for-byte, but this is the kind of gate that could subtly change pull cadence for edge cases (e.g., hwm === 0, negative desired size from a size algorithm) and deserves reviewer confirmation.
  • Fix #4's clearOngoingIfOurs lambda and the "only publish if current ongoing is not pending" guard are non-obvious invariants; the three added tests cover the reentrancy crash, the ordering property, and the queued-next()-resolution-order property, which is reassuring, but the interaction with onAsyncIteratorNextAfterOngoingSettled (which also calls runAsyncIteratorNextSteps) is subtle enough that a human familiar with #33193 should confirm.
  • The one bug-hunter finding is a documentation nit (stale "runs no user JS" comment on invokeDecode) — no correctness issues surfaced.

Comment thread src/jsc/bindings/webcore/streams/JSTextDecoderStream.cpp
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun can you rebase and fix the conflicts

@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

✅ Rebased onto main; one conflict in drainQueueEntriesInto resolved (reset-before-pull ordering kept with __restrict from #33817), stale invokeDecode comment fixed. Builds clean; all nine regression tests and the sync-pull fast-path suite pass.

The remaining red lane is test/regression/issue/26030.test.ts on Alpine (MySQL Docker container health check times out at 60s), which also fails on the pre-rebase build #71046 and is unrelated to the streams changes here. Ready for review.

Constructing any of the ten stream classes via Reflect.construct with a
newTarget whose function realm is not a Zig global (e.g. a node:vm
context) ran uncheckedDowncast<JSDOMGlobalObject> on that realm's global
object — an invalid static_cast in release builds, reading and writing
the DOM structure caches past the end of the smaller global allocation.

Replace the ten copy-pasted structureForNewTarget helpers with one
shared template in StreamConstructor.h that dynamicDowncasts the
newTarget realm and falls back to the constructor's own realm's cached
instance Structure when the realm has no DOM structure caches.
TransferArrayBuffer is specified as ArrayBufferCopyAndDetach(O,
undefined, fixed-length), but transferArrayBufferImpl used
ArrayBuffer::transferTo directly, which carries m_maxByteLength across
the transfer — so the stream-internal buffer behind byte-queue entries
and BYOB pull-into descriptors stayed resizable. User JS reaching that
buffer through controller.byobRequest.view.buffer could then resize()
it, invalidating every byte length the controller recorded at transfer
time: enqueue after a shrink tripped the canCopyDataBlockBytes
RELEASE_ASSERT (process abort), and the respond() remainder-clone path
subspanned past the live length (out-of-bounds read surfaced to a
subsequent read()).

Mirror JSC's arrayBufferCopyAndDetach FixedLength slow path: copy a
resizable source into a fixed-length block, then detach the original.
Non-resizable buffers keep the zero-copy transfer.
drainQueueEntriesInto (behind reader.readMany() and the buffered
consumers: text(), bytes(), Bun.readableStreamTo*, ...) removed every
queue entry, ran the close-if-requested / pull-if-needed step, and only
then called ResetQueue. When that pull fired (queue total below the
high-water mark), a chunk it enqueued synchronously landed in the
still-live queue and was wiped by the final reset, and a close() in the
same pull saw a momentarily non-empty queue, never re-evaluated, and
left the stream readable forever — consumers hung and the data
vanished.

Reset the queue first (the drain loop has already removed every entry),
then run the close/pull step. The pull DECISION still runs against the
pre-drain [[queueTotalSize]] — the established readMany cadence, where
a full batch defers the next pull to the next wake — but anything a
firing pull enqueues now survives into the next drain instead of being
destroyed.
The async iterator only published m_ongoingPromise AFTER
runAsyncIteratorNextSteps returned, but those steps run the user pull()
synchronously (via the dequeue fast path's CallPullIfNeeded, or via the
issued read). A next()/return() reentrantly called from inside that
pull saw a stale non-pending ongoing promise, skipped the chaining
path, and ran the return steps against an in-flight read — tripping
ASSERT(reader->m_readRequests.isEmpty()) in debug builds and releasing
the reader out from under the pending read in release builds.

Publish the result promise as m_ongoingPromise before any user JS can
run, but only when the current ongoing promise is not pending: when the
steps run as an ongoing-settled reaction, the pending chained promise
already serves as the guard and must remain the chain tail, or queued
next() calls could resolve out of order. The host function no longer
overwrites m_ongoingPromise afterwards, since a reentrant caller may
have chained onto it while the steps ran.
@robobun
robobun force-pushed the claude/webstreams-audit-fixes branch from 36747eb to ee6974d Compare July 9, 2026 23:07
The TextEncoderStream/TextDecoderStream transform and flush algorithms
wrapped only the encode/decode invocation in the completion-record
catch; the subsequent transformStreamDefaultControllerEnqueue was
followed by a plain RETURN_IF_EXCEPTION. Both encode and decode can run
user JS (ToString of the chunk; the patchable TextDecoder.prototype
.decode), which can cancel the readable side mid-transform — the
enqueue then throws TypeError, and that exception escaped synchronously
out of writer.write()/writer.close() (a transform algorithm must never
throw into ProcessWrite/ProcessClose). Worse, the in-flight write/close
was already marked and never settled, so writer.abort()/close()/closed
hung forever and pinned the stream graph.

Extend the catch to cover the enqueue at all three sites (encoder
transform, encoder flush, decoder transform+flush), converting the
abrupt completion into a rejected promise that flows through
onTSPerformTransformRejected / onTSSinkCloseFlushRejected into
transformStreamError, matching defaultTransformAlgorithm.
@robobun
robobun force-pushed the claude/webstreams-audit-fixes branch from ee6974d to 116fcfb Compare July 9, 2026 23:08

@claude claude Bot left a comment

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.

No issues found and the stale comment I flagged earlier is now fixed — but given this touches memory-safety-critical JSC bindings (type confusion, OOB read, reentrancy ordering across five separate fixes), it deserves a human pass before landing.

Extended reasoning...

Overview

This PR fixes five distinct bugs in the C++ WebStreams implementation (src/jsc/bindings/webcore/streams/): a foreign-realm newTarget type-confusion across all 10 stream constructors (consolidated into a shared template in StreamConstructor.h), a resizable-ArrayBuffer transfer that permitted user resize() to invalidate byte-controller invariants (OOB heap read), a reset-before-pull reordering in drainQueueEntriesInto, an async-iterator m_ongoingPromise publish-before-user-JS reordering, and widened catch scopes in the TextEncoderStream/TextDecoderStream transform algorithms. Nine regression tests are added across three test files.

Security risks

Two of the five fixes are memory-safety issues reachable from user JS in release builds: an invalid static_cast of a sibling global-object type (heap type confusion via getDOMStructure), and an out-of-bounds heap read whose bytes were delivered to a subsequent read(). The fixes look correct — dynamicDowncast with a per-realm fallback, and copy-into-fixed-length matching JSC's own arrayBufferCopyAndDetach slow path — but these are exactly the paths where a second pair of eyes on the invariants is warranted.

Level of scrutiny

High. This is hand-written C++ operating on JSC cells with GC-visible state, WriteBarriers, and reentrancy windows where user JS can synchronously mutate the objects being iterated. The drainQueueEntriesInto change also introduces a new hwm - size > 0 gate around CallPullIfNeeded (previously the pull-decision was delegated entirely to the controller with the un-reset queue size); the PR description explains this preserves the readMany batching cadence and the existing readMany batches the pipelined pull's chunk test still passes, but it's a behavioral subtlety worth a maintainer confirming. The async-iterator m_ongoingPromise publish/clear dance (clearOngoingIfOurs) is similarly subtle.

Other factors

The bug-hunting system found no issues. My earlier inline comment (stale invokeDecode doc comment) was addressed in the rebase. CodeRabbit's only note was a trivial test.concurrent nit (resolved). The PR is well-tested and well-documented, but the combination of memory safety, GC interaction, and reentrancy ordering across five independent fixes puts it well outside the auto-approval threshold.

@Jarred-Sumner
Jarred-Sumner merged commit 5496119 into main Jul 10, 2026
73 of 77 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/webstreams-audit-fixes branch July 10, 2026 03:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants