webstreams: rewrite ReadableStream, WritableStream, and TransformStream in C++ (zero JS builtins) - #33193
Conversation
|
Updated 8:51 PM PT - Jul 3rd, 2026
❌ @Jarred-Sumner, your commit 2d0e6e5 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 33193That installs a local version of the PR into your bun-33193 --bun |
|
Found 11 issues this PR may fix:
🤖 Generated with Claude Code |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThis PR adds Web Streams consumer/source implementations, build and binding wiring for the new C++ sources, benchmark scripts and docs, async stack trace attachment helpers, and a stdin read/release refactor. ChangesWeb Streams runtime and tooling
Async stack trace attachment
stdin stream lifecycle
Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 19
🤖 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 `@specs/ARCH-REVIEW.md`:
- Around line 11-137: Mark the three critical findings in this section as
v1-only since ARCHITECTURE.md already defines the Transform/SinkKind plumbing
and shared promise-reaction approach elsewhere. Update the headings and prose
around the JSCell virtual-method issue, the SourceKind/SinkKind gap, and the
no-closures mechanism so they are clearly labeled historical notes or rewritten
to match the current design. Use the existing section references and symbols
like SourceKind, SinkKind, JSReadRequest, and WebStreamsInternals.h to keep the
edits aligned with the frozen text.
In `@specs/ARCH-SELF-REVIEW.md`:
- Around line 13-15: Update the architecture note to use the same enum spellings
as the ABI spec: replace the current SinkKind::Transform / SourceKind::Transform
wording with the exact TransformSink / TransformSource names used in
specs/OP-SIGNATURES.md. Keep the SinkKind and SourceKind references aligned with
the ABI table so the enums in this section match the spec terminology exactly.
In `@specs/ARCHITECTURE.md`:
- Around line 196-205: The readable-stream controller exception is
underspecified because not every `stream.[[controller]]` consumer is listed.
Update the `JSReadableStream::m_controller` documentation in ARCHITECTURE.md to
enumerate the full erased-controller dispatch table, explicitly covering the
generic release path and all controller-typed call sites in addition to
`readableStreamCancel`, and make clear how each `ControllerKind` arm (`None`,
`Default`, `Byte`, `Direct`, `NativeSink`) is handled. Use the
`JSReadableStream::m_controller` and `ControllerKind` symbols to anchor the
description.
- Around line 79-84: Special-case JSReadableStreamAsyncIterator in the
public-class registration rules so it is excluded from the DOMConstructorID/lazy
getter plumbing described in §2. Update the architecture spec around the class
list and constructor-registration guidance to explicitly note that Class 14 has
no globalThis constructor, no lazy getter, and is created internally via
values()/[Symbol.asyncIterator](), while still keeping the prototype behavior
described elsewhere.
- Around line 118-120: The architecture entry for Bun stream consumers is
missing ownership of the generic text accumulator helper, so update the
`BunStreamConsumers.cpp` row to also name `readableStreamIntoText` and
`withoutUTF8BOM` alongside `Bun.readableStreamToText` / the other
`readableStreamTo*` paths. Keep the surrounding
`ReadableStream.prototype.{text,json,bytes,blob}` references intact, and ensure
the `BunStreamConsumers.cpp` coverage explicitly includes the internal text
accumulation path so Phase B has a clear owner.
In `@specs/BUN-EXTENSIONS.md`:
- Around line 39-40: The JSSink controller surface in BUN-EXTENSIONS.md is
overstated because it lists a user-visible .sink getter even though the
controller.sink contract is currently undefined; update the section describing
the native JSSink controller generated by generate-jssink.ts and the path (A)
pull surface to omit .sink from the public API, keeping it as internal/private
only. Align the documented methods with the actual controller wrapper surface
(write, end, flush, close, start) and ensure the later controller.sink ===
undefined description remains consistent.
In `@specs/check-streams.py`:
- Around line 69-79: The streams probe in check-streams.py uses a fixed
/tmp/streams_header_probe.cpp path, which can be overwritten or symlinked by
another process. Update the probe creation inside the not targets branch to use
a unique temporary file or directory via tempfile.NamedTemporaryFile or
TemporaryDirectory, keep the existing run(clangxx, ...) flow in
check-streams.py, and ensure the temporary probe is removed after compilation.
- Around line 26-35: Normalize the compile database entry path before searching
for REFERENCE_TU in reference_flags(), since entry["file"] may use native
separators and fail the endswith check on Windows. Update the matching logic to
compare a normalized path form from the compile_commands.json entries so the
JSCookie.cpp reference TU is found reliably across platforms.
- Around line 31-47: The flag parsing in reference_flags is losing information
by re-tokenizing entry["arguments"] with shlex.split, which can alter valid
quoted or spaced compiler args. Update reference_flags to use entry["arguments"]
directly when that field exists, and only fall back to splitting
entry["command"] when arguments is missing; keep the rest of the filtering logic
in reference_flags unchanged.
In `@specs/digest/03-writable.md`:
- Around line 154-157: Update the `[[stream]]` slot description in the
`WritableStreamDefaultWriter` internal slots table so it refers to the writer,
not a reader. Keep the other slot entries unchanged, and adjust the wording in
the spec text near `[[closedPromise]]`, `[[readyPromise]]`, and `[[stream]]` to
consistently describe `[[stream]]` as the `WritableStream` instance owned by the
default writer.
In `@specs/digest/04-transform-queuing-support.md`:
- Around line 701-709: The CanCopyDataBlockBytes predicate is checking detached
state backwards, so it currently rejects the normal attached-buffer case. Update
the detached-buffer guards in CanCopyDataBlockBytes so it only returns false
when either toBuffer or fromBuffer is detached, and otherwise allows copying;
keep the existing self-copy check intact. Verify the logic around
IsDetachedBuffer in the CanCopyDataBlockBytes algorithm matches the intended
byte-copy behavior.
In `@specs/OP-SIGNATURES.md`:
- Around line 525-531: Fix the coverage math in the summary table by reconciling
the internal-method row counts for `01-readable-classes.md` and the total.
Update the `Readable` controller entries so the counts match the audit’s stated
5 internal-method rows, and make the “Total” row reflect the corrected sum
across all digests. Use the digest table and the
`CancelSteps`/`PullSteps`/`ReleaseSteps` references to verify the row totals are
internally consistent.
- Line 111: The `ReadableByteStreamControllerConvertPullIntoDescriptor`
signature note currently implies only OOM/alloc failure, but the
`readableByteStreamControllerConvertPullIntoDescriptor` path can also fail via
`TransferArrayBuffer` before constructing the intrinsic view. Update the spec
entry to explicitly mention this non-OOM abrupt-completion path, keeping the
`JSReadableByteStreamController.cpp`/`JSC::JSArrayBufferView*
readableByteStreamControllerConvertPullIntoDescriptor(...)` reference intact so
callers don’t treat it as allocation-only.
In `@specs/PLUMBING.md`:
- Around line 84-91: Update the AbortSignal plumbing note to match the existing
removable GC-visited API already provided by
AbortSignal::addAbortAlgorithmToSignal and
AbortSignal::removeAbortAlgorithmFromSignal, and remove the speculative
requirement for a new AbortAlgorithm/visitJSFunction contract. The spec should
point readers to the existing WebCore::AbortSignal implementation and the
JSAbortSignalCustom visitation path, and describe that native stream code should
register, retain the returned id, and explicitly remove it on
close/error/finalize using the existing API.
In `@specs/probes/adversarial-smoke.js`:
- Around line 2-4: Make the probe actually fail the process instead of only
logging errors: in adversarial-smoke.js, update the
process.on("unhandledRejection") and process.on("uncaughtException") handlers to
increment failures (and set a non-zero process.exitCode), and ensure the final
result in the main probe flow uses that accumulated failure state rather than a
fixed 50 ms tail sleep. Use the existing withTimeout helper and the
VERIFY_FAIL/exit path near the end of the file so process-level exceptions are
counted and the script exits non-zero when any probe step fails.
In `@specs/probes/sync-throw-matrix.js`:
- Around line 1-10: The probe script only logs results, so mismatches like
“resolved!?”, “wrong:...”, and “HANG” do not fail the run. Update the `t` helper
and the top-level `await t(...)` cases in `sync-throw-matrix.js` to track any
non-success outcome and set `process.exitCode = 1` when a case is not
“rejected-correctly” or “ctor-threw-correctly”, including timeout/HANG results.
In `@specs/review-cpp/ReadableStreamOperations-A.md`:
- Around line 18-76: `ReadableStream.from` is using an iterator helper that
incorrectly rejects primitive strings, so the `readableStreamFromIterable` path
does not match `GetIterator(async)` semantics. Update
`readableStreamFromIterable` to use a lookup that works on primitives (or relax
`getAsyncIteratorExported`/`getAsyncIteratorImpl` to only reject nullish
values), and ensure the fallback sync-iterator path still calls the iterator
with the original value as `this`. Also add the string iterable case to the
`ReadableStream.from` WPT coverage so `"ab"` produces code points instead of
throwing.
In `@specs/review-cpp/TransformStreamOperations-A.md`:
- Around line 17-70: The documented context contract for the reaction handlers
is inconsistent with the actual registrations in TransformStreamOperations,
especially the onTSSinkAbortCancel* and onTSSourceCancel* paths. Update the
contract comment near JSStreamsRuntime.h so it explicitly distinguishes the bare
JSTransformStream cases from the InternalFieldTuple{transformStream, reason}
cases used by the cancel/abort handlers, and keep the tuple field meanings
aligned with the corresponding handler names and registration sites.
In `@specs/review-cpp/TransformStreamOperations-B.md`:
- Around line 17-47: The three fulfilled handlers in TransformStreamOperations-B
are calling resolvePromise and returning immediately without observing
exceptions, which breaks exception-check discipline. Update the resolvePromise
sites in onTSSinkAbortCancelFulfilled, onTSSinkCloseFlushFulfilled, and
onTSSourceCancelFulfilled to match the existing safe patterns already used in
this file, using scope.assertNoException() or RETURN_IF_EXCEPTION before
returning. Keep the fix localized to those handlers so the [[finishPromise]]
settlement path is exception-safe and consistent with the other resolvePromise
call sites.
🪄 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: 15facdfa-6c24-44d1-a728-b26628093c4a
📒 Files selected for processing (285)
scripts/build/unified.tsscripts/glob-sources.tsspecs/ARCH-REVIEW.mdspecs/ARCH-SELF-REVIEW.mdspecs/ARCHITECTURE.mdspecs/BASELINE.mdspecs/BUN-EXTENSIONS.mdspecs/BUN-LAYER-DESIGN.mdspecs/BUN-LAYER-REVIEW-FIDELITY.mdspecs/BUN-LAYER-REVIEW-GC.mdspecs/CONSUMERS.mdspecs/CPP-SURFACE.mdspecs/HEADER-REVIEW-1.mdspecs/HEADER-REVIEW-2.mdspecs/HEADER-REVIEW-3.mdspecs/OP-SIGNATURES.mdspecs/PHASE-A-NOTES.mdspecs/PHASE-B-LOG.mdspecs/PHASE-C-BLOCKERS.mdspecs/PHASE-D-NOTES.mdspecs/PLUMBING.mdspecs/SLOT-TABLES.mdspecs/TEST-SURFACE.mdspecs/WPT-BASELINE.mdspecs/check-streams.pyspecs/compile-errors/round1.txtspecs/compile-errors/round2.txtspecs/compile-errors/round3.txtspecs/digest/01-readable-classes.mdspecs/digest/02-readable-abstract-ops.mdspecs/digest/03-writable.mdspecs/digest/04-transform-queuing-support.mdspecs/probes/adversarial-smoke.jsspecs/probes/sync-throw-matrix.jsspecs/review-cpp/CONTRACT-AUDIT.mdspecs/review-cpp/DISCIPLINE-SWEEP.mdspecs/review-cpp/JSReadableByteStreamController-A.mdspecs/review-cpp/JSReadableStream-A.mdspecs/review-cpp/JSStreamPipeToOperation-A.mdspecs/review-cpp/JSTransformStreamDefaultController-AB.mdspecs/review-cpp/ReadableStreamOperations-A.mdspecs/review-cpp/TransformStreamOperations-A.mdspecs/review-cpp/TransformStreamOperations-B.mdspecs/review-cpp/WritableStreamOperations-A.mdspecs/review-cpp/WritableStreamOperations-B.mdspecs/streams-baseline.jsspecs/streams-spec.bsspecs/streams-spec.htmlspecs/streams-spec.txtsrc/codegen/generate-jssink.tssrc/js/README.mdsrc/js/builtins.d.tssrc/js/builtins/BunBuiltinNames.hsrc/js/builtins/ByteLengthQueuingStrategy.tssrc/js/builtins/CountQueuingStrategy.tssrc/js/builtins/Fifo.tssrc/js/builtins/ReadableByteStreamController.tssrc/js/builtins/ReadableByteStreamInternals.tssrc/js/builtins/ReadableStream.tssrc/js/builtins/ReadableStreamBYOBReader.tssrc/js/builtins/ReadableStreamBYOBRequest.tssrc/js/builtins/ReadableStreamDefaultController.tssrc/js/builtins/ReadableStreamDefaultReader.tssrc/js/builtins/ReadableStreamInternals.tssrc/js/builtins/StreamInternals.tssrc/js/builtins/TextDecoderStream.tssrc/js/builtins/TextEncoderStream.tssrc/js/builtins/TransformStream.tssrc/js/builtins/TransformStreamDefaultController.tssrc/js/builtins/TransformStreamInternals.tssrc/js/builtins/WritableStreamDefaultController.tssrc/js/builtins/WritableStreamDefaultWriter.tssrc/js/builtins/WritableStreamInternals.tssrc/js/internal/sql/query.tssrc/js/internal/streams/native-readable.tssrc/jsc/STREAMS.mdsrc/jsc/bindings/BunObject.cppsrc/jsc/bindings/JS2Native.cppsrc/jsc/bindings/ZigGlobalObject.cppsrc/jsc/bindings/ZigGlobalObject.hsrc/jsc/bindings/bindings.cppsrc/jsc/bindings/js_classes.tssrc/jsc/bindings/webcore/DOMClientIsoSubspaces.hsrc/jsc/bindings/webcore/DOMConstructors.hsrc/jsc/bindings/webcore/DOMIsoSubspaces.hsrc/jsc/bindings/webcore/InternalWritableStream.cppsrc/jsc/bindings/webcore/InternalWritableStream.hsrc/jsc/bindings/webcore/JSByteLengthQueuingStrategy.cppsrc/jsc/bindings/webcore/JSByteLengthQueuingStrategy.hsrc/jsc/bindings/webcore/JSCountQueuingStrategy.cppsrc/jsc/bindings/webcore/JSCountQueuingStrategy.hsrc/jsc/bindings/webcore/JSReadableByteStreamController.cppsrc/jsc/bindings/webcore/JSReadableByteStreamController.hsrc/jsc/bindings/webcore/JSReadableStream.cppsrc/jsc/bindings/webcore/JSReadableStream.hsrc/jsc/bindings/webcore/JSReadableStreamBYOBReader.cppsrc/jsc/bindings/webcore/JSReadableStreamBYOBReader.hsrc/jsc/bindings/webcore/JSReadableStreamBYOBRequest.cppsrc/jsc/bindings/webcore/JSReadableStreamBYOBRequest.hsrc/jsc/bindings/webcore/JSReadableStreamDefaultController.cppsrc/jsc/bindings/webcore/JSReadableStreamDefaultController.hsrc/jsc/bindings/webcore/JSReadableStreamDefaultReader.cppsrc/jsc/bindings/webcore/JSReadableStreamDefaultReader.hsrc/jsc/bindings/webcore/JSReadableStreamSink.cppsrc/jsc/bindings/webcore/JSReadableStreamSink.hsrc/jsc/bindings/webcore/JSReadableStreamSource.cppsrc/jsc/bindings/webcore/JSReadableStreamSource.hsrc/jsc/bindings/webcore/JSReadableStreamSourceCustom.cppsrc/jsc/bindings/webcore/JSTextDecoderStream.cppsrc/jsc/bindings/webcore/JSTextDecoderStream.hsrc/jsc/bindings/webcore/JSTextEncoderStream.cppsrc/jsc/bindings/webcore/JSTextEncoderStream.hsrc/jsc/bindings/webcore/JSTransformStream.cppsrc/jsc/bindings/webcore/JSTransformStream.hsrc/jsc/bindings/webcore/JSTransformStreamDefaultController.cppsrc/jsc/bindings/webcore/JSTransformStreamDefaultController.hsrc/jsc/bindings/webcore/JSWritableStream.cppsrc/jsc/bindings/webcore/JSWritableStream.hsrc/jsc/bindings/webcore/JSWritableStreamDefaultController.cppsrc/jsc/bindings/webcore/JSWritableStreamDefaultController.hsrc/jsc/bindings/webcore/JSWritableStreamDefaultWriter.cppsrc/jsc/bindings/webcore/JSWritableStreamDefaultWriter.hsrc/jsc/bindings/webcore/JSWritableStreamSink.cppsrc/jsc/bindings/webcore/JSWritableStreamSink.hsrc/jsc/bindings/webcore/ReadableStream.cppsrc/jsc/bindings/webcore/ReadableStream.hsrc/jsc/bindings/webcore/ReadableStreamDefaultController.cppsrc/jsc/bindings/webcore/ReadableStreamDefaultController.hsrc/jsc/bindings/webcore/ReadableStreamSink.cppsrc/jsc/bindings/webcore/ReadableStreamSink.hsrc/jsc/bindings/webcore/ReadableStreamSource.cppsrc/jsc/bindings/webcore/ReadableStreamSource.hsrc/jsc/bindings/webcore/WritableStream.cppsrc/jsc/bindings/webcore/WritableStream.hsrc/jsc/bindings/webcore/WritableStream.idlsrc/jsc/bindings/webcore/WritableStreamSink.hsrc/jsc/bindings/webcore/streams/BunStandaloneTextSink.hsrc/jsc/bindings/webcore/streams/BunStreamConsumers.cppsrc/jsc/bindings/webcore/streams/BunStreamConsumers.hsrc/jsc/bindings/webcore/streams/BunStreamSource.cppsrc/jsc/bindings/webcore/streams/BunStreamSource.hsrc/jsc/bindings/webcore/streams/CrossRealmTransform.cppsrc/jsc/bindings/webcore/streams/JSByteLengthQueuingStrategy.cppsrc/jsc/bindings/webcore/streams/JSByteLengthQueuingStrategy.hsrc/jsc/bindings/webcore/streams/JSCountQueuingStrategy.cppsrc/jsc/bindings/webcore/streams/JSCountQueuingStrategy.hsrc/jsc/bindings/webcore/streams/JSCrossRealmTransformState.cppsrc/jsc/bindings/webcore/streams/JSCrossRealmTransformState.hsrc/jsc/bindings/webcore/streams/JSDirectSinkCloseState.hsrc/jsc/bindings/webcore/streams/JSDirectStreamController.cppsrc/jsc/bindings/webcore/streams/JSDirectStreamController.hsrc/jsc/bindings/webcore/streams/JSOneShotDirectSink.hsrc/jsc/bindings/webcore/streams/JSPullIntoDescriptor.cppsrc/jsc/bindings/webcore/streams/JSPullIntoDescriptor.hsrc/jsc/bindings/webcore/streams/JSReadRequest.cppsrc/jsc/bindings/webcore/streams/JSReadRequest.hsrc/jsc/bindings/webcore/streams/JSReadStreamIntoSinkOperation.hsrc/jsc/bindings/webcore/streams/JSReadableByteStreamController.cppsrc/jsc/bindings/webcore/streams/JSReadableByteStreamController.hsrc/jsc/bindings/webcore/streams/JSReadableStream.cppsrc/jsc/bindings/webcore/streams/JSReadableStream.hsrc/jsc/bindings/webcore/streams/JSReadableStreamAsyncIterator.cppsrc/jsc/bindings/webcore/streams/JSReadableStreamAsyncIterator.hsrc/jsc/bindings/webcore/streams/JSReadableStreamBYOBReader.cppsrc/jsc/bindings/webcore/streams/JSReadableStreamBYOBReader.hsrc/jsc/bindings/webcore/streams/JSReadableStreamBYOBRequest.cppsrc/jsc/bindings/webcore/streams/JSReadableStreamBYOBRequest.hsrc/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cppsrc/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.hsrc/jsc/bindings/webcore/streams/JSReadableStreamDefaultReader.cppsrc/jsc/bindings/webcore/streams/JSReadableStreamDefaultReader.hsrc/jsc/bindings/webcore/streams/JSReadableStreamReaderBase.cppsrc/jsc/bindings/webcore/streams/JSReadableStreamReaderBase.hsrc/jsc/bindings/webcore/streams/JSResumableSinkPumpOperation.hsrc/jsc/bindings/webcore/streams/JSStreamAlgorithmContexts.cppsrc/jsc/bindings/webcore/streams/JSStreamAlgorithmContexts.hsrc/jsc/bindings/webcore/streams/JSStreamPipeToOperation.cppsrc/jsc/bindings/webcore/streams/JSStreamPipeToOperation.hsrc/jsc/bindings/webcore/streams/JSStreamTeeState.cppsrc/jsc/bindings/webcore/streams/JSStreamTeeState.hsrc/jsc/bindings/webcore/streams/JSStreamsRuntime.cppsrc/jsc/bindings/webcore/streams/JSStreamsRuntime.hsrc/jsc/bindings/webcore/streams/JSTextDecoderStream.cppsrc/jsc/bindings/webcore/streams/JSTextDecoderStream.hsrc/jsc/bindings/webcore/streams/JSTextEncoderStream.cppsrc/jsc/bindings/webcore/streams/JSTextEncoderStream.hsrc/jsc/bindings/webcore/streams/JSTransformStream.cppsrc/jsc/bindings/webcore/streams/JSTransformStream.hsrc/jsc/bindings/webcore/streams/JSTransformStreamDefaultController.cppsrc/jsc/bindings/webcore/streams/JSTransformStreamDefaultController.hsrc/jsc/bindings/webcore/streams/JSWritableStream.cppsrc/jsc/bindings/webcore/streams/JSWritableStream.hsrc/jsc/bindings/webcore/streams/JSWritableStreamDefaultController.cppsrc/jsc/bindings/webcore/streams/JSWritableStreamDefaultController.hsrc/jsc/bindings/webcore/streams/JSWritableStreamDefaultWriter.cppsrc/jsc/bindings/webcore/streams/JSWritableStreamDefaultWriter.hsrc/jsc/bindings/webcore/streams/ReadableStreamOperations.cppsrc/jsc/bindings/webcore/streams/StreamConstructor.hsrc/jsc/bindings/webcore/streams/StreamQueue.hsrc/jsc/bindings/webcore/streams/StreamsForward.hsrc/jsc/bindings/webcore/streams/TransformStreamOperations.cppsrc/jsc/bindings/webcore/streams/WebStreamsExports.cppsrc/jsc/bindings/webcore/streams/WebStreamsInternals.hsrc/jsc/bindings/webcore/streams/WebStreamsMisc.cppsrc/jsc/bindings/webcore/streams/WritableStreamOperations.cpptest/js/third_party/wpt-h2/run.test.tstest/js/third_party/wpt-h2/testharness-shim.tstest/js/third_party/wpt-streams/RESULTS.mdtest/js/third_party/wpt-streams/UPSTREAM.mdtest/js/third_party/wpt-streams/common/gc.jstest/js/third_party/wpt-streams/expectations.jsontest/js/third_party/wpt-streams/streams/piping/abort.any.jstest/js/third_party/wpt-streams/streams/piping/close-propagation-backward.any.jstest/js/third_party/wpt-streams/streams/piping/close-propagation-forward.any.jstest/js/third_party/wpt-streams/streams/piping/error-propagation-backward.any.jstest/js/third_party/wpt-streams/streams/piping/error-propagation-forward.any.jstest/js/third_party/wpt-streams/streams/piping/flow-control.any.jstest/js/third_party/wpt-streams/streams/piping/general-addition.any.jstest/js/third_party/wpt-streams/streams/piping/general.any.jstest/js/third_party/wpt-streams/streams/piping/multiple-propagation.any.jstest/js/third_party/wpt-streams/streams/piping/pipe-through.any.jstest/js/third_party/wpt-streams/streams/piping/then-interception.any.jstest/js/third_party/wpt-streams/streams/piping/throwing-options.any.jstest/js/third_party/wpt-streams/streams/piping/transform-streams.any.jstest/js/third_party/wpt-streams/streams/queuing-strategies.any.jstest/js/third_party/wpt-streams/streams/readable-byte-streams/bad-buffers-and-views.any.jstest/js/third_party/wpt-streams/streams/readable-byte-streams/construct-byob-request.any.jstest/js/third_party/wpt-streams/streams/readable-byte-streams/crashtests/tee-locked-stream.any.jstest/js/third_party/wpt-streams/streams/readable-byte-streams/enqueue-with-detached-buffer.any.jstest/js/third_party/wpt-streams/streams/readable-byte-streams/general.any.jstest/js/third_party/wpt-streams/streams/readable-byte-streams/non-transferable-buffers.any.jstest/js/third_party/wpt-streams/streams/readable-byte-streams/patched-global.any.jstest/js/third_party/wpt-streams/streams/readable-byte-streams/read-min.any.jstest/js/third_party/wpt-streams/streams/readable-byte-streams/respond-after-enqueue.any.jstest/js/third_party/wpt-streams/streams/readable-byte-streams/tee.any.jstest/js/third_party/wpt-streams/streams/readable-byte-streams/templated.any.jstest/js/third_party/wpt-streams/streams/readable-streams/async-iterator.any.jstest/js/third_party/wpt-streams/streams/readable-streams/bad-strategies.any.jstest/js/third_party/wpt-streams/streams/readable-streams/bad-underlying-sources.any.jstest/js/third_party/wpt-streams/streams/readable-streams/cancel.any.jstest/js/third_party/wpt-streams/streams/readable-streams/constructor.any.jstest/js/third_party/wpt-streams/streams/readable-streams/count-queuing-strategy-integration.any.jstest/js/third_party/wpt-streams/streams/readable-streams/crashtests/garbage-collection.any.jstest/js/third_party/wpt-streams/streams/readable-streams/default-reader.any.jstest/js/third_party/wpt-streams/streams/readable-streams/floating-point-total-queue-size.any.jstest/js/third_party/wpt-streams/streams/readable-streams/from.any.jstest/js/third_party/wpt-streams/streams/readable-streams/garbage-collection.any.jstest/js/third_party/wpt-streams/streams/readable-streams/general.any.jstest/js/third_party/wpt-streams/streams/readable-streams/patched-global.any.jstest/js/third_party/wpt-streams/streams/readable-streams/reentrant-strategies.any.jstest/js/third_party/wpt-streams/streams/readable-streams/tee.any.jstest/js/third_party/wpt-streams/streams/readable-streams/templated.any.jstest/js/third_party/wpt-streams/streams/resources/recording-streams.jstest/js/third_party/wpt-streams/streams/resources/rs-test-templates.jstest/js/third_party/wpt-streams/streams/resources/rs-utils.jstest/js/third_party/wpt-streams/streams/resources/test-utils.jstest/js/third_party/wpt-streams/streams/transform-streams/backpressure.any.jstest/js/third_party/wpt-streams/streams/transform-streams/cancel.any.jstest/js/third_party/wpt-streams/streams/transform-streams/errors.any.jstest/js/third_party/wpt-streams/streams/transform-streams/flush.any.jstest/js/third_party/wpt-streams/streams/transform-streams/general.any.jstest/js/third_party/wpt-streams/streams/transform-streams/lipfuzz.any.jstest/js/third_party/wpt-streams/streams/transform-streams/patched-global.any.jstest/js/third_party/wpt-streams/streams/transform-streams/properties.any.jstest/js/third_party/wpt-streams/streams/transform-streams/reentrant-strategies.any.jstest/js/third_party/wpt-streams/streams/transform-streams/strategies.any.jstest/js/third_party/wpt-streams/streams/transform-streams/terminate.any.jstest/js/third_party/wpt-streams/streams/writable-streams/aborting.any.jstest/js/third_party/wpt-streams/streams/writable-streams/bad-strategies.any.jstest/js/third_party/wpt-streams/streams/writable-streams/bad-underlying-sinks.any.jstest/js/third_party/wpt-streams/streams/writable-streams/byte-length-queuing-strategy.any.jstest/js/third_party/wpt-streams/streams/writable-streams/close.any.jstest/js/third_party/wpt-streams/streams/writable-streams/constructor.any.jstest/js/third_party/wpt-streams/streams/writable-streams/count-queuing-strategy.any.jstest/js/third_party/wpt-streams/streams/writable-streams/crashtests/garbage-collection.any.jstest/js/third_party/wpt-streams/streams/writable-streams/error.any.jstest/js/third_party/wpt-streams/streams/writable-streams/floating-point-total-queue-size.any.jstest/js/third_party/wpt-streams/streams/writable-streams/garbage-collection.any.jstest/js/third_party/wpt-streams/streams/writable-streams/general.any.jstest/js/third_party/wpt-streams/streams/writable-streams/properties.any.jstest/js/third_party/wpt-streams/streams/writable-streams/reentrant-strategy.any.jstest/js/third_party/wpt-streams/streams/writable-streams/start.any.jstest/js/third_party/wpt-streams/streams/writable-streams/write.any.jstest/js/third_party/wpt-streams/wpt-streams.test.tstest/js/third_party/wpt-testharness-shim.ts
💤 Files with no reviewable changes (12)
- src/js/builtins/ReadableByteStreamInternals.ts
- src/js/builtins/ByteLengthQueuingStrategy.ts
- src/js/builtins/CountQueuingStrategy.ts
- src/js/builtins/ReadableStreamBYOBReader.ts
- src/js/builtins/ReadableStreamDefaultController.ts
- src/js/builtins/ReadableStreamBYOBRequest.ts
- src/js/builtins/ReadableStreamDefaultReader.ts
- src/codegen/generate-jssink.ts
- src/js/builtins/BunBuiltinNames.h
- src/js/builtins/ReadableStream.ts
- src/js/builtins/ReadableByteStreamController.ts
- src/js/builtins.d.ts
| ### [SEVERITY: CRITICAL] §5's `virtual` methods on a `JSCell` subclass are impossible in JSC — this is memory corruption, not a style problem | ||
|
|
||
| - **Claim under attack**: §5: | ||
| `class JSReadRequest : public JSC::JSInternalFieldObjectImpl<0> /* or JSNonFinalObject */ { public: virtual void chunkSteps(JSC::JSGlobalObject*, JSC::JSValue chunk) = 0; virtual void closeSteps(...) = 0; virtual void errorSteps(...) = 0; ... }` | ||
| and “Because they are C++-virtual, they need per-subclass `ClassInfo` and iso subspaces.” | ||
| - **Spec evidence**: n/a — this is a JSC ABI fact, not a spec fact. Verified against the vendored | ||
| engine: `/root/oven-webkit/Source/JavaScriptCore/runtime/JSDestructibleObject.h` has **no virtual | ||
| destructor and no virtual functions** (it stores `const ClassInfo* m_classInfo` precisely so the | ||
| sweeper can find the static `MethodTable::destroy` without a vtable). A grep of every header in | ||
| `JavaScriptCore/runtime/` shows **zero** `JSCell` subclasses with a `virtual` member — the only | ||
| polymorphic classes there (`VM.h`, `ConsoleClient.h`, `JSRunLoopTimer.h`, …) are non-GC C++ | ||
| objects. There is also no `static_assert(!is_polymorphic)` guard anywhere in `heap/`/`runtime/`, | ||
| so this compiles and fails at runtime. | ||
| - **Why it fails**: a `JSCell` must have the cell header (`m_structureID`, `m_type`, `m_cellState`, | ||
| the `JSCellLock` byte) at **offset 0 of the GC allocation**. Introducing the first `virtual` | ||
| function on a class whose primary base (`JSNonFinalObject`) is non-polymorphic makes the Itanium | ||
| ABI place the **vptr at offset 0** and the entire `JSCell` subobject at offset +8. The GC | ||
| allocates atoms at the block-aligned address, but every `JSValue`/`WriteBarrier`/`visitChildren` | ||
| then carries `addr+8` as “the cell”: `MarkedBlock::atomNumber(cell)` mis-rounds, `cellLock()` | ||
| (`reinterpret_cast<JSCellLock*>(this)`, `JSCell.h:152`) locks the wrong byte, marking and | ||
| isLive checks are off by one atom. Silent heap corruption on the very first `reader.read()`. | ||
| (Secondary: `JSInternalFieldObjectImpl<0>` instantiates a zero-length | ||
| `m_internalFields[0]` array — also not a thing to build 5 subclasses on.) | ||
| - **Proposed fix**: keep the “read request is a C++ object, not 3 promises” idea, drop C++ | ||
| `virtual`. Use the exact same device §4 already uses for algorithms: a | ||
| `enum class ReadRequestKind : uint8_t { Promise, PipeTo, Tee, AsyncIterator, ToText, ... }` | ||
| member on a **single, non-polymorphic** `JSReadRequest` cell, with | ||
| `void chunkSteps(...)` being a `switch (m_kind)` over free functions (or, if separate cell | ||
| classes are wanted for their `visitChildren`, dispatch through | ||
| `classInfo()->isSubClassOf(...)` / `jsDynamicCast` — never a C++ vtable). Same for | ||
| `JSReadIntoRequest`. State this in §5 with the same force §4 uses for “no closures”. | ||
|
|
||
| --- | ||
|
|
||
| ### [SEVERITY: CRITICAL] The Transform default source/sink algorithms don’t exist in §4’s `SourceKind`, and `SinkKind` is never enumerated — a `TransformStream` cannot be built from this document | ||
|
|
||
| - **Claim under attack**: §4: | ||
| `enum class SourceKind : uint8_t { JavaScript, Native, Direct, TeeBranch, FromIterable, CrossRealm, Nothing /*empty stream*/, /* TBD(bun-ext) */ };` | ||
| and “Same design for the writable controller (`SinkKind` + `m_underlyingSink` + method | ||
| WriteBarriers)” — `SinkKind`’s variants are never listed anywhere in the document. | ||
| - **Spec evidence**: digest 04, `InitializeTransformStream` steps 2–8: the writable side is | ||
| `CreateWritableStream(startAlgorithm, writeAlgorithm, closeAlgorithm, abortAlgorithm, …)` where | ||
| the three sink algorithms are `TransformStreamDefaultSink{Write,Close,Abort}Algorithm(stream, …)` | ||
| — native algorithms **closing over `stream`, the TransformStream**. The readable side is | ||
| `CreateReadableStream(startAlgorithm, pullAlgorithm, cancelAlgorithm, readableHighWaterMark, …)` | ||
| with `TransformStreamDefaultSource{Pull,Cancel}Algorithm(stream, …)`. Also step 1: | ||
| `startAlgorithm` for **both** sides is “an algorithm that returns `startPromise`” — an | ||
| externally-created, still-**pending** promise that the `TransformStream` constructor resolves | ||
| later (digest 04, constructor steps 9, 12–13). | ||
| - **Why it fails**: three independent unimplementabilities. | ||
| 1. There is no `SourceKind::Transform` (nor `SinkKind::Transform`, nor a `SinkKind` at all). | ||
| `runPullAlgorithm`’s `switch (m_sourceKind)` has no arm that can express | ||
| `TransformStreamDefaultSourcePullAlgorithm`. | ||
| 2. Even with the arm added, the algorithm needs a back-pointer to the `JSTransformStream` | ||
| (`stream.[[backpressure]]`, `stream.[[backpressureChangePromise]]`, `stream.[[controller]]`, | ||
| `stream.[[writable]]`). §4’s controller layout has exactly three WriteBarriers | ||
| (`m_underlyingSource`, `m_pullMethod`, `m_cancelMethod`) and nowhere to put a | ||
| `WriteBarrier<JSTransformStream>` on the readable’s controller or the writable’s controller. | ||
| `new TransformStream()` — the headline “61 objects → 7 cells” number in §0 — is exactly this | ||
| case. | ||
| 3. `[[startAlgorithm]]` for the transform’s two inner streams is **not** the trivial algorithm and | ||
| is **not** a user method: it must return a specific pre-existing `startPromise`. §4 only | ||
| defines two representations of start (“invoke the user `start` method once” or “null ⇒ | ||
| trivial”), and the internal `CreateReadableStream`/`CreateWritableStream` C++ signatures are | ||
| never specified, so a `.cpp` writer has no sanctioned way to pass a start *result value* | ||
| through set-up. (§4’s “`[[startAlgorithm]]` … never stored. Do not add a `m_startMethod`” is | ||
| still satisfiable — start never needs re-invoking anywhere in the digests, I checked every | ||
| `SetUp*` — but only if the internal creation API takes `JSValue startResult`.) | ||
| - **Proposed fix**: (a) add `Transform` to `SourceKind` and enumerate `SinkKind` explicitly: | ||
| `{ JavaScript, Transform, CrossRealm, Nothing /*, TBD(bun-ext) */ }`. (b) State that each | ||
| non-`JavaScript` kind gets a **kind-payload WriteBarrier slot** on the controller (a single | ||
| `WriteBarrier<JSC::JSCell> m_sourceState` is enough: `JSTransformStream*` for `Transform`, | ||
| `JSStreamTeeState*` for `TeeBranch`, an iterator-record cell for `FromIterable`, the port | ||
| wrapper for `CrossRealm`) and that it is visited. (c) Declare the internal creation signature in | ||
| `WebStreamsInternals.h` as | ||
| `CreateReadableStream(global, SourceKind, JSValue sourceState, JSValue startResult, double hwm, JSObject* sizeAlg)` | ||
| (mirrored for writable) so all six internal callers (default tee ×2, byte tee ×2, | ||
| from-iterable, cross-realm, transform ×2) are expressible. | ||
|
|
||
| --- | ||
|
|
||
| ### [SEVERITY: CRITICAL] “No closures, ever” is unsatisfiable: the spec needs ~20 *promise reactions* carrying GC-visited native context, and the document never says how | ||
|
|
||
| - **Claim under attack**: §4 heading “Algorithms: no closures, ever” + “**We store none of them**” | ||
| + §7.6 “No `JSC::Strong`, no `protect()` … anywhere in this subsystem”. | ||
| - **Spec evidence**: §4 only eliminates the *stored* `[[xxxAlgorithm]]` slots. It says nothing | ||
| about the spec’s other, more numerous closure family: **“Upon fulfillment of P …”** where `P` | ||
| is a promise (often a *user-returned* one) and the reaction body captures internal state. A | ||
| non-exhaustive list from the digests: | ||
| `ReadableStreamDefaultControllerCallPullIfNeeded` steps 7–8 (reaction captures `controller`; `pullPromise` is the *user’s* promise); | ||
| `SetUpReadableStreamDefaultController` steps 11–12; the byte equivalents; | ||
| `WritableStreamDefaultControllerProcessWrite` steps 4–5 (`sinkWritePromise`) and `ProcessClose`; | ||
| `WritableStreamFinishErroring` steps 12–13 (`[[AbortSteps]]` result); | ||
| `ReadableStreamCancel` step 8 (“reacting to sourceCancelPromise”); | ||
| `ReadableStreamDefaultTee` step 19 (“Upon rejection of `reader.[[closedPromise]]`” capturing | ||
| branch1/branch2/cancelPromise); `ReadableByteStreamTee`’s `forwardReaderError` (captures | ||
| `thisReader` **per registration**); `ReadableStreamFromIterable` pull step 4 (reaction on | ||
| `nextPromise` capturing `stream`); `TransformStreamDefaultSinkWriteAlgorithm` step 3.3 | ||
| (reaction on `backpressureChangePromise` capturing `stream` **and** `chunk`); | ||
| `TransformStreamDefaultSink{Close,Abort}` / `SourceCancel` steps 7 (reactions capturing | ||
| `controller` + `readable`/`writable`); `TransformStreamDefaultControllerPerformTransform` | ||
| step 2; the whole of `ReadableStreamPipeTo`; `SetUpCrossRealmTransformWritable` write step 2. | ||
| - **Why it fails**: every one of these must become a native fulfillment/rejection handler | ||
| **plus a GC-visited edge to the captured cell(s)**. The document forbids the two easy answers | ||
| (a stored bound `JSFunction` = a closure; a `JSC::Strong` in a native lambda = banned) and names | ||
| no third. The in-tree pattern the writers *will* reach for — | ||
| `JSC::JSNativeStdFunction::create` with a C++ lambda capturing `controller` (used in | ||
| `ModuleLoader.cpp`, `napi.cpp`) — is a **GC hole**: `JSNativeStdFunction`’s lambda captures are | ||
| not visited, so a raw `JSFoo*` capture is a use-after-free and a `Strong` capture is banned. | ||
| With 60 files written in parallel against frozen headers, each author invents their own | ||
| mechanism; several will be wrong; this is the single largest defect surface in the plan. | ||
| This also silently falsifies §0’s object-count table: `new ReadableStream({pull})` needs at | ||
| least the start-fulfillment reaction and (per pull) a pull-promise reaction, so “2 cells” is | ||
| not the steady-state allocation count unless the reaction is closure-free. | ||
| - **Proposed fix**: mandate ONE mechanism in a new §4.1 and put it in `WebStreamsInternals.h`. | ||
| The engine already has exactly the right primitives (verified in | ||
| `/root/oven-webkit/Source/JavaScriptCore/runtime/JSPromise.h:139–152`): | ||
| `JSPromise::performPromiseThenWithContext(VM&, JSGlobalObject*, onFulfilled, onRejected, JSValue, JSValue context)` | ||
| and, better, `performPromiseThenWithInternalMicrotask(VM&, JSGlobalObject*, InternalMicrotask, JSValue promise, JSValue context)` | ||
| — the reaction’s `context` is a JSValue stored on the (GC-visited) reaction, and Bun’s fork | ||
| already extends the `InternalMicrotask` enum (`BunPerformMicrotaskJob`, | ||
| `BunInvokeJobWithArguments`). So: one **non-capturing** native `JSFunction` per reaction kind, | ||
| cached lazily on the global (or one new `InternalMicrotask` value per kind), with the owning | ||
| cell (`controller` / `pipeOp` / `teeState`) passed as `context`; when two values are needed | ||
| (transform sink write: `{stream, chunk}`; byte-tee `forwardReaderError`: | ||
| `{teeState, thisReader}`) the context is a 2-field internal cell. Zero closures, zero | ||
| Strong, GC-correct, and it makes §0’s numbers true. Freeze this helper’s signature in Phase A. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Mark these findings as v1-only.
ARCHITECTURE.md:252-419 already pins down the Transform/SinkKind plumbing and the shared promise-reaction mechanism, so these opening criticals no longer describe the frozen design. Please mark them as historical v1 notes or rewrite them against the current text.
🧰 Tools
🪛 LanguageTool
[style] ~35-~35: ‘exact same’ might be wordy. Consider a shorter alternative.
Context: ...es” idea, drop C++ virtual. Use the exact same device §4 already uses for algorithms: ...
(EN_WORDINESS_PREMIUM_EXACT_SAME)
🪛 markdownlint-cli2 (0.22.1)
[warning] 11-11: Heading levels should only increment by one level at a time
Expected: h2; Actual: h3
(MD001, heading-increment)
🤖 Prompt for 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.
In `@specs/ARCH-REVIEW.md` around lines 11 - 137, Mark the three critical findings
in this section as v1-only since ARCHITECTURE.md already defines the
Transform/SinkKind plumbing and shared promise-reaction approach elsewhere.
Update the headings and prose around the JSCell virtual-method issue, the
SourceKind/SinkKind gap, and the no-closures mechanism so they are clearly
labeled historical notes or rewritten to match the current design. Use the
existing section references and symbols like SourceKind, SinkKind,
JSReadRequest, and WebStreamsInternals.h to keep the edits aligned with the
frozen text.
| | `BunStreamConsumers.cpp` | BUN-LAYER-DESIGN §3: the `readableStreamTo*` set, `tryUseReadableStreamBufferedFastPath`, the `*Direct` consumers, `withoutUTF8BOM`, `ReadableStream.prototype.{text,json,bytes,blob}`. (Added by PHASE-A-NOTES ruling §4.5.) | | ||
| | `WebStreamsMisc.cpp` | `TransferArrayBuffer`, `CanTransferArrayBuffer`, `CloneAsUint8Array`, `StructuredClone`, `CanCopyDataBlockBytes`, `IsNonNegativeNumber`, `ExtractHighWaterMark`, `ExtractSizeAlgorithm`, the sanctioned catch helper (§7.1a), promise helpers | | ||
| | *(Bun layer — its own designed & reviewed module set)* | The `Native` source kind, the `type:"direct"` stream mode + `JSDirectStreamController`, the JSSink glue (`assignToStream`/`readDirectStream`/`readStreamIntoSink`/ResumableSink), the `readableStreamTo*` fast paths, and `WebStreamsExports.cpp` (the entire `extern "C"` + Rust FFI surface). File list, class list, and every signature: **`specs/BUN-LAYER-DESIGN.md`** — designed and adversarially reviewed exactly like the spec core, BEFORE the headers freeze. | |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Name the generic text accumulator path too.
As noted in specs/BUN-LAYER-REVIEW-FIDELITY.md:67-99, Bun.readableStreamToText still has a separate readableStreamIntoText / withoutUTF8BOM path. If this section only names the public readableStreamTo* wrappers, Phase B still has no owner for that helper.
🤖 Prompt for 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.
In `@specs/ARCHITECTURE.md` around lines 118 - 120, The architecture entry for Bun
stream consumers is missing ownership of the generic text accumulator helper, so
update the `BunStreamConsumers.cpp` row to also name `readableStreamIntoText`
and `withoutUTF8BOM` alongside `Bun.readableStreamToText` / the other
`readableStreamTo*` paths. Keep the surrounding
`ReadableStream.prototype.{text,json,bytes,blob}` references intact, and ensure
the `BunStreamConsumers.cpp` coverage explicitly includes the internal text
accumulation path so Phase B has a clear owner.
| process.on("unhandledRejection", (e) => log("UNHANDLED_REJECTION ::", String(e))); | ||
| process.on("uncaughtException", (e) => log("UNCAUGHT ::", String(e && e.stack || e))); | ||
| const withTimeout = (name, p, ms = 3000) => Promise.race([p, new Promise((_, rj) => setTimeout(() => rj(new Error("STEP_TIMEOUT " + name)), ms))]); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Make probe failures fail the process.
unhandledRejection/uncaughtException are only logged, and Line 24 still exits 0 even after VERIFY_FAIL. That makes this verification probe false-green in automation or bisects. Count process-level failures in failures and set process.exitCode from the final result instead of relying on a fixed 50 ms tail sleep.
Also applies to: 17-24
🧰 Tools
🪛 ast-grep (0.44.0)
[warning] 3-3: Avoid using the initial state variable in setState
Context: setTimeout(() => rj(new Error("STEP_TIMEOUT " + name)), ms)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.
(setstate-same-var)
🤖 Prompt for 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.
In `@specs/probes/adversarial-smoke.js` around lines 2 - 4, Make the probe
actually fail the process instead of only logging errors: in
adversarial-smoke.js, update the process.on("unhandledRejection") and
process.on("uncaughtException") handlers to increment failures (and set a
non-zero process.exitCode), and ensure the final result in the main probe flow
uses that accumulated failure state rather than a fixed 50 ms tail sleep. Use
the existing withTimeout helper and the VERIFY_FAIL/exit path near the end of
the file so process-level exceptions are counted and the script exits non-zero
when any probe step fails.
| const t = (name, fn) => Promise.race([fn().then(r => " " + name + " -> " + r), new Promise(r => setTimeout(() => r(" " + name + " -> HANG"), 1500))]).then(console.log); | ||
| await t("RS pull SYNC-THROW ", async () => { const e = Error("E1"); const s = new ReadableStream({ pull() { throw e; } }); return s.getReader().read().then(() => "resolved!?", (x) => x === e ? "rejected-correctly" : "rejected-wrong:" + x); }); | ||
| await t("RS pull REJECTS ", async () => { const e = Error("E2"); const s = new ReadableStream({ pull() { return Promise.reject(e); } }); return s.getReader().read().then(() => "resolved!?", (x) => x === e ? "rejected-correctly" : "rejected-wrong:" + x); }); | ||
| await t("RS start SYNC-THROW", async () => { const e = Error("E3"); let s; try { s = new ReadableStream({ start() { throw e; } }); } catch (x) { return x === e ? "ctor-threw-correctly" : "wrong:" + x; } return "no-throw!?"; }); | ||
| await t("RS cancel SYNC-THROW", async () => { const e = Error("E4"); const s = new ReadableStream({ cancel() { throw e; } }); return s.cancel().then(() => "resolved!?", (x) => x === e ? "rejected-correctly" : "wrong:" + x); }); | ||
| await t("WS write SYNC-THROW", async () => { const e = Error("E5"); const w = new WritableStream({ write() { throw e; } }).getWriter(); return w.write("x").then(() => "resolved!?", (x) => x === e ? "rejected-correctly" : "wrong:" + x); }); | ||
| await t("WS write REJECTS ", async () => { const e = Error("E6"); const w = new WritableStream({ write() { return Promise.reject(e); } }).getWriter(); return w.write("x").then(() => "resolved!?", (x) => x === e ? "rejected-correctly" : "wrong:" + x); }); | ||
| await t("WS abort SYNC-THROW", async () => { const e = Error("E7"); const w = new WritableStream({ abort() { throw e; } }).getWriter(); return w.abort("r").then(() => "resolved!?", (x) => x === e ? "rejected-correctly" : "wrong:" + x); }); | ||
| await t("TS transform SYNC-THROW", async () => { const e = Error("E8"); const ts = new TransformStream({ transform() { throw e; } }); const w = ts.writable.getWriter(); w.write("x").catch(() => {}); return Bun.readableStreamToText(ts.readable).then(() => "resolved!?", (x) => x === e ? "rejected-correctly" : "wrong:" + x); }); | ||
| await t("BYTE pull SYNC-THROW", async () => { const e = Error("E9"); const s = new ReadableStream({ type: "bytes", pull() { throw e; } }); return s.getReader().read().then(() => "resolved!?", (x) => x === e ? "rejected-correctly" : "wrong:" + x); }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Turn mismatches and hangs into a failing exit status.
Right now every case only prints a line, so "resolved!?", "wrong:...", and "HANG" still leave the script green. If this probe is used for verification, it needs to accumulate non-success results and set process.exitCode = 1 on any mismatch.
🤖 Prompt for 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.
In `@specs/probes/sync-throw-matrix.js` around lines 1 - 10, The probe script only
logs results, so mismatches like “resolved!?”, “wrong:...”, and “HANG” do not
fail the run. Update the `t` helper and the top-level `await t(...)` cases in
`sync-throw-matrix.js` to track any non-success outcome and set
`process.exitCode = 1` when a case is not “rejected-correctly” or
“ctor-threw-correctly”, including timeout/HANG results.
| ### [MAJOR] ReadableStreamFromIterable step 2 — GetIterator(asyncIterable, async) rejects primitive iterables (strings) | ||
|
|
||
| Digest (02-readable-abstract-ops.md:113-115): | ||
|
|
||
| > ### ReadableStreamFromIterable(asyncIterable) → ReadableStream | ||
| > 2. Let iteratorRecord be ? GetIterator(asyncIterable, async). | ||
|
|
||
| ES `GetIterator(obj, ASYNC)` resolves `@@asyncIterator` / `@@iterator` via | ||
| `GetMethod(V, P)` → `GetV(V, P)`, which `ToObject`s primitives for the *lookup* but calls the | ||
| method with the original primitive as `this`. A primitive string is therefore a valid (sync) | ||
| iterable and `ReadableStream.from("ab")` must return a stream of `"a"`, `"b"`. | ||
|
|
||
| .cpp (ReadableStreamOperations.cpp:661-675): | ||
|
|
||
| ```cpp | ||
| JSReadableStream* readableStreamFromIterable(JSGlobalObject* globalObject, JSValue asyncIterable) | ||
| { | ||
| ... | ||
| IterationRecord iteratorRecord = getAsyncIteratorExported(*globalObject, asyncIterable); | ||
| RETURN_IF_EXCEPTION(scope, nullptr); | ||
| ``` | ||
|
|
||
| `getAsyncIteratorExported` → JSC `getAsyncIteratorImpl` | ||
| (oven-webkit IteratorOperations.cpp:308-317) begins with: | ||
|
|
||
| ```cpp | ||
| auto* iterableObject = iterable.getObject(); | ||
| if (!iterableObject) [[unlikely]] { | ||
| throwTypeError(&globalObject, throwScope, "iterable should be an object"_s); | ||
| return { }; | ||
| } | ||
| ``` | ||
|
|
||
| i.e. the JSC helper imposes an **is-Object** requirement that `GetIterator` does not have. | ||
|
|
||
| **Observable divergence:** `ReadableStream.from("ab")` throws | ||
| `TypeError: iterable should be an object` instead of producing a two-chunk stream. This is | ||
| directly covered by WPT `streams/readable-streams/from.any.js` (the repo's vendored copy, | ||
| `test/js/third_party/wpt-streams/streams/readable-streams/from.any.js:21-24`): | ||
|
|
||
| ```js | ||
| ['a string', () => { | ||
| // This iterates over the code points of the string. | ||
| return 'ab'; | ||
| }], | ||
| ``` | ||
|
|
||
| No caller pre-normalizes: `jsReadableStreamStaticFunction_from` (JSReadableStream.cpp:704-711) | ||
| passes `callFrame->argument(0)` straight through. All other non-object inputs (`null`, | ||
| `undefined`, numbers, `{}` with no `@@iterator`) still end in a `TypeError` on both paths, so | ||
| strings (and monkey-patched primitive prototypes) are the whole affected class. | ||
|
|
||
| **Minimal fix:** don't route through `getAsyncIteratorExported`'s object gate. Either (a) add a | ||
| local `GetIterator(async)` in this file that does the ES lookup with `JSValue::get(globalObject, | ||
| vm.propertyNames->asyncIteratorSymbol)` (GetV works on primitives) and, on the sync-fallback | ||
| path, `JSAsyncFromSyncIterator::create(...)` exactly as the JSC impl does — calling the iterator | ||
| method with the *original* `asyncIterable` as `this`; or (b) patch the vendored | ||
| `getAsyncIteratorImpl` to only reject `undefined`/`null` (matching `GetV`) rather than all | ||
| non-objects. Add the `from('ab')` WPT case to the streams test surface. |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Fix ReadableStream.from for primitive iterables.
getAsyncIteratorExported rejects primitive strings, so ReadableStream.from("ab") fails instead of streaming code points. Switch this path to real GetIterator(async) semantics (or loosen the helper to only reject null/undefined) and add the WPT string case.
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 18-18: Heading levels should only increment by one level at a time
Expected: h2; Actual: h3
(MD001, heading-increment)
[warning] 22-22: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below
(MD022, blanks-around-headings)
[warning] 23-23: Ordered list item prefix
Expected: 1; Actual: 2; Style: 1/1/1
(MD029, ol-prefix)
🤖 Prompt for 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.
In `@specs/review-cpp/ReadableStreamOperations-A.md` around lines 18 - 76,
`ReadableStream.from` is using an iterator helper that incorrectly rejects
primitive strings, so the `readableStreamFromIterable` path does not match
`GetIterator(async)` semantics. Update `readableStreamFromIterable` to use a
lookup that works on primitives (or relax
`getAsyncIteratorExported`/`getAsyncIteratorImpl` to only reject nullish
values), and ensure the fallback sync-iterator path still calls the iterator
with the original value as `this`. Also add the string iterable case to the
`ReadableStream.from` WPT coverage so `"ab"` produces code points instead of
throwing.
| ### [MINOR] `_TS_OPERATIONS` handlers — registration context contradicts the frozen header's documented contract | ||
|
|
||
| The frozen header, `JSStreamsRuntime.h:115-117`: | ||
|
|
||
| > ``` | ||
| > // owner: TransformStreamOperations.cpp. context = the JSTransformStream, EXCEPT | ||
| > // onTSSinkWriteBackpressureChangeFulfilled, whose context is an | ||
| > // InternalFieldTuple{transformStream, chunk}. | ||
| > ``` | ||
|
|
||
| i.e. per the header, only ONE of the seven handlers takes a tuple; the other six take the | ||
| bare `JSTransformStream`. | ||
|
|
||
| The .cpp registers FOUR of them with an `InternalFieldTuple{stream, reason}` instead: | ||
|
|
||
| ```cpp | ||
| // transformStreamDefaultSinkAbortAlgorithm, line 243-245 | ||
| auto* context = InternalFieldTuple::create(vm, globalObject->internalFieldTupleStructure(), stream, reason); | ||
| cancelPromise->performPromiseThenWithContext(vm, globalObject, runtime->onTSSinkAbortCancelFulfilled(), runtime->onTSSinkAbortCancelRejected(), jsUndefined(), context); | ||
| ``` | ||
| ```cpp | ||
| // transformStreamDefaultSourceCancelAlgorithm, line 282-284 | ||
| auto* context = InternalFieldTuple::create(vm, globalObject->internalFieldTupleStructure(), stream, reason); | ||
| cancelPromise->performPromiseThenWithContext(vm, globalObject, runtime->onTSSourceCancelFulfilled(), runtime->onTSSourceCancelRejected(), jsUndefined(), context); | ||
| ``` | ||
|
|
||
| and the four handler bodies (`onTSSinkAbortCancelFulfilled/Rejected` at lines 329-331, | ||
| 350; `onTSSourceCancelFulfilled/Rejected` at 395-397, 417) correspondingly do | ||
| `uncheckedDowncast<InternalFieldTuple>(callFrame->argument(1))->getInternalField(0/1)`. | ||
|
|
||
| **Divergence.** Handler ↔ registration DO agree on every field index (I checked all | ||
| four: field 0 = stream, field 1 = reason; the rejected handlers take `r` from | ||
| `argument(0)` and only `stream` from field 0 — all correct per digest steps | ||
| 7.1.2.1/7.2.1 of SinkAbort and 7.1.2.1/7.2.1 of SourceCancel). So there is no | ||
| runtime bug TODAY. But the file violates the frozen header's stated contract for | ||
| `onTSSinkAbortCancelFulfilled`, `onTSSinkAbortCancelRejected`, | ||
| `onTSSourceCancelFulfilled`, `onTSSourceCancelRejected`. Anyone adding a second | ||
| registration site from the header comment (passing the bare `JSTransformStream`) | ||
| would hit `uncheckedDowncast<InternalFieldTuple>` type confusion on a | ||
| `JSTransformStream` cell. Only `onTSSinkCloseFlush{Fulfilled,Rejected}` actually | ||
| match the header's "context = the JSTransformStream". | ||
|
|
||
| Note the .cpp is arguably RIGHT and the header WRONG: the digest requires `reason` | ||
| inside the reaction (SinkAbort 7.1.2.1 "Perform ! | ||
| ReadableStreamDefaultControllerError(readable.[[controller]], **reason**)"; | ||
| SourceCancel 7.1.2.1 likewise), and a bare-stream context has nowhere to carry it. | ||
|
|
||
| **Minimal fix.** Update `JSStreamsRuntime.h:115-117` to: | ||
| "context = the JSTransformStream for onTSSinkCloseFlush{Fulfilled,Rejected}; | ||
| an InternalFieldTuple{transformStream, chunk} for | ||
| onTSSinkWriteBackpressureChangeFulfilled; an InternalFieldTuple{transformStream, | ||
| reason} for onTSSinkAbortCancel* and onTSSourceCancel*." (If the header is truly | ||
| frozen and unamendable, the .cpp instead needs a different reason channel — but | ||
| there is none that is spec-faithful, so the comment is the bug.) |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Align the reaction-handler contract with the actual context types.
JSStreamsRuntime.h still documents most handlers as taking a bare JSTransformStream, but these registrations pass InternalFieldTuple{transformStream, reason} for the abort/source-cancel paths. That mismatch can mislead future call sites into an unchecked-downcast bug. Update the header contract to match the tuple-based handlers.
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 21-21: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
[warning] 36-36: Fenced code blocks should be surrounded by blank lines
(MD031, blanks-around-fences)
[warning] 37-37: Fenced code blocks should be surrounded by blank lines
(MD031, blanks-around-fences)
[warning] 68-68: Spaces inside emphasis markers
(MD037, no-space-in-emphasis)
🤖 Prompt for 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.
In `@specs/review-cpp/TransformStreamOperations-A.md` around lines 17 - 70, The
documented context contract for the reaction handlers is inconsistent with the
actual registrations in TransformStreamOperations, especially the
onTSSinkAbortCancel* and onTSSourceCancel* paths. Update the contract comment
near JSStreamsRuntime.h so it explicitly distinguishes the bare
JSTransformStream cases from the InternalFieldTuple{transformStream, reason}
cases used by the cancel/abort handlers, and keep the tuple field meanings
aligned with the corresponding handler names and registration sites.
| ### [MAJOR] Three `resolvePromise` (`userJS: yes`) calls with no exception observation, inside fire-and-forget reaction handlers | ||
|
|
||
| **Lines:** 341 (`onTSSinkAbortCancelFulfilled`), 373 (`onTSSinkCloseFlushFulfilled`), | ||
| 408 (`onTSSourceCancelFulfilled`). | ||
|
|
||
| **Rule:** §7.1 ("after EVERY call that can … run user JS: `RETURN_IF_EXCEPTION`"; the §7 | ||
| preamble requires `BUN_JSC_validateExceptionChecks=1` clean) + §4.1 fact 5 (all three | ||
| handlers are registered with `resultPromiseOrJSUndefined == jsUndefined()` — lines 245, 264, | ||
| 284 — so a pending exception on return is an **uncaught error at the microtask level**). | ||
|
|
||
| **Failure:** `resolvePromise` is annotated `userJS: yes` (`WebStreamsInternals.h:147`). | ||
| Each of the three sites calls it and then does `return JSValue::encode(jsUndefined());` | ||
| without `RETURN_IF_EXCEPTION`, `scope.release()`, or `scope.assertNoException()`. Because | ||
| `resolvePromise` will itself declare a throw scope, this leaves `vm.m_needExceptionCheck` | ||
| set at `~ThrowScope` → `BUN_JSC_validateExceptionChecks=1` trips, violating the §7 | ||
| non-negotiable. The file *itself* already proves what the correct form is: the identical | ||
| `resolvePromise(previous, jsUndefined())` at **lines 168–171** carries | ||
| `// Resolving with undefined performs no thenable lookup and cannot throw.` + | ||
| `scope.assertNoException();`, and the fourth site (line 120) uses `RETURN_IF_EXCEPTION`. | ||
| So 2 of 5 `resolvePromise` sites in the file are disciplined and 3 are not — this is an | ||
| inconsistency inside one file, not a defensible convention. | ||
|
|
||
| (These are the fulfilled arms; if a real exception ever *did* escape, `[[finishPromise]]` | ||
| would additionally never settle — a hung `writer.abort()` / `readable.cancel()` / | ||
| `writer.close()` caller. Today it is a validator failure, not a runtime bug, because the | ||
| resolution value is `jsUndefined()`.) | ||
|
|
||
| **Minimal fix:** at 341, 373, 408 add the exact line-170/171 pair | ||
| (`// Resolving with undefined performs no thenable lookup and cannot throw.` + | ||
| `scope.assertNoException();`) before the `return`. (Equivalently `RETURN_IF_EXCEPTION(scope, {})`.) | ||
|
|
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Observe the resolvePromise result before returning.
These three fulfilled handlers settle [[finishPromise]] and immediately return; if resolvePromise(...) throws, the file will violate exception-check discipline and can leave the caller hanging. Reuse the existing scope.assertNoException() / RETURN_IF_EXCEPTION pattern already shown later in the file.
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 17-17: Heading levels should only increment by one level at a time
Expected: h2; Actual: h3
(MD001, heading-increment)
🤖 Prompt for 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.
In `@specs/review-cpp/TransformStreamOperations-B.md` around lines 17 - 47, The
three fulfilled handlers in TransformStreamOperations-B are calling
resolvePromise and returning immediately without observing exceptions, which
breaks exception-check discipline. Update the resolvePromise sites in
onTSSinkAbortCancelFulfilled, onTSSinkCloseFlushFulfilled, and
onTSSourceCancelFulfilled to match the existing safe patterns already used in
this file, using scope.assertNoException() or RETURN_IF_EXCEPTION before
returning. Keep the fix localized to those handlers so the [[finishPromise]]
settlement path is exception-safe and consistent with the other resolvePromise
call sites.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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 `@bench/snippets/webstreams-memory.mjs`:
- Line 4: The GC fallback in the webstreams memory benchmark is silently
becoming a no-op when neither Bun.gc nor an exposed globalThis.gc exists, which
makes the RSS measurements unreliable. Update the setup near the gc constant to
explicitly detect the absence of a usable GC function and surface a clear
warning or fail fast before running the benchmark, so the measurement only
proceeds when forced garbage collection is actually available.
In `@src/js/builtins/ProcessObjectInternals.ts`:
- Around line 149-155: Use a per-read release sentinel in ProcessObjectInternals
so internalRead() does not rely on the mutable reader field to detect a
disown/release. Capture the specific reader (or a release token) used by the
current read and have the rejection path around internalRead()/disown() only
treat that exact release as expected, instead of checking just !reader. Add a
regression test covering quick disown→own to verify reader A’s rejected
continuation does not destroy the stream when reader B has already been
acquired.
🪄 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: 1f9e0851-2583-47e4-bb4d-12ac5d0b7cac
📒 Files selected for processing (18)
.gitignorebench/snippets/webstreams-memory.mjsbench/snippets/webstreams-throughput.mjsbench/snippets/webstreams.mjssrc/js/builtins/ProcessObjectInternals.tssrc/jsc/bindings/webcore/streams/JSReadableStreamAsyncIterator.cppsrc/jsc/bindings/webcore/streams/JSReadableStreamBYOBReader.cppsrc/jsc/bindings/webcore/streams/JSReadableStreamDefaultReader.cppsrc/jsc/bindings/webcore/streams/JSStreamPipeToOperation.cppsrc/jsc/bindings/webcore/streams/JSStreamPipeToOperation.hsrc/jsc/bindings/webcore/streams/JSStreamsRuntime.hsrc/jsc/bindings/webcore/streams/JSWritableStreamDefaultWriter.cppsrc/jsc/bindings/webcore/streams/ReadableStreamOperations.cppsrc/jsc/bindings/webcore/streams/WebStreamsInternals.hsrc/jsc/bindings/webcore/streams/WebStreamsMisc.cpptest/js/third_party/wpt-streams/RESULTS.mdtest/js/third_party/wpt-streams/expectations.jsontest/js/web/streams/streams.test.js
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp (3)
421-424: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winDo not strip BOM from later mixed-path string chunks.
In the mixed buffer/string path,
bytesmay already contain earlier chunks, so strippingrope[0]removes a legitimate U+FEFF that appears mid-stream. This contradicts the stated mixed-path behavior and loses user data.Proposed fix
if (accumulator.rope.length()) { WTF::String rope = accumulator.rope.toString(); - if (rope[0] == 0xFEFF) - rope = rope.substring(1); WTF::CString utf8 = rope.utf8();🤖 Prompt for 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. In `@src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp` around lines 421 - 424, The mixed buffer/string path in BunStreamConsumers::arrayBufferAsString is stripping a leading BOM from rope even when bytes already contains earlier chunks, which can remove a legitimate U+FEFF from the middle of the stream. Update the BOM handling in the accumulator.rope branch so it only removes a BOM when this is the first and only chunk being materialized, and preserve rope contents unchanged for later mixed-path chunks.
308-310: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winReturn an
ArrayBufferfor single string chunks.
convertChunksToArrayBuffer()currently returnsencodeStringToUint8Array()for a one-string stream, soBun.readableStreamToArrayBuffer()can resolve to aUint8Arrayonly on this fast path. Let it fall through toconcatenateChunks(..., false)or add an ArrayBuffer-specific string encoder.Proposed fix
- if (chunk.isString()) - RELEASE_AND_RETURN(scope, encodeStringToUint8Array(globalObject, chunk));🤖 Prompt for 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. In `@src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp` around lines 308 - 310, The fast path in convertChunksToArrayBuffer currently returns encodeStringToUint8Array() for a single string chunk, which makes Bun.readableStreamToArrayBuffer() resolve to a Uint8Array instead of an ArrayBuffer. Update this path so it produces an ArrayBuffer consistently, either by letting the single-string case fall through to concatenateChunks(..., false) or by adding an ArrayBuffer-specific string encoding helper, and keep the change localized to convertChunksToArrayBuffer/BunStreamConsumers logic.
721-748: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftFinish fallible setup before locking and disturbing the stream.
After Line 721 clears
m_directUnderlyingSourceand marks the stream locked/disturbed, the subsequentunderlyingSource->get(...)calls and bound-method setup can still throw. That leaves the direct stream unusable instead of returning a clean rejection. Move the property reads and method setup before mutating stream state, or restore state on every abrupt completion. As per coding guidelines, anything that can run user JS can synchronously free or mutate state, so perform observable operations before state transitions.Sketch
- stream->m_directUnderlyingSource.clear(); - stream->m_bunMode = BunStreamMode::Default; - stream->m_lockedWithoutReader = true; - stream->m_disturbed = true; - JSObject* startOptions = constructEmptyObject(globalObject); bool hasNumericHighWaterMark = stream->m_bunHighWaterMarkIsNumber || !std::isnan(stream->m_bunHighWaterMark); startOptions->putDirect(vm, Identifier::fromString(vm, "highWaterMark"_s), hasNumericHighWaterMark ? jsNumber(stream->m_bunHighWaterMark) : jsUndefined()); @@ auto* closeContext = InternalFieldTuple::create(vm, domGlobalObject->internalFieldTupleStructure(), sink, closeFunction); installOneShotMethods(globalObject, sink, closeContext); RETURN_IF_EXCEPTION(scope, {}); + + stream->m_directUnderlyingSource.clear(); + stream->m_bunMode = BunStreamMode::Default; + stream->m_lockedWithoutReader = true; + stream->m_disturbed = true;🤖 Prompt for 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. In `@src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp` around lines 721 - 748, The setup in JSOneShotDirectSink creation is mutating stream state before all fallible JS-facing work is finished. Move the underlyingSource property reads and installOneShotMethods-related setup ahead of clearing m_directUnderlyingSource and setting m_bunMode/m_lockedWithoutReader/m_disturbed, or make sure any exception path restores the previous state in BunStreamConsumers handling. Use the JSOneShotDirectSink and underlyingSource flow as the anchor when updating the order so synchronous throws do not leave the stream permanently unusable.Source: Coding guidelines
🤖 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.
Outside diff comments:
In `@src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp`:
- Around line 421-424: The mixed buffer/string path in
BunStreamConsumers::arrayBufferAsString is stripping a leading BOM from rope
even when bytes already contains earlier chunks, which can remove a legitimate
U+FEFF from the middle of the stream. Update the BOM handling in the
accumulator.rope branch so it only removes a BOM when this is the first and only
chunk being materialized, and preserve rope contents unchanged for later
mixed-path chunks.
- Around line 308-310: The fast path in convertChunksToArrayBuffer currently
returns encodeStringToUint8Array() for a single string chunk, which makes
Bun.readableStreamToArrayBuffer() resolve to a Uint8Array instead of an
ArrayBuffer. Update this path so it produces an ArrayBuffer consistently, either
by letting the single-string case fall through to concatenateChunks(..., false)
or by adding an ArrayBuffer-specific string encoding helper, and keep the change
localized to convertChunksToArrayBuffer/BunStreamConsumers logic.
- Around line 721-748: The setup in JSOneShotDirectSink creation is mutating
stream state before all fallible JS-facing work is finished. Move the
underlyingSource property reads and installOneShotMethods-related setup ahead of
clearing m_directUnderlyingSource and setting
m_bunMode/m_lockedWithoutReader/m_disturbed, or make sure any exception path
restores the previous state in BunStreamConsumers handling. Use the
JSOneShotDirectSink and underlyingSource flow as the anchor when updating the
order so synchronous throws do not leave the stream permanently unusable.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 3fa8a498-0164-40dc-97cf-03a2f65573cf
📒 Files selected for processing (5)
bench/snippets/webstreams-consumers.mjssrc/jsc/bindings/BunObject.cppsrc/jsc/bindings/BunObject.hsrc/jsc/bindings/webcore/streams/BunStreamConsumers.cpptest/js/web/streams/streams.test.js
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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 `@bench/snippets/webstreams-tee.mjs`:
- Line 7: The benchmark’s GC hook is falling back to a no-op when neither Bun.gc
nor globalThis.gc is available, which silently disables the memory cleanup
checkpoints. Update the webstreams-tee benchmark setup around the gc constant to
explicitly detect the missing GC capability and either fail fast or skip the
memory/RSS portion with a clear message, instead of using an empty fallback
function. Keep the change localized to the gc initialization and any checkpoint
logic that relies on it.
In `@src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp`:
- Around line 982-1002: The fast path in BunStreamConsumers should preserve
promise-based async semantics for all non-text consumers: in the branch handling
convertChunks() and arrayPromise->result(), ensure failures are returned as
rejected promises instead of being synchronously rethrown, and use
promiseResolvedWith() for the fulfilled object path rather than directly
creating a JSPromise and calling fulfill(). Update the logic around
convertChunks, throwException, and the final fulfillment in the relevant
consumer helper so readableStreamToArrayBuffer()/readableStreamToBytes() always
settle through the normal Promise contract.
🪄 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: 409f351e-8010-4c4d-8d7b-ed71d414580a
📒 Files selected for processing (18)
bench/snippets/webstreams-memory.mjsbench/snippets/webstreams-tee.mjssrc/jsc/bindings/webcore/JSDOMConvertNumbers.cppsrc/jsc/bindings/webcore/streams/BunStreamConsumers.cppsrc/jsc/bindings/webcore/streams/JSReadableByteStreamController.cppsrc/jsc/bindings/webcore/streams/JSReadableStream.cppsrc/jsc/bindings/webcore/streams/JSReadableStreamBYOBReader.cppsrc/jsc/bindings/webcore/streams/JSReadableStreamDefaultReader.cppsrc/jsc/bindings/webcore/streams/JSStreamsRuntime.hsrc/jsc/bindings/webcore/streams/JSTransformStreamDefaultController.cppsrc/jsc/bindings/webcore/streams/JSWritableStream.cppsrc/jsc/bindings/webcore/streams/JSWritableStreamDefaultWriter.cppsrc/jsc/bindings/webcore/streams/ReadableStreamOperations.cppsrc/jsc/bindings/webcore/streams/TransformStreamOperations.cppsrc/jsc/bindings/webcore/streams/WebStreamsInternals.hsrc/jsc/bindings/webcore/streams/WritableStreamOperations.cpptest/js/web/fetch/body-clone.test.tstest/js/web/streams/streams.test.js
|
Addressed the review feedback in 28f593f:
The remaining comments point at files under |
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/js/builtins/ProcessObjectInternals.ts:242-245— TheERR_STREAM_RELEASE_LOCKerror-code declaration is now dead: this PR deleted its only producer (the$ERR_STREAM_RELEASE_LOCK(...)calls in the removedReadableStreamInternals.ts) and its only consumer (theerr?.code === 'ERR_STREAM_RELEASE_LOCK'check replaced here). Per the "delete dead code in the same PR that makes it dead" rule, the entry atsrc/jsc/bindings/ErrorCode.ts:249and the manual switch arm atsrc/jsc/bindings/ErrorCode.cpp:2642-2643should be removed too (ErrorCode.rsregenerates from the .ts).Extended reasoning...
What was left behind
This hunk replaces the old
err?.code === 'ERR_STREAM_RELEASE_LOCK'check ininternalRead's catch handler withif (!reader). The PR description's "Behavior changes" section explicitly states: "The old Bun-specificAbortErrorwithcode: 'ERR_STREAM_RELEASE_LOCK'is no longer produced (nothing in the tree produced or consumed it after the old builtins were removed)."That statement is accurate — but the error-code declaration itself was left in place:
src/jsc/bindings/ErrorCode.ts:249—["ERR_STREAM_RELEASE_LOCK", Error, "AbortError"](the source-of-truth entry that generates the$ERR_STREAM_RELEASE_LOCKbuiltin, theErrorCode::ERR_STREAM_RELEASE_LOCKC++ enum value, and the Rust constant)src/jsc/bindings/ErrorCode.cpp:2642-2643— the manualcase ErrorCode::ERR_STREAM_RELEASE_LOCK:arm in the message-formatting switchsrc/jsc/ErrorCode.rs:532-533,1003,1344— auto-generated from ErrorCode.ts, so removing the .ts entry regenerates these away
Step-by-step proof
- Producers before this PR: The only sites that ever constructed this error were
$ERR_STREAM_RELEASE_LOCK("Stream reader cancelled via releaseLock()")calls insidereadableStreamDefaultReaderReleaseandreadableStreamReaderGenericReleaseinsrc/js/builtins/ReadableStreamInternals.ts. This PR deletes that file in full (visible in the diff: 2,644 lines removed). - Consumers before this PR: The only site that read this code was
if (err?.code === "ERR_STREAM_RELEASE_LOCK")insrc/js/builtins/ProcessObjectInternals.ts, which this hunk replaces withif (!reader). - Post-PR grep:
grep -rn 'STREAM_RELEASE_LOCK' src/ test/ packages/returns exactly six hits — the ErrorCode.ts declaration, the ErrorCode.cpp switch arm, and the four auto-generated ErrorCode.rs lines. No producers, no consumers, no tests. - Node.js has no
ERR_STREAM_RELEASE_LOCKcode (Node usesERR_INVALID_STATEfor the equivalent condition, which is exactly what this PR now produces per the description), so this is not a compat surface being preserved.
Why nothing else prevents removal
The ErrorCode.ts entry generates a
$ERR_STREAM_RELEASE_LOCKlink-time-constant builtin function and a C++/Rust enum value. With no callers of the builtin and no code that constructs or matches the enum value, it's pure dead surface: an unused generated JS function, an unreachable C++ switch arm, and unused Rust constants. It compiles and links fine, so nothing structural forces its removal.Impact
Zero runtime impact — the generated
$ERR_STREAM_RELEASE_LOCKfunction is never called, and the switch arm is unreachable. This is purely incomplete cleanup. Per CLAUDE.md's "Delete dead code in the same PR that makes it dead (required scope — name the deletions in the description)", and consistent with this PR already deleting ~50 deadBunBuiltinNames.hentries andbuiltins.d.tsdeclarations for the same reason, the ErrorCode entry belongs in the same sweep.Fix
Two-line change: delete line 249 of
src/jsc/bindings/ErrorCode.tsand the two-linecase ErrorCode::ERR_STREAM_RELEASE_LOCK:atsrc/jsc/bindings/ErrorCode.cpp:2642-2643. Rebuild regeneratesErrorCode.rswithout the four derived lines.
|
Two more findings came in after my last pass; both are handled:
|
| if (readerForThisRead !== reader) { | ||
| // disown() released this read's reader while it was in flight (stdin may have been | ||
| // re-owned since), so the read rejected because the stream was unref()ed, not | ||
| // because it failed. triggerRead() re-arms if/when it is ref()ed again. |
There was a problem hiding this comment.
🟡 This hunk removes the last consumer of ERR_STREAM_RELEASE_LOCK (the err?.code === "ERR_STREAM_RELEASE_LOCK" check), and the PR deletes all producers (the $ERR_STREAM_RELEASE_LOCK(...) throws in the removed stream builtins). The registry entry at src/jsc/bindings/ErrorCode.ts:249 (["ERR_STREAM_RELEASE_LOCK", Error, "AbortError"]) and the hand-written switch arm at ErrorCode.cpp:2642-2643 are now dead — per the repo's "delete dead code in the same PR that makes it dead" rule, they should be removed here.
Extended reasoning...
What was left behind
This PR intentionally retires the Bun-specific ERR_STREAM_RELEASE_LOCK error code. The PR description says so explicitly under "Behavior changes (intentional)": "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)." However, the error-code registry entry and its downstream artifacts were not deleted:
src/jsc/bindings/ErrorCode.ts:249—["ERR_STREAM_RELEASE_LOCK", Error, "AbortError"]src/jsc/bindings/ErrorCode.cpp:2642-2643— the hand-written zero-argument$ERR_STREAM_RELEASE_LOCK()switch arm with the default message"Stream reader cancelled via releaseLock()"src/jsc/ErrorCode.rs:532/1003/1344— generated from the.tsentry (will disappear on rebuild once the.tsline is removed)
Why it's dead
Producers. Before this PR, $ERR_STREAM_RELEASE_LOCK(...) was thrown at three sites, all in the deleted JS builtins: ReadableStreamInternals.ts (readableStreamDefaultReaderRelease and readableStreamReaderGenericRelease). This PR deletes those files entirely. Grep of the post-PR tree for ERR_STREAM_RELEASE_LOCK returns only the five registry lines above — zero $ERR_STREAM_RELEASE_LOCK( call sites and zero ErrorCode::ERR_STREAM_RELEASE_LOCK throw sites remain.
Consumers. The only consumer was the err?.code === "ERR_STREAM_RELEASE_LOCK" check in ProcessObjectInternals.ts's internalRead catch handler, which this exact hunk replaces with a reader-identity comparison (readerForThisRead !== reader). Grep confirms zero remaining string comparisons against "ERR_STREAM_RELEASE_LOCK" in src/, test/, or packages/.
Step-by-step proof
git grep -n ERR_STREAM_RELEASE_LOCKon this branch returns exactly five hits:ErrorCode.ts:249,ErrorCode.cpp:2642-2643, and the three generatedErrorCode.rslines.- None of those five hits throws the error —
ErrorCode.ts:249is the registry declaration,ErrorCode.cpp:2642-2643is the switch arm reachable only when a$ERR_STREAM_RELEASE_LOCK()builtin call routes throughBun::createErrorWithCode, and the.rsentries are the generated enum/table. - None of those five hits consumes the error — there is no
.code ===comparison and nocatchpredicate anywhere in the tree. - The new C++ implementation produces
ERR_INVALID_STATETypeErrors on reader/writer release (per the PR description's Node-compat pass), so nothing new reuses this code. - Therefore the registry entry, the
.cppswitch arm, and the generated.rsconstants are unreachable dead code created dead by this PR.
Impact
None at runtime — an unused ErrorCode enum member costs one entry in a static table and one unreachable switch arm. This is purely a "delete dead code in the same PR that makes it dead" cleanup item per CLAUDE.md, hence nit severity: it does not block the merge, cause incorrect behavior, or regress anything.
Fix
Remove line 249 from src/jsc/bindings/ErrorCode.ts and the case ErrorCode::ERR_STREAM_RELEASE_LOCK: arm at src/jsc/bindings/ErrorCode.cpp:2642-2643. The ErrorCode.rs entries are generated from ErrorCode.ts and will disappear on the next bun bd. Two-line source change.
|
Latest round of review findings, all handled in 7bb3c94 and the two commits before it:
Separately, from the CI failure list: restored Bun's async-iterable/async-generator body semantics ( |
|
Two more review findings triaged; everything through d95685e is pushed:
Also from the CI failure list on the last build: the WPT |
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
src/js/builtins/ProcessObjectInternals.ts (1)
246-252: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winAvoid rearming
internalRead()while another read is already pending. Ifown()has already replayedneedsInternalReadRefresh, the stale rejection from the old reader can calltriggerRead()again and start a second read on the new reader, which can duplicate or reorder stdin chunks. Gate the re-arm so only one read is active per reader.🤖 Prompt for 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. In `@src/js/builtins/ProcessObjectInternals.ts` around lines 246 - 252, Avoid rearming internalRead() when a newer read is already pending, because the stale rejection path in ProcessObjectInternals can start a second read on the new reader and duplicate or reorder stdin chunks. Update the triggerRead() re-arm logic in the reader mismatch branch so it checks the current pending read/reader state before calling triggerRead.$call(stream, undefined), and only schedules a new read when no read is already active for that reader.src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp (2)
367-368: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winReturn an
ArrayBufferfor single string chunks.Line 368 returns
encodeStringToUint8Array(...), soBun.readableStreamToArrayBuffer()returns aUint8Arrayonly for the single-string fast path, while the fallback path returns anArrayBuffer.Proposed fix
- if (chunk.isString()) - RELEASE_AND_RETURN(scope, encodeStringToUint8Array(globalObject, chunk)); + if (chunk.isString()) { + auto* bytes = encodeStringToUint8Array(globalObject, chunk); + RETURN_IF_EXCEPTION(scope, {}); + RELEASE_AND_RETURN(scope, bytes->possiblySharedJSBuffer(globalObject)); + }🤖 Prompt for 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. In `@src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp` around lines 367 - 368, The single-string fast path in BunStreamConsumers::readableStreamToArrayBuffer is returning a Uint8Array via encodeStringToUint8Array, which makes it inconsistent with the fallback path that returns an ArrayBuffer. Update the chunk.isString() branch to produce an ArrayBuffer in the same way as the non-fast-path logic, keeping the return type consistent for Bun.readableStreamToArrayBuffer().
906-923: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy liftDelay direct-stream state mutation until fallible setup succeeds.
After Lines 906-909 clear the direct source and mark the stream locked/disturbed,
ArrayBufferSink.startand thepull/closeproperty gets can throw. That escapes synchronously and leaves the stream consumed/locked instead of returning a rejected consumer promise. Wrap this setup likeconsumeDirectStream()does, or move the fallible work before mutating stream state.🤖 Prompt for 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. In `@src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp` around lines 906 - 923, The direct-stream setup in BunStreamConsumers should not mutate stream state before fallible work completes. In the consume path around the ArrayBufferSink.start call and the pull/close property accesses, either wrap the setup in the same try/fallible flow used by consumeDirectStream() or move the stream->m_directUnderlyingSource.clear(), m_bunMode, m_lockedWithoutReader, and m_disturbed updates until after those operations succeed. Ensure any exception from start, pull, or close results in a rejected consumer promise rather than leaving the stream consumed/locked.src/jsc/bindings/webcore/streams/BunStreamSource.cpp (2)
1313-1315: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winUse presence checks, not truthiness, for chunks and errors.
Falsy chunks (
0,false,"") are valid stream values and are currently skipped; falsy thrown reasons are also treated as “no error”. Use!error.isEmpty()for stored errors, and write the chunk wheneverdoneis false. As per coding guidelines, empty, zero, and unset are distinct states.Proposed direction
- bool hasTruthyError = !error.isEmpty() && error.toBoolean(globalObject); + bool hasError = !error.isEmpty();- bool hasChunk = chunk.toBoolean(globalObject); if (isDone) { op->m_closed = true; - if (hasChunk) { - MarkedArgumentBuffer args; - args.append(chunk); - ASSERT(!args.hasOverflowed()); - invokeMethod(globalObject, op->m_sink.get(), Identifier::fromString(vm, "write"_s), args); - RETURN_IF_EXCEPTION(scope, ); - } op->m_reading = false; RELEASE_AND_RETURN(scope, resumableEnd(globalObject, op, jsUndefined(), false)); } - if (hasChunk) { + { MarkedArgumentBuffer args; args.append(chunk);Also applies to: 1371-1384, 1448-1450
🤖 Prompt for 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. In `@src/jsc/bindings/webcore/streams/BunStreamSource.cpp` around lines 1313 - 1315, The stream handling in BunStreamSource is using truthiness checks for stored errors and chunk values, which incorrectly drops valid falsy values like 0, false, and empty strings. Update the error path in the relevant read methods to use !error.isEmpty() instead of toBoolean-based checks, and in the chunk-writing logic always write the chunk whenever done is false, regardless of its truthiness. Make the same presence-check adjustment in the affected ReadableStreamSource helpers so empty, zero, and unset remain distinct states.Source: Coding guidelines
447-452: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick winClamp the JS-returned sizes before casting to
size_t.
result.asNumber()andstartResult.toNumber(globalObject)can still beNaN,+Infinity, negative, or larger thansize_t, so thestatic_cast<size_t>(...)paths here can overflow or hit UB. TheautoAllocateChunkSizeinput is already range-checked upstream.🤖 Prompt for 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. In `@src/jsc/bindings/webcore/streams/BunStreamSource.cpp` around lines 447 - 452, Clamp the JS numeric results before converting them in BunStreamSource, since result.asNumber() and startResult.toNumber(globalObject) can still be NaN, infinite, negative, or exceed size_t. Update the size handling in the relevant stream-reading path (including nativeAdjustChunkSize and the chunk-length min logic) to validate and bound the values before any static_cast<size_t> conversion, using the existing range-safe behavior already established for autoAllocateChunkSize as a guide.Source: Coding guidelines
🤖 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 `@src/js/builtins/AsyncIterableStream.ts`:
- Around line 71-78: The cleanup block in AsyncIterableStream’s close path
currently uses a nested finally that always rethrows closingError, which can
mask a different failure from iter.throw?.(closingError). Update the logic
around the iter.throw?.(closingError) call so that any exception/rejection from
the iterator cleanup is preserved and surfaced instead of being overwritten by
the original closingError, while still clearing iter to undefined in the
shutdown path.
In `@src/jsc/bindings/AsyncStackTrace.cpp`:
- Around line 22-30: Shorten the oversized comment blocks in AsyncStackTrace by
reducing the inline rationale to 3 lines max and keeping only the local
invariant near the relevant logic in the affected sections around the async
stack trace walk and combinator handling. Move the detailed algorithm
explanation from the top-of-file and other long comment blocks into separate
docs or a more concise note, and keep the remaining comments tied to the nearby
symbols such as the async stack trace collection logic in AsyncStackTrace.
- Around line 94-102: The promise-reaction traversal in AsyncStackTrace should
not stop after inspecting only the first JSPromiseReaction from payloadCell().
Update the logic around the reaction-walking code to keep iterating through the
full sibling chain of JSPromiseReaction nodes, checking each entry with
unwrapGeneratorFromContext(JSPromiseReaction::tryGetContext(...)) before falling
back to reaction->promise(). This ensures prepended reactions don’t hide an
awaiting async generator and preserves async stack frames regardless of
attachment order.
In `@src/jsc/bindings/AsyncStackTrace.h`:
- Around line 1-4: The file header comment in AsyncStackTrace.h exceeds the
3-line limit and should be compressed into a shorter invariant summary. Update
the top-of-file comment near the AsyncStackTrace header so it keeps the same
meaning but fits within 3 lines, removing extra detail while still referencing
the async stack recovery behavior and the AsyncStackTrace.cpp implementation.
In `@src/jsc/STREAMS.md`:
- Around line 71-72: The syntax-check example is incorrect because
compile_commands.json is not a clang response file, so the shown @<flags from
build/debug/compile_commands.json> usage will not work. Update the STREAMS.md
guidance to say that the TU’s compile command should be extracted from
compile_commands.json and then rerun with -fsyntax-only. Refer to the
syntax-check command example in the STREAMS.md section and keep the instruction
focused on using the existing TU command rather than trying to pass the JSON
file directly to clang++.
---
Outside diff comments:
In `@src/js/builtins/ProcessObjectInternals.ts`:
- Around line 246-252: Avoid rearming internalRead() when a newer read is
already pending, because the stale rejection path in ProcessObjectInternals can
start a second read on the new reader and duplicate or reorder stdin chunks.
Update the triggerRead() re-arm logic in the reader mismatch branch so it checks
the current pending read/reader state before calling triggerRead.$call(stream,
undefined), and only schedules a new read when no read is already active for
that reader.
In `@src/jsc/bindings/webcore/streams/BunStreamConsumers.cpp`:
- Around line 367-368: The single-string fast path in
BunStreamConsumers::readableStreamToArrayBuffer is returning a Uint8Array via
encodeStringToUint8Array, which makes it inconsistent with the fallback path
that returns an ArrayBuffer. Update the chunk.isString() branch to produce an
ArrayBuffer in the same way as the non-fast-path logic, keeping the return type
consistent for Bun.readableStreamToArrayBuffer().
- Around line 906-923: The direct-stream setup in BunStreamConsumers should not
mutate stream state before fallible work completes. In the consume path around
the ArrayBufferSink.start call and the pull/close property accesses, either wrap
the setup in the same try/fallible flow used by consumeDirectStream() or move
the stream->m_directUnderlyingSource.clear(), m_bunMode, m_lockedWithoutReader,
and m_disturbed updates until after those operations succeed. Ensure any
exception from start, pull, or close results in a rejected consumer promise
rather than leaving the stream consumed/locked.
In `@src/jsc/bindings/webcore/streams/BunStreamSource.cpp`:
- Around line 1313-1315: The stream handling in BunStreamSource is using
truthiness checks for stored errors and chunk values, which incorrectly drops
valid falsy values like 0, false, and empty strings. Update the error path in
the relevant read methods to use !error.isEmpty() instead of toBoolean-based
checks, and in the chunk-writing logic always write the chunk whenever done is
false, regardless of its truthiness. Make the same presence-check adjustment in
the affected ReadableStreamSource helpers so empty, zero, and unset remain
distinct states.
- Around line 447-452: Clamp the JS numeric results before converting them in
BunStreamSource, since result.asNumber() and startResult.toNumber(globalObject)
can still be NaN, infinite, negative, or exceed size_t. Update the size handling
in the relevant stream-reading path (including nativeAdjustChunkSize and the
chunk-length min logic) to validate and bound the values before any
static_cast<size_t> conversion, using the existing range-safe behavior already
established for autoAllocateChunkSize as a guide.
🪄 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: f74541a7-cf40-48fd-8d2e-71e68a24d177
📒 Files selected for processing (52)
bench/snippets/webstreams-memory.mjsbench/snippets/webstreams-tee.mjsbench/snippets/webstreams-throughput.mjssrc/js/CLAUDE.mdsrc/js/builtins.d.tssrc/js/builtins/AsyncIterableStream.tssrc/js/builtins/ProcessObjectInternals.tssrc/jsc/STREAMS.mdsrc/jsc/bindings/AsyncStackTrace.cppsrc/jsc/bindings/AsyncStackTrace.hsrc/jsc/bindings/ZigGlobalObject.cppsrc/jsc/bindings/bindings.cppsrc/jsc/bindings/webcore/streams/BunStandaloneTextSink.hsrc/jsc/bindings/webcore/streams/BunStreamConsumers.cppsrc/jsc/bindings/webcore/streams/BunStreamSource.cppsrc/jsc/bindings/webcore/streams/JSByteLengthQueuingStrategy.cppsrc/jsc/bindings/webcore/streams/JSCountQueuingStrategy.cppsrc/jsc/bindings/webcore/streams/JSDirectStreamController.cppsrc/jsc/bindings/webcore/streams/JSReadStreamIntoSinkOperation.hsrc/jsc/bindings/webcore/streams/JSReadableByteStreamController.cppsrc/jsc/bindings/webcore/streams/JSReadableStream.cppsrc/jsc/bindings/webcore/streams/JSReadableStreamAsyncIterator.cppsrc/jsc/bindings/webcore/streams/JSReadableStreamBYOBReader.cppsrc/jsc/bindings/webcore/streams/JSReadableStreamBYOBRequest.cppsrc/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cppsrc/jsc/bindings/webcore/streams/JSReadableStreamDefaultReader.cppsrc/jsc/bindings/webcore/streams/JSStreamPipeToOperation.cppsrc/jsc/bindings/webcore/streams/JSStreamsRuntime.hsrc/jsc/bindings/webcore/streams/JSTextDecoderStream.cppsrc/jsc/bindings/webcore/streams/JSTextEncoderStream.cppsrc/jsc/bindings/webcore/streams/JSTransformStream.cppsrc/jsc/bindings/webcore/streams/JSTransformStreamDefaultController.cppsrc/jsc/bindings/webcore/streams/JSWritableStream.cppsrc/jsc/bindings/webcore/streams/JSWritableStreamDefaultController.cppsrc/jsc/bindings/webcore/streams/JSWritableStreamDefaultWriter.cppsrc/jsc/bindings/webcore/streams/ReadableStreamOperations.cppsrc/jsc/bindings/webcore/streams/StreamQueue.hsrc/jsc/bindings/webcore/streams/TransformStreamOperations.cppsrc/jsc/bindings/webcore/streams/WebStreamsExports.cppsrc/jsc/bindings/webcore/streams/WebStreamsInternals.hsrc/jsc/bindings/webcore/streams/WebStreamsMisc.cppsrc/jsc/bindings/webcore/streams/WritableStreamOperations.cppsrc/runtime/webcore/Body.rssrc/runtime/webcore/ReadableStream.rssrc/runtime/webcore/Request.rstest/js/bun/http/serve.test.tstest/js/bun/util/readablestreamtoarraybuffer.test.tstest/js/node/process/process-stdin.test.tstest/js/third_party/wpt-testharness-shim.tstest/js/web/fetch/body.test.tstest/js/web/fetch/fetch.stream.test.tstest/js/web/streams/streams.test.js
| if (closingError) { | ||
| try { | ||
| await iter.throw?.(closingError); | ||
| } finally { | ||
| iter = undefined; | ||
| // eslint-disable-next-line no-throw-literal | ||
| throw closingError; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Nested finally discards the real error from iter.throw().
if (closingError) {
try {
await iter.throw?.(closingError);
} finally {
iter = undefined;
throw closingError;
}
}
If iter.throw?.(closingError) itself throws/rejects with a different error (e.g. the generator's own cleanup fails), that error is silently discarded and only the original closingError is ever surfaced. This matches Biome's noUnsafeFinally flag at line 77 and is a real (not false-positive) case here, since the inner try can genuinely produce a distinct exception.
🐛 Suggested fix
if (closingError) {
try {
await iter.throw?.(closingError);
+ } catch (throwError) {
+ // Prefer the error raised while notifying the iterator (it may
+ // carry more diagnostic context), but never lose the original.
+ closingError = throwError ?? closingError;
} finally {
iter = undefined;
- // eslint-disable-next-line no-throw-literal
- throw closingError;
}
+ // eslint-disable-next-line no-throw-literal
+ throw closingError;
} else {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (closingError) { | |
| try { | |
| await iter.throw?.(closingError); | |
| } finally { | |
| iter = undefined; | |
| // eslint-disable-next-line no-throw-literal | |
| throw closingError; | |
| } | |
| if (closingError) { | |
| try { | |
| await iter.throw?.(closingError); | |
| } catch (throwError) { | |
| closingError = throwError ?? closingError; | |
| } finally { | |
| iter = undefined; | |
| } | |
| // eslint-disable-next-line no-throw-literal | |
| throw closingError; | |
| } |
🧰 Tools
🪛 Biome (2.5.1)
[error] 77-77: Unsafe usage of 'throw'.
(lint/correctness/noUnsafeFinally)
🤖 Prompt for 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.
In `@src/js/builtins/AsyncIterableStream.ts` around lines 71 - 78, The cleanup
block in AsyncIterableStream’s close path currently uses a nested finally that
always rethrows closingError, which can mask a different failure from
iter.throw?.(closingError). Update the logic around the
iter.throw?.(closingError) call so that any exception/rejection from the
iterator cleanup is preserved and surfaced instead of being overwritten by the
original closingError, while still clearing iter to undefined in the shutdown
path.
Source: Linters/SAST tools
| // Walk a promise's reaction chain to find the async generators awaiting it, | ||
| // and collect them as async StackFrames. Used when an error is created from | ||
| // native code at the top of the event loop (e.g. run_from_js_thread in node_fs.rs) | ||
| // where there's no JS call stack, but the promise being rejected has an await | ||
| // chain that tells us where the user's code is. | ||
| // | ||
| // This replicates the minimal chain-walking from JSC's private | ||
| // Interpreter::getAsyncStackTrace for the common case (direct await). Promise | ||
| // combinators (all/race/any) are not traced through — we stop at them. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Shorten the long inline rationale blocks.
These new comments exceed Bun’s 3-line comment limit. Keep the invariant locally and move detailed algorithm notes to docs if needed.
As per coding guidelines, “Keep code comments to 3 lines max.”
Also applies to: 54-65, 158-162
🤖 Prompt for 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.
In `@src/jsc/bindings/AsyncStackTrace.cpp` around lines 22 - 30, Shorten the
oversized comment blocks in AsyncStackTrace by reducing the inline rationale to
3 lines max and keeping only the local invariant near the relevant logic in the
affected sections around the async stack trace walk and combinator handling.
Move the detailed algorithm explanation from the top-of-file and other long
comment blocks into separate docs or a more concise note, and keep the remaining
comments tied to the nearby symbols such as the async stack trace collection
logic in AsyncStackTrace.
Source: Coding guidelines
|
Latest review round, handled in 734b10b:
WPT 1174/1174, streams suite 115/115 (including the new regression tests), async-iterator 86/86 on the pushed head. |
|
Confirmed and fixed in b815c9e: |
| } | ||
|
|
||
| // The readableStreamIntoArray readMany continuation. Runs synchronously until readMany | ||
| // returns a promise, then chains the next hop onto a fresh derived promise it returns. |
There was a problem hiding this comment.
🔴 Actual location: JSDirectStreamController.cpp:409-423 × JSReadableStreamDefaultReader.cpp:123-131 (anchored here because those files' diffs are collapsed). When a type:'direct' stream has its final chunk armed (set by onClose at :593-594 when there are flushed bytes but no reader — the exact fallthrough introduced by this PR's fix for the earlier onClose null-deref), a subsequent read via a non-Promise-kind read request (pipeTo, tee, for await) silently drops the chunk: readableStreamDefaultReaderRead at :125 queues the readRequest, then :127 calls onPull, whose m_finalChunkArmed branch (:413-419) creates a fulfilled promise (adopted only by the Promise-kind path at :121) and calls readableStreamCloseIfPossible — which dispatches closeSteps() on the just-queued request. The returned promise is discarded at :129 (m_pendingRead is null on this branch). The regression test at streams.test.js:637-653 covers the Promise-kind path (rs.getReader().read()); the for await/pipeTo/tee siblings drop the chunk. Fix: in onPull's m_finalChunkArmed branch, when a readRequest is queued, deliver via readableStreamFulfillReadRequest(stream, chunk, false) before closing — mirroring onClose at :587-590.
Extended reasoning...
What the bug is
When a type: 'direct' stream has its final chunk armed via m_finalChunkArmed (set by onClose at JSDirectStreamController.cpp:593-594 when there are flushed bytes but no reader / no pending read requests — the fallthrough this PR introduced when fixing the onClose null-deref in commit 219e226b), a subsequent read via a non-Promise-kind read request (ReadRequestKind::PipeTo, DefaultTee, ByteTee, AsyncIterator) silently drops the chunk: the read request receives closeSteps() (i.e. {done: true}) instead of chunkSteps(finalChunk).
Code path
readableStreamDefaultReaderRead (JSReadableStreamDefaultReader.cpp:107-131) for a Direct controller with a non-Promise-kind readRequest:
- Line 125:
readableStreamAddReadRequest(vm, stream, readRequest)— queues the request inreader->m_readRequests. - Line 127:
controller->onPull(globalObject). onPullat :409:m_finalChunkArmedis true → :413-416 creates a promise fulfilled with{value: chunk, done: false}, then :419readableStreamCloseIfPossible(globalObject, stream)→readableStreamClose→ detachesreader->m_readRequestsand callscloseSteps()on the request queued at step 1 → :422 returns the fulfilled promise.m_pendingReadis never set on this branch.- Back at :129:
!hadPendingRead && controller->m_pendingRead—m_pendingReadis null, condition false → the returned promise carrying the chunk is discarded.
Result: the pipeTo/tee/async-iterator read request sees {done: true} and never receives the buffered bytes. The Promise-kind path at :111-121 works because it adopts the returned promise via resolvePromise(readPromise, pulled) at :121.
Why nothing prevents it
The comment at :123-124 says "Other read-request kinds wait in [[readRequests]]; the pump's unobserved head-of-line promise for this read is dropped so delivery reaches the request" — but the m_finalChunkArmed branch (:409-423) doesn't put the chunk anywhere the queued readRequest can see it: it delivers only via the returned promise (which the non-Promise arm intentionally drops), and its only side effect is readableStreamClose, which runs closeSteps on queued requests — not chunkSteps. The branch was written assuming only the Promise-kind caller (which is also what its sibling onClose at :575-582 handles via m_pendingRead), and the non-Promise arm at :123-131 was written assuming onPull delivers via m_pendingRead or via a queued read-request fulfillment — neither of which the m_finalChunkArmed branch does.
Regression from the deleted JS builtin
The old onCloseDirectStream (ReadableStreamInternals.ts) armed the final chunk by swapping this.$pull = $onCloseDirectStreamFinalPull.bind(...), and the old readableStreamDefaultReaderRead returned controller.$pull(controller) — a promise resolving to {value: flushed, done: false}. The old pipeTo/tee/for-await all consumed via that returned promise, so the chunk was delivered regardless of consumer type. The C++ rewrite split reads into Promise-kind (adopts the returned promise at :121) and non-Promise-kind (queued, waits for chunkSteps/closeSteps), and the m_finalChunkArmed branch was written for only the former.
Step-by-step proof
The setup exactly matches the author's own regression test at streams.test.js:637-653, differing only in the follow-up consumer:
const rs = new ReadableStream({
type: 'direct',
async pull(c) { await Promise.resolve(); c.write(new Uint8Array(10)); c.end(); },
});
const reader = rs.getReader();
reader.read().catch(() => {}); // triggers onPull; m_deferClose reset to 0 when it returns
reader.releaseLock(); // clears stream->m_reader AND controller->m_pendingRead (per 219e226b)
await Bun.sleep(0); // async pull body runs: c.write(10) then c.end() → onClose
// → readableStreamHasDefaultReader(stream) is false (no reader)
// → :593-594 sets m_finalChunkArmed = true, m_finalChunk = 10-byte chunk
// → :595 returns without closing; state stays Readable, stream unlocked
// Existing test does rs.getReader().read() here → Promise kind → adopts onPull's returned promise → works.
// Non-Promise consumers instead:
let seen = 0;
for await (const c of rs) seen += c.byteLength; // → seen = 0 (chunk dropped)
// or: await rs.pipeTo(new WritableStream({ write(c){ seen += c.byteLength; } })); → seen = 0
// or: const [a, b] = rs.tee(); for await (const c of a) seen += c.byteLength; → seen = 0Reachability of each consumer: the stream is unlocked (m_reader cleared, m_lockedWithoutReader false) and state is Readable (:595 returned without closing), so values()/pipeTo/tee all succeed in acquiring a reader. JSReadableStreamAsyncIterator.cpp:160-161 creates ReadRequestKind::AsyncIterator; JSStreamPipeToOperation.cpp:139-141 creates ReadRequestKind::PipeTo; ReadableStreamOperations.cpp:910 creates ReadRequestKind::DefaultTee — all call readableStreamDefaultReaderRead and reach the :123-131 arm.
Impact
Silent data loss (the worst failure mode — no error, no hang, just missing bytes) in code this PR wrote, in exactly the scenario class the author is actively fixing on this PR (release-reader-during-async-pull on direct streams; commits 219e226b and 7bb3c94), and the PR's own regression test at streams.test.js:637-653 covers the Promise-kind sibling but not the for await/pipeTo/tee siblings. This is the missed sibling of those fixes per CLAUDE.md's "fix the whole class in the same PR". Not a duplicate of any prior comment on this PR — the resolved onClose null-deref (which created the m_finalChunkArmed fallthrough) and the still-open reader.cancel() hang at BunStreamConsumers.cpp:500 are the same scenario setup with different failure sinks.
Fix
In onPull's m_finalChunkArmed branch (JSDirectStreamController.cpp:409-423), before creating the fulfilled promise, check for a queued readRequest and deliver via readableStreamFulfillReadRequest — exactly what onClose at :587-590 already does:
if (m_finalChunkArmed) {
m_finalChunkArmed = false;
JSValue chunk = m_finalChunk.get();
m_finalChunk.clear();
auto* stream = m_stream.get();
// A non-Promise-kind read (pipeTo/tee/for-await) queued its readRequest before
// calling onPull; deliver via chunkSteps rather than the returned promise.
if (stream && readableStreamHasDefaultReader(stream) && readableStreamGetNumReadRequests(stream) > 0) {
readableStreamFulfillReadRequest(globalObject, stream, chunk, false);
RETURN_IF_EXCEPTION(scope, {});
readableStreamCloseIfPossible(globalObject, stream);
RETURN_IF_EXCEPTION(scope, {});
return jsUndefined();
}
JSObject* result = createIteratorResultObject(globalObject, chunk, false);
...
}A ~6-line addition. The regression test at :637-653 should be extended with for await/pipeTo/tee variants (same setup, different follow-up consumer).
| // The reader this read belongs to. releaseLock() rejects the in-flight read(); by the | ||
| // time that rejection lands, own() may already have acquired a NEW reader, so the catch | ||
| // must key on this acquisition rather than on the current `reader`. | ||
| const readerForThisRead = reader; | ||
| try { | ||
| $assert(reader); | ||
| const { value } = await reader.read(); | ||
| $assert(readerForThisRead); | ||
| const { value } = await readerForThisRead.read(); |
There was a problem hiding this comment.
🟡 The new pause() and resume() churn regression test at test/js/node/process/process-stdin.test.ts:305-332 (added in 28f593f for this internalRead fix) spawns with stderr: "pipe" (:321) but only awaits Promise.all([proc.stdout.text(), proc.exited]) (:329) — stderr is never drained, unlike the two neighboring tests in the same file (:277-278, :301-302). If the child ever writes to stderr the diagnostic is lost (and in the limit, an unread pipe can deadlock the child) — add proc.stderr.text() to the Promise.all. Minor: "x".repeat(1024) at :325 → Buffer.alloc(1024, "x").toString() per test/CLAUDE.md.
Extended reasoning...
What the issue is
The regression test "pause() and resume() churn while data is in flight never destroys stdin" at test/js/node/process/process-stdin.test.ts:305-332 was added in commit 28f593f as coverage for this PR's internalRead reader-identity fix in ProcessObjectInternals.ts. It spawns a child with stderr: "pipe" (line 321) but then only awaits:
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);at line 329 — proc.stderr is never read. This diverges from the root CLAUDE.md rule "Subprocess tests: drain pipes concurrently. Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]) — an unread pipe fills the ~64KB OS buffer and deadlocks the child", and from the two immediately-preceding tests in the same file, which both drain stderr (lines 277-278 and 301-302 each do expect(await proc.stderr.text())).
Step-by-step
- Line 321 configures the spawn with
stderr: "pipe", so the child's fd 2 is a pipe the parent must read. - The child's
-escript writes only to stdout on success, so in the happy path stderr stays empty and the un-read pipe never fills — the test passes. - On the failure path this test is designed to catch — the pre-fix bug where
process.stdinis destroyed withTypeError [ERR_INVALID_STATE]: Invalid state: Reader released— the child's line 313 doesconsole.log("ERROR " + ...); process.exit(1), which goes to stdout, so that specific failure is still visible. But any other failure mode (a debug-build warning, an uncaught-exception dump, an ASAN report, a native assertion) writes to stderr, and that diagnostic is silently discarded: the test just reportsexpect(stdout.trim()).toBe("TOTAL 20480")failing with an empty or truncated string and no explanation. - In the limit, if a future regression made the child write >64 KB to stderr (e.g. repeated warnings inside the 5 ms
setIntervalchurn loop), the child would block onwrite(2, ...)and the test would hang until the harness timeout — the exact scenario the CLAUDE.md rule exists to prevent.await using procbounds that hang, but only after the test times out.
Why it's a nit, not a blocker
The child never writes to stderr in normal operation, so there's no deadlock in practice today; await using proc provides a backstop; and this is test-quality only, with zero runtime impact. It doesn't justify blocking a merge. But it's new code in this PR, it's a one-line fix, and it's the exact shape Bun reviewers routinely catch (per CLAUDE.md's "Landing PRs" → "Subprocess tests: drain pipes concurrently").
Secondary items
"x".repeat(1024)at :325: test/CLAUDE.md says "To create a repetitive string, useBuffer.alloc(count, fill).toString()instead of"A".repeat(count). "".repeat is very slow in debug JavaScriptCore builds." At 20×1 KB this is negligible in wall-clock terms, so purely a convention nit.await Bun.sleep(10)× 20 at :326: three verifiers noted this against test/CLAUDE.md's "never wait for time to pass", but I'm not pressing it — this is a bounded-window "X does not happen" race test where the sleeps space parent writes so the child's 5 mssetIntervalpause/resume churn can interleave, and the neighboring test at :297 usesBun.sleep(1000)for the same class of assertion. Root CLAUDE.md explicitly allows "For 'X does not happen', poll a bounded window".
Fix
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe(`TOTAL ${20 * 1024}`);
expect(stderr).toBe("");
expect(exitCode).toBe(0);and optionally proc.stdin.write(Buffer.alloc(1024, "x").toString()) at :325.
(Anchored at ProcessObjectInternals.ts because the test file's diff hunk is collapsed by GitHub.)
|
f554ff82d1ef adds the piece this thread's flush questions pointed at, measured end to end: Finding. The HTTP response sink already flushes buffered writes at the end of the tick (it registers with the event loop's deferred-task queue, which runs right after every microtask drain) — but the JS-facing direct-stream sink had no equivalent, so a producer that wrote and then suspended (an async-generator Change. The direct controller now arms a one-shot end-of-tick flush on the same deferred queue when a write buffers data (new Measured (slow async generator yielding every 120 ms):
Regression tests cover both shapes (a direct source writing inside a never-resolving |
…lementation (#33849) ### 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. ```js 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 `dynamicDowncast`s 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: ```js 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: ```js 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): ```js 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: ```js 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). <!-- robobun:evidence:begin --> --- **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:evidence:end --> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: robobun <117481402+robobun@users.noreply.github.com> Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
…t as debug (#34297) `test/js/bun/http/serve-body-leak.test.ts` went red on the debian 13 x64-asan lane in [build 73611](https://buildkite.com/bun/bun/builds/73611): the "should not leak memory when streaming the body and echoing it back" case timed out at 40s on all four retries. The PR under test (#33580, tty `setRawMode`) does not touch anything related, so this is the test's own budget. ### Cause This is not a hang and not a code regression. Scraping the timestamped logs from 35 recent x64-asan runs (builds 73562-73623) plus 4 pre-#33193 runs: | case | release (debian 13 x64) | release-asan (debian 13 x64-asan) | |---|---|---| | `callIgnore` | ~5s | 15-24s | | `callStreamingEcho` | ~8s | **27-39s** (median ~31s) | | total file | ~40-47s | ~125-195s | The streaming-echo case has been running at ~31s median on ASAN since well before the webstreams rewrite (pre-#33193 samples: 28.2 / 30.9 / 28.2 / 31.6s), so the 40s budget has always been tight there. On build 73611 every case in the file ran ~35% slower than typical (the shard landed on a slower EC2 instance; `callIgnore` 23.6s vs a typical ~17s), which is enough to push echo past 40s. A local 15000-request `/streaming-echo` probe against a debug build runs at a flat ~395 req/s with no stalls, confirming throughput, not a hang. The file already scales its `end_memory` threshold for ASAN (#32520), and `scripts/runner.node.mjs` already applies a 3x ASAN multiplier to the default `--timeout` for the same reason, but that multiplier does not reach tests that pass their own explicit third-argument timeout. Skipping on ASAN was tried in #28301 and reverted in #28337; this change keeps the test running there with a budget that matches the measured cost. ### Fix ```diff - isDebug ? 60_000 : 40_000, + isDebug || isASAN ? 60_000 : 40_000, ``` Matches the `isDebug || isASAN` convention already used by 16 other test files for timeouts/iteration counts. 60s is ~2x the ASAN median and ~1.4x the extrapolated worst case (73611). Release lanes stay at 40s. ### Verification - `USE_SYSTEM_BUN=1 bun test test/js/bun/http/serve-body-leak.test.ts`: 8 pass, echo 11.4s (release, budget unchanged at 40s). - `bun bd test test/js/bun/http/serve-body-leak.test.ts -t 'ignoring the body'`: passes; file parses and the unchanged isDebug=60s branch applies under the debug build. - `/tmp/echo-probe.ts` against debug build: 15000 `/streaming-echo` requests at a steady ~395 req/s, no stalls. <!-- robobun:evidence:begin --> --- **no test proof** · iteration 0 · Platform-specific test-only change; deferring to CI. <!-- robobun:evidence:end -->
…, fix the gaps they found (#32627) ## What this does Vendors 17 web-streams test files from the Node v26.3.0 test suite and fixes the behaviours they caught. It also restores an 18th, `test-whatwg-encoding-custom-textdecoder-streaming.js`, to its upstream text; it carried a local rewrite of one loop that changed nothing. This takes Bun's coverage of Node v26.3.0's web-streams tests from **11/37 to 28/37 — 30% → 76%** of the upstream suite (`test/parallel` + `test/sequential`, matched by basename against the `v26.3.0` tag). All 18 pass — nothing is added to `test/expectations.txt`. 15 of the 17 are byte-identical to upstream. The other two relax one assertion apiece: upstream pins the brand-check failure to V8's `Cannot read private member` text (and, in one case, a `at get <name>` stack shape). JavaScriptCore words the same failure differently — `ByteLengthQueuingStrategy.highWaterMark getter called on incompatible |this| value.` — so those two files assert the error *type* and drop the engine-specific message. Both deviations are commented in place. ## Why now The web streams rewrite (#33193) moved the whole subsystem to C++. These upstream tests are the conformance baseline for it, and they surfaced six real gaps. Without them the gaps stay invisible. ## What changed Each runtime fix below is what makes a specific vendored test pass. **`addAbortSignal(signal, webStream)` threw instead of aborting.** Node stores a bound `controller.error` on the stream under `Symbol.for('nodejs.webstream.controllerErrorFunction')`. Bun's controllers live in C++ and never set it, so `stream[kControllerErrorFunction](...)` was a call on `undefined`. Adds a `$webStreamControllerError` intrinsic that dispatches to the stream's real controller and no-ops once the stream is no longer readable/writable — what `controller.error()` does. It mirrors the existing `$webStreamClosedPromise` intrinsic, so the common case allocates nothing per stream. A stream that *does* define Node's symbol (a polyfill) still goes through it. Test: `test-webstreams-abort-controller.js`. **`ReadableStream.from()` threw untagged TypeErrors.** Now matches Node: `ERR_ARG_NOT_ITERABLE` with the argument rendered the way `%s` renders it (`{ a: 1 } must be iterable`), and `ERR_INVALID_STATE` when the iterator method returns a non-object. Test: `test-webstream-readable-from.js`. **`new TextDecoderStream(label, options)` silently ignored a non-object `options`.** `new TextDecoder(label, options)` already throws `ERR_INVALID_ARG_TYPE` for the same value; the stream wrapper built its own options object and never looked. Test: `test-whatwg-webstreams-encoding.js`. **`TextEncoderStream`/`TextDecoderStream` inspect returned the receiver on a bad `this`** instead of throwing `ERR_INVALID_THIS`. These two are the only web streams classes Node brand-checks in their inspect methods — the rest fault on a property access, so they are deliberately left alone. Tests: `test-webstream-encoding-inspect.js`, plus two cases in `test/js/node/util/custom-inspect.test.js`. **`DecompressionStream` accepted trailing bytes after the compressed data.** The Compression Streams spec requires erroring. A private `rejectGarbageAfterEnd` option on the zlib engine drives it; nothing else sets it, so `node:zlib` behaviour is unchanged. Test: `test-webstreams-decompression-reject-trailing.js`. **`new TextDecoder(1)` reported a generic "label is invalid"** instead of stringifying the label per WebIDL and reporting `ERR_ENCODING_NOT_SUPPORTED`. Test: `test-whatwg-webstreams-encoding.js`. **Several errors had the right class and message but no `code`.** Node-compatible callers branch on `err.code`, so it read as `undefined`. `ReadableStreamBYOBReader.read()` with a zero-length / zero-length-buffer / detached view, and `WritableStream` `close()`/`abort()` on a locked stream (and `close()` on an already-closing one), now carry `ERR_INVALID_STATE`; all three `byobRequest.respondWithNewView()` checks (byte offset, length overflow, buffer length) carry `ERR_INVALID_ARG_VALUE`, as they do in node. Node reports one generic message for the whole BYOB group; Bun's say which condition failed, so the messages are kept and only the code is added. Tests: `test-whatwg-readablebytestream-bad-buffers-and-views.js`, `test-whatwg-webstreams-adapters-to-streamwritable.js`. **`addAbortSignal()` on a `type: "direct"` stream closed it cleanly instead of erroring.** The intrinsic's Direct arm dispatched to the controller's `onClose()` — the graceful `end()` path, which fulfils the pending read with `{done: true}` — so `reader.closed` resolved and `finished()` called back with `null`, discarding the AbortError. It now calls `handleError()`, which is what the direct controller's own `error()` dispatches to. **`handleError()` could run the user's `close()` twice.** It guarded `closeDirectSinkForError()` behind `!m_closed` but called `callUnderlyingSourceClose()` unconditionally. The JS-facing `error()` throws once the controller is closed, so nothing reached that path before; routing `addAbortSignal()` through `handleError()` does. `end()` arms the final chunk and sets `m_closed` while the stream stays `Readable`, so an abort landing in that window double-closed the sink. **Test harness.** Exposes `internal/webstreams/adapters`, and shims `internal/webstreams/util`'s `kState`/`isPromisePending` — backed by a `getWebStreamState` helper on `bun:internal-for-testing` that reads the stream's native closed promise rather than reaching into internals. ## How we know it works All 18 files pass individually on a debug build (they must run one file at a time — `common/index.js` only installs the `--expose-internals` shim when `process.argv.length === 2`). Each one fails on a build without the corresponding fix. The two added `custom-inspect.test.js` cases fail under `USE_SYSTEM_BUN=1` and pass on the debug build. No regressions in `test/js/node/stream/node-stream.test.js` (94/94), `test/js/node/util/custom-inspect.test.js` (44/44), and `test/js/web/streams/streams.test.js` (156/158 — the two failures are pre-existing 10s+ timeouts under the ASAN debug build, unchanged by this PR). The two behaviours found in review were driven directly against the debug binary rather than only through the suite: an abort on a direct stream now rejects `reader.closed`/`read()` and reports the AbortError through `finished()`, a late abort after `close()` is still a silent no-op, and the direct sink's `close()` runs exactly once. The `OwnedString` fix was confirmed by A/B: 200k coerced-label constructions now track the untouched string-label path to within ~1 byte/call. ## Known gaps, deliberately not vendored `ReadableStreamBYOBReader.read(view, { min })` is a further divergence this PR does not address: node validates `min` **before** the view checks and throws `ERR_INVALID_ARG_VALUE` synchronously, while Bun returns a rejected promise carrying an uncoded TypeError. Fixing it means changing both the validation order and the sync/async shape, which is a different concern from adding the missing codes, and no vendored test covers it. Two upstream files are left out rather than marked failing: - `test-whatwg-webstreams-adapters-to-streamreadable.js` expects `Readable.fromWeb()` to lock its source at construction time. Bun locks lazily on the first read so `Response.body` can still be consumed through the native body after the getter is touched (#32863, #33300). Changing that is a design decision for the adapter, not for a test-porting PR. - `test-whatwg-webstreams-adapters-to-writablestream.js` trips #34115: requiring `node:stream` from a preload script leaves `internal/webstreams_adapters` half-initialized, so `Writable.toWeb()` returns a broken stream. It reproduces on released 1.3.14 with a two-line repro and has nothing to do with web streams conformance; the node-test harness's preload requires `node:stream`, which is why the file cannot pass today. --------- Co-authored-by: Jarred Sumner <jarred@jarredsumner.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
What
Rewrites the WHATWG Streams implementation (
ReadableStream,WritableStream,TransformStream, all readers/writers/controllers,pipeTo/pipeThrough/tee, and the async iterator) as pure C++ insrc/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):
new ReadableStream({pull(){}})getReader()new WritableStream({write(){}})new TransformStream()The old implementation allocated ~3.1M JS
Functionobjects 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.reader.read()loopfor awaitpipeTo(WritableStream)pipeThrough(TransformStream)tee()+ drain bothReal 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:
reader.read()loop (fresh buffers)for await(fresh buffers)pipeTo(fresh buffers)pipeThrough(fresh buffers)tee()+ drain both (fresh buffers)Consumers and byte sources (materialize real output; MB/s):
new Response(stream).arrayBuffer()Response.text()(Bun extension)Bun.readableStreamToBytes(stream)(Bun extension)readableStreamToBytes(Bun extension)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):TextEncoderStreamTextDecoderStreamCompressionStream(gzip)DecompressionStream(gzip)TextDecoderStreamis 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). NoteCompressionStreamon 1.3.14 (447) vs today'smain(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
ArrayBufferimpls instead ofJSArrayBufferwrapper 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):toTexttoArrayBuffertoBytestoArrayResponse.textResponse.arrayBufferfor awaitThe 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
toTextand a few 64 KiBarrayBuffercells (±10%).Memory (
bench/snippets/webstreams-memory.mjs, release builds):new ReadableStream({pull(){}})getReader()new WritableStream({write(){}})new TransformStream()pipeTo512 MiB (64 KiB chunks)for await512 MiBResponse(stream 256 MiB).arrayBuffer()Response(stream 256 MiB of text).text()The implementation allocates no closures per stream (~3.1M
Functionobjects → ~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):tee(): both branches drained concurrentlytee(): branch B read only after A finishestee(): read A, cancel Bfetch().clone(): read both bodiesfetch().clone(): read one, cancel cloneBun.serve:req.clone(), read both bodiesThe 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.jsfiles includingidlharness.any.js, 1402 subtests, run in CI):expectations.jsonis empty — no expected-failure list. This includes WPT'sidlharness.any.js(228 WebIDL surface subtests: interface-object descriptors, prototype layout, methodlength/name, brand checks), the same file Node runs, vendored withidlharness.js+ the webidl2 parser + the.idldefinitions 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 nowDontEnumlikeURLalready was. The other non-streams constructors (Response,Blob, …) have the same pre-existing issue and are a separate follow-up.testharness.js'sassert_object_equalsrecurses 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 thenread(view)resolving with a zero-length view) matches the WHATWG algorithm text, the reference implementation, Node, and Deno.streams/test files both run in their WPT CI, minus the.tentativeowning-typeproposal (Node expected-fails it) andtransferable/**(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):
ReadableStream.from()#3700 /ReadableStreamfromnode:stream/webdoes not implement methodfrom#32529 —ReadableStream.from()(global andnode:stream/web)ReadableStreamshould always cause aReadableStream is lockederror #6860 — reusing a consumedReadableStreamnow always rejects (Bun.readableStreamTo*on a disturbed, unlocked stream rejects with "ReadableStream has already been used"; the spec'dnew Response(stream)path already threw)ReadableStreamBYOBReader.prototype.read(view, { min })#7091 —ReadableStreamBYOBReader.read(view, { min })values({ preventCancel: true })ReadableStreamBYOBReader.releaseLock()with pending reads (rejects them per spec instead of throwing)pipeTohonorsAbortSignal(sinkabort()and sourcecancel()both run)WritableStreamDefaultController.signalread(view)detaches the supplied bufferReadableStreamDefaultControllermethods failing brand checks ("thisis 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.(#19006 from the same triage list is not claimed: its repro already passes on current
main.)Fixes #3700
Fixes #6860
Fixes #7091
Fixes #10431
Fixes #17081
Fixes #17837
Fixes #26392
Fixes #31156
Fixes #32402
Fixes #32529
Review feedback round (2026-07-03)
All applied on top of the rewrite, each with tests where behavior changed:
Identifier::fromStringin the streams sources (71 sites, several per-chunk) now usesvm.propertyNames/BunBuiltinNames; the three helpers that took a method-name literal takeconst Identifier&.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.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 thebun:internal-for-testingsynthetic allocation limit.JSReadableStreamIntoArrayOperationcell; 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.MarkedArgumentBuffer, strings materialized and sized once); no user-provided iterable was ever iterated twice (ReadableStream.frombuilds one iterator record).VM&threading: internal helpers across the streams sources takeJSC::VM&from their callers instead of re-deriving it from the global object; entry points derive it once.Behavior changes (intentional)
ReadableStreamwithBun.readableStreamTo*now rejects (ERR_INVALID_STATE, "ReadableStream has already been used") instead of resolving with an empty result (Reusing an already consumedReadableStreamshould always cause aReadableStream is lockederror #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).globalThisare non-enumerable, per Web IDL (matches Node and the browsers; found by WPT'sidlharness).reader.closed,writer.closed/ready, and post-release method calls reject with the sameTypeError(+code: "ERR_INVALID_STATE", same messages) that Node 26 produces — verified case-by-case against Node. The old Bun-specificAbortErrorwithcode: "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 onBun(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).pipeTo/pipeThrough(not covered by any WPT subtest) intest/js/web/streams/streams.test.js; thereleaseLocktest 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.Status
idlharness.any.js): 1402/1402 subtests passing,expectations.jsonemptyyield, sink backpressure, iteratorthrow/return),reader.readMany(), direct streams,AsyncLocalStoragepropagation into source callbacksBUN_JSC_validateExceptionChecks=1tee()/Response.clone()/Request.clone()faster with lower peak RSS