Skip to content

HTMLRewriter: drive the input body through an HTMLRewriterInputSink JSSink - #36697

Closed
robobun wants to merge 3 commits into
mainfrom
farm/c5307143/htmlrewriter-jssink-input
Closed

robobun wants to merge 3 commits into
mainfrom
farm/c5307143/htmlrewriter-jssink-input

Conversation

@robobun

@robobun robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator

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

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. More fundamentally, BufferOutputSink owned the lol-html rewriter and was its output target: SinkRef(*mut BufferOutputSink) 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 why 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-backed ReadableStream from the start; SinkRef(*mut ByteStream) 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 = HTMLRewriterInputSink. A new JsSinkType alongside FetchRequestBodySink / NetworkSink. start_reading_input does to_readable_stream() + JSSink::<HTMLRewriterInputSink>::assign_to_stream for stream bodies (including Source::JavaScript / Source::Direct / Source::Bytes / file-backed blobs), 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. The native SinkHandle fast path is deliberately skipped: feed drives HtmlRewriter::write, which runs async handlers via wait_for_promise (nested event loop); a ByteStream/FileReader push-pipe could deliver the next chunk while write() is still on the stack, whereas the readStreamIntoSink JS pump is call-return sequenced.

  • Per-chunk rewriter.write(). feed() copies the chunk before write() because lol-html tokenises the first chunk straight from the input slice and a handler could otherwise mutate or transfer bytes it has not yet parsed.

  • Error handling. HandlerErrorScope is an RAII guard pointing vm.unhandled_pending_rejection_to_capture at a local cell and installing the quiet rejection handler. Every feed/finish runs under one, so create_lolhtml_error recovers the original JS error a handler threw on both sync and async paths. fail() latches the error in a strong::Optional so a sync handler error inside init() still makes transform() throw, pushes it to the output ByteStream so .text()/.body reject, and get_pending_error() rethrows it on the pump's next write/end so rsisAbrupt aborts instead of reading a never-closing source forever.

Deletions

Net -62 lines.

  • ValueBufferer (~420 lines of Body.rs) and its Bun__BodyValueBufferer__* FFI / NativePromiseContext::Tag::BodyValueBufferer / PromiseFunctions entries / SinkHandle::ValueBufferer variant.
  • 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.
  • crate::Error::{UnsupportedStreamType, StreamAlreadyUsed, InvalidStream}.

Verification

Tests land in test/js/workerd/html-rewriter.test.js. 21 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, stopping the pump once a handler throws, a handler-error leak probe, and the Bun.serve shape from #11758. The two it.todo("works with payload of type direct" / "default") cases were todo for this reason and are un-skipped.

The transform rejects when the upstream body fails > .body ... 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().

$ USE_SYSTEM_BUN=1 bun test test/js/workerd/html-rewriter.test.js -t 'JavaScript-backed'
 1 pass  20 fail    (ERR_STREAM_CANNOT_PIPE)

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

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

$ BUN_JSC_validateExceptionChecks=1 bun bd test test/js/workerd/html-rewriter.test.js
 92 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 #35324 / #33310

#33310 routed the same arm through readableStreamToArrayBuffer and was blocked on the grounds that HTMLRewriter should stop buffering. #35324 did this same restructure on top of ResumableSink, then #36087 deleted ResumableSink in favour of the JsSinkType family. This PR is #35324 redone on the post-#36087 architecture; the test coverage from that PR is carried over.


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

fails on main (without fix)
ASAN without fix: 22 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 (1e8b477a3)

test/js/workerd/html-rewriter.test.js:
(pass) HTMLRewriter > error handling [20.43ms]
(pass) HTMLRewriter > error inside element handler [10.85ms]
(pass) HTMLRewriter > error inside element handler (string) [8.84ms]
(pass) HTMLRewriter > fast async error inside element handler [36.25ms]
(pass) HTMLRewriter > slow async error inside element handler [16.27ms]
(pass) HTMLRewriter > HTMLRewriter: async replacement [165.16ms]
(pass) HTMLRewriter > HTMLRewriter handles Symbol invalid type error [7.87ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > control: .text() on the untransformed response rejects [431.00ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .text() on the transformed response rejects [69.23ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .arrayBuffer() on the transformed response rejects [46.01ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .body on the tra
... (truncated)

release without fix: 26 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/workerd/html-rewriter.test.js:
(pass) HTMLRewriter > error handling [0.14ms]
(pass) HTMLRewriter > error inside element handler [2.11ms]
(pass) HTMLRewriter > error inside element handler (string) [0.08ms]
(pass) HTMLRewriter > fast async error inside element handler [10.24ms]
(pass) HTMLRewriter > slow async error inside element handler [3.47ms]
(pass) HTMLRewriter > HTMLRewriter: async replacement [18.79ms]
(pass) HTMLRewriter > HTMLRewriter handles Symbol invalid type error [1.09ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > control: .text() on the untransformed response rejects [9.09ms]
184 |         const transformed = rewriter().transform(res);
185 |         const text = settle(transformed.text());
186 |         release();
187 |         // Must reject with the upstream connection error, and must never
188 |         // resolve with the truncated document.
189 |         expect(await text).toEqual(rejectedWithConnectionError);
                                 ^
error: expect(received).toEqual(expected)

  {
-   "message": StringMatching /socket|connection|ECONNRESET/i,
-   "name": "TypeErro
... (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 (1e8b477a3)

test/js/workerd/html-rewriter.test.js:
(pass) HTMLRewriter > error handling [21.43ms]
(pass) HTMLRewriter > error inside element handler [13.89ms]
(pass) HTMLRewriter > error inside element handler (string) [8.99ms]
(pass) HTMLRewriter > fast async error inside element handler [41.28ms]
(pass) HTMLRewriter > slow async error inside element handler [19.03ms]
(pass) HTMLRewriter > HTMLRewriter: async replacement [204.24ms]
(pass) HTMLRewriter > HTMLRewriter handles Symbol invalid type error [12.59ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > control: .text() on the untransformed response rejects [651.72ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .text() on the transformed response rejects [79.92ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .arrayBuffer() on the transformed response rejects [47.60ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .body on the tr
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     1e8b477a31
  features     baseline

22 deps, 108 codegen, 1171 objects in 3995ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] fetch zlib
[zlib] up to date
[2/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[3/1234] fetch tinycc
[tinycc] up to date
[4/1234] fetch picohttpparser
[picohttpparser] up to date
[5/1234] gen ErrorCode+*.h
[6/1234] gen bindgenv2
[7/1234] gen .bind.ts → GeneratedBindings.cpp
[8/1234] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[9/1234] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[10/1234] fetch nodejs (prebuilt)
[nodejs] up to date
[11/1234] subst deps/zlib/zlib.h
[12/1234] subst deps/zlib/zconf.h
[13/1234] subst deps/libjpeg-turbo/jconfigint.h
[14/1234] subst deps/libjpeg-turbo/jversion.h
[15/1234] subst deps/libjpeg
... (truncated)
diff hotspot
src/codegen/generate-jssink.ts                     |   2 +
 src/jsc/bindings/NativePromiseContext.h            |   1 -
 src/jsc/bindings/Sink.h                            |   3 +-
 src/jsc/bindings/ZigGlobalObject.cpp               |  18 +-
 src/jsc/bindings/ZigGlobalObject.h                 |   9 +-
 src/jsc/bindings/headers.h                         |  30 +-
 .../bindings/webcore/streams/BunStreamSource.cpp   |   1 +
 src/runtime/api/NativePromiseContext.rs            |  25 +-
 src/runtime/api/html_rewriter.rs                   | 824 ++++++++++++---------
 src/runtime/error.rs                               |   9 -
 src/runtime/webcore.rs                             |   9 -
 src/runtime/webcore/Body.rs                        | 429 +----------
 src/runtime/webcore/Sink.rs                        |  13 -
 test/js/workerd/html-rewriter.test.js              | 391 +++++++++-
 14 files changed, 913 insertions(+), 851 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                  reads  edits  tests
src/codegen/generate-jssink.ts                            1      1      0
src/jsc/bindings/NativePromiseContext.h                   1      1      0
src/jsc/bindings/Sink.h                                   1      1      0
src/jsc/bindings/ZigGlobalObject.cpp                      1      2      0
src/jsc/bindings/ZigGlobalObject.h                        2      3      0
src/jsc/bindings/headers.h                                1      3      0
src/jsc/bindings/webcore/streams/BunStreamSource.cpp      1      1      0
src/runtime/api/NativePromiseContext.rs                   3      2      0
src/runtime/api/html_rewriter.rs                          4      7      0
src/runtime/error.rs                                      2      2      0
src/runtime/webcore.rs                                    1      1      0
src/runtime/webcore/Body.rs                               3      4      0
src/runtime/webcore/Sink.rs                               1      1      0
test/js/workerd/html-rewriter.test.js                     2      5      0

…SSink

Fixes #14216
Fixes #11758
Fixes #19305

BufferOutputSink was the only caller of ValueBufferer, a bespoke
"buffer a whole body into one slice" helper that rejected
Source::JavaScript / Source::Direct streams outright and surfaced as
ERR_STREAM_CANNOT_PIPE from transform().

Restructure BufferOutputSink so the rewriter's output target is a
ByteStream (a separate allocation, so the rewriter never re-enters its
owner and feed/finish/fail take &self) and the input is driven per chunk
by a new HTMLRewriterInputSink JsSinkType via assign_to_stream, the same
readStreamIntoSink pump fetch and S3 already use. Materialised bodies
(string / ArrayBuffer / in-memory Blob) keep a synchronous fast path so
transform(String) still returns a value synchronously.

Handler errors are latched under a HandlerErrorScope RAII guard and
re-thrown from get_pending_error on the next write/end/flush so the pump
aborts instead of reading a never-closing source forever.

Delete ValueBufferer (~420 lines) and its FFI / NativePromiseContext
tag / PromiseFunctions / SinkHandle::ValueBufferer / crate::Error
surface; html_rewriter was its only consumer. Net -62 lines.

Supersedes #35324, which did the same restructure on top of
ResumableSink before #36087 deleted that abstraction.
@coderabbitai

coderabbitai Bot commented Aug 1, 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: 6 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: 3a8047b5-1a2e-4e14-8ccf-730a78012bc3

📥 Commits

Reviewing files that changed from the base of the PR and between f91d5c9 and baad918.

📒 Files selected for processing (14)
  • src/codegen/generate-jssink.ts
  • src/jsc/bindings/NativePromiseContext.h
  • src/jsc/bindings/Sink.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • src/jsc/bindings/headers.h
  • src/jsc/bindings/webcore/streams/BunStreamSource.cpp
  • src/runtime/api/NativePromiseContext.rs
  • src/runtime/api/html_rewriter.rs
  • src/runtime/error.rs
  • src/runtime/webcore.rs
  • src/runtime/webcore/Body.rs
  • src/runtime/webcore/Sink.rs
  • test/js/workerd/html-rewriter.test.js

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

Comment on lines +474 to +476
// on the stack via ensure_still_alive above). String/ArrayBuffer
// input took the synchronous `feed` path, so the output ByteStream
// is complete and `to_any_blob` drains it.

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 +546 to +550
/// RAII guard installing `vm.unhandled_pending_rejection_to_capture` so
/// `handler_callback` / `create_lolhtml_error` can recover the original JS
/// error a handler threw (sync or via a rejected promise awaited by
/// `wait_for_promise`). Restores the previous capture slot and rejection
/// handler on drop.

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 +584 to +586
/// Heap-boxed rewriter; `Cell` so `feed`/`fail`/`finish` can take `&self`.
/// The rewriter's output sink is `SinkRef(*mut ByteStream)` (a separate
/// allocation), so driving it 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 on lines +589 to +590
/// GC root for the output `ByteStream`'s JS wrapper. `SinkRef` writes into
/// the `ByteStream` pointed at by this stream's `Source::Bytes` payload.

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 +592 to +593
/// First error latched by [`Self::fail`]; read back by `init()` so a
/// synchronous handler error 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 +608 to +609
// Output: a `ByteStream`-backed native ReadableStream. `SinkRef` writes
// rewritten chunks here; the returned Response's body wraps it.

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 +733 to +739
/// Route the input body to the rewriter. Materialised bodies
/// (String/ArrayBuffer/InternalBlob, and Blobs that do not need a file
/// read) feed the rewriter synchronously; everything else becomes a
/// `ReadableStream` pumped through `HTMLRewriterInputSink` via the
/// standard `assign_to_stream` JS pump, which accepts every stream source
/// kind (including `JavaScript`/`Direct`).
///

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 +741 to +743
/// Called with an in-flight +1 on `self`; that ref is consumed by
/// `on_input_end` on every return-`Ok(())` path. On `Err` the caller's
/// `ScopedRef` releases it instead.

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 +802 to +807
// Deliberately no native `SinkHandle` fast path: `feed` drives
// `HtmlRewriter::write`, which runs async handlers via
// `wait_for_promise` (nested event loop). A ByteStream/FileReader
// push-pipe could deliver the next chunk while `write()` is still on
// the stack; the `readStreamIntoSink` JS pump is call-return
// sequenced so it cannot.

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 +856 to +858
/// Feed one chunk to the rewriter. Copies first: lol-html tokenizes the
/// first chunk in place, and a handler that mutates or transfers the
/// source buffer would corrupt tokens past the cursor.

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 +871 to +872
/// Terminal: `end()` the rewriter on success (flushes the final chunk to
/// the output ByteStream via `SinkRef`), or propagate `err` via `fail`.

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 +894 to +896
/// Latch the first error: destroy the rewriter, store `err` in `failed`
/// (for `init()` to throw synchronously), and push it into the output
/// ByteStream so `.text()`/`.body` reject. Idempotent.

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 +915 to +917
/// End-of-input: run `finish` under a `HandlerErrorScope` (so an `end()`
/// handler that throws is captured), then release the in-flight +1 taken
/// in `init()`. `self` must not be touched after this call.

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 +930 to +932
/// Writes chunks to the output `ByteStream` (a separate allocation), so the
/// rewriter never re-enters `BufferOutputSink` and `feed`/`finish`/`fail` can
/// take `&self`.

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 +964 to +965
/// JSSink driving a `ReadableStream` body into `BufferOutputSink::feed` per
/// chunk via the standard `assign_to_stream` pump. Not user-constructible.

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 +967 to +970
/// Non-owning; the owning `BufferOutputSink` carries a +1 intrusive ref
/// (taken in `init()`) while this is `Some`. Cleared by the
/// assign_to_stream-result path before it releases that ref via
/// `on_input_end`; `finalize` releases it as a fallback.

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 +996 to +998
// `fail()` destroyed the rewriter; the latched error surfaces on
// the pump's next `write`/`end`/`flush` via `get_pending_error`,
// which throws it so `rsisAbrupt` cancels the source.

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

@github-actions

github-actions Bot commented Aug 1, 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) in Bun.serve now works because the output is a proper ByteStream-backed ReadableStream and the input sink handles all body source types
  2. HTMLRewriter doesn't support reading from a Blob #17259 - HTMLRewriter with Blob bodies should now work via the new HTMLRewriterInputSink which handles materialised bodies and to_readable_stream() conversion

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

Fixes #6068
Fixes #17259

🤖 Generated with Claude Code

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

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

Checked both against this branch:


Re the comment-cop annotations: the comment-cop check itself passes. Every flagged block is either a // SAFETY: comment (required by clippy::undocumented_unsafe_blocks), a # Safety section on an unsafe fn (required by clippy::missing_safety_doc), or a doc comment on a function/field. The one at :807 is the design note for why the SinkHandle fast path is skipped (re-entrancy under wait_for_promise), which is exactly the kind of invariant a future reader would otherwise spend tool calls rediscovering. None are justifying a workaround; the net diff replaces many longer such comments on main with shorter ones.

@robobun

robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:03 AM PT - Aug 1st, 2026

@robobun, your commit baad918 is building: #87168

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
…; reject on undefined error

Three findings from automated review, all correct:

1. `Box::leak(HTMLRewriterInputSink)` was never freed on any path:
   `end()` nulls `m_sinkPtr` before the controller destructor so
   `__finalize` never fires on the normal path, and `finalize()` did
   not self-free. Store the pointer on `BufferOutputSink.input_sink`
   and free it via `clear_input_sink()` (detach + heap::take) from
   `on_input_end` and `Drop`, mirroring `FetchTasklet::clear_sink`.
   `finalize()` self-frees as the GC-without-end fallback.

2. `on_reject_rewriter_input` mapped an `undefined`/`null` rejection
   to `on_input_end(None)`, closing the output as a truncated success.
   Pass `Some(err)` unconditionally (matching
   `on_reject_request_stream`).

3. `init()` read `failed` via `try_swap()`, clearing it; a still-
   Pending pump whose first sync chunk made a handler throw would then
   see `get_pending_error() == None` and keep reading forever. Read
   non-destructively.

New tests cover (2) and (3).
Comment on lines +592 to +593
/// First error latched by [`Self::fail`]; read back non-destructively by
/// `init()` (sync throw) and `get_pending_error()` (pump abort).

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 +595 to +596
/// Owned Box from `start_reading_input`; freed by [`Self::clear_input_sink`]
/// (the `FetchTasklet::clear_sink` pattern).

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 +856 to +859
/// Reclaim the `Box<HTMLRewriterInputSink>` leaked in
/// `start_reading_input`: null the controller's `m_sinkPtr` via
/// [`JSSink::detach`] so `__finalize` cannot later touch the freed
/// allocation, then drop the Box. Idempotent.

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 +938 to +940
/// End-of-input: run `finish` under a `HandlerErrorScope` (so an `end()`
/// handler that throws is captured), free the input sink, then release
/// the in-flight +1 taken in `init()`. `self` must not be touched after.

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 +1044 to +1047
// Reached only when the controller is collected with `m_sinkPtr` still
// set, i.e. `clear_input_sink` never ran. Null the owner's slot so its
// `Drop` cannot double-free, release the in-flight +1, then self-free
// (the `ArrayBufferSink::finalize` pattern).

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 +1131 to +1133
// Pass the rejection through unconditionally: `controller.error()`
// with no argument rejects with `undefined`, which must still fail
// the transform rather than close the output as a truncated success.

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

@robobun

robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status for baad918

test/js/workerd/html-rewriter.test.js is green on every lane that ran; none of the build #87168 failures touch this diff:

test lane status
bun-upgrade.test.ts win-aarch64 [new] "Canary builds are not available for this platform yet" (release-artifact availability; reported to main-break triage)
bun-write.test.js debian x64-asan [new] LSAN leak of Box<Store> in find_or_create_file_from_path (Blob.rs:3766 / Store.rs:157); this diff does not touch Blob.rs, blob/, BunObject.rs, or webcore_types.rs. Reported to main-break triage.
bun-install-proxy.test.ts debian x64 [flaky] exit 143
quic-endpoint.test.ts debian x64-asan [flaky] lsquic pr_queue.c leak
test-error-code-done-callback.test.ts alpine aarch64 [flaky] passed alone
36577.test.ts win x64 [flaky] passed alone
inspect-error-leak.test.js, 23865.test.ts, request-clone-leak.test.ts, tty-reopen-after-stdin-eof.test.ts, 22650-shell-crash.test.ts various [flaky] passed alone
robobun agent alpine x64 EC2 Unsupported configuration (infra)
darwin-14-{aarch64,x64}, darwin-26-aarch64 test lanes darwin Expired (no agent)

Not re-rolling: the [new] failures are artifact availability and an unrelated Blob.rs Store leak, neither of which a retrigger would clear, and the darwin lanes are agent availability.

All three automated review findings (Box leak, undefined-rejection-as-success, try_swap clearing failed) are fixed in baad918; threads resolved. Two consecutive clean automated review passes. Ready for a maintainer pass.

@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 three findings from the previous pass (Box leak, failed cleared by try_swap letting the pump spin, controller.error(undefined) closing as truncated success) look addressed in baad918 — clear_input_sink + self-freeing finalize, non-destructive failed.get().get() in init(), and unconditional Some(err) in the reject handler, each with a covering test. No new issues surfaced this pass.

That said, this restructures BufferOutputSink's ownership model end-to-end (new JSSink type, output → ByteStream, deletes ValueBufferer and its SinkHandle/NativePromiseContext wiring), so it's worth a human pass on the overall design and the refcount/GC-root balance.

What was reviewed

  • HTMLRewriterInputSink Box lifecycle across assign_to_stream completion / .then() reactions / GC finalize / clear_input_sink — no double-free or leak path found.
  • SinkRef(*mut ByteStream) liveness: rooted via BufferOutputSink::output Strong for the rewriter's lifetime.
  • start_reading_input in-flight +1 balanced on every Ok(()) arm via on_input_end, and by ScopedRef drop on the ? path.
  • HandlerErrorScope restores prev_capture and rejection handler on drop; nested scopes (init → write_utf8) stack correctly.
Extended reasoning...

Overview

This PR restructures how HTMLRewriter.transform() consumes a Response body. It replaces the bespoke ValueBufferer (~420 lines, HTMLRewriter's only caller) with a new HTMLRewriterInputSink JsSinkType driven through the standard assign_to_stream pump, and changes the output from a Locked(PendingValue) body to a ByteStream-backed ReadableStream from the start. Touches 14 files: the core html_rewriter.rs restructure (~824 lines changed), Body.rs (-429), C++ JSSink codegen and ZigGlobalObject wiring, NativePromiseContext tag removal, SinkHandle::ValueBufferer variant removal, and 391 lines of new tests.

Security risks

None identified. HTMLRewriter processes user-provided HTML through lol-html; this PR does not change parsing or add any trust boundary. The feed() chunk copy defends against a handler mutating the source buffer mid-scan (covered by a test).

Level of scrutiny

High. This is memory-safety-critical native code — the most-blocked category per the repo's review guidance. It introduces a new refcounted object with a Box::leak + external-owner reclaim pattern, a raw *mut ByteStream output sink whose liveness depends on a GC Strong root, a new HandlerErrorScope RAII guard that swaps VM-global rejection state, and rewires how the in-flight +1 on BufferOutputSink is balanced across sync/async/error paths. The previous review pass found three real bugs here (a leak, a pump-spin after a cleared error latch, and a silent-truncation on error(undefined)); baad918 addresses each with a matching test, but the density of ownership transitions warrants a human confirming the overall model.

Other factors

  • Two prior attempts at this restructure (#33310, #35324) were superseded; this is the third iteration on post-#36087 architecture, so there is design history a maintainer will have context on.
  • The comment-cop bot flagged several long comments; robobun explained they are SAFETY/doc comments required by clippy lints, and the check itself passes.
  • Test coverage is thorough (21 new cases + 2 un-todo'd), passes under ASAN and validateExceptionChecks, and includes GC-stress and leak probes.
  • The deliberate skip of the native SinkHandle fast path (to avoid re-entrancy under wait_for_promise) is a design trade-off a maintainer should sign off on.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun conflicts, rebase

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun nvm

@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Understood. Superseded by #36733, which goes further (forks lol-html to suspend instead of nesting wait_for_promise, enabling the native SinkHandle pipe with end-to-end backpressure that this PR deliberately avoided). The test coverage here is a subset of #36733's, so nothing to carry over.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants