Skip to content

HTMLRewriter: accept a Response whose body is a JS-created ReadableStream - #33310

Closed
robobun wants to merge 6 commits into
mainfrom
farm/af70c3fa/htmlrewriter-js-readable-stream
Closed

HTMLRewriter: accept a Response whose body is a JS-created ReadableStream#33310
robobun wants to merge 6 commits into
mainfrom
farm/af70c3fa/htmlrewriter-js-readable-stream

Conversation

@robobun

@robobun robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #14216
Fixes #11758

Repro

const body = new ReadableStream({
  start(c) { c.enqueue(new TextEncoder().encode("<p>hi</p>")); c.close(); },
});

new HTMLRewriter()
  .on("p", { element(e) { e.setInnerContent("bye"); } })
  .transform(new Response(body));
// error: Failed to pipe stream  (code: ERR_STREAM_CANNOT_PIPE)

transform() itself throws, before anything is read. The same body without the rewriter reads fine (new Response(body).text()), and the same rewriter accepts string / Blob / blob.stream() / fetch() bodies. The broken composition is the documented streaming-SSR and middleware shape: build or wrap an HTML body in JS, rewrite it on the way out.

Cause

ValueBufferer::buffer_locked_body_value (src/runtime/webcore/Body.rs) matches on the source stream's kind and rejects two of them outright:

Source::JavaScript | Source::Direct => {
    // this is broken right now
    // return self.create_js_sink(stream);
    return Err(bun_core::err!("UnsupportedStreamType"));
}

HTMLRewriter's BufferOutputSink is the bufferer's only caller, and it maps every error other than StreamAlreadyUsed to ERR_STREAM_CANNOT_PIPE. So a bug class the rewriter never supported surfaces as a generic pipe failure that reads like user error. Source::Bytes (fetch/file/blob bodies) has a native pipe; Source::JavaScript and Source::Direct only the JS runtime knows how to drive, which is what the removed create_js_sink was trying to work around with an ArrayBufferSink.

Fix

Route those two sources through readableStreamToArrayBuffer, the same builtin new Response(stream).arrayBuffer() already uses, and feed the resolved bytes to on_finished_buffering:

  • already-complete stream → the builtin's promise is fulfilled, bytes are delivered synchronously, and transform() keeps its synchronous contract (including throwing a handler's error);
  • pending stream → settles through Bun__BodyValueBufferer__onResolveStream / onRejectStream, the promise reactions that were already wired up and registered in promiseHandlerID but unreachable. An upstream error therefore rejects the transformed body rather than truncating it, matching HTMLRewriter: reject the transformed body when the upstream body fails #32927's behavior for native bodies.

The pending-promise context is a NativePromiseContext cell, so a promise collected without ever settling releases the sink's ref through Bun__NativePromiseContext__destroy instead of leaking.

readableStreamToArrayBuffer can also throw synchronously (for example a chunk that is neither a string nor a view). That exception is already pending on the VM, so it is now propagated instead of being masked by ERR_STREAM_CANNOT_PIPE.

js_sink and its ArrayBufferJSSink alias were the dead remains of the create_js_sink approach being replaced; nothing ever assigned the field.

Scope: this is the input side only

The rewriter still buffers the whole document before running lol-html, as it always has for every body type. The output side is untouched; #32988 and #32956 are the orthogonal output-side changes, and neither one fixes this arm (both still carry // this is broken right now).

One consequence is worth naming, because it is the one thing this PR does not get you. A JS stream that is already complete when transform() returns (the common shape: start() enqueues and closes) resolves synchronously, so the transformed body is a plain blob and every consumer works, including Bun.serve. A JS stream that is still pending at that point now behaves exactly like a native body that is still mid-stream: .text() / .arrayBuffer() / .bytes() / .blob() all work, but reading .body yields "" and returning the response straight from Bun.serve hangs.

That is not introduced here. Both reproduce on unmodified main with a native source — a fetch() whose body is still arriving when transform() runs:

// raw TCP upstream sends "<p>h" then stalls
const up = await fetch(url);
const out = new HTMLRewriter().on("p", {...}).transform(up);
await out.body.getReader().read();   // main: resolves empty   (#19305)
// Bun.serve(() => out)              // main: hangs            (#6068)

So this PR brings JS-backed sources to parity with native ones rather than past them, and #32988 is what closes the remaining gap for both. A .todo in the test file records it.

Three follow-up fixes, each its own commit

The first revision was wrong in three ways. All three were caught by machinery, not by me, and each has a test that fails without its fix:

  • b641742readableStreamToArrayBuffer's C++ wrapper returns under a ThrowScope, whose destructor calls simulateThrow() when returning to native code. Checking the returned JSValue for emptiness is a value check, invisible to JSC's exception-check validation, so the outstanding need-check tripped the next scope (asArrayBuffer's ASSERT_NO_PENDING_EXCEPTION) and aborted the x64-asan lane. Now wrapped in validation_scope! with assert_exception_presence_matches(value.is_empty()).
  • b641742handle_resolve_stream handed lol-html a slice borrowed straight from the resolved ArrayBuffer. toArrayBuffer()'s single-chunk fast path returns the user's own buffer, and lol-html tokenizes in place while dispatching handlers, so a handler could reach into bytes not yet parsed (<a>x</a><zzz> came out as <a>x</a><qqq>) or transfer the buffer and free it mid-read. Now copied into stream_buffer first, as the Source::Bytes path already does.
  • b8c5ff6buffer_locked_body_value roots the source stream, and on this path that root transitively reaches the NativePromiseContext cell holding the sink's refcount, so an abandoned transform leaked its whole BufferOutputSink. Measured: 200 abandoned transforms → +200 live Response objects. Released once the builtin owns the stream, mirroring what set_promise already does for Response.arrayBuffer() (with a comment citing Memory leak if accessing body of fetch() response before consuming the stream #13678 for the same reason).

Verification

Tests land in test/js/workerd/html-rewriter.test.js (the existing HTMLRewriter file — this behavior was never correct, so it is not a regression test). 17 new cases cover: single binary chunk, single string chunk (the builtin returns a Uint8Array view there, a different branch), an element split across chunk boundaries, mixed string/binary chunks, an empty stream, a type: "direct" stream, a stream that only produces after transform() returns, all seven consumption paths (.text(), .arrayBuffer(), .bytes(), .blob(), .json(), getReader(), Bun.readableStreamToText), element handlers observing the document, a stream that errors (before and after transform() returns), a bad chunk surfacing its real TypeError, reuse of a consumed source, the Bun.serve shape from #11758, plus the detach and leak regressions above.

Every one of them fails on main. The two it.todo("works with payload of type direct" / "default") cases in that file were todo for exactly this reason and are un-skipped; they pass now.

Full suite
$ USE_SYSTEM_BUN=1 bun test test/js/workerd/html-rewriter.test.js -t "JavaScript-backed ReadableStream"
 0 pass / 16 fail

$ bun bd test test/js/workerd/html-rewriter.test.js
 87 pass / 1 todo / 0 fail

$ BUN_JSC_validateExceptionChecks=1 bun bd test test/js/workerd/html-rewriter.test.js
 87 pass / 1 todo / 0 fail

$ bun bd test test/js/workerd/ test/js/web/html/ test/regression/issue/htmlrewriter-additional-bugs.test.ts
 245 pass / 1 skip / 1 todo / 0 fail

A note on the leak, since it is the kind of thing that hides: Bun__NativePromiseContext__destroy schedules the deref on the event loop, so Bun.gc(true) followed by heapStats() in the same synchronous turn never observes it. An earlier ad-hoc probe of this exact scenario looked clean for that reason and for the unrelated reason that it only watched for ASAN crashes rather than growth. The committed test drains the loop between collections, and fails with Received: 200 on the unfixed build.


Rebased onto be77b65 (main). Conflicts were with #33909 (bun_core::err! → per-crate thiserror::Error enums) and #33193 (webstreams rewrite in C++); both resolved mechanically — the new crate::Error variants and crate::Result<()> in my code, and the readableStreamToArrayBuffer FFI point is unchanged and still wraps a ThrowScope. All 19 new tests pass unmodified and still fail on stock bun.

This PR still awaits @Jarred-Sumner's steer per the analysis above — rebased to keep it reviewable in the meantime.


[review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: 21 failed, 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/workerd/html-rewriter.test.js
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (b1acf2727)

test/js/workerd/html-rewriter.test.js:
(pass) HTMLRewriter > error handling [4.71ms]
(pass) HTMLRewriter > error inside element handler [5.56ms]
(pass) HTMLRewriter > error inside element handler (string) [4.46ms]
(pass) HTMLRewriter > fast async error inside element handler [20.75ms]
(pass) HTMLRewriter > slow async error inside element handler [17.77ms]
(pass) HTMLRewriter > HTMLRewriter: async replacement [111.30ms]
(pass) HTMLRewriter > HTMLRewriter handles Symbol invalid type error [11.14ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > control: .text() on the untransformed response rejects [325.70ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .text() on the t
... (truncated)

release without fix: 1 skipped
bun test v1.4.0-canary.1 (c9fd3907c)

test/js/workerd/html-rewriter.test.js:
(pass) HTMLRewriter > error handling [0.09ms]
(pass) HTMLRewriter > error inside element handler [0.11ms]
(pass) HTMLRewriter > error inside element handler (string) [0.06ms]
(pass) HTMLRewriter > fast async error inside element handler [12.38ms]
(pass) HTMLRewriter > slow async error inside element handler [1.28ms]
(pass) HTMLRewriter > HTMLRewriter: async replacement [9.55ms]
(pass) HTMLRewriter > HTMLRewriter handles Symbol invalid type error [0.10ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > control: .text() on the untransformed response rejects [6.26ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .text() on the transformed response rejects [2.76ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .arrayBuffer() on the transformed response rejects [1.57ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .body on the transformed response is an errored stream [1.60ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > a read already pending on .body when the upstream fa
... (truncated)
passes on PR (with fix)
ASAN with fix: 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/workerd/html-rewriter.test.js
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (b1acf2727)

test/js/workerd/html-rewriter.test.js:
(pass) HTMLRewriter > error handling [4.67ms]
(pass) HTMLRewriter > error inside element handler [6.26ms]
(pass) HTMLRewriter > error inside element handler (string) [4.70ms]
(pass) HTMLRewriter > fast async error inside element handler [21.15ms]
(pass) HTMLRewriter > slow async error inside element handler [17.03ms]
(pass) HTMLRewriter > HTMLRewriter: async replacement [110.98ms]
(pass) HTMLRewriter > HTMLRewriter handles Symbol invalid type error [11.30ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > control: .text() on the untransformed response rejects [327.31ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .text() on the t
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 719ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 243 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�
... (truncated)
diff hotspot
src/runtime/api/html_rewriter.rs      |   3 +
 src/runtime/error.rs                  |   3 -
 src/runtime/webcore/Body.rs           | 108 +++++++----
 test/js/workerd/html-rewriter.test.js | 338 +++++++++++++++++++++++++++++++++-
 4 files changed, 415 insertions(+), 37 deletions(-)

gate history · 2 passed · 0 rejected · iteration 0

evidence per changed file
file                                   reads  edits  tests
src/runtime/api/html_rewriter.rs           2      3      0
src/runtime/error.rs                       1      2      0
src/runtime/webcore/Body.rs               12     19      0
test/js/workerd/html-rewriter.test.js      4      7      0

@github-actions github-actions Bot added the claude label Jul 3, 2026
@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:58 AM PT - Jul 15th, 2026

@robobun, your commit b1acf27 has 2 failures in Build #73243 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33310

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

bun-33310 --bun

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. HTMLRewriter + new Response(Bun.file) causes Bun.serve to think a non-Response is returned #6068 - HTMLRewriter + new Response(Bun.file) produces a Source::Direct stream body, which this PR now routes through readableStreamToArrayBuffer instead of rejecting
  2. S3Client writes empty file for HTMLRewriter transformed fetch Response #19305 - S3Client writing empty files for HTMLRewriter-transformed fetch Responses is a downstream consequence of the stream source rejection this PR fixes

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #6068
Fixes #19305

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR propagates pending JS exceptions in HTMLRewriter, reworks JavaScript-backed ReadableStream buffering in Body.rs, and expands tests for stream handling, errors, and regressions.

Changes

JS ReadableStream buffering support

Layer / File(s) Summary
Propagate pending JS exception
src/runtime/api/html_rewriter.rs
BufferOutputSink::init returns JsError::Thrown when buffering reports JSError.
ValueBufferer lifecycle rework
src/runtime/webcore/Body.rs
Removes unused sink plumbing, adds extract, replaces js_sink with native stream tracking and a rooted ReadableStream, and updates Drop and initialization.
JS stream completion path
src/runtime/webcore/Body.rs
on_resolve_stream forwards the resolved JS value, rejection values are rooted and passed through, handle_resolve_stream extracts ArrayBuffer bytes, and locked JavaScript or Direct streams are buffered through buffer_js_readable_stream.
HTMLRewriter JS stream tests
test/js/workerd/html-rewriter.test.js
Adds coverage for JavaScript-backed ReadableStream inputs, async production, error cases, buffer detachment, GC regressions, pending-body behavior, and enabled payload cases.

Possibly related PRs

  • oven-sh/bun#30196: Both PRs adjust HTMLRewriter BufferOutputSink error handling.
  • oven-sh/bun#32927: Both PRs modify the HTMLRewriter buffering and error propagation paths.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly states the main change: accepting JS-created ReadableStream bodies in HTMLRewriter.
Description check ✅ Passed The description covers the change, repro/cause/fix, and verification, though it doesn't use the exact template headings.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

Inline comments:
In `@test/js/workerd/html-rewriter.test.js`:
- Around line 470-474: The test "reusing the transformed response's source
stream throws" is too broad because it uses a bare toThrow() and does not verify
the intended failure. Update the assertion around rewriter().transform(response)
to check the exact error message or error class used by this repo’s conventions,
specifically matching the "Response body already used" failure. Keep the test
focused on the transform(response) reuse path and assert the normalized error
value instead of any thrown exception.
🪄 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: a0f1afd2-c307-4af4-9814-5ef40de3d585

📥 Commits

Reviewing files that changed from the base of the PR and between 1498d7b and 90a1783.

📒 Files selected for processing (3)
  • src/runtime/api/html_rewriter.rs
  • src/runtime/webcore/Body.rs
  • test/js/workerd/html-rewriter.test.js

Comment thread test/js/workerd/html-rewriter.test.js
@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Found 2 issues this PR may fix: #6068, #19305

Checked both against this branch. Neither is fixed here, and the reasoning in the suggestion does not hold:

Both reproduce on unmodified main with a native source — a fetch() whose body is still arriving when transform() returns — so they cannot be a consequence of the Source::JavaScript/Source::Direct rejection this PR removes:

// raw TCP upstream writes "<p>h" then stalls
const up = await fetch(url);
const out = new HTMLRewriter().on("p", { element: e => e.setInnerContent("bye") }).transform(up);

await out.body.getReader().read();    // resolves { value: undefined, done: true }   -> #19305
// Bun.serve({ fetch: () => out })    // hangs forever                               -> #6068

Verified on this branch: both still reproduce, byte for byte. What this PR does change is that a JS-backed source now reaches that same (still-broken) output path instead of throwing ERR_STREAM_CANNOT_PIPE up front, which is parity with native sources rather than a fix for them. #32988 is the output-side change that closes both; the PR description now spells this out and the test file carries a .todo recording the gap.

Not adding the Fixes lines.

Comment thread src/runtime/webcore/Body.rs
Comment thread test/js/workerd/html-rewriter.test.js
Comment thread src/runtime/webcore/Body.rs Outdated
@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Both review findings were right, and both are fixed in b641742. Thanks — the second one is a bug I would not have found from the CI log alone.

1. Unchecked exception scope (the x64-asan failure)

Correct diagnosis. ~ThrowScope() calls simulateThrow() unconditionally when returning to native code (not LLInt/JIT), so m_needExceptionCheck is set even on the success path. promise_value.is_empty() is a value check, invisible to the validator, so the obligation was still outstanding when as_array_buffer constructed the next scope.

Reproduced locally, byte for byte:

$ BUN_JSC_validateExceptionChecks=1 bun bd test ... -t "single Uint8Array chunk"
ERROR: Unchecked JS exception:
    This scope can throw a JS exception: ZigGlobalObject__readableStreamToArrayBufferBody @ ReadableStream.cpp:531
    But the exception was unchecked as of this scope: JSC__JSValue__asArrayBuffer @ bindings.cpp:3296
ASSERTION FAILED: exception check validation failed

Fixed with validation_scope!, which makes the empty-value/pending-exception correspondence the thing the validator observes:

let promise_value = {
    bun_jsc::validation_scope!(scope, global);
    let value = global.readable_stream_to_array_buffer(stream.value);
    scope.assert_exception_presence_matches(value.is_empty());
    value
};

Whole file now passes under BUN_JSC_validateExceptionChecks=1; reverting just this hunk reproduces the abort.

2. Rewriting out of a detachable user buffer

Also correct, and it is observable without even getting to the free. toArrayBuffer()'s single-chunk fast path returns view.buffer verbatim, and lol-html tokenizes in place while dispatching handlers, so the handler is writing into bytes the parser has not reached yet:

let chunk;
const body = new ReadableStream({
  start(c) { chunk = new TextEncoder().encode("<a>x</a><zzz>y</zzz>"); c.enqueue(chunk); c.close(); },
});
const out = new HTMLRewriter()
  .on("a", { element() { chunk.set(new TextEncoder().encode("qqq"), 9); } })
  .transform(new Response(body));

await out.text();
// before: "<a>x</a><qqq>y</zzz>"   <- the handler's write leaked into the unparsed tail
// after:  "<a>x</a><zzz>y</zzz>"

Adding chunk.buffer.transfer() + Bun.gc(true) is the same aliasing with a free at the end; ASAN does not flag it because the backing store is bmalloc/libpas-allocated, which is exactly why the mutation variant is the useful assertion.

Fixed by copying into the bufferer's existing stream_buffer before handing the slice on, which is what the Source::Bytes path already does and what the doc comment was (wrongly) claiming. The regression test does not rewrite out of the source buffer a handler can detach fails on the unfixed build with <qqq> and passes now.

The bare toThrow() nit was already fixed in 6820d83.

Comment thread src/runtime/webcore/Body.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

b8c5ff6 addresses the last finding (the readable_stream_ref Strong is now released before the builtin returns, and the new leak test covers it) — nothing further from me, but given the JSC GC-root / refcount / exception-scope interactions this touches and the three memory-safety fixes already made during review, this warrants a human pass over the final state.

Extended reasoning...

Overview

This PR routes Source::JavaScript and Source::Direct ReadableStream bodies through readableStreamToArrayBuffer so HTMLRewriter.transform() accepts a JS-created stream instead of throwing ERR_STREAM_CANNOT_PIPE. It touches ~130 lines of native code across src/runtime/webcore/Body.rs (new buffer_js_readable_stream, reworked handle_resolve_stream, dead js_sink/ArrayBufferJSSink removal) and src/runtime/api/html_rewriter.rs (surface pending JS exceptions from the bufferer), plus ~280 lines of new tests in test/js/workerd/html-rewriter.test.js and un-skips two existing .todo payload tests.

What changed since my last review

Commit b8c5ff6 addresses the Strong-ref leak I flagged: buffer_js_readable_stream now calls self.readable_stream_ref.deinit() immediately after handing the stream to the builtin, with a comment explaining the native↔JS cycle it would otherwise create, and a new heapStats-based leak test (does not leak a transform whose source stream never settles) that abandons 200 default-stream transforms and asserts live Response count doesn't grow. All three prior findings (detachable-buffer UAF, unchecked exception scope, Strong-ref leak) now have both the fix and a targeted regression test in the diff. The current bug-hunting run found nothing new.

Security risks

No new externally-reachable attack surface — the input is a developer-supplied ReadableStream, not remote data. The memory-safety concerns (borrowed slice into a user-detachable ArrayBuffer fed to lol-html while it re-enters user JS; refcount balance on the abandoned-promise path; exception-scope validation) have each been addressed with a fix and a test. I did not find further issues on this pass.

Level of scrutiny

High. This is native runtime code at the intersection of JSC GC roots (Strong, NativePromiseContext), intrusive refcounting (BufferOutputSink), JSC exception-scope validation, and re-entry into user JS while holding borrowed native state — exactly the bug classes CLAUDE.md flags as most-blocked. Three separate memory-safety issues surfaced and were fixed during review; that history alone argues for a human confirming the final composition (particularly the readable_stream_ref.deinit() placement relative to the synchronous Fulfilled/Rejected arms and the is_empty() early return, and the stream_buffer copy on the resolve path).

Other factors

Test coverage is thorough (14 new cases spanning sync/async, direct/default, error paths, all consumption APIs, the detach case, the leak case, and the Bun.serve shape). The PR description is precise about what is and isn't fixed (output-side .body gap recorded as .todo). CI on the latest commit (build #68226) was still running when I reviewed.

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed that this wants a human pass. The two things named as worth checking are the right two, so e33494e pins both down in-tree rather than leaving them as prose.

readable_stream_ref.deinit() placement

The invariant it rests on: anything able to settle the stream holds the controller, and the controller holds the stream ([[controlledReadableStream]]). So dropping our root cannot strand a transform that still has a way to finish, and a transform with no way to finish is exactly the one that should be collectable.

Against the three arms:

  • is_empty() early return — the builtin threw after draining; the body is already Value::Used and locked.readable was moved out, so nothing can reach the stream. Drop for ValueBufferer deinits again, which is a no-op (handle.take()).
  • Fulfilled / Rejected (synchronous) — the bytes are already copied out and the stream is consumed. stream.value is still a live local, so it is conservatively scanned for the rest of the call anyway.
  • Pending — covered by the invariant above.

New describe block exercises the shapes where the stream is reachable from nothing a user holds, collecting hard mid-flight before letting it finish: controller held only by a timer, controller escaped to an outer scope and driven from a microtask, and a direct stream whose controller is reachable only from a pending pull. All three complete. (For direct streams the user's pull closure also roots capability.promise independently, which is the same reason they never hit the leak.)

stream_buffer copy on the resolve path

Two distinct questions, both now machine-checked:

  • Is the slice still valid when lol-html reads it? stream_buffer lives inside the sink, which is pinned by the ScopedRef::adopt held across on_finished_buffering. run_output_sink appends only to (*sink).bytes, a different field, so nothing reallocates the buffer while it is borrowed. That is the whole point of the copy: the previous code borrowed the user's ArrayBuffer, which could go away.
  • Can the buffer already hold bytes, making the copy append rather than replace? No — only the Source::Bytes pipe appends to stream_buffer, and a bufferer drives exactly one source. Now a debug_assert!(self.stream_buffer.list.is_empty()), matching the two debug_assert!s the Source::Bytes arm already carries a few lines down, so every debug/ASAN run checks it.

Where that leaves it

90 pass / 1 todo / 0 fail on the file, clean under BUN_JSC_validateExceptionChecks=1, 245+ across the adjacent suites, and CI was green on the previous commit (70 pass, 0 fail). Each of the three memory-safety fixes has a test that fails on the build without it: <qqq> for the detach, the exception-check abort for the scope, Received: 200 for the leak.

The honest summary for whoever picks this up: three of the four commits fix bugs in my own first revision, all in the same class — I reasoned about a contract (exception-check obligation, slice lifetime, refcount balance) instead of checking what the code actually does. The leak is the clearest case: set_promise, two thousand lines up in the same file, already performed the exact deinit() I was missing, with a comment citing #13678 for the same reason. Worth weighing when deciding how hard to look at the final composition.

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Ready for review. The one red lane is not this diff.

Build #68227 on e33494e: 285 jobs passed, 1 failed. The failure is test/js/bun/terminal/terminal.test.ts on 🍎 14 x64:

✗ Bun.spawn with terminal option > creates subprocess with terminal attached [90000.00ms]
  ^ this test timed out after 90000ms.

That is a PTY test. This diff touches src/runtime/webcore/Body.rs and src/runtime/api/html_rewriter.rs — HTMLRewriter body buffering, nothing near Bun.spawn.

It is currently failing pipeline-wide, on unrelated branches:

build branch same failure
#68214 farm/1eb15047/redis-close-during-reconnect yes
#68210 farm/1eb15047/redis-close-during-reconnect yes
#68197 farm/9a2d4aa3/child-process-spawn-error yes

So I'm not re-rolling — it would land on the same lane. test/js/workerd/html-rewriter.test.js carries no error annotation on any lane, including x64-asan, which is the lane that caught the exception-scope bug in b641742.

What's here

HTMLRewriter.transform() threw ERR_STREAM_CANNOT_PIPE for any Response built from new ReadableStream({...}) or a type: "direct" stream — buffer_locked_body_value rejected those two sources outright, in an arm commented // this is broken right now. They now route through readableStreamToArrayBuffer, the same builtin new Response(stream).arrayBuffer() already uses. Fixes #14216 and #11758.

90 pass / 1 todo / 0 fail on the file, clean under BUN_JSC_validateExceptionChecks=1, 245+ across adjacent suites. Each memory-safety fix has a test that fails on the build without it.

What I'd want a reviewer to look at

Three of the five commits fix bugs in my own first revision — unobserved ThrowScope, lol-html tokenizing a user-detachable ArrayBuffer, and a GC root pinning the NativePromiseContext cell. All three were the same failure mode: I reasoned about a contract instead of checking what the code does. The leak is the clearest case — set_promise, two thousand lines up in the same file, already performed the exact deinit() I had left out, with a comment citing #13678 for the same reason.

The code is right now, and the invariants are spelled out in this comment and encoded as tests. But that history is the honest argument for a careful pass over the final composition rather than taking my word for it — particularly the readable_stream_ref.deinit() placement and the stream_buffer copy on the resolve path.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The correct fix to make HTMLRewriter not buffered.

@robobun

robobun commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

You're right, and digging into why has made the two problems collapse into one. This PR is the wrong fix.

The buffering and the JS-stream rejection are the same bug

HTMLRewriter is the only caller of ValueBufferer (Body.rs). ValueBufferer is a bespoke "read a whole body into one slice" helper that switches on the source kind and rejects two of them outright:

Source::JavaScript | Source::Direct => {
    // this is broken right now
    // return self.create_js_sink(stream);
    return Err(bun_core::err!("UnsupportedStreamType"));
}

Meanwhile the runtime already has the primitive this wants to be: ResumableSink (src/runtime/webcore/ResumableSink.rs).

ResumableSink allows a simplified way of reading a stream into a native Writable Interface, allowing to pause and resume the stream without the use of promises. [...] Different from JSSink this is not intended to be exposed to the users.

It is a two-method trait:

pub trait ResumableSinkContext {
    fn write_request_data(&mut self, bytes: &[u8]) -> ResumableSinkBackpressure;
    fn write_end_request(&mut self, err: Option<JSValue>);
}

ResumableSink::init handles every source kind in one function: locked/disturbed errors out, Source::Bytes takes the native Pipe fast path, and everything else (JavaScript, Direct, Blob, File) goes through Bun__assignStreamIntoResumableSink. It already streams chunk-by-chunk with backpressure. fetch() request-body uploads and S3 multipart uploads are built on it.

So HTMLRewriter buffers because it reimplemented this badly, and it rejects JS streams because that reimplementation never finished. Pointing BufferOutputSink at ResumableSink instead of ValueBufferer makes the input streaming and makes Source::JavaScript/Source::Direct work as a consequence, while deleting code rather than adding it: this PR's readableStreamToArrayBuffer detour, the NativePromiseContext plumbing, the dead js_sink/create_js_sink remnants, and ValueBufferer itself (nothing else uses it).

The one real prerequisite

html_rewriter.rs runs an async content handler by nesting the event loop inside HtmlRewriter::write():

if let Some(promise) = result.as_any_promise() {
    vm().wait_for_promise(promise);   // nested drain, mid-write

That is only safe today because write() is called exactly once, after the body is fully buffered. Under streaming it re-enters the pump while HtmlRewriter::write(&mut self) is borrowed. #33243 is the fix for that, and it is the gate on everything else here. ResumableSink's pause/drain() is the natural backpressure handle for the deferred-drive design that falls out of it.

Proposal

There are four open PRs in this area, all mine, all overlapping, none landing: #32956 (output streaming), #32988 (full streaming rework, bolts an on_chunk callback onto ValueBufferer), #33243 (suspend async handlers), and this one. That sprawl is my fault and it is not helping you.

Unless you say otherwise I will:

  1. Land HTMLRewriter: suspend async content handlers instead of nesting the event loop #33243 first, since nothing else is safe without it.
  2. Replace ValueBufferer with ResumableSink in BufferOutputSink. Input streams; JS/direct sources fixed as a side effect; HTMLRewriter does not work on ReadableStream + .text() #14216 and HTMLRewriter fails to process Responses created from ReadableStreams #11758 close; ValueBufferer is deleted.
  3. Then the output side (the ByteStream handoff in HTMLRewriter: deliver the transformed output to consumers of the response's body stream #32956/HTMLRewriter: stream the rewrite instead of buffering the whole document #32988) as its own change.
  4. Close this PR and fold HTMLRewriter: stream the rewrite instead of buffering the whole document #32988 into step 2, which should shrink it substantially, since the on_chunk bolt-on and the unsafe it drags along both disappear.

The test suite here (JS/direct sources, chunk boundaries, all the consumption paths, the error and abandoned-stream cases) carries over unchanged and is the regression net for step 2, so the work is not wasted.

Say the word if you'd rather I keep them separate or sequence them differently.

robobun added 5 commits July 15, 2026 10:38
…ream

transform() threw ERR_STREAM_CANNOT_PIPE ("Failed to pipe stream") for any
Response built from `new ReadableStream({...})` or a `type: "direct"` stream.
ValueBufferer::buffer_locked_body_value rejected Source::JavaScript and
Source::Direct outright; the arm it returned from was commented "this is
broken right now".

Route those sources through readableStreamToArrayBuffer, which is the same
path `new Response(stream).arrayBuffer()` already takes, and hand the
resolved bytes to on_finished_buffering. A stream that is already complete
resolves synchronously and keeps transform()'s synchronous contract; a
pending one settles through the existing onResolveStream/onRejectStream
promise reactions, so an upstream error rejects the transformed body instead
of truncating it.

readableStreamToArrayBuffer can also throw synchronously (a chunk that is
neither a string nor a view). Propagate that pending exception instead of
masking it with ERR_STREAM_CANNOT_PIPE.

js_sink and its ArrayBufferJSSink alias were the dead remains of the
create_js_sink approach this replaces; nothing ever set the field.

Fixes #14216
Fixes #11758
…d bytes

Two fixes to the JS-ReadableStream path.

`readableStreamToArrayBuffer`'s C++ wrapper returns under a ThrowScope, whose
destructor runs simulateThrow() when returning to native code. Checking the
returned JSValue for emptiness is invisible to JSC's exception-check
validation, so the unobserved need-check tripped the next scope
(`JSC__JSValue__asArrayBuffer`'s ASSERT_NO_PENDING_EXCEPTION) and aborted the
x64-asan lane. Wrap the call in a validation_scope! and assert the
empty-value/pending-exception correspondence.

handle_resolve_stream handed lol-html a slice borrowed straight from the
resolved ArrayBuffer. toArrayBuffer()'s single-chunk fast path returns the
user's own buffer verbatim, and lol-html tokenizes its input in place while
dispatching handlers, so a handler could mutate the bytes still being parsed
(or transfer the buffer and free the backing store). Copy into stream_buffer
first, as the Source::Bytes path already does.
buffer_locked_body_value roots the source stream in readable_stream_ref before
dispatching. On the JS-stream path that root also transitively reaches the
NativePromiseContext cell (stream -> reader -> readRequests -> promise
reactions -> cell), and the cell holds the sink's in-flight +1. So the cell was
never collected, Bun__NativePromiseContext__destroy never fired, and a transform
over a stream that never settles leaked its whole BufferOutputSink: the
rewriter, its output buffer, and the Strong pinning the transformed Response.

Release the root once readableStreamToArrayBuffer has taken the stream over
through its own reader, exactly as set_promise already does for
Response.arrayBuffer(). Whatever drives the source keeps the returned promise
alive while the transform is live; when nothing does, the chain is collectable
and the deferred deref runs.

Measured over 200 abandoned transforms: +200 live Responses before, 0 after.
Direct streams never hit this (readableStreamToArrayBufferDirect stores nothing
on the stream that reaches the capability promise).
… unrooted sources

The bufferer drops its GC root on the source once readableStreamToArrayBuffer
owns it, so a live transform is kept alive only by whatever can still settle the
stream. Pin that down: settling needs the controller, and the controller holds
the stream, so the three shapes that hide the stream from userland (controller
held by a timer, escaped to an outer scope, or reachable only from a pending
direct pull) still complete after collecting hard mid-flight.

Also debug_assert that handle_resolve_stream is the sole writer to
stream_buffer, matching the assertions the Source::Bytes arm already carries.
@robobun
robobun force-pushed the farm/af70c3fa/htmlrewriter-js-readable-stream branch from e33494e to c9fd390 Compare July 15, 2026 10:45
Comment thread src/runtime/webcore/Body.rs
The only construction site was the Source::JavaScript | Source::Direct arm of
buffer_locked_body_value, which this PR replaces.
@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #35324, which does the ResumableSink swap proposed in the analysis above: BufferOutputSink reads the input body through a new ResumableHTMLRewriterSink variant, ValueBufferer is deleted, and the Source::JavaScript/Source::Direct arm is handled by the same JS pump fetch and S3 already use. The test coverage from this PR is carried over there.

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #36697.

@robobun robobun closed this Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTMLRewriter does not work on ReadableStream + .text() HTMLRewriter fails to process Responses created from ReadableStreams

2 participants