Skip to content

HTMLRewriter: read the input body through ResumableSink instead of ValueBufferer - #35324

Closed
robobun wants to merge 13 commits into
mainfrom
farm/04e92bf8/htmlrewriter-resumable-sink
Closed

HTMLRewriter: read the input body through ResumableSink instead of ValueBufferer#35324
robobun wants to merge 13 commits into
mainfrom
farm/04e92bf8/htmlrewriter-resumable-sink

Conversation

@robobun

@robobun robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Fixes #14216
Fixes #11758
Fixes #19305

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() throws for any Response whose body is a JS-created ReadableStream. The same body without the rewriter reads fine, 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 matched on the stream source kind and rejected Source::JavaScript | Source::Direct outright. ValueBufferer was a bespoke "read a whole body into one slice" helper whose only caller was HTMLRewriter, and it never finished implementing the JS-driven source kinds. More fundamentally, BufferOutputSink owned the lol-html rewriter and was its output target: SinkRef wrote to self.bytes and called self.done(), so every driver of the rewriter had to hold BufferOutputSink as a root *mut to avoid re-entering its own &mut. That is the reason run_output_sink on main takes *mut Self and init() is peppered with "do not hold &mut *sink" notes.

Fix

BufferOutputSink is restructured so the rewriter's output target is a separate allocation and the input is driven per chunk:

  • Output = ByteStream. The output Response body is a ByteStream from the start; SinkRef writes chunks to it via on_data, never back into BufferOutputSink. The self-reference is gone, so feed/finish/fail take &self and the raw-pointer field-access pattern in init() is gone. This also fixes S3Client writes empty file for HTMLRewriter transformed fetch Response #19305: .body.getReader(), Bun.readableStreamToText(body) and Bun.serve returning a transformed response all read the same ByteStream regardless of whether the input has settled.

  • Input = ResumableSink. A third codegen'd sink variant ResumableHTMLRewriterSink sits alongside ResumableFetchSink / ResumableS3UploadSink. start_reading_input does to_readable_stream() + ResumableHTMLRewriterSink::init for stream bodies (including Source::JavaScript / Source::Direct / file-backed blobs / Source::Bytes), and a short synchronous path for materialised bodies so transform(String | ArrayBuffer) still returns a value synchronously. Value::Error is handled synchronously so transform() of an already-failed body still throws.

  • Per-chunk rewriter.write(). write_request_data calls HtmlRewriter::write per chunk. ResumableSinkContext::AVOID_NATIVE_PIPE (true for BufferOutputSink, default false) routes Source::Bytes through the JS pump instead of the native pipe: the pump's m_reading guard and read-after-write ordering in BunStreamSource.cpp prevent the nested wait_for_promise in an async handler from delivering the next chunk while write() is on the stack, so no re-entrancy guard is needed. feed copies the chunk before write() because lol-html parses the first chunk straight from the input slice and a handler could otherwise mutate bytes it has not yet tokenized.

  • Error handling. HandlerErrorScope is an RAII guard that points vm.unhandled_pending_rejection_to_capture at a local cell and installs the quiet rejection handler. Every feed/finish call runs under one, so create_lolhtml_error recovers the original JS error a handler threw on both sync and async paths. fail() stores the error in a strong::Optional field so a sync handler error inside init() still makes transform() throw, and pushes the error to the output ByteStream so .text()/.body reject.

Deletions

Net -285 lines.

  • ValueBufferer (~430 lines of Body.rs) and its FFI / NativePromiseContext::Tag / PromiseFunctions / crate::Error surface.
  • BufferOutputSink fields bytes, response, response_value, body_value_bufferer, tmp_sync_error and methods on_finished_buffering, run_output_sink, done, write, write_tmp_sync_error.
  • JSSink<ArrayBufferSink>::detach_self and the ArrayBufferJSSink alias.
  • crate::Error::{UnsupportedStreamType, StreamAlreadyUsed, InvalidStream}.

Verification

Tests land in test/js/workerd/html-rewriter.test.js. 20 new cases cover single/multi/mixed-chunk JS streams, type: "direct", a stream that only produces after transform() returns, every consumption path (.text(), .arrayBuffer(), .bytes(), .blob(), .json(), .body.getReader(), Bun.readableStreamToText), handlers observing the document, upstream errors before and after transform() returns, a bad chunk type surfacing its TypeError, reuse of a consumed source, a handler mutating/transferring the source buffer mid-scan, aggressive GC while the source is in flight, .body of a transform whose source is still pending, and the Bun.serve shape from #11758. The two pre-existing it.todo('works with payload of type direct' / 'default') cases were todo for this reason and now pass.

The transform rejects when the upstream body fails suite is adapted for streaming: chunks delivered before the failure now reach .body, so the assertions read to completion instead of expecting a single rejected .read(); the second-.text() assertion is removed because the body has been consumed (spec behavior).

All 21 fail on main with ERR_STREAM_CANNOT_PIPE.

Runs
$ USE_SYSTEM_BUN=1 bun test test/js/workerd/html-rewriter.test.js -t 'JavaScript-backed'
 0 pass  19 fail

$ USE_SYSTEM_BUN=1 bun test test/js/workerd/html-rewriter.test.js -t 'payload of type'
 4 pass  2 fail

$ bun bd test test/js/workerd/html-rewriter.test.js
 90 pass  0 fail

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

$ bun bd test test/js/workerd/html-rewriter-end-error.test.ts test/js/web/html/html-rewriter-doctype.test.ts test/regression/issue/21680.test.ts test/regression/issue/19219.test.ts
 8 pass  0 fail

Relationship to #33310

#33310 routed the same arm through readableStreamToArrayBuffer and was blocked on the grounds that HTMLRewriter should stop buffering. This PR is the direction that review pointed at: the rewriter runs per chunk, the output is a stream from the start, and the bespoke bufferer is deleted. It supersedes #33310; the test coverage from that PR is carried over here.


[review] gate passed · iteration 9 · 18 files touched

fails on main (without fix)
ASAN without fix: 24 FAILED
$ 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
bun test v1.4.0 (7353853d2)

test/js/workerd/html-rewriter.test.js:
(pass) HTMLRewriter > error handling [11.76ms]
(pass) HTMLRewriter > error inside element handler [6.86ms]
(pass) HTMLRewriter > error inside element handler (string) [5.45ms]
(pass) HTMLRewriter > fast async error inside element handler [22.18ms]
(pass) HTMLRewriter > slow async error inside element handler [18.40ms]
(pass) HTMLRewriter > HTMLRewriter: async replacement [127.27ms]
(pass) HTMLRewriter > HTMLRewriter handles Symbol invalid type error [14.25ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > control: .text() on the untransformed response rejects [372.80ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .text() on the transformed response rejects [63.68ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .arrayBuffer() on the transformed response rejects [47.27ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .body on the tra
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (293173d6b)

test/js/workerd/html-rewriter.test.js:
(pass) HTMLRewriter > error handling [0.40ms]
(pass) HTMLRewriter > error inside element handler [0.18ms]
(pass) HTMLRewriter > error inside element handler (string) [0.05ms]
(pass) HTMLRewriter > fast async error inside element handler [3.60ms]
(pass) HTMLRewriter > slow async error inside element handler [1.26ms]
(pass) HTMLRewriter > HTMLRewriter: async replacement [11.02ms]
(pass) HTMLRewriter > HTMLRewriter handles Symbol invalid type error [0.14ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > control: .text() on the untransformed response rejects [6.98ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .text() on the transformed response rejects [2.69ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .arrayBuffer() on the transformed response rejects [1.93ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .body on the transformed response is an errored stream [2.00ms]
(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: all passed
$ 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
bun test v1.4.0 (7353853d2)

test/js/workerd/html-rewriter.test.js:
(pass) HTMLRewriter > error handling [11.35ms]
(pass) HTMLRewriter > error inside element handler [7.06ms]
(pass) HTMLRewriter > error inside element handler (string) [5.02ms]
(pass) HTMLRewriter > fast async error inside element handler [21.75ms]
(pass) HTMLRewriter > slow async error inside element handler [19.99ms]
(pass) HTMLRewriter > HTMLRewriter: async replacement [118.41ms]
(pass) HTMLRewriter > HTMLRewriter handles Symbol invalid type error [19.39ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > control: .text() on the untransformed response rejects [351.70ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .text() on the transformed response rejects [66.08ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .arrayBuffer() on the transformed response rejects [40.83ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .body on the tra
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 910ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/122] gen cpp.rs (cppbind)
[2/122] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[3/122] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api/h2.classes.ts
  - H2Fra
... (truncated)
diff hotspot
src/jsc/bindings/NativePromiseContext.h  |   1 -
 src/jsc/bindings/ZigGlobalObject.cpp     |   8 +-
 src/jsc/bindings/ZigGlobalObject.h       |   4 +-
 src/jsc/bindings/headers-cpp.h           |   3 -
 src/jsc/bindings/headers.h               |  12 +-
 src/jsc/generated.rs                     |   5 +-
 src/jsc/generated_classes_list.rs        |   1 +
 src/runtime/api/NativePromiseContext.rs  |  25 +-
 src/runtime/api/ResumableSink.classes.ts |   6 +-
 src/runtime/api/html_rewriter.rs         | 623 +++++++++++++------------------
 src/runtime/error.rs                     |   9 -
 src/runtime/webcore.rs                   |   4 +-
 src/runtime/webcore/Blob.rs              |  32 ++
 src/runtime/webcore/Body.rs              | 432 +--------------------
 src/runtime/webcore/ResumableSink.rs     |  33 +-
 src/runtime/webcore/Sink.rs              |  13 -
 src/runtime/webcore/blob/write_file.rs   |  52 ++-
 test/js/workerd/html-rewriter.test.js    | 391 +++++++++++++++++--
 18 files changed, 758 insertions(+), 896 deletions(-)

gate history · 3 passed · 2 rejected · iteration 9

evidence per changed file
file                                      reads  edits  tests
src/jsc/bindings/NativePromiseContext.h       1      1      0
src/jsc/bindings/ZigGlobalObject.cpp          2      2      0
src/jsc/bindings/ZigGlobalObject.h            2      2      0
src/jsc/bindings/headers-cpp.h                2      1      0
src/jsc/bindings/headers.h                    3      3      0
src/jsc/generated.rs                          1      1      0
src/jsc/generated_classes_list.rs             1      1      0
src/runtime/api/NativePromiseContext.rs       2      1      0
src/runtime/api/ResumableSink.classes.ts      1      1      0
src/runtime/api/html_rewriter.rs             22     19      0
src/runtime/error.rs                          1      2      0
src/runtime/webcore.rs                        1      1      0
src/runtime/webcore/Blob.rs                   4      4      0
src/runtime/webcore/Body.rs                  13     11      0
src/runtime/webcore/ResumableSink.rs          7      4      0
src/runtime/webcore/Sink.rs                   1      1      0
(+ 2 more files)

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 11 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2a092aec-2541-490a-87e3-375cf97b8262

📥 Commits

Reviewing files that changed from the base of the PR and between 44f6469 and 7353853.

📒 Files selected for processing (18)
  • src/jsc/bindings/NativePromiseContext.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • src/jsc/bindings/headers-cpp.h
  • src/jsc/bindings/headers.h
  • src/jsc/generated.rs
  • src/jsc/generated_classes_list.rs
  • src/runtime/api/NativePromiseContext.rs
  • src/runtime/api/ResumableSink.classes.ts
  • src/runtime/api/html_rewriter.rs
  • src/runtime/error.rs
  • src/runtime/webcore.rs
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/Body.rs
  • src/runtime/webcore/ResumableSink.rs
  • src/runtime/webcore/Sink.rs
  • src/runtime/webcore/blob/write_file.rs
  • test/js/workerd/html-rewriter.test.js

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

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:40 PM PT - Jul 25th, 2026

@robobun, your commit 7353853 is building: #81817

@github-actions

Copy link
Copy Markdown
Contributor

Comment thread src/runtime/api/html_rewriter.rs Outdated

@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.

The Stacked Borrows concern is resolved — dropping the back-pointer entirely (20789b1) is cleaner than either fix I suggested, and the init() ref_count=1 path leaves the sink owned solely by its own pipe-ref/JS-wrapper lifecycle. No further issues found, but this rewires enough unsafe refcounting and provenance-sensitive code that a human should sign off.

What was reviewed:

  • Refcount balance on BufferOutputSink across every start_reading_input arm (Used/Empty/Error/Blob/file/Locked) — the in-flight +1 is consumed exactly once by on_input_end on Ok, or released by the caller on Err.
  • ResumableHTMLRewriterSink ownership after the back-pointer removal: no reverse edge from BufferOutputSink::Drop into end_pipe's live &mut self.
  • is_async derivation from tmp_sync_error.is_none() and its nulling after start_reading_input returns — matches the old ValueBufferer sync/async split.
  • Deleted ValueBufferer/NativePromiseContext::BodyValueBufferer/error variants have no remaining callers.
Extended reasoning...

Overview

This PR replaces HTMLRewriter's bespoke ValueBufferer (the only caller of ~430 lines in Body.rs) with the existing ResumableSink primitive that fetch() request-body uploads and S3 multipart uploads already use. A third codegen'd monomorphization ResumableHTMLRewriterSink is added (one-line entries in .classes.ts, generated.rs, generated_classes_list.rs, ResumableSink.rs), and BufferOutputSink implements ResumableSinkContext to receive chunks. start_reading_input now dispatches on the body Value variant directly instead of delegating to ValueBufferer::run. All ValueBufferer-only machinery is deleted: the struct, its promise host-fns, its NativePromiseContext::Tag entry, three crate::Error variants, and JSSink<ArrayBufferSink>::detach_self. Net -36 lines across 16 files.

Security risks

None identified. This is internal plumbing between a Response body and lol-html; no new user-controlled input parsing, no auth/crypto, no filesystem/network surface. The chunk-copy into input_buffer before running the rewriter (tested by "does not rewrite out of the source buffer a handler can detach") is a memory-safety improvement over reading a user-mutable buffer.

Level of scrutiny

High. The core of the change is unsafe Rust with raw *mut BufferOutputSink pointers, intrusive Cell<u32> refcounts, and explicit Stacked Borrows provenance management (the this self-pointer field, the "recover root provenance" pattern in write_end_request, the raw-pointer-only accesses in init()). My previous review found a real SB violation on the reverse edge (BufferOutputSink::Drop re-entering ResumableSink while end_pipe held &mut self); the author fixed it by removing the back-pointer entirely, which is correct — the ResumableSink allocation is now owned solely by its own lifecycle (pipe ref on the Bytes path, JS wrapper Strong on the JS-pump path), and BufferOutputSink never touches it after init() returns. I re-traced the refcount on BufferOutputSink through every start_reading_input arm and every terminal path lands at exactly one on_input_end (which adopts the in-flight +1) or one explicit deref on Err.

Other factors

  • Test coverage is thorough: 18 new cases plus 2 un-todo'd, covering sync/async delivery, error before/after transform() returns, GC-under-load, buffer detachment mid-scan, and the Bun.serve shape from #11758. All fail on main with ERR_STREAM_CANNOT_PIPE and pass on the PR (verified in the description under ASAN and validateExceptionChecks).
  • The PR explicitly supersedes #33310, which a maintainer blocked on the grounds that HTMLRewriter should stop buffering — this is the direction that review pointed at, so a maintainer confirming the architectural choice is appropriate.
  • The acknowledged pre-existing limitations (#19305 output-side, and the ResumableSink abandoned-transform leak shape shared with fetch/S3) are documented as .todo / not-changed-here rather than silently carried; that's the right call for scope but worth a maintainer nod.
  • CI shows one unrelated flake (test/js/bun/spawn/spawn.test.ts) on x64 lanes; not touched by this PR.

Given the scope of unsafe code and the explicit relationship to a previously-blocked PR, this should get a human review even though I found nothing further to flag.

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Ready for review

At 7353853 (rebased onto current main). Two consecutive automated review passes with no findings; all ten substantive review threads (three from @Jarred-Sumner, seven across three bot rounds) are resolved. The open threads are comment-cop lints on doc-comment length; the comment-cop check itself passes. test/js/workerd/html-rewriter.test.js: 92 pass / 0 fail locally (debug+ASAN and release), BUN_JSC_validateExceptionChecks=1 clean, clippy and rust:check-all clean.

CI #81817: html-rewriter.test.js and bun-write.test.js are green on every lane that ran. The build is red because linux-aarch64-build-cpp never got an agent (still scheduled after 1h20m; same on main #81770), which timed out the downstream build-bun waiter and cascaded 42 waiting_failed test lanes. The eight test-level failures are all retry-passes on unrelated files (Windows EBUSY in transpiler-cache, ConPTY escape variance in terminal-platform-gaps, darwin RSS jitter in fetch-leak abort test, etc.). Nothing touches HTMLRewriter, ResumableSink, or the Bun.write path.

Shape after the rework (see the updated PR body for the full writeup):

  • output Response body is a ByteStream from the start; SinkRef writes there, never back into its owner (no self-reference, feed/finish/fail take &self, fixes S3Client writes empty file for HTMLRewriter transformed fetch Response #19305)
  • write_request_data drives HtmlRewriter::write per chunk; AVOID_NATIVE_PIPE routes Source::Bytes through the JS pump, whose m_reading guard already prevents re-entrancy, so no spill buffer needed
  • ResumableSinkBackpressure::Done now cancels the pump in js_write, so a handler failure stops reading a never-closing source (idempotent for FetchTasklet/S3, which already set status = Done before returning it)
  • HandlerErrorScope RAII for the capture slot on every entry; handler_callback's unbalanced gcProtect() is gone (covered by a leak test)
  • Bun.write now reads a stream-backed Locked body via readableStreamToBytes instead of waiting forever for an on_receive_value that never fires (fixes bun-write.test.js hang surfaced after ValueBufferer removal)

Net -285 lines of native code vs. main. Deferring to a maintainer for the shared js_write Done change and the refcount/ownership rework.

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Why BufferOutputSink is structurally wrong

The this: *mut Self field in this PR is a symptom. The underlying problem predates the PR and is the reason run_output_sink on main already takes *mut Self instead of &mut self (html_rewriter.rs:874-878 on main, with the same Stacked-Borrows comment). This PR made it worse only by adding a third entry point; on main the same self-reference is threaded through function parameters instead of a field.

The self-reference

BufferOutputSink owns its lol-html rewriter:

BufferOutputSink
├── rewriter: *mut HtmlRewriter<'static, SinkRef>
│             └── SinkRef(*mut BufferOutputSink)   ← captured at construction
├── bytes: MutableString                           ← SinkRef::write touches this
├── response: *mut Response, global                ← SinkRef::done touches these
└── ...

lol_html::OutputSink is just fn handle_chunk(&mut self, &[u8]) (vendor/lolhtml/src/transform_stream/dispatcher.rs:55-67). Bun's SinkRef implements it by forming &mut *self.0 (the root *mut BufferOutputSink) and writing to bytes or, on the final empty chunk, calling done() which moves bytes into (*response).body.

So driving the rewriter ((*rewriter).write(input)) re-enters the rewriter's own owner through a root-provenance raw pointer. Every caller that drives the rewriter has to hold its BufferOutputSink handle as a root *mut too, never as &mut self, or the inner retag pops the outer borrow. That is why on main:

  • run_output_sink takes *mut Self (html_rewriter.rs:883)
  • on_finished_buffering takes *mut Self via the c_void trampoline (html_rewriter.rs:804)
  • init() is peppered with "do not hold a long-lived &mut *sink" comments (html_rewriter.rs:604-607)

This PR added ResumableSinkContext::write_end_request(&mut self) as a fourth entry point. The trait forces &mut self, so the root pointer had to be stashed in a field to recover it. That is the this field.

What the struct conflates

Field Concern Touched by SinkRef?
bytes output accumulator yes (write)
response, response_value output Response yes (done)
global JS context yes (done)
rewriter, context lol-html state no (but rewriter owns SinkRef)
input_buffer input accumulator no
tmp_sync_error sync-error plumbing (points at a stack local in init()) no
ref_count, this lifecycle / provenance scaffolding no

The only reason SinkRef reaches back into BufferOutputSink is that the output target lives on the same struct as the rewriter. In every native transform precedent in the codebase (fetch response body → ByteStream, DecompressionStream, TextDecoderStream), the processor writes to an object it does not also own. BufferOutputSink is the only place that conflates processor and output.

The wait_for_promise constraint

handler_callback (html_rewriter.rs:1438) nests the event loop inside HtmlRewriter::write() to wait for an async element handler. That is only sound because write() is called once with the whole buffered body: nothing can deliver another input chunk during the nested drain, so write()'s &mut TransformStream is never re-entered. A streaming input (calling write() per chunk) would let the nested event loop pump the next chunk and re-enter write() while it's already borrowed. That is the real blocker for streaming and why #33243 exists.

The correct shape

Separate the output target from the rewriter's owner. The output Response body should be a ByteStream from the start, and the rewriter's OutputSink should write to that ByteStream, not to a field of its owner:

RewriteSession (renamed BufferOutputSink)
├── rewriter: Box<HtmlRewriter<'static, SinkRef>>
│             └── SinkRef { out: *mut ByteStream }  ← separate allocation
├── context: Rc<RefCell<LOLHTMLContext>>
├── response_value: Strong                          ← roots output Response
├── ref_count, global
  • Output: transform() creates the output Response with a Locked body whose on_start_streaming hands back the ByteStream (the same hook fetch() response bodies use, Body.rs:782-841). SinkRef::handle_chunk calls ByteStream::on_data for non-empty chunks and closes it on the final empty chunk. No bytes field; the ByteStream is the accumulator.
  • Input: ResumableSinkContext::write_request_data(&mut self, chunk) appends to an input buffer (while wait_for_promise stays) or calls (*self.rewriter).write(chunk) directly once HTMLRewriter: suspend async content handlers instead of nesting the event loop #33243 lands.
  • No self-reference: driving the rewriter touches ByteStream (separate heap allocation), never RewriteSession. write_end_request can be a plain &mut self method; run_output_sink can take &mut self; the this field, the raw-pointer field access pattern in init(), and the "do not materialise &mut *sink" comments all disappear.

This also fixes #19305 (.body of a transformed response yields an empty stream) as a direct consequence: the ByteStream is response.body from the moment transform() returns, so .body.getReader() and Bun.serve both work regardless of whether the input has settled.

tmp_sync_error

This field points at a stack local in init() and is Some only while init() is on the stack. It exists so a handler error that occurs synchronously during the one-shot write() can be thrown from transform() instead of rejecting the body. With the ByteStream output, the sync/async split goes away: a handler error is delivered as ByteStream::on_data(StreamResult::Err(..)), which errors the body's stream, and transform() always returns a Response. An already-errored stream is observable synchronously if anyone cares (workerd does the same). The field and the unhandled_rejection_scope dance in init() are then dead.

What to actually do with this PR

The ResumableSink input side and the ValueBufferer deletion are correct and independent of the output shape. The this field and input_buffer are the part that papers over the self-reference. Two options:

  1. Redo the output side here: replace bytes/response/tmp_sync_error/this with a ByteStream target as above. Keeps the single write()-at-end shape (so no HTMLRewriter: suspend async content handlers instead of nesting the event loop #33243 dependency), removes the self-reference, and fixes S3Client writes empty file for HTMLRewriter transformed fetch Response #19305. More churn, but the struct comes out clean.
  2. Land as-is, follow up: the this field is ugly but no worse than main's *mut Self-parameter threading, and the JS-stream fix is real. File the ByteStream output rework as the follow-up that also closes S3Client writes empty file for HTMLRewriter transformed fetch Response #19305.

Happy to do (1) here if that's the call; it's roughly the same size as what's already in this PR.

Comment thread src/runtime/api/html_rewriter.rs Outdated
Comment thread src/runtime/api/html_rewriter.rs Outdated
Comment thread src/runtime/api/html_rewriter.rs Outdated
robobun added a commit that referenced this pull request Jul 25, 2026
…chunk

Addresses review on #35324:

- write_request_data now drives lol_html::HtmlRewriter::write() per chunk
  instead of buffering the whole body first. A spill buffer covers the one
  re-entrant path (an async handler's wait_for_promise draining the next
  Source::Bytes pipe chunk while write() is on the stack); the JS pump
  cannot re-enter (JSResumableSinkPumpOperation::m_reading guards the drain
  loop and the next read is not issued until sink.write() returns).

- The output Response body is a ByteStream from the start and SinkRef writes
  to it, not back into BufferOutputSink. That removes the self-reference
  (the reason run_output_sink took *mut Self and the this: *mut Self field
  existed) and makes .body.getReader() / Bun.serve work regardless of
  whether the input has settled (#19305).

- ResumableSinkContext now takes *mut Self (borrow = ptr per
  src/CLAUDE.md), since HTMLRewriter's write_request_data can re-enter the
  sink. FetchTasklet and S3UploadStreamWrapper impls dereference once.

- The in-flight ref on BufferOutputSink is RAII (ScopedRef::new / forget /
  adopt) instead of manual ref_() / deref().

- start_reading_input collapses to the materialised-body fast path (so
  transform(String | ArrayBuffer) keeps its synchronous contract) plus
  to_readable_stream() + ResumableSink::init for everything else; the
  per-variant file-read adapter is gone.

Dropped fields: bytes, response, response_value, input_buffer, this,
tmp_sync_error. Dropped methods: write_tmp_sync_error, on_file_read,
on_input_end, run_output_sink, done, write. Handler errors are captured
per write() (Self::rewrite) so the original JS error surfaces on async
chunks too, not just inside init().
Comment thread src/runtime/api/html_rewriter.rs Outdated
Comment on lines +539 to +548
/// Drives one `HTMLRewriter.transform()` call: pulls input chunks from the
/// source body via `ResumableSink`, feeds them to lol-html, and delivers the
/// rewritten output to a `ByteStream` that backs the returned `Response` body.
///
/// The rewriter's `OutputSink` writes to that `ByteStream` (a separate
/// allocation), not back into this struct, so driving the rewriter never
/// re-enters its owner. lol-html itself is still borrowed exclusively during
/// `write()`/`end()`; `writing` + `pending_*` guard the one path
/// (`wait_for_promise` inside a handler on the `Source::Bytes` native pipe)
/// that can deliver the next input chunk while a `write()` is on the stack.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/api/html_rewriter.rs Outdated
Comment on lines +555 to +556
/// GC root for the output `ByteStream`'s JS wrapper; `SinkRef` writes to
/// its `context` payload via [`output_bytes`](Self::output_bytes).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/api/html_rewriter.rs Outdated
Comment on lines +595 to +597
// The output Response body is a ByteStream from the start so `SinkRef`
// never reaches back into this struct and every consumer path
// (`.text()`, `.body.getReader()`, `Bun.serve`) reads the same stream.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/api/html_rewriter.rs Outdated
Comment on lines +737 to +738
/// refcount > 0; `(*sink).rewriter` must be set. The +1 taken for the
/// in-flight reader in `init()` is consumed by `finish` on every path.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/api/html_rewriter.rs Outdated
Comment on lines +755 to +758
// Materialised bodies run the rewrite synchronously so that
// `transform(String | ArrayBuffer)` (which reads the output
// body back as a blob before returning) keeps its synchronous
// contract.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/api/html_rewriter.rs Outdated
Comment on lines +792 to +794
// The in-flight +1 on `BufferOutputSink` keeps `context` valid until
// `write_end_request` fires; the sink's own lifecycle (pipe ref / JS
// wrapper) governs its allocation.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/api/html_rewriter.rs Outdated
Comment on lines +799 to +808
/// Feed one input chunk to lol-html.
///
/// A re-entrant call (handler `wait_for_promise` draining the next
/// `Source::Bytes` pipe chunk) spills into `pending_input`; the outer call
/// drains it after `write()` returns. The JS-pump path cannot re-enter
/// (`JSResumableSinkPumpOperation::m_reading` guards the drain loop and the
/// next `resumableIssueRead` is not issued until `sink.write()` returns).
///
/// # Safety
/// `sink` must be a live `BufferOutputSink` heap allocation (refcount > 0).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/api/html_rewriter.rs Outdated
Comment on lines +842 to +844
/// Drive one `HtmlRewriter::write()` under a handler-error capture scope so
/// a thrown / rejected handler surfaces its original JS error instead of
/// the generic "rewriter has been stopped" lol-html wrapper.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/api/html_rewriter.rs Outdated
Comment on lines +879 to +881
/// Close the transform: consume the rewriter with `end()` (emits the final
/// empty chunk, which `SinkRef` forwards as `Done`), or push an upstream
/// error to the output stream.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/api/html_rewriter.rs Outdated
Comment on lines +926 to +928
/// Writes to the output `ByteStream` (rooted via `BufferOutputSink::output`),
/// not back into its owner, so driving the rewriter never re-enters
/// `BufferOutputSink`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/webcore/ResumableSink.rs Outdated
Comment on lines +41 to +53
///
/// Both methods take `*mut Self` (not `&mut self`) per the "borrow = ptr"
/// dispatch rule in src/CLAUDE.md: HTMLRewriter's `write_request_data` drives
/// `lol_html::HtmlRewriter::write`, which runs user async handlers via
/// `vm.wait_for_promise`; on the `Source::Bytes` native-pipe path that nested
/// event loop can deliver the next chunk and re-enter `on_write` on the same
/// context. A `&mut self` receiver would be aliased on the re-entrant call.
/// Impls that do not re-enter (FetchTasklet, S3) dereference once at the top.
///
/// # Safety
/// `this` is the live heap allocation stored in [`ResumableSink::context`];
/// callers only invoke these via [`ResumableSink::on_write`] /
/// [`ResumableSink::on_end`].

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/api/html_rewriter.rs Outdated
Comment thread src/runtime/webcore/ResumableSink.rs
Comment thread src/runtime/api/html_rewriter.rs Outdated
Comment thread src/runtime/api/html_rewriter.rs
Comment thread src/runtime/api/html_rewriter.rs Outdated
Comment on lines +470 to +471
// on the stack via ensure_still_alive above). `to_js` migrated the
// output ByteStream to the wrapper's cached stream slot.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +540 to +542
/// Drives one `HTMLRewriter.transform()` call: pulls input chunks from the
/// source body via `ResumableSink`, feeds them to lol-html, and delivers the
/// rewritten output to a `ByteStream` that backs the returned `Response` body.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +551 to +552
/// First error pushed via [`fail`](Self::fail), so a synchronous handler
/// error inside `init()` still makes `transform()` throw.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +42 to +46
/// Skip the `Source::Bytes` native-pipe fast path and always drive the
/// stream through the JS pump. Set by contexts whose `write_request_data`
/// nests the event loop (HTMLRewriter's `wait_for_promise`), which could
/// re-enter the native pipe callback; the JS pump's `m_reading` guard and
/// read-after-write sequencing prevent that.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/api/html_rewriter.rs
Comment thread src/runtime/api/html_rewriter.rs
Comment on lines +1153 to +1155
// `err_ptr` is a stack Cell owned by `HandlerErrorScope`'s
// caller; that frame is conservatively scanned until
// `create_lolhtml_error` reads it back, so no `protect()`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/api/html_rewriter.rs
Comment thread test/js/workerd/html-rewriter.test.js
Comment on lines +568 to +569
// `fail()` destroyed the rewriter; stop the pump so the source
// is cancelled and `write_end_request` fires.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No new issues on c055503 — the earlier findings (Response leak on clone_this error, unbalanced gcProtect in handler_callback, input pump not cancelled on handler failure, require nit) are all addressed. Deferring to a human: this is a structural rewrite of BufferOutputSink's ownership/refcount graph plus a behaviour change to the shared ResumableSink::js_write Done arm that FetchTasklet and S3 also flow through, so it warrants a maintainer pass.

What was reviewed:

  • Refcount balance on BufferOutputSink across every terminal path (sync blob, Value::Error, stream end, handler throw, start_reading_input ?-propagation) and the paired output Strong / failed Strong lifetimes.
  • The new js_write Done → cancel(undefined) arm against FetchTasklet/S3: both only return Done after their own abort paths have set status = Done, so cancel()'s status guard makes it a no-op there.
  • SinkRef(*mut ByteStream) liveness — rooted by BufferOutputSink::output for the rewriter's whole lifetime; feed's owned copy vs. handler-mutated source buffers.
  • HandlerErrorScope save/restore ordering and the dropped exc_value.protect() (stack-local capture cell is conservatively scanned).
Extended reasoning...

Overview

Restructures HTMLRewriter.transform() to drive lol-html per chunk via a new ResumableHTMLRewriterSink (third monomorphization of ResumableSink) and write output to a ByteStream-backed Response body, deleting the ~430-line ValueBufferer and its FFI/NativePromiseContext surface. Touches 16 files: the core rewrite in html_rewriter.rs, a shared behaviour change in ResumableSink.rs (AVOID_NATIVE_PIPE const + js_write Done now cancels), codegen plumbing for the new sink class, and mechanical deletions of the bufferer's C++/Rust hooks. ~340 lines of new tests.

Security risks

None identified. Input is HTML bytes fed to lol-html (unchanged parser); no auth/crypto/permissions. The feed copy-before-write closes a handler-can-mutate-source-buffer window that would otherwise be a memory-safety concern.

Level of scrutiny

High. This is native Rust with manual refcounting (CellRefCounted, ScopedRef::adopt/new/forget), raw *mut fields whose liveness depends on GC Strong roots, an RAII guard that mutates VM-global state across a nested event-loop drain, and a change to shared ResumableSink code that fetch and S3 upload also use. Three prior review rounds each surfaced a real defect (leak-on-error-path, unbalanced gcProtect, uncancelled input pump), all fixed — which is exactly why a human should do the final pass on the ownership graph and the js_write Done semantics change.

Other factors

  • Test coverage is thorough (20+ new cases including GC-stress, leak counting via heapStats, source cancellation, buffer-detach mid-scan, Bun.serve integration) and the two pre-existing .todo cases are un-skipped.
  • Several comment-cop bot warnings about long justification comments remain open; these are style-lint, not correctness, but a maintainer may want them tightened before merge.
  • The one candidate raised and refuted this run (js_write Done → cancel reaching a non-aborted FetchTasklet) was traced: FetchTasklet's write_request_data returns Done only when is_body_finished(), at which point its own cleanup has already set status = Done, so cancel() early-returns.
  • The acknowledged pre-existing limitation (unrelated unhandled rejections swallowed during wait_for_promise under HandlerErrorScope) is left as a follow-up per the resolved thread; not a regression.

Comment on lines +5251 to +5252
// A body backed by a ReadableStream has no producer to
// fire `on_receive_value`; read the stream ourselves.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1329 to +1331
/// `.then` reaction for a `Locked` body that already has a readable: the
/// body was read via `readableStreamToBytes`; wrap the resolved bytes as
/// an `InternalBlob` and hand them to [`then`](Self::then).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/webcore/Blob.rs Outdated
robobun and others added 11 commits July 26, 2026 00:06
…lueBufferer

HTMLRewriter.transform() rejected any Response whose body was a JS-created
ReadableStream (start/enqueue source, TransformStream readable, async
generator, type: 'direct') with ERR_STREAM_CANNOT_PIPE. The
ValueBufferer::buffer_locked_body_value match on the stream source kind had:

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

ValueBufferer was a bespoke 'read a whole body into one slice' helper whose
only caller was HTMLRewriter, and it never finished implementing two of the
five stream source kinds. The runtime already has the primitive it wanted to
be: ResumableSink (src/runtime/webcore/ResumableSink.rs), a two-method
write_request_data / write_end_request trait that handles every source kind
(Source::Bytes via native Pipe, JavaScript/Direct/Blob/File via the JS pump
Bun__assignStreamIntoResumableSink). fetch() request-body uploads and S3
multipart uploads are built on it.

This swaps BufferOutputSink from ValueBufferer to ResumableSink:

- A third codegen'd sink variant ResumableHTMLRewriterSink sits alongside
  ResumableFetchSink / ResumableS3UploadSink.
- BufferOutputSink::start_reading_input does the Value dispatch ValueBufferer
  used to: materialised bodies (string/Blob/buffer/empty) run the rewrite
  immediately, a file-backed Blob schedules an async read, and any body
  carrying a ReadableStream goes to ResumableSink::init.
- impl ResumableSinkContext for BufferOutputSink buffers each chunk and runs
  the rewrite once in write_end_request. This keeps the single write()+end()
  shape the nested-event-loop async-handler path still relies on; moving the
  write into write_request_data is the one-line follow-up for true streaming
  once that constraint is lifted.

Deleted, all dead once ValueBufferer is gone: the ValueBufferer struct/impls
(~430 lines of Body.rs), Bun__BodyValueBufferer__onResolveStream/onRejectStream
and their PromiseFunctions enum entries (ZigGlobalObject.{h,cpp}, headers.h,
headers-cpp.h), the NativePromiseContext BodyValueBufferer tag (Rust and C++),
JSSink<ArrayBufferSink>::detach_self, the ArrayBufferJSSink alias, and the
crate::Error variants UnsupportedStreamType/StreamAlreadyUsed/InvalidStream.

Tests land in test/js/workerd/html-rewriter.test.js: 18 new cases covering
single/multi/mixed-chunk JS streams, direct streams, streams that produce
after transform() returns, every promise-returning reader, handlers observing
the document, upstream errors before/after transform() returns, bad chunk
types, source reuse, handler-mutated source buffers, GC while the source is
in flight, and the Bun.serve shape from #11758. The two pre-existing
it.todo('works with payload of type direct'/'default') cases in that file were
todo for exactly this reason and now pass. All 20 fail on main with
ERR_STREAM_CANNOT_PIPE.

Fixes #14216
Fixes #11758
BufferOutputSink::Drop ran inside ResumableSink::end_pipe's &mut self on the
Source::Bytes path (end_pipe -> on_end -> write_end_request -> on_input_end ->
ScopedRef drop -> BufferOutputSink::Drop -> clear_input_sink), and
clear_input_sink re-derived a fresh &mut ResumableSink from the stored root
pointer, popping end_pipe's Unique tag before end_pipe read
self.js_this.is_strong(). No runtime consequence (the allocation was still
live and detach_js was a no-op), but it broke the Stacked Borrows discipline
the rest of this change is careful about.

BufferOutputSink never needed to own a ref on the ResumableSink in the first
place: the in-flight +1 on BufferOutputSink keeps the context pointer valid
until write_end_request fires, and the ResumableSink's own lifecycle (pipe
ref or JS wrapper) governs its allocation. Dropping to init() (ref_count 1)
and not storing the pointer removes the reverse edge entirely.
…chunk

Addresses review on #35324:

- write_request_data now drives lol_html::HtmlRewriter::write() per chunk
  instead of buffering the whole body first. A spill buffer covers the one
  re-entrant path (an async handler's wait_for_promise draining the next
  Source::Bytes pipe chunk while write() is on the stack); the JS pump
  cannot re-enter (JSResumableSinkPumpOperation::m_reading guards the drain
  loop and the next read is not issued until sink.write() returns).

- The output Response body is a ByteStream from the start and SinkRef writes
  to it, not back into BufferOutputSink. That removes the self-reference
  (the reason run_output_sink took *mut Self and the this: *mut Self field
  existed) and makes .body.getReader() / Bun.serve work regardless of
  whether the input has settled (#19305).

- ResumableSinkContext now takes *mut Self (borrow = ptr per
  src/CLAUDE.md), since HTMLRewriter's write_request_data can re-enter the
  sink. FetchTasklet and S3UploadStreamWrapper impls dereference once.

- The in-flight ref on BufferOutputSink is RAII (ScopedRef::new / forget /
  adopt) instead of manual ref_() / deref().

- start_reading_input collapses to the materialised-body fast path (so
  transform(String | ArrayBuffer) keeps its synchronous contract) plus
  to_readable_stream() + ResumableSink::init for everything else; the
  per-variant file-read adapter is gone.

Dropped fields: bytes, response, response_value, input_buffer, this,
tmp_sync_error. Dropped methods: write_tmp_sync_error, on_file_read,
on_input_end, run_output_sink, done, write. Handler errors are captured
per write() (Self::rewrite) so the original JS error surfaces on async
chunks too, not just inside init().
…cy guard

Addresses the five review findings on 15f3adb:

- The Source::Bytes native-pipe path could re-enter ResumableSink's own
  &mut self chain (Wrap::pipe -> on_stream_pipe -> end_pipe) when a handler's
  wait_for_promise nested the event loop, not just the context layer. Rather
  than propagating *mut Self through PipeHandler, ResumableSinkContext gains
  AVOID_NATIVE_PIPE (default false, true for BufferOutputSink) so HTMLRewriter
  always goes through the JS pump, whose m_reading guard and read-after-write
  sequencing already prevent re-entry. The trait reverts to &mut self and the
  writing / pending_input / pending_end fields (and the unrooted JSValue they
  stored) are gone.

- rewrite() shadowed init()'s capture scope, so a sync handler error no longer
  made transform() throw. rewrite() is gone; create_lolhtml_error already
  reads vm.unhandled_pending_rejection_to_capture. A HandlerErrorScope RAII
  guard installs the capture slot around every feed/finish call (init()'s
  sync path, write_request_data, write_end_request), and a strong::Optional
  failed field on BufferOutputSink lets init() throw what fail() stored.

- finish() now runs under the same capture scope as feed(), so an
  onDocument.end handler error on the async path surfaces the user's error.

- The output Response is wrapped in a scopeguard so a clone_this? failure
  no longer leaks it and the Strong on its body stream.

Other fallout of the ByteStream output landing:

- transform(String | ArrayBuffer) reads the output back via
  get_body_readable_stream().to_any_blob(): Response::to_js migrates
  Locked.readable to the wrapper's cached stream slot, so
  use_as_any_blob_allow_non_utf8_string found an empty Strong.

- start_reading_input handles Value::Error synchronously so transform() of
  an already-failed body still throws.

- feed() copies the chunk before HtmlRewriter::write(): lol-html parses the
  first chunk straight from the input slice, and a handler can otherwise
  mutate bytes it has not tokenized yet.

- The 'transform rejects when the upstream body fails' tests are adapted for
  streaming: chunks delivered before the failure now reach .body, so the
  assertions read to completion instead of expecting a single rejected read.

- The '.body of a transform whose source is still pending' test is
  un-skipped (#19305 is fixed), and getReader / readableStreamToText are
  back in the 'every way of reading' suite.
…tion

handler_callback wrote the thrown exception into the HandlerErrorScope
capture cell and then gcProtect()ed it; create_lolhtml_error reads that slot
back and zeros it without unprotecting. The capture cell is a stack local on
a conservatively-scanned frame that lives from the store through the read, so
the protect provided no GC safety and leaked one Exception (and its Error)
per transform on the throwing-handler path. Verified by the new leak test:
200 iterations pinned exactly 200 Error instances pre-fix.
write_request_data returned WantMore unconditionally, so after fail()
destroyed the rewriter the JS pump kept reading the source stream into a
dead context until the source closed on its own. For a never-closing
pull()-based source that means write_end_request never fires and the
in-flight +1 (plus the sink's self-rooting js_this Strong, the reader and
the source) stay alive forever after .text() has already rejected.

js_write now treats ResumableSinkBackpressure::Done as a request to cancel
the pump (cancel() releases the reader, fires on_end, and detaches the JS
wrapper). write_request_data returns Done once the rewriter is gone;
finish() early-returns if fail() already ran so the cancel-driven
write_end_request is a no-op. FetchTasklet and S3 already return Done only
after their own cancel()/abort paths have set status = Done, so the new
js_write behaviour is idempotent for them.

Covered by the new 'cancels the source stream once a handler throws' test,
which hung forever pre-fix. Also moved the heapStats import to module
scope per test/CLAUDE.md.
Bun.write(path, response) with a Response whose body was Locked with a
readable (Locked { readable: Strong(stream) }) set on_receive_value and
waited for a producer to call Value::resolve(). For bodies that are streams
from the start (new Response(readableStream), and now
HTMLRewriter.transform()'s ByteStream output), nothing ever fires that
callback and the write hangs forever. Reproducible on main with
Bun.write(path, new Response(new ReadableStream(...))).

When the Locked body already has a readable (cached on the JS wrapper or in
locked.readable), read it via readableStreamToBytes and feed the resolved
bytes to WriteFileWaitFromLockedValueTask::then as an InternalBlob. A
rejected stream reaches then() as Value::Error.

Fixes the 'Bun.write(output.html, HTMLRewriter.transform(Bun.file))' test
that regressed when HTMLRewriter's output body became a ByteStream.
readable_stream_to_bytes enters JS and can return JSValue::ZERO with a
pending exception (or a non-promise value). The previous revision called
.then() on the result unconditionally, which in debug asserts on
.asCell() and in release silently no-ops, leaking the heap-allocated
WriteFileWaitFromLockedValueTask (its JSPromiseStrong root and file_blob)
with a promise that never settles.

Wrap the call in from_js_host_call and, if the result is not a promise,
reject the task's promise with the exception (or the returned value) and
free the task.
@robobun
robobun force-pushed the farm/04e92bf8/htmlrewriter-resumable-sink branch from 6728c1f to c08a95e Compare July 26, 2026 00:14

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No new issues found in this pass, but this restructures memory-safety-critical native code (BufferOutputSink refcounting, GC rooting of the output ByteStream, a shared ResumableSink::js_write behavior change that also affects FetchTasklet/S3, and a tangential Bun.write Locked-body arm), so it warrants a human look.

What was reviewed:

  • Refcount balance on BufferOutputSink across the sync/error/ResumableSink paths and the new Done → cancel() mapping in js_write (idempotent for Fetch/S3 via the status == Done guard).
  • HandlerErrorScope save/restore of unhandled_pending_rejection_to_capture and the removed .protect() in handler_callback — the leak test covers it.
  • The Bun.write readable_stream_to_bytes arm: exception + non-promise result now free the task and reject its promise; a non-promise resolved bytes value was considered and ruled out (rejects with the value, which is odd but not a leak).
  • Input-pump cancellation when a handler throws on a never-closing source.
Extended reasoning...

Overview

This PR restructures HTMLRewriter.transform() to drive input through the existing ResumableSink machinery (adding a third codegen'd variant ResumableHTMLRewriterSink) and to write output to a standalone ByteStream instead of back into the sink itself. It deletes ~430 lines of the bespoke ValueBufferer in Body.rs plus its FFI/NativePromiseContext::Tag/PromiseFunctions surface, and adds a new Bun.write arm for stream-backed Locked bodies (with new Bun__WriteFileLocked__onStream{Resolved,Rejected} promise reactions). It also changes shared behavior in ResumableSink::js_write: ResumableSinkBackpressure::Done now maps to this.cancel(undefined) + return false, which affects the FetchTasklet and S3 sink monomorphizations as well.

Security risks

None identified. The change surface is stream plumbing and lol-html invocation; the input-buffer copy in feed() defends against a handler mutating/transferring the source buffer mid-parse (covered by a test).

Level of scrutiny

High. This is native Rust with intrusive refcounting (CellRefCounted), raw *mut field access, GC roots (readable_stream::Strong, jsc::strong::Optional, JsRef), an RAII guard that mutates VM-global state (HandlerErrorScope), and a nested-event-loop path (wait_for_promise inside feed). Several prior review rounds surfaced real memory-safety issues (an unbalanced gcProtect, an uncancelled input pump leaking the sink and its Strong root, and an unchecked-exception path in the new Bun.write arm) — all now addressed with tests, but the density of fixes is itself a signal that this code needs a maintainer's eye. The js_write Done change is cross-cutting to two other sink contexts and, while argued idempotent, deserves confirmation.

Other factors

Test coverage is good (20+ new cases including GC-stress, leak, cancellation, and Bun.serve shapes; two pre-existing .todo cases un-skipped). Several comment-cop bot comments about paragraph-length justification comments remain unresolved on the thread. The Bun.write change is somewhat tangential to the HTMLRewriter fix and expands the PR's blast radius. Net -285 lines and the removal of the self-referential *mut Self output-sink pattern are structural improvements, but the amount of unsafe pointer choreography in init()/start_reading_input() and the SinkRef(*mut ByteStream) lifetime tie to output: Strong are exactly the kind of thing REVIEW.md flags for human sign-off.

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #36697. #36087 deleted ResumableSink in favour of the JsSinkType family, so this branch cannot rebase onto main without a rewrite; #36697 is that rewrite (HTMLRewriterInputSink as a JsSinkType driven via assign_to_stream). Same output-side restructure, same test coverage.

@robobun robobun closed this Aug 1, 2026
Jarred-Sumner pushed a commit that referenced this pull request Aug 4, 2026
…andlers instead of wait_for_promise (#36733)

### What

`HTMLRewriter.transform()` now streams: input body chunks flow through
`lol-html` into an output `ByteStream` as they arrive, with backpressure
propagated end-to-end via the `SinkHandle`/`SourceHandle` pattern from
#36087. Async content handlers no longer nest the event loop.

```
input body ──► SinkHandle::HTMLRewriter ──► lol_html::HtmlRewriter ──► output ByteStream
     ▲                                                                       │
     └─────────── SourceHandle::HTMLRewriter (producer) ◄────────────────────┘
```

### Why

`BufferOutputSink` fully buffered the source body via `ValueBufferer`
before a single `rewriter.write()` + `end()`, and `handler_callback`
spun `vm.wait_for_promise()` six native frames deep inside lol-html for
any handler that returned a Promise. That meant handlers fired only at
source-end (TTFB = full download), `.body` / `Bun.serve` saw an empty
stream (#6068, #19305), JS-backed `ReadableStream` inputs were rejected
outright (#11758, #14216), and the nested loop was a known hazard class
(deadlocks, ready-poll clobbering, pending-exception leaks).

lol-html could not be suspended before because its tokens are stack
locals borrowing a stack-local lexeme; returning from `write()` destroys
them, and an async handler must be able to mutate the element after its
`await`.

### lol-html fork (`oven-sh/lol-html`, branch `bun`)

A handler returns `Err(SuspensionRequest)` to suspend. The in-flight
unit is deep-copied onto the heap, `write()`/`end()` return the
non-poisoning `RewritingError::Suspended`, and `HtmlRewriter::resume()`
continues from a `StateMachineBookmark`. The pending captured-text flush
is hoisted from `Dispatcher::handle_tag` into the lexer actions so every
suspension point has a uniform shape. `Arena::shift` advances a start
offset instead of memmoving the tail (a suspended rewrite re-feeds its
unconsumed tail on every resume). 23 in-crate tests added; the new
`.github/workflows/lolhtml.yml` runs the fork's own `cargo test` at the
pinned commit, gated on `scripts/build/deps/lolhtml.ts`. The fork is a
`github-archive` source (no patch file), so rebasing onto a new upstream
tag is a `git rebase --onto` in the fork plus a commit bump here.

### Bun side (`RewriterPipe` replaces `BufferOutputSink`)

- **Ownership**: a generated `HTMLRewriterTransform` JS cell (not
user-visible) owns the pipe; its GC finalizer frees it, and nothing pins
anything. Liveness is plain GC edges: the output Response's `transform`
WriteBarrier slot and the `.then()` context of a suspended handler's (or
the JS pump's) promise reach the cell, the cell's five slots root the
Response, the input/output streams, the pending flush promise, and a
captured handler error, and a wired native source's `owner` WriteBarrier
slot (new on the generated NewSource cells) points back at the cell, so
I/O that roots the source (a FetchTasklet, a FileReader's read refs, a
reader on the output stream) roots the rewrite for exactly the window
the raw `SinkHandle`/producer backrefs are wired. A handler promise
collected without settling lets `finalize` defer to an event-loop task
(`abandon_suspension`) that rejects the body before freeing; the pipe
holds one native `+1` on the Response (released in `Drop`) so that task
can still reach the body. No intrusive refcount, no `Strong` fields on
the pipe; `finish()` frees the boxed lol-html state machine eagerly, and
`fail()`/output-cancel close the upstream producer instead of draining
it to EOF.
- **Output**: the returned `Response`'s body is `Locked(PendingValue {
task: pipe, on_start_streaming, on_readable_stream_available, producer:
SourceHandle::HTMLRewriter })`, the `FetchTasklet::to_body_value` shape.
The `ByteStream` is created lazily when a consumer reads `.body` or a
body-mixin method; until then, rewriter output buffers in a `Vec<u8>`
handed over as `DrainResult::Owned`.
- **Input**: mirrors `FetchTasklet::start_request_stream`. Native
`ByteStream`/`FileReader` sources wire `byte_stream.sink =
SinkHandle::HTMLRewriter` + `lock_native` + `drain()`; other stream
kinds go through `JSSink::<RewriterPipe>::assign_to_stream` (new
`HTMLRewriterSink` codegen entry). Materialized bodies
(`InternalBlob`/bytes `Blob`/`WTFStringImpl`) feed synchronously.
- **Backpressure**: `RewriterPipe::write` feeds one chunk through
`rewriter.write()` (output chunks push via `ByteStream::on_data`). If
the output is paused or a handler suspended, `write` returns
`Writable::Backpressure`; `ByteStream::resume` →
`SourceHandle::HTMLRewriter::on_ready` → `pipe.resume()` drains
`pending_input` then `input_source.ready()`.
- **Async handlers**: `handler_callback` returns
`HandlerOutcome::{Continue, Stop, Suspend}`. On a pending Promise it
runs one microtask checkpoint (`process.nextTick` then promise jobs,
never the loop); a genuinely pending Promise suspends. The JS wrapper is
retargeted at the heap-parked token so post-`await` mutations land where
they should. The `.then()` context is the Transform cell itself (the
reactions recover the pipe via `from_js`), so a handler promise
collected without settling abandons the parked rewrite instead of
leaking it.
- **Error handling**: `handler_error` on the pipe replaces the stack
`Cell` + `unhandled_pending_rejection_to_capture` override. A handler
error on a streaming input now rejects the body with the real error
instead of `The rewriter has been stopped.`.
- `AttributeIterator` holds a backref to the `Element` plus an index
instead of a boxed `slice::Iter`, so `for (const [k, v] of
el.attributes) { await ... }` keeps working across a suspension.

### Deletions

`ValueBufferer` (~430 lines of `Body.rs`) and its host-fn exports;
`SinkHandle::ValueBufferer` + `SinkWriteFn`;
`JSSink<ArrayBufferSink>::detach_self`;
`crate::Error::{StreamAlreadyUsed, InvalidStream,
UnsupportedStreamType}`; `NativePromiseContext::Tag::BodyValueBufferer`
(ordinal 4 reused for `HTMLRewriterSuspension`); the two
`Bun__BodyValueBufferer__*` `PromiseFunctions` (slots reused for
`Bun__HTMLRewriter__onHandler{Resolve,Reject}`).

### Behavior changes

1. **Error channel is decided by the overload, not by timing.**
`transform(string)` / `transform(ArrayBuffer)` throw from `transform()`.
Every `Response` input rejects its output body instead. Five existing
tests that pinned the old timing-dependent split are updated. Input-body
errors (already-failed or aborted body) still throw synchronously from
`transform()`.
2. A handler whose Promise needs the event loop to turn makes
`transform(string)` / `transform(ArrayBuffer)` throw a `TypeError`
(`pass a Response and await its body`) instead of spinning. A Promise
that settles within a microtask checkpoint still works.
3. A rejection a handler neither awaits nor returns reaches
`unhandledRejection` instead of being captured and thrown from
`transform()`.
4. `transform()` types corrected: `Bun.BufferSource` returns
`ArrayBuffer` (it always has at runtime); `Blob` removed from the
overload (it threw at runtime).
5. `Bun.serve` with an HTMLRewriter-produced response body defers
status/headers until the first body byte or clean end, so a handler that
fails before emitting any bytes is routed to the server'''s `error()`
hook instead of committing `200 OK` then force-closing the connection.
Headers never preceded the first byte on this path before either (the
old implementation buffered the whole rewrite). All other
native-ByteStream bodies (proxied `fetch()`, S3, spawn stdout) keep
sending status/headers immediately, and JS `ReadableStream` bodies
(`do_render_stream`) are unchanged.

`docs/runtime/html-rewriter.mdx` and
`packages/bun-types/html-rewriter.d.ts` cover 1-4.

### Verification

- `test/js/workerd/html-rewriter.test.js`: 107 pass, 0 fail under ASAN
debug and under `BUN_JSC_validateExceptionChecks=1`. New coverage: async
element/text/comment/doctype/onEndTag/document-end handlers,
`Bun.gc(true)` while an element is heap-parked across an `await`,
re-suspension by a second handler on the same element, nested
`transform()` inside a suspended handler, strict document-order across 8
awaiting handlers, `Bun.serve` with a live client, client abort
mid-suspension, the consumer matrix for a pending output body, JS-backed
and `type:'direct'` `ReadableStream` inputs.
- `test/js/workerd/html-rewriter-leak.test.ts`: protected-object +
`Response` count regression over 120 suspending rewrites; a
never-settling handler rejects the body instead of leaking.
- Fail-before: `works with payload of type direct` →
`ERR_STREAM_CANNOT_PIPE` on the released bun.
- `bun-types` green, `cargo clippy -p bun_runtime` clean.
- `vendor/lolhtml/.ref` re-fetch of the fork verified through the real
ninja edge.


Fixes #11758
Fixes #14216
Fixes #6068
Fixes #19305

Closes #33243 (same lol-html fork, different input layer), Closes #35324
(ResumableSink, deleted in #36087), Closes #32988 (per-chunk
ValueBufferer callback).


<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 20 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/bun/io/bun-write.test.js test/js/node/process/process.test.js
test/js/workerd/html-rewriter-leak.test.ts

<!-- robobun:evidence:end -->
springmin pushed a commit to springmin/bun that referenced this pull request Aug 4, 2026
…andlers instead of wait_for_promise (oven-sh#36733)

`HTMLRewriter.transform()` now streams: input body chunks flow through
`lol-html` into an output `ByteStream` as they arrive, with backpressure
propagated end-to-end via the `SinkHandle`/`SourceHandle` pattern from

```
input body ──► SinkHandle::HTMLRewriter ──► lol_html::HtmlRewriter ──► output ByteStream
     ▲                                                                       │
     └─────────── SourceHandle::HTMLRewriter (producer) ◄────────────────────┘
```

`BufferOutputSink` fully buffered the source body via `ValueBufferer`
before a single `rewriter.write()` + `end()`, and `handler_callback`
spun `vm.wait_for_promise()` six native frames deep inside lol-html for
any handler that returned a Promise. That meant handlers fired only at
source-end (TTFB = full download), `.body` / `Bun.serve` saw an empty
stream (oven-sh#6068, oven-sh#19305), JS-backed `ReadableStream` inputs were rejected
outright (oven-sh#11758, oven-sh#14216), and the nested loop was a known hazard class
(deadlocks, ready-poll clobbering, pending-exception leaks).

lol-html could not be suspended before because its tokens are stack
locals borrowing a stack-local lexeme; returning from `write()` destroys
them, and an async handler must be able to mutate the element after its
`await`.

A handler returns `Err(SuspensionRequest)` to suspend. The in-flight
unit is deep-copied onto the heap, `write()`/`end()` return the
non-poisoning `RewritingError::Suspended`, and `HtmlRewriter::resume()`
continues from a `StateMachineBookmark`. The pending captured-text flush
is hoisted from `Dispatcher::handle_tag` into the lexer actions so every
suspension point has a uniform shape. `Arena::shift` advances a start
offset instead of memmoving the tail (a suspended rewrite re-feeds its
unconsumed tail on every resume). 23 in-crate tests added; the new
`.github/workflows/lolhtml.yml` runs the fork's own `cargo test` at the
pinned commit, gated on `scripts/build/deps/lolhtml.ts`. The fork is a
`github-archive` source (no patch file), so rebasing onto a new upstream
tag is a `git rebase --onto` in the fork plus a commit bump here.

- **Ownership**: a generated `HTMLRewriterTransform` JS cell (not
user-visible) owns the pipe; its GC finalizer frees it, and nothing pins
anything. Liveness is plain GC edges: the output Response's `transform`
WriteBarrier slot and the `.then()` context of a suspended handler's (or
the JS pump's) promise reach the cell, the cell's five slots root the
Response, the input/output streams, the pending flush promise, and a
captured handler error, and a wired native source's `owner` WriteBarrier
slot (new on the generated NewSource cells) points back at the cell, so
I/O that roots the source (a FetchTasklet, a FileReader's read refs, a
reader on the output stream) roots the rewrite for exactly the window
the raw `SinkHandle`/producer backrefs are wired. A handler promise
collected without settling lets `finalize` defer to an event-loop task
(`abandon_suspension`) that rejects the body before freeing; the pipe
holds one native `+1` on the Response (released in `Drop`) so that task
can still reach the body. No intrusive refcount, no `Strong` fields on
the pipe; `finish()` frees the boxed lol-html state machine eagerly, and
`fail()`/output-cancel close the upstream producer instead of draining
it to EOF.
- **Output**: the returned `Response`'s body is `Locked(PendingValue {
task: pipe, on_start_streaming, on_readable_stream_available, producer:
SourceHandle::HTMLRewriter })`, the `FetchTasklet::to_body_value` shape.
The `ByteStream` is created lazily when a consumer reads `.body` or a
body-mixin method; until then, rewriter output buffers in a `Vec<u8>`
handed over as `DrainResult::Owned`.
- **Input**: mirrors `FetchTasklet::start_request_stream`. Native
`ByteStream`/`FileReader` sources wire `byte_stream.sink =
SinkHandle::HTMLRewriter` + `lock_native` + `drain()`; other stream
kinds go through `JSSink::<RewriterPipe>::assign_to_stream` (new
`HTMLRewriterSink` codegen entry). Materialized bodies
(`InternalBlob`/bytes `Blob`/`WTFStringImpl`) feed synchronously.
- **Backpressure**: `RewriterPipe::write` feeds one chunk through
`rewriter.write()` (output chunks push via `ByteStream::on_data`). If
the output is paused or a handler suspended, `write` returns
`Writable::Backpressure`; `ByteStream::resume` →
`SourceHandle::HTMLRewriter::on_ready` → `pipe.resume()` drains
`pending_input` then `input_source.ready()`.
- **Async handlers**: `handler_callback` returns
`HandlerOutcome::{Continue, Stop, Suspend}`. On a pending Promise it
runs one microtask checkpoint (`process.nextTick` then promise jobs,
never the loop); a genuinely pending Promise suspends. The JS wrapper is
retargeted at the heap-parked token so post-`await` mutations land where
they should. The `.then()` context is the Transform cell itself (the
reactions recover the pipe via `from_js`), so a handler promise
collected without settling abandons the parked rewrite instead of
leaking it.
- **Error handling**: `handler_error` on the pipe replaces the stack
`Cell` + `unhandled_pending_rejection_to_capture` override. A handler
error on a streaming input now rejects the body with the real error
instead of `The rewriter has been stopped.`.
- `AttributeIterator` holds a backref to the `Element` plus an index
instead of a boxed `slice::Iter`, so `for (const [k, v] of
el.attributes) { await ... }` keeps working across a suspension.

`ValueBufferer` (~430 lines of `Body.rs`) and its host-fn exports;
`SinkHandle::ValueBufferer` + `SinkWriteFn`;
`JSSink<ArrayBufferSink>::detach_self`;
`crate::Error::{StreamAlreadyUsed, InvalidStream,
UnsupportedStreamType}`; `NativePromiseContext::Tag::BodyValueBufferer`
(ordinal 4 reused for `HTMLRewriterSuspension`); the two
`Bun__BodyValueBufferer__*` `PromiseFunctions` (slots reused for
`Bun__HTMLRewriter__onHandler{Resolve,Reject}`).

1. **Error channel is decided by the overload, not by timing.**
`transform(string)` / `transform(ArrayBuffer)` throw from `transform()`.
Every `Response` input rejects its output body instead. Five existing
tests that pinned the old timing-dependent split are updated. Input-body
errors (already-failed or aborted body) still throw synchronously from
`transform()`.
2. A handler whose Promise needs the event loop to turn makes
`transform(string)` / `transform(ArrayBuffer)` throw a `TypeError`
(`pass a Response and await its body`) instead of spinning. A Promise
that settles within a microtask checkpoint still works.
3. A rejection a handler neither awaits nor returns reaches
`unhandledRejection` instead of being captured and thrown from
`transform()`.
4. `transform()` types corrected: `Bun.BufferSource` returns
`ArrayBuffer` (it always has at runtime); `Blob` removed from the
overload (it threw at runtime).
5. `Bun.serve` with an HTMLRewriter-produced response body defers
status/headers until the first body byte or clean end, so a handler that
fails before emitting any bytes is routed to the server'''s `error()`
hook instead of committing `200 OK` then force-closing the connection.
Headers never preceded the first byte on this path before either (the
old implementation buffered the whole rewrite). All other
native-ByteStream bodies (proxied `fetch()`, S3, spawn stdout) keep
sending status/headers immediately, and JS `ReadableStream` bodies
(`do_render_stream`) are unchanged.

`docs/runtime/html-rewriter.mdx` and
`packages/bun-types/html-rewriter.d.ts` cover 1-4.

- `test/js/workerd/html-rewriter.test.js`: 107 pass, 0 fail under ASAN
debug and under `BUN_JSC_validateExceptionChecks=1`. New coverage: async
element/text/comment/doctype/onEndTag/document-end handlers,
`Bun.gc(true)` while an element is heap-parked across an `await`,
re-suspension by a second handler on the same element, nested
`transform()` inside a suspended handler, strict document-order across 8
awaiting handlers, `Bun.serve` with a live client, client abort
mid-suspension, the consumer matrix for a pending output body, JS-backed
and `type:'direct'` `ReadableStream` inputs.
- `test/js/workerd/html-rewriter-leak.test.ts`: protected-object +
`Response` count regression over 120 suspending rewrites; a
never-settling handler rejects the body instead of leaking.
- Fail-before: `works with payload of type direct` →
`ERR_STREAM_CANNOT_PIPE` on the released bun.
- `bun-types` green, `cargo clippy -p bun_runtime` clean.
- `vendor/lolhtml/.ref` re-fetch of the fork verified through the real
ninja edge.

Fixes oven-sh#11758
Fixes oven-sh#14216
Fixes oven-sh#6068
Fixes oven-sh#19305

Closes oven-sh#33243 (same lol-html fork, different input layer), Closes oven-sh#35324
(ResumableSink, deleted in oven-sh#36087), Closes oven-sh#32988 (per-chunk
ValueBufferer callback).

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 20 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/bun/io/bun-write.test.js test/js/node/process/process.test.js
test/js/workerd/html-rewriter-leak.test.ts

<!-- robobun:evidence:end -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

3 participants