Conversation
WalkthroughAdds lazy streaming output to ChangesHTMLRewriter lazy streaming output
Documentation formatting fixes
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
Comment |
98339c8 to
d9d9955
Compare
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
d9d9955 to
3ad4ee6
Compare
|
Thanks, both bot findings are addressed. The #6068 suggestion was correct, and it also pointed at a gap: a The duplicate-PR finding matches the "Related PRs" section in the description: #32956 and #32927 each fix one symptom of the same root cause. This PR supersedes #32956's mechanism (but not its separate |
3ad4ee6 to
7805d6e
Compare
7805d6e to
a47d290
Compare
a47d290 to
30345c2
Compare
There was a problem hiding this comment.
The microtask-deferral fix and its dedicated test address my last concern; I have nothing further, but given the volume of new unsafe pointer/refcount/re-entrancy machinery here a human pass is still warranted.
Extended reasoning...
Overview
This PR rewrites HTMLRewriter.transform(response) to stream both input and output instead of buffering the whole document. It adds ~400 lines of new logic to src/runtime/api/html_rewriter.rs (lazy output-stream hooks on the pending body, a per-chunk lol-html driver with a re-entrancy guard, microtask-deferred draining to avoid running lolhtml::write inside FetchTasklet's critical section, eager error capture, and Drop-time back-pointer detachment), extends ValueBufferer in src/runtime/webcore/Body.rs with an optional per-chunk callback and proper StreamResult::Err handling, and adds ~500 lines of gate-driven tests.
Security risks
No auth/crypto/permission surface. The risk axis is memory safety: the new code is dense with unsafe, raw *mut Self field accesses across FFI re-entry, manual refcounting (ref_/adopt), JsCell aliasing, and lifetime coupling between the sink and a GC-rooted Response. Four issues in this space (a UAF on the prefix slice, lol-html re-entry, a take-once thread-local error race, and a producer-mutex deadlock) were raised on earlier revisions and have all been addressed in the current diff — the last via the new is_async → schedule_drain → drain_microtask path plus the "does not stall the HTTP client" test.
Level of scrutiny
High. This is a behavioral redesign of a production code path (transform(await fetch(...)), Bun.serve proxying) with cross-thread locking implications and substantial new unsafe Rust. It is not mechanical, and the review history itself surfaced multiple real correctness/safety issues that required follow-up commits.
Other factors
Test coverage is unusually thorough (twelve deterministic gate-driven tests plus ASAN soaks per the description), all four prior inline findings are resolved in the current revision, and the multi-agent bug hunter found nothing new on this pass. The PR also overlaps two other open PRs (#32956, #32927) whose interaction the description calls out. None of that changes the bar: the scope and unsafe density put this well outside what I'd approve without a human reviewer.
|
Thanks for the thorough review. All four inline findings across the three rounds (the take-once lol-html last-error buffer, Agreed that a human pass is warranted. This is a behavioral redesign of a production code path with a significant amount of new unsafe pointer, refcount, and cross-thread reasoning, and it overlaps two other open PRs. The description's "Related PRs" section lays out exactly how #32956, #32927, and #31835 interact with this one so a maintainer can choose an order. |
561cec5 to
38cbdaa
Compare
|
CI status for a reviewer: the diff is green, and the remaining red is CI infrastructure that is independent of this change. I have used my one retrigger and will not push further ones. Across the last three full builds of this branch (66265, 66404, 66423), 279-280 jobs pass each time and every HTMLRewriter test passes on every lane that actually ran. The only failures, all three times:
The binary-size check reports this change as within a few KB of main on every target (largest delta +3.0 KB, windows-aarch64). All four review rounds are addressed with every thread resolved, and the description's Verification and Related PRs sections are current. If someone can retry the darwin lane (or it is known broken fleet-wide), the build should go green aside from the |
Jarred-Sumner
left a comment
There was a problem hiding this comment.
This code uses far too much unsafe. Instead, let's refactor our bindings to use the Rust LOLHTML crate directly instead of going through their C API.
|
Agreed, and that is the right diagnosis. Every It is also a smaller lift than it looks, because
I will do this as a behavior-preserving precursor PR (same buffered semantics as |
|
The binding refactor is up: #33048. It deletes Net diff: 23 files, +808 / -2310. Once #33048 lands I will rebase this PR on top of it. The streaming change here should shed most of its |
38cbdaa to
54c7be8
Compare
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
|
Caution Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted. Error details |
|
Final CI result on the current head
Bun Bake, Review state: @Jarred-Sumner's two changes-requested items are both done and replied to individually (the microtask draining is gone in 4dccae5, the raw-pointer |
…PI (#33048) Precursor to #32988, at @Jarred-Sumner's direction there: > This code uses far too much unsafe. Instead, let's refactor our bindings to use the Rust LOLHTML crate directly instead of going through their C API. ### Problem Bun's runtime is Rust, but its lol-html bindings still went through lol-html's **C API**: a 1,299-line FFI layer (`src/lolhtml_sys/`, 72 `extern "C"` declarations) wrapping opaque handles, plus `extern "C"` output-sink and content-handler trampolines that manufacture aliased `&mut` references from userdata pointers, and a take-once thread-local last-error buffer (the direct cause of two correctness findings in #32988's review). That arrangement is a leftover from the Zig runtime, which had no other option; from Rust it is a Rust -> `extern "C"` -> Rust round trip for no benefit, and it is the structural reason HTMLRewriter code has been so `unsafe`-heavy. `lol_html` (v2.7.2) is already vendored at `vendor/lolhtml` and was already in the cargo dependency graph (the c-api crate we consumed is a thin wrapper over it), so using it directly is a dependency-graph no-op. ### What changed - `src/lolhtml_sys/` is deleted entirely: 1,340 lines, 74 `unsafe` blocks, all 72 `extern "C"` declarations. `lol_html = { path = "vendor/lolhtml" }` becomes a direct workspace dependency of `bun_runtime` and `bun_bundler`; the vendor fetch step is unchanged. - `src/runtime/api/html_rewriter.rs` builds a `lol_html::Settings` from the selectors and handlers accumulated on `LOLHTMLContext` and constructs `HtmlRewriter` directly. Handler registration is safe closures capturing raw `NonNull<Handler>` pointers into the `Rc<RefCell<LOLHTMLContext>>` that already outlives the rewriter (the same invariant the C userdata relied on, now visible in Rust). The output sink is a safe `impl lol_html::OutputSink for SinkRef(*mut BufferOutputSink)`; lol-html signals completion with a zero-length final chunk. `HtmlRewriter::end(self)` consumes, so the sink holds the rewriter behind a raw heap pointer read into a local before each call (the same re-entrancy discipline the file already uses) and never calls `end` after a failed `write`. - The eight JS wrapper classes keep their exact shape: a `Cell<*mut X>` handle that is only valid inside its content handler and is nulled before the handler returns. The handle is now a real `lol_html::html_content` type behind one documented lifetime-erasure helper (`cell_get`), and every wrapper method is a safe call into it, because the `lol_html` content operations are safe Rust. - The take-once thread-local error channel is gone. `lol_html`'s methods return typed errors (`RewritingError`, `SelectorError`, `TagNameError`, `AttributeNameError`, `CommentTextError`), so error text comes from the error the failing call returned, never a global consumed as a side channel. - `src/bundler/HTMLScanner.rs` and `src/bundler/linker_context/generateCompileResultForHtmlChunk.rs` migrate the same way. - The two `patches/lolhtml/*` patches only served the now-unused c-api crate and are removed, as is the orphaned `DISABLE_LOLHTML` feature flag (its only reader was the deleted FFI crate). - `HTMLRewriterLoader` (239 lines) is deleted outright rather than migrated. It is a `pub` Zig-port carryover with zero callers: nothing constructs one, it is absent from the JSSink codegen registry (`src/codegen/generate-jssink.ts`), and no `.rs`/`.ts`/`.cpp`/`.h`/`.zig` file references it outside its own definition. Being `pub` is what kept it off the `dead_code` lint. Review (claude, then CodeRabbit) found three distinct latent memory-safety bugs in it, none reachable; deleting the class fixes all three at the root instead of hardening code that cannot run. Net: 26 files, +877 / -2489. ### One behavioral fix: `onEndTag()` no longer leaks its callback This is the only user-visible behavior change, and it falls out of the model change rather than being a goal. The C API binds an element's end-tag handler as a raw `(function pointer, userdata)` pair, so Bun heap-allocated a per-call handler holding a `JSValue::protect()` GC root on the callback and handed the raw pointer to lol-html. Nothing ever freed it on the success path (the only free was the registration-error branch), so **every `element.onEndTag(fn)` call permanently leaked one protected JS function**, plus everything its closure captured, for the life of the process. A long-running `Bun.serve` + `HTMLRewriter` + `onEndTag` grows without bound. `lol_html`'s Rust API instead takes an owning `Box<dyn FnOnce(..)>`. The closure owns the protection; lol-html owns the closure and drops it (releasing the root) whether or not the end tag is ever reached. The leak is structurally impossible. Covered by an exact-count regression test in `test/js/workerd/html-rewriter-leak.test.ts` using `bun:jsc`'s `heapStats().protectedObjectTypeCounts` (no RSS threshold, stable on debug builds): two passes of 400 `onEndTag` registrations after a baseline leave **800** extra protected functions on `main` and **0** with this change. Every other HTMLRewriter and bundler-HTML test passes. Besides the new leak file, the only test change is four added cases in `html-rewriter.test.js` covering the `element.tagName`, `endTag.name`, and `comment.text` setters, which had no coverage anywhere in the tree; no existing test is modified. ### Verification - `cargo check -p bun_runtime -p bun_bundler`, `cargo clippy -p bun_runtime -p bun_bundler`, and `cargo fmt --all --check`: clean. - All five HTMLRewriter test files, 80 pass / 0 fail (75 pre-existing, unmodified, plus the new leak test and four new setter tests): `bun bd test test/js/workerd/html-rewriter.test.js test/js/workerd/html-rewriter-end-error.test.ts test/js/workerd/html-rewriter-leak.test.ts test/js/web/html/html-rewriter-doctype.test.ts test/regression/issue/htmlrewriter-additional-bugs.test.ts` - The new leak test on `main` (fail-before): `expect(after - before).toBe(0)` receives **800**. - All three bundler HTML test files, 30 pass / 0 fail: `bun bd test test/bundler/bundler_html.test.ts test/bundler/bundler_html_server.test.ts test/bundler/html-import-manifest.test.ts` - `grep -rn bun_lolhtml_sys --include='*.rs' --include='*.toml' .` outside `vendor/`: nothing. - `grep 'extern "C"' src/runtime/api/html_rewriter.rs src/bundler/HTMLScanner.rs`: nothing. ### Honest unsafe accounting The `unsafe` that was *about* the lol-html boundary is gone: the 74-block FFI layer, every trampoline, every opaque-handle call. `src/bundler/HTMLScanner.rs` drops from 13 `unsafe` blocks to 3. `html_rewriter.rs` itself still contains `unsafe`, and it is worth being precise about what that is, because almost none of it is about lol-html anymore: 1. One documented lifetime-erasure helper (`cell_get`) at the handler-to-JS-wrapper boundary. A JS object cannot carry a Rust borrow lifetime, so a handler's `&mut Element<'r, 't>` has to be parked as a raw pointer for the duration of the JS callback and nulled before the handler returns; that is inherent to exposing lol-html handles to JS at all, and it is now one place instead of being baked into 72 FFI signatures. Every call site obeys its ordering rule: a setter given a raw `JSValue` runs the value's ToString (arbitrary user JS, which can re-enter `cell_get` on the same wrapper) before the borrow is taken, through one shared `setter_utf8_arg` helper that carries the rule. 2. The pre-existing `JsClass` refcount and `*mut Response` plumbing, which is independent of which lol-html API is used and is unchanged by this PR. The practical payoff lands in #32988: its streaming change was fighting the C boundary (four review rounds found five real bugs, two of them caused directly by the take-once error buffer and the opaque-handle poisoning model). Rebased on this, that diff sheds the classes of unsafe that made it hard to get right. ### Relationship to #32988 #32988 (make HTMLRewriter actually stream) stays open and will be rebased on top of this once it lands. The two are deliberately separate so each is reviewable on its own: this one is the binding migration, and that one is the streaming behavior change.
baca4f5 to
cd5e79a
Compare
Jarred-Sumner
left a comment
There was a problem hiding this comment.
I don't think this should use any JSC::Strong? visitChildren should visit the WriteBarrier it needs to keep alive.
|
Done in 8ab5455. You're right, and it came out strictly simpler: the sink now holds zero
One thing to flag so it is not a surprise in the diff: Verified with |
|
The walkthrough's "Out of Scope Changes" warning about
|
Jarred-Sumner
left a comment
There was a problem hiding this comment.
We shouldn't be draining microtasks anymore in here. Refactor it to not do that.
Remove all the unsafe usage.
Two comments still referenced run_output_sink, which this branch replaces with drive_rewriter / write_chunk / finish, and the concurrent-errors test described the C API's take-once thread-local error buffer, which the lol_html crate port removed. Name the real invariant holders instead. Comments only, no behavior change.
The BufferOutputSink held three JSC::Strong handles. All three go away. - output_stream (readable_stream::Strong), and the two functions whose only job was servicing it, are deleted. The stream a consumer attaches is stashed in the output Response cell's "stream" WriteBarrier slot (values: ["stream"], visited by the generated visitChildrenImpl) and read back through Response::get_body_readable_stream. The cell slot, not the body Value, is the stable owner: Bun.serve's do_render_with_body moves the stream out of the body and replaces the Value with Used, so nothing inside the body reaches it afterwards. - write_error (StrongOptional) becomes failed: bool. The JS error is built on the failing frame, carried as a stack value, and reported from the same drive_rewriter invocation on both the synchronous and asynchronous paths, never stored on the sink. - response_value (StrongOptional, pre-existing) becomes response_ref: JsRef, strong for the sink's active lifetime, which is JsRef's documented pattern. It is the sink's only GC reference. No behavior change; the existing 79 HTMLRewriter tests cover it, including the three Bun.serve ones that distinguish the cell slot from the body Value.
|
Working on both. Stating my reading first so you can correct it cheaply before I rewrite. "We shouldn't be draining microtasks anymore in here." I believe the problem is the nesting this PR introduced, not Fix: the deferral becomes an event-loop task instead of a microtask, so "Remove all the unsafe usage." The file has 107 I'll reply here with the result. The branch is also rebased onto current |
8ab5455 to
a60ca70
Compare
The two async-handler tests gated their upstream's chunks only on the first request, so the handler's independent fetch to the same server would not block on the same unresolved gates. That fetch now goes to a separate sidecar server and upstream is fetched exactly once per test, so the toggle is never observed false. Unconditionally await the gates. No behavior change.
…a microtask schedule_drain used JSGlobalObject::queue_microtask_callback to move the lol-html drive out of the HTTP producer's critical section. That is the wrong primitive: HtmlRewriter::write runs user element handlers, and an async handler blocks the write by spinning the full event loop (wait_for_promise), which drains the microtask queue. Running that from inside a microtask callback re-enters the microtask drain, something the pre-streaming code never did because lol-html only ever ran from transform()'s own top-level synchronous frame. The deferral is now a plain event-loop task (AnyTask embedded on the sink, enqueued via VirtualMachine::enqueue_task), the same mechanism FetchTasklet, PasswordObject, and FileResponseStream use. A task runs at the top of an event-loop turn, the same kind of frame lol-html has always been driven from, so wait_for_promise never nests inside a microtask. queue_microtask_callback is no longer used here. No behavior change: 79 pass, 0 fail on the HTMLRewriter suite, and 0 failures across 20 consecutive full-file runs.
|
The first one is done in 4dccae5: no more microtask draining, and the reading in my previous comment held up.
It is now a plain event-loop task, the same Now doing the second one (remove the unsafe). The shape: everything that lol-html's output sink and the body's streaming hooks touch moves into its own interior-mutable cell on a shared |
Every function on BufferOutputSink reached its fields through
`unsafe { (*sink).field }` place expressions, because `drive_rewriter`
cannot hold `&mut Self` across `HtmlRewriter::write()`: the write
re-enters `SinkRef::handle_chunk` through the sink pointer and runs
user element handlers that can reach the output body's hooks and, for
an async handler, spin the whole event loop.
It never needed `&mut`. The mutable state now lives in three disjoint
interior-mutability domains, so every entry point forms only a shared
`&Self` from its `*mut Self` (`src/CLAUDE.md`'s `borrow = shared`):
- `rewriter`: borrowed across `write()`/`end()` by `drive_rewriter` and
`finish`, lol-html's only drivers. Nothing re-entered touches it.
- `driver` (writing, failed, the queued input and deferred terminal):
what a source chunk, the terminal, or the drain task touches while
`drive_rewriter` is suspended.
- `out` (the buffered output, the Response, its JsRef): what
`handle_chunk` and the body's streaming hooks touch from inside a
`write()`.
A borrow of one domain is never held across a call that can enter
another, and RefCell turns a violation of that into a deterministic
panic with a line number instead of aliasing UB. It proved that on its
first test run: the `if let` scrutinee at `drive_rewriter`'s tail was
holding the `driver` borrow across `finish()` (a temporary lives to
the end of the whole `if let` block), which the raw-pointer version
could never have surfaced.
The rewriter also moves inline into its cell, deleting its separate
heap allocation and `Drop`'s manual `heap::destroy`. The intrusive
refcount and the `*mut c_void` callback seams are unchanged: that
protocol is already correct and `Rc::from_raw` is exactly as unsafe
as what it would replace.
`unsafe` in the file: 107 before, 78 after (`main` has 64). What
remains is one `&*sink` liveness assertion per entry point under the
contract the file already stated, `init()`'s construction, and the
`*mut Response` m_ctx derefs.
79 pass, 0 fail; 0 failures across 25 consecutive full-file runs.
|
Both done. No more microtask draining (4dccae5): covered in my previous comment. The deferral out of the HTTP producer's critical section is an event-loop task now ( The unsafe (a23c3a4): every function on
A borrow of one domain is never held across a call that can enter another, and The rewriter also moved inline into its cell, so its separate heap allocation and The honest residual, so you can push further if you want: Verified: |
…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 -->
Fixes #19305
Fixes #6068
Problem
HTMLRewriter.transform(response)fully buffers the source body before running lol-html, then does a singlewrite()+end(). The outputResponse's pending body also has none of the stream callbacks fetch bodies install, so no consumer that wants a stream is ever told about theByteStreamit is reading. Three user-visible consequences, all reproducible with a plain streaming upstream:res.bodyyields an empty, closed stream: the.bodygetter creates aByteStreamthe producer never learns about (on_readable_stream_availableis not set), thendone()'sValue::resolvesees the orphan readable and closes it empty. Every.bodyconsumer (a reader,new Response(res.body),S3.write) gets"". That isS3Clientwrites empty file forHTMLRewritertransformed fetch Response #19305.Bun.servereturning the transformed response hangs forever: the server seesLocked { task: Some }, callsValue::to_readable_stream, and pipes aByteStreamnobody ever writes to.HTMLRewriter+new Response(Bun.file)causesBun.serveto think a non-Response is returned #6068 is theBun.file()shape of the same bug (a file-backed body is read asynchronously, so the server always gets the pending body first).Rebased twice: onto #32927 / #32840, then onto #33048
An earlier revision of this PR also fixed the source-stream error swallowing (
ValueBufferer::on_stream_pipetreatingStreamResult::Erras a clean zero-byte end), theValueBuffererhalf of #31964. #32927 landed that fix first and more completely (it also covers the parked-error short-circuit, the synchronoustransform()error shape, and aValue::Errorguard on every body reader), so in the rebase main's version wins and this PR's copy is removed. This PR no longer claims #31964, and its two source-stream-error tests are deleted as exact functional duplicates of #32927's merged suite (verified: both pass on post-#32927main). What remains is purely the streaming change this PR was filed for.One merged test is adapted, and it is the part of the resolution to review. #32927's
a read already pending on .body when the upstream fails rejectsstarts a singlereader.read()before the upstream fails and asserts that that read rejects. That is only true of a fully buffered implementation, where nothing can reach.bodyuntil the whole input has arrived. With this PR the bytes that arrive before the failure are rewritten and delivered immediately (that is the point), so the pending read is legitimately satisfied by a chunk of rewritten output first. The test now reads in a loop, which protects the same invariant its author named ("the error must reach the attached stream; a read must never be stranded") and is strictly stronger: it still fails if the stream closes cleanly (the exact bug #32927 fixed), still fails on a hang, and passes only when a read eventually rejects with the upstream error. I verified the invariant holds before changing anything: on this branch a subsequent read does reject with the connection error. Every other test #32927 and #32840 added passes unmodified.Separately, the post-rebase soak surfaced a real, pre-existing HTTP-client race that is independent of this PR. An async element handler runs inside
wait_for_promise(handler_callbackhas always spun the full event loop for an async handler; that is not introduced here). The two async-handler tests' "independent round trip" fetched the samehost:portas the still-streaming chunked source, and about 1 in 20 full-file runs got aMalformed_HTTP_Responsewhose raw bytes were the previous response's terminal chunked trailer (0\r\n\r\n) prepended to the next response: the source's connection returns to the pool while the nested loop is spinning, before its trailer is drained, and the handler's fetch reuses it. The two tests now give that round trip its own sidecar server so it cannot share a pooled connection with the source under test; their purpose (prove the HTTP-client thread is not parked) is unchanged. After the change: 0 failures in 110 consecutive runs (50 full-file, 60 filtered). The underlying client bug (a chunked keep-alive connection re-pooled with an unconsumed trailer under a re-entrant event loop) is worth a separate issue.#33048 (requested in review on this PR) then landed, and this diff is now re-expressed on top of its
lol_htmlRust-crate bindings. The streaming design turned out to be orthogonal to the bindings: of the fourteen functions it adds, eleven never touch lol-html and ported verbatim, and only three places changed shape.write_chunknow destructures the typed per-callResult<(), RewritingError>and builds the JS error from it directly;finish's flush uses the crate's consumingend(self)(null the field,heap::take(rewriter).end()); andDropusesheap::destroy.Body.rsneeded nothing (#33048 never touched it). This is also what resolves the original review concern on this PR: theextern "C"trampolines, the opaque-handle FFI, and lol-html's take-once thread-local error buffer that this diff previously had to reason about are all gone from the code it is built on.One honest finding from re-verifying on the new bindings: on the asynchronous (mid-stream) path, a throwing handler's JS error value is not recoverable by the time the failure is reported, because the VM's exception-capture slot is only armed for the synchronous
transform()window, so the reported message is lol-html's generic one. That is byte-for-bytemain's behavior on its own asynchronous path (which formats the sameRewritingErrordisplay), so it is preserved here, not introduced, and it is why the concurrent-failure test asserts the absence of cross-contamination rather than each handler's literal message. Recovering the thrown value on the asynchronous path is a worthwhile follow-up, independent of streaming.Fix
Input side (
Body.rs):ValueBufferergains an optional per-chunk callback. When set, body bytes are delivered incrementally andon_finished_bufferingonly ever signals completion or an error.HTMLRewriter (
html_rewriter.rs):BufferOutputSinkregisters that callback and feeds each source chunk intoHtmlRewriter::write. The output body'sPendingValuegetson_start_streaming(hands over any already-rewritten bytes as the new stream's initial buffer) andon_readable_stream_available, the same lazy contractFetchTaskletuses. The second hook stashes the consumer's newReadableStreaminto the outputResponsecell'sstreamWriteBarrier slot (values: ["stream"]inresponse.classes.ts, visited by the generatedvisitChildrenImpl); the sink holds no reference of its own and reads it back throughResponse::get_body_readable_stream. The cell slot, not the bodyValue, is the right owner becauseBun.serve'sdo_render_with_bodymoves the stream out of the body and replaces theValuewithUsed. Once a consumer streams,OutputSink::writepushes rewritten bytes straight into that stream anddone()closes it. With no streaming consumer,done()'s bufferedValue::resolvepath is unchanged, so.text()/.json()and string / blob inputs behave exactly as before.The pipe callback must never drive lol-html inline.
ValueBufferer's pipe handler runs insideFetchTasklet::on_progress_update, which holds the tasklet's mutex untilByteStream::on_datareturns, and the singleton HTTP-client thread takes that same mutex before delivering the next chunk.HtmlRewriter::writecan suspend on an async element handler spinning the full event loop (vm().wait_for_promise), which would park the HTTP thread (and every in-flightfetch) on that mutex for the whole await, and deadlock outright if the handler's ownawaitneeds it. lol-html is also not re-entrant, so a chunk or source end arriving during that spin must not re-enter it. So:AnyTaskembedded on the sink +VirtualMachine::enqueue_task, theFetchTaskletshape). A task runs on a fresh stack, strictly after the producer's critical section ends, and at the top of an event-loop turn. It must be a task and not a microtask: an async handler'swait_for_promisedrains the microtask queue, and doing that from inside a microtask callback would re-enter the microtask drain.drive_rewritersetswritingfor the duration of its lol-html calls; chunks, the source's end, or an error arriving while awriteis suspended are queued / deferred and drained by that suspended call's own loop and tail.transform(string/blob)and a source that is already complete) drive lol-html directly: no producer lock exists there, which preservestransform()'s synchronous contract for non-streaming inputs. The pre-buffered prefix of a still-streaming source goes through the deferred path like every other pipe delivery: the slice handoff stays synchronous (no user JS runs while it is borrowed), and no&mut ValueBuffereris ever live when a handler spins the event loop and re-enters the pipe.Lifetime: the sink's only GC reference is a
JsRefon the outputResponsewrapper, strong for the sink's active lifetime (the patternJsRefdocuments andServerWebSocket/UDPSocketuse); the cell'svisitChildrenImplkeeps everything else reachable. It holds noJSC::Strong. In the other direction, the output bodyValue(and anyReadableStreama consumer holds on it) can outlive the sink, soDrop for BufferOutputSinknulls everyPendingValueback-pointer into the sink (detach_from_output_body) and a late.body/.text()can never invokeon_start_streamingon freed memory.Three more corrections the per-chunk input made load-bearing:
done()no longer overwrites a body already in theErrorstate with a partialInternalBlob.writeis built at the failing call rather than when the source ends. The typedRewritingErrorexists only on that call's frame, and the pending JS exception a throwing handler may have left is consume-once, so neither can be read later (at source end) without having been lost or consumed in between. The JS error is carried as a stack value and reported from that samedrive_rewriterinvocation; it is never stored on the sink.end()is skipped when an earlierwritealready failed (a handler threw mid-stream and the source then ended cleanly). lol-html poisons the rewriter after a failedwriteand panics (Attempt to use the HtmlRewriter after a fatal error) if it is used again. A source that ends with an error never reachesend()at all; that path is HTMLRewriter: reject the transformed body when the upstream body fails #32927's and is unchanged here.writefailure on a still-open source is reported to every consumer immediately instead of when the source eventually ends. A failedwritepoisons the rewriter and drops every later chunk, so there is nothing left to wait for, and a.text()/.body/Bun.serveconsumer of a long or never-ending upstream would otherwise hang. The eventual source end then has nothing left to do;finishon a poisoned sink returns without touching the rewriter.The task deferral, the re-entrancy guard, the four corrections above, the removal of every
JSC::Strongfrom the sink (in favor of theResponsecell's WriteBarrier graph plus oneJsRef), and the replacement of the raw-pointer(*sink).fieldaccess pattern with three disjointRefCelldomains (so every entry point forms only a shared&Self, and an aliasing violation is a deterministic panic instead of UB) were all surfaced by review on this PR.Not included: JS / direct
ReadableStreamsources (#11758, #14216) still returnERR_STREAM_CANNOT_PIPE. That path needs the disabledArrayBufferJSSinkpump, a separate mechanism.Verification
Ten new tests in
test/js/workerd/html-rewriter.test.js, in adescribe.concurrent("HTMLRewriter streaming")block plus a sequentialdescribe("HTMLRewriter streaming with async handlers")for the two whose handlers spin the event loop (a nestedwait_for_promiseis hostile to concurrently running server / fetch tests). Each is gate-driven: the upstream refuses to emit its final chunk until the behavior under test has already been observed, so a buffer-everything implementation deadlocks deterministically rather than flaking, and the gate also guarantees the source is never already complete whentransform()runs..bodyof a transformed streaming response yields the rewritten outputBun.servestreams a transformed response through to the clientBun.servestreams a transformedBun.file()response through to the client (HTMLRewriter+new Response(Bun.file)causesBun.serveto think a non-Response is returned #6068)Bun.serveproxy delivers rewritten bytes to the client before the upstream ends.bodyreadertransform()has returned (forcing the pipe path), and the handler then awaits an independent round trip through the HTTP client, which can never complete if the rewriter is driven from inside the fetch tasklet's critical sectionWith this change all ten pass, and at the final revision (re-expressed on the #33048 bindings) the full file passes 0 failures across 80 consecutive runs (40 of the whole file, 40 filtered to the streaming describes), on top of the earlier 110 and 80 clean runs at the prior revisions. With
src/reverted to post-#33048main(the gate's fail-before base) exactly these ten fail, deterministically, by timeout or a missing-first-chunk assertion, and every pre-existing test in the file, including everything #32927, #32840, and #33048 added, still passes.Existing suite and soaks
Re-run after the rebase, on a debug + ASAN build:
it.todos, intentionally unchanged), 1 skip.test/js/web/fetch/{body,body-stream,body-clone,body-mixin-errors,body-async-iterator,blob,content-length}.test.*(this PR'sBody.rschange now merges with HTMLRewriter: reject the transformed body when the upstream body fails #32927'sBody.rsand body-reader changes): 9489 pass, 0 fail.html-rewriter.test.js(50 of the whole file, 60 filtered to the streaming describes): 0 failures.Run on the pre-rebase revision; the streaming and lifetime code they exercise is unchanged by the rebase resolution:
.text(), fully-drained.body, abandoned reader, dropped Response,Bun.serveproxy, mid-transform cancel) withBun.gc(true)every 5: clean.out.body.getReader()from insidelolhtml::write, handler throws mid-stream with a.bodyreader attached, reader cancelled from inside a handler, handler throws with a.text()consumer): clean.Related PRs
Related PRs touching the same code. This one was originally written against
mainwithout any of them.on_readable_stream_availablehook and delivers the output as a single terminal chunk atdone()time. That fixes the.body-empty andBun.serve-hang symptoms, but the input is still fully buffered and the output still arrives only at end-of-stream. This PR supersedes that mechanism; the two cannot merge cleanly. HTMLRewriter: deliver the transformed output to consumers of the response's body stream #32956's separateBun.file()size-sentinel fix (non-seekable or missing files report their length asu64::MAXand trip a lol-html preallocation assert) is a distinct pre-existing bug, is not made more reachable here, and is not duplicated here.has_received_last_chunkparked-error short-circuit, surfaces the real body error from the synchronoustransform()branch, and adds aValue::Errorguard to every body reader. The one change both made (theErrarm inon_stream_pipe) is removed from this PR in the rebase in favor of main's; see the rebase section above.ValueBufferer::run's error return (exception fidelity, a different concern). Semantically independent; textually conflicts with the newValueBuffererfield andinitsignature.