Conversation
|
Updated 12:18 AM PT - Jul 4th, 2026
❌ @robobun, your commit 6d6ae4e has 4 failures in
🧪 To try this PR locally: bunx bun-pr 33243That installs a local version of the PR into your bun-33243 --bun |
|
Found 5 issues this PR may fix:
🤖 Generated with Claude Code |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThis PR adds async suspend/resume support for HTMLRewriter handlers, introduces ChangesAsync handler suspension
Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/api/html_rewriter.rs`:
- Around line 1648-1699: The fulfilled-branch in `html_rewriter.rs`’s
promise-status match is missing the same error-value check used by the
synchronous handler path, so an async handler that resolves to an `Error` or
`AggregateError` continues the rewrite instead of stopping it. Update the
`promise.status()` handling in the `HandlerOutcome` logic to inspect the
resolved value in the `Fulfilled` arm, mirroring the existing `is_error() ||
is_aggregate_error()` check from the sync path, and return
`HandlerOutcome::Stop` after recording the handler error when the fulfilled
value is an error.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: a62edcec-a4c1-4d5e-bbb7-8b7f03f862d7
📒 Files selected for processing (9)
patches/lolhtml/content-handler-suspension.patchscripts/build/deps/lolhtml.tssrc/jsc/VirtualMachine.rssrc/jsc/bindings/ZigGlobalObject.cppsrc/jsc/bindings/ZigGlobalObject.hsrc/jsc/bindings/headers.hsrc/runtime/api/html_rewriter.rssrc/runtime/server/RequestContext.rstest/js/workerd/html-rewriter.test.js
|
I checked all 5 suggested issues against this branch before adding any
The |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/api/html_rewriter.rs`:
- Around line 2672-2679: The retarget path in HtmlRewriterElement::retarget
detaches old AttributeIterator instances but leaves stale raw pointers stored in
attribute_iterators, which can be revisited later by invalidate() or mutation
paths. Update retarget() to mirror invalidate() by calling
detach_attribute_iterators() and then clearing the attribute_iterators
collection so abandoned iterators cannot be used again after the element is
retargeted.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 97c11821-bc19-41a1-ab1b-055a06d526d8
📒 Files selected for processing (2)
src/runtime/api/html_rewriter.rstest/js/workerd/html-rewriter.test.js
There was a problem hiding this comment.
The retarget() UAF fix in 1de1c59 looks correct and the regression test covers it; no further issues found on re-review. That said, this is a large architectural change (a ~2.8k-line lol-html fork patch, refcount/GC-lifetime rewiring across async boundaries, a user-visible behavior change for async handler rejections, and a RequestContext refcount adjustment) that should get human eyes before merging.
Extended reasoning...
Overview
This PR removes vm.wait_for_promise() from HTMLRewriter's async content-handler path, replacing nested-event-loop re-entry with a proper suspend/resume mechanism. It touches:
patches/lolhtml/content-handler-suspension.patch(~2850 lines): forks lol-html to addSuspensionRequest/RewritingError::Suspended/HtmlRewriter::resume(), deep-copying the in-flight rewritable unit onto the heap and bookmarking the parser's state machine sowrite()can return and later continue. This is a substantial modification to a vendored parser's internals (lexer actions, dispatcher, text decoder, every token type gains atake_owned()).src/runtime/api/html_rewriter.rs(~700 lines net): introducesHandlerOutcome,PendingSuspension,ActiveSinkGuard(LIFO per-VM active-sink slot),RewritePhase, and theWrapperLike::retarget/detach/suspended_rawmachinery. ReworksBufferOutputSinkerror handling from a stack-local capture slot to a sink-ownedCell<JSValue>, adds two new.thenhost functions, and changes how the outputResponsebody'sPendingValueis initialized.src/runtime/server/RequestContext.rs: addsref_()+has_marked_pendingwhen registeringon_receive_valueon aLockedbody, with the balancingderefindo_render_with_body_locked.src/jsc/VirtualMachine.rs/ZigGlobalObject.{h,cpp}/headers.h: newhtml_rewriter_active_sinkVM field and two newPromiseFunctionsentries.test/js/workerd/html-rewriter.test.js: rewrites two existing tests to reflect the new async-rejection contract and adds ~15 new tests covering the suspension path.
Security risks
No injection, auth, or data-exposure surface is touched. The relevant risk class is memory safety: raw-pointer wrapper retargeting across async boundaries, refcount balancing for the sink across .then reactions, GC-safety of the PendingSuspension.promise JSValue held across a purely-native unwind, and the LIFO active-sink guard for nested transforms. My previous review found one real UAF here (Element::retarget() not detaching pre-await AttributeIterators), which was fixed with a regression test. The lol-html patch itself introduces unsafe-adjacent parser bookkeeping (bookmark-relative offsets, re-buffered tail feeding) that would silently corrupt output rather than crash if wrong.
Level of scrutiny
High. This is not a mechanical or config change — it is an architectural rewrite of a memory-safety-critical subsystem, plus a substantial fork of a vendored dependency's parser internals. It also includes a documented user-visible behavior change (async handler rejections that need a macrotask now reject the response body instead of throwing from transform()) and touches the production HTTP-serving hot path (RequestContext). The lol-html patch alone (parser state-machine bookmarking, hoisting pending-text flushes into lexer actions, per-token take_owned()) needs review by someone who can reason about the parser's invariants.
Other factors
- All three inline review threads on this PR are resolved: two CodeRabbit findings were withdrawn as non-issues, and my UAF finding was fixed in the head commit.
- Test coverage for the new suspension path is thorough (per-token-type suspensions, GC-during-await, nested transforms, multi-handler ordering, the new
transform(string)error), and the lol-html patch ships its own Rust unit tests. - The
RequestContextchange is small but affects request lifetime inBun.serve; the PR description notes it guards a branch that only becomes reachable with this change, but a reviewer familiar with thehas_marked_pending/should_render_missingcontract should confirm the ref/deref pairing on all exit paths. - No human reviewer has looked at this yet.
…vent loop `handler_callback` used `vm.wait_for_promise(promise)` whenever a JS content handler returned a pending promise. That spins the entire event loop (timers, I/O, GC, arbitrary user JS) from six frames deep inside lol-html's `write()`, while the Element token is a stack borrow the handler's JS wrapper points at. It also forced `init()` to stay on the stack for the whole transform, so the error slot was a stack local whose address was stored in a VM-global field. Replace the nested event loop with real suspension: lol-html (vendored, patches/lolhtml/content-handler-suspension.patch): - A content handler can return `Err(SuspensionRequest)`. The current rewritable unit (Element/TextChunk/Comment/Doctype/EndTag/DocumentEnd) is deep-copied onto the heap, `write()`/`end()` return the non-poisoning `RewritingError::Suspended`, and `resume()` picks up from a `StateMachineBookmark` once the caller is ready. - The pending captured-text flush moves from `Dispatcher::handle_tag` into the lexer's `emit_tag`/`emit_current_token`/`emit_eof` actions, BEFORE the lexeme is built. That makes every suspension uniform: the in-flight lexeme is either fully consumed or not yet built, so resuming never needs a saved lexeme. The parser side reuses the existing `continue_from_bookmark` machinery verbatim. - `DocumentEnd` buffers appends instead of holding `&mut` the output sink, so it can be parked too. - Covered by 17 new in-crate tests plus the existing 146 and the html5lib integration fixtures. bun: - `handler_callback`: drain microtasks once (never the event loop) when the promise is pending; if it is still pending, disarm the wrapper's cleanup guard, record a `PendingSuspension` on the sink, and return `Suspend`. After `write()` unwinds, the JS wrapper is re-pointed at lol-html's heap-parked unit (so post-`await` mutations land) and a `.then` reaction (Bun__HTMLRewriter__onHandlerResolve/Reject, new `PromiseFunctions` entries) resumes the rewrite from the real event loop. - The error slot moves from a stack `Cell` in `init()` onto the sink, and the `on_unhandled_rejection` / `unhandled_pending_rejection_to_capture` overrides are gone entirely. New VM field `html_rewriter_active_sink` (saved/restored LIFO around each lol-html call) is how handlers reach their sink. This also fixes handler exceptions on the streaming-input path, which used to surface as "The rewriter has been stopped." instead of the actual error. - `transform(string)`/`transform(ArrayBuffer)` throw a TypeError when a handler genuinely needs the event loop; microtask-only async handlers still complete synchronously. - `init()` no longer stamps `pv.task` on the output body as an identity tag. `Bun.serve` reads `lock.task.is_some()` as "someone else owns this body" and piped a ReadableStream nobody ever feeds. RequestContext: the `on_receive_value` registration for a `Locked` response body never took a ref or set `has_marked_pending`, so `should_render_missing` 404'd the request and released the context back to the pool while the body's owner still held its callback pointer. That branch was unreachable from JS until now. Behavior change: a handler that rejects only after a macrotask (`await Bun.sleep(1); throw`) no longer throws synchronously out of `transform()`; the transformed body rejects instead, matching Cloudflare Workers. Rejections reachable by a microtask drain alone still throw synchronously. Two tests in test/js/workerd/html-rewriter.test.js asserted the old behavior and are updated; twelve new tests cover the suspension paths, including `Bun.gc(true)` twice while an Element is heap-parked mid-await.
…targets `Element::retarget` re-points the JS wrapper at the heap copy lol-html parks on suspension, but an `AttributeIterator` handed to JS before the handler's `await` still borrows the abandoned stack `StartTag`'s attribute buffer. `set_attribute` / `remove_attribute` / `detach` all already route through `detach_attribute_iterators()` for this exact invariant; `retarget` is a new invalidation point and was missing it. Found by review.
1de1c59 to
61e2889
Compare
|
Rebased onto main (61e2889). No conflicts, no changes to the diff. The failures in build 67889 did not come from this PR:
After the rebase, |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/runtime/server/RequestContext.rs (1)
3200-3213: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winRelease the pending RequestContext ref on abort/finalize.
this.ref_()here is only balanced bydo_render_with_body_locked().on_abort()andfinalize_without_deinit()tear down the response and stream refs, but they never runon_receive_value, so a locked body abandoned beforeValue::resolve()can leave theRequestContextpinned.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/runtime/server/RequestContext.rs` around lines 3200 - 3213, The locked-body pending ref in RequestContext is only released through do_render_with_body_locked, so an abort or finalize path can leak the extra ref when Value::resolve never happens. Update the RequestContext flow around the pending-body setup and the on_abort/finalize_without_deinit paths so they explicitly clear the locked-body callback/task and drop the ref added by this.ref_(), ensuring the pending RequestContext is unpinned even if the body is abandoned.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/api/html_rewriter.rs`:
- Around line 587-651: The suspension state in PendingSuspension is holding
JSValue fields in native storage, so the GC cannot see the promise or error
while lol-html unwinds. Update PendingSuspension to keep a rooted/protected
handle for promise, and apply the same rooting approach to handler_error below,
then unroot/release them when the continuation or cleanup path completes in the
related handler_callback and BufferOutputSink suspension flow.
---
Outside diff comments:
In `@src/runtime/server/RequestContext.rs`:
- Around line 3200-3213: The locked-body pending ref in RequestContext is only
released through do_render_with_body_locked, so an abort or finalize path can
leak the extra ref when Value::resolve never happens. Update the RequestContext
flow around the pending-body setup and the on_abort/finalize_without_deinit
paths so they explicitly clear the locked-body callback/task and drop the ref
added by this.ref_(), ensuring the pending RequestContext is unpinned even if
the body is abandoned.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: f0b75af3-ce23-4858-88dc-912dfc46857c
📒 Files selected for processing (9)
patches/lolhtml/content-handler-suspension.patchscripts/build/deps/lolhtml.tssrc/jsc/VirtualMachine.rssrc/jsc/bindings/ZigGlobalObject.cppsrc/jsc/bindings/ZigGlobalObject.hsrc/jsc/bindings/headers.hsrc/runtime/api/html_rewriter.rssrc/runtime/server/RequestContext.rstest/js/workerd/html-rewriter.test.js
Bun.serve registers an on_receive_value callback (plus a ref on the RequestContext) for the transform's still-pending response body. Cover the path where the client aborts while the handler is suspended and the handler then resolves (or rejects) against the dead request: the value delivery must still release the ref and the server must keep serving.
|
On CodeRabbit's outside-diff comment about I looked at this carefully because it is the scariest line in the PR, and I've added tests for it rather than arguing from theory. In b1c9872:
Both run the exact sequence the comment describes (client gone, locked body still pending, value delivered afterwards) under the ASAN debug build. Both pass, the ref is released, and the server keeps serving. Why that works: The ref also isn't optional bookkeeping. Without it, The one scenario the comment is technically right about is a locked body whose producer is itself abandoned without ever settling (a handler promise that never settles, or an input stream that never ends and never errors). That is a user-authored hang that already pins the sink, the rewriter, and the bufferer, here and on main; the |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/workerd/html-rewriter.test.js`:
- Line 367: The test in the HTML rewriter abort path is only checking the
DOMException type, which is too broad. Update the assertion in the clientResult
expectation to verify the specific abort contract by checking the DOMException’s
.name or .code, using the same test case around clientResult and DOMException.
Keep the assertion pinned to the exact abort-related value so unrelated
DOMExceptions do not pass.
- Around line 330-381: The two HTMLRewriter abort cases in the test block are
independent, so the current outer for-loop with it keeps them sequential
unnecessarily. Refactor the loop to use it.concurrent.each for the ["resolves",
"rejects"] cases, keeping the same test body and local state (suspended,
serverSawAbort, gate, handlerFinished, and Bun.serve) so both variants can run
concurrently without sharing state.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 6ffcf248-0e20-46e6-9cfd-d2d09a46d7a2
📒 Files selected for processing (1)
test/js/workerd/html-rewriter.test.js
There was a problem hiding this comment.
Thanks for the fix in 61e2889 — the retarget UAF is closed and I have nothing further, but given the scope (a 2.8k-line lol-html fork patch, the RequestContext refcount change, and the new suspend/resume lifetime model) this one really wants human eyes before merge.
Extended reasoning...
Overview
This PR removes vm.wait_for_promise() from HTMLRewriter's async content-handler path and replaces it with a true suspend/resume mechanism. It touches: a 2,850-line patch to vendored lol-html adding SuspensionRequest/RewritingError::Suspended/HtmlRewriter::resume() plus take_owned() on every rewritable-unit type; ~700 lines of html_rewriter.rs rewiring the sink drive loop, error propagation, wrapper detach/retarget, and adding ActiveSinkGuard/PendingSuspension/promise reactions; a new html_rewriter_active_sink field on VirtualMachine; two new PromiseFunctions entries in ZigGlobalObject; a ref_() + has_marked_pending change in RequestContext::do_render_with_body for locked bodies; and ~280 lines of new tests.
Security risks
No injection/auth/data-exposure surface is touched. The real risk class here is memory safety: heap-parked lol-html units with raw-pointer JS wrappers retargeted at them, manual refcounting on BufferOutputSink across promise reactions, a JSValue held in a native Cell across the lol-html unwind (justified by a safepoint argument in the thread), and a new ref on pooled RequestContext objects. My earlier review found one UAF on this exact axis (pre-await AttributeIterators dangling after retarget), which was fixed and tested. The author's responses to the GC-rooting and RequestContext-ref concerns are careful and I find them convincing, but this is precisely the class of reasoning a maintainer should sign off on.
Level of scrutiny
High. This is a production-critical path (Bun.serve + HTMLRewriter), the change is architecturally significant (eliminating a nested event loop, which changes user-observable timing — async handler rejections now reject the body instead of throwing from transform()), it forks a vendored dependency substantially, and the RequestContext refcount change reaches into the HTTP server's request-lifetime bookkeeping. None of this is mechanical.
Other factors
- My prior inline finding (the
Element::retargetattribute-iterator UAF) was fixed in 61e2889 with a regression test; the follow-up abort tests landed in b1c9872. - All three CodeRabbit correctness threads were resolved with (in my view correct) rebuttals; the two open CodeRabbit comments are test-style nits (
it.concurrent.each, assertingAbortErrorby name) and not blocking. - Test coverage is thorough (ordering, GC survival, nested transforms, iterator detachment, client-abort-during-suspension, plus an extensive Rust-side
mod suspensionin the lol-html patch). - The bug-hunting system found nothing this run.
I'm not approving because the combination of a large vendored-dep fork, a new lifetime model built on unsafe raw pointers across GC boundaries, a user-visible behavior change, and a RequestContext refcount adjustment is well outside the "simple and obviously correct" bar.
…or name Addresses two review nits on the new client-abort tests: use it.concurrent.each so the two variants overlap (each spins up its own server and shares no state), and pin the client-side rejection to name === "AbortError" rather than accepting any DOMException. Running them concurrently is also the stronger regression: two overlapping aborted requests whose transforms are suspended deadlock the nested event loop this PR removes, so the concurrent form hangs on the old implementation where the sequential form could get lucky.
alii
left a comment
There was a problem hiding this comment.
Reviewed at the current tip (origin/farm/b12d70b3/htmlrewriter-suspend), against the vendored lol-html patch itself and against workerd's implementation of the same API on the same library (which is the reference for the mutate-after-await / poisoning / cancellation semantics). Verified every claim below against the branch source; file:line references are to this branch.
Design review: HTMLRewriter — suspend async content handlers instead of nesting the event loop
Verdict: request-changes. The core design — suspend/resume in lol-html instead of wait_for_promise re-entering the event loop — is the right approach and I don't want a different one. But the PR ships three genuine behavior regressions relative to every released Bun (attribute iterators, res.clone()/res.body consumers, a token-ordering bug in the vendored lexer patch), two structurally unbounded native leaks with no teardown owner, and it inverts documented user-visible semantics in both directions without touching docs/runtime/html-rewriter.mdx or packages/bun-types/html-rewriter.d.ts. All of these are fixable inside this design.
Blocking
1. The docs and types are now actively wrong in both directions, and the PR ships zero doc/type changes
docs/runtime/html-rewriter.mdx:116 says "Async operations block the transformation until they complete" with an async element(el) { await Bun.sleep(1000) } example, directly after the canonical transform(html) → string example at mdx:43. On this branch that exact composition throws TypeError: HTMLRewriter.transform() cannot synchronously return a string... (src/runtime/api/html_rewriter.rs:509-533) — your own test pins it ("transform(string) throws if a handler needs the event loop to settle"). In the other direction, mdx:311-327's try { transform(new Response(...)) } catch no longer catches handler errors: they now reject the body — your diff itself rewrites the two pre-existing "async error inside element handler" tests from try/catch + expect.unreachable() to await expect(res.text()).rejects.toThrow(). So you knew the documented contract changed and didn't update the doc.
Worse, the new transform(string) contract is data-dependent: it succeeds iff every handler promise settles within one drain_microtasks() (html_rewriter.rs:1663-1677). async element(el) { await cache.get(k) } returns a string on a warm cache and throws TypeError on a cold one — same source, outcome decided by runtime data, and the throw is non-atomic (earlier handlers already ran and mutated). That is a contract no user can read off their code, and it is documented nowhere.
Do in this PR, not a follow-up:
- Rewrite mdx:114-125: delete the "async operations block" paragraph. State the real contract:
transform(Response)— handlers suspend, transform returns immediately, consume viaawait res.text(), handler errors reject the body;transform(string | BufferSource)— a handler promise must settle within a microtask drain or transform throws TypeError (quote the message and the remedy). - Update
packages/bun-types/html-rewriter.d.ts:transform(input: string): stringcurrently advertises an unconditional sync string; document the@throws. - Update mdx:311-327's error-handling example to
await res.text()+ rejection. - Add one sentence to the PR body's "Behavior change" section for the un-listed second change: fire-and-forget rejections inside an async handler no longer become
transform()'s thrown error (they flow tounhandledRejection, matching sync handlers). Do not restore that capture — the old one hijacked unrelated rejections and swallowed them with exit 0 — but do pin the new semantics with a subprocess test.
2. Element::retarget detaches live AttributeIterators instead of retargeting them — silent data loss vs every released Bun and vs workerd, and the PR pins it as intended
src/runtime/api/html_rewriter.rs:2668-2680: retarget calls detach_attribute_iterators(), so for (const [k,v] of element.attributes) { await ... } gets done:true on the second next() and silently processes only the first attribute. On main the iterator survives the await (the invalidating scopeguard fires only after the promise settles, main:1310/1963); workerd also survives. Your own new test at test/js/workerd/html-rewriter.test.js:150-172 proves the parked element still has both attributes (after = [...element.attributes] at :163) — only the pre-await iterator was killed. This is the quietest failure mode possible: a user loop that writes N attributes to KV today writes exactly 1 after upgrading, with no error, and the test frames the truncation as principled so a future maintainer will close the bug report.
Fix: retarget instead of detaching. Change AttributeIterator from boxing a raw slice::Iter<'static, Attribute<'static>> to a Cell<usize> index whose next() reads element.attributes().get(i) through the Element's already-retargeted Cell<*mut RawElement>; then retarget needs no iterator work. Flip the test at :150 to assert the iterator SURVIVES, and add the real regression case (for..of with an await in the body visits all N).
3. res.clone() on a suspended transform hangs forever; res.body.getReader() drains empty; new Response(res.body) throws AbortError — all work on 1.3.14, none tested
Value::resolve (src/runtime/webcore/Body.rs:1091-1100) handles a settled Locked body by calling readable.done() and discarding the bytes; only the on_receive_value/promise branches get data. Its error twin (Body.rs:1409-1412) does feed the readable — which is exactly why the file's three existing non-.text() tests (.body, pending-read, .clone() at test:198/212/225) pass: they cover only the error quadrant. Pre-PR, a pending output body was reachable only from a streaming input (a corner case). This PR makes it the default state for the feature it ships — any async handler on a plain new Response("<string>") — and clone()/.body/new Response(res.body) are among the most idiomatic Workers HTMLRewriter patterns. Verified on released 1.3.14 with the PR's own flagship input: clone hangs, body reader drains empty, AbortError. All 12 new suspension tests use only .text().
Fix: the root belongs in Value::resolve (Body.rs:1091-1100) — the layer that owns "settle a Locked body" — not html_rewriter.rs: when a readable is attached, feed it the bytes (mirror to_error_instance) instead of discarding them. Then add the consumer matrix to the suspension block: .body + reader drain, .clone().text(), new Response(res.body).text(), .blob(), Bun.write(file, res).
4. A never-settling (or GC'd-unsettled) handler promise, or a Worker terminating mid-suspension, permanently leaks the entire parked rewrite — no teardown path exists at any layer
Two faces of one structural problem: the sole owner of a parked suspension is an anonymous promise-reaction argument.
begin_suspensiontakes(*sink).ref_()(:1157) and attaches the continuation with the raw-pointerpromise.then(&global, sink, onResolve, onReject)(:1166-1171). The only release sites are the reactions themselves (ScopedRef::adoptat :1307/:1324);Drop(:1340-48) is the only thing that frees the boxed lol-html rewriter. Bun's own doc onthen_with_value(src/jsc/JSValue.rs:177-185) names this exact failure: with a raw-pointer ctx, GC of an unreachable pending promise collects the reactions and orphans the +1 forever — the release code IS the collected reaction. What then leaks unrecoverably: theStrong-rooted output Response (:705), theProtectedJSValuehandler closures, the boxed parser + copied document tail (potentially MBs), and under Bun.serve a ref'dRequestContext.async element(){ await new Promise(()=>{}) }— a stalled fetch with no timeout — is an ordinary user bug, per-request, unbounded.- Nothing at VM/Worker teardown can even enumerate parked suspensions.
worker.terminate()while a request-scoped handler awaits a subrequest — the flagship Cloudflare-shaped use case — leaks a document-sized native allocation into the long-lived parent process every time. The PR body doesn't acknowledge this, and no test covers it. workerd, the semantic reference, explicitly rejects the continuation when the promise is GC'd unsettled ("Promise will never complete.", io-context.h:1572-1574).
Fix: give the suspension a terminal state that doesn't depend on the promise settling. (a) Route the continuation through a GC-owned context via then_with_value (already in-tree, src/jsc/JSValue.rs:177-195) whose finalizer, if it runs with the suspension still armed, derefs the sink and delivers a body error — mirroring workerd. (b) Keep a per-VM registry of parked suspensions (a Vec<NonNull<BufferOutputSink>> next to html_rewriter_active_sink), pushed in begin_suspension, removed in the reactions, and drained during web_worker.rs::shutdown() step 2 (next to cancel_all_timers, before WebWorker__teardownJSCVM) while the JSC heap is still alive. State the chosen teardown story in the PR body.
5. The lol-html patch reorders tokens at EOF: unterminated comments/doctypes are emitted BEFORE the pending lastInTextNode text chunk — a divergence from upstream, with a runnable proof
The patch deletes the old universal flush at dispatcher.rs:388-393 and re-adds flush_pending_text in exactly four lexer actions (emit_eof, emit_current_token, emit_tag, emit_raw_without_token) — but not in the two composite EOF actions emit_current_token_and_eof (vendor/lolhtml/src/parser/lexer/actions.rs:117-132) and emit_raw_without_token_and_eof (:150-165). grep and_eof on the patch: zero hits. Both inline create_lexeme_with_raw_exclusive + emit_lexeme and only THEN call emit_eof, so the comment/doctype token dispatches before the pending text flush. Reachable from every comment/doctype EOF state (6 sites in comment.rs, 14 in doctype.rs) — truncated/aborted upstream bodies ending inside <!--... are a real input class for a streaming rewriter. Result: handler order inverts and output byte order inverts relative to upstream; the canonical if (text.lastInTextNode) text.after(...) idiom emits AFTER the trailing comment. Verified with a runnable diff against the unpatched vendor crate.
Fix: add self.flush_pending_text(context)?; as the first statement of both composite actions, exactly mirroring the four you already fixed. Add in-crate tests for text preceding an unterminated <!--comment and an unterminated <!doctype.
6. transform(string) throws synchronously but deliberately leaves the suspended rewrite running as an orphan — later handlers still fire, and a post-throw rejection is silently swallowed
html_rewriter.rs:508-533 disarms out_guard (:522) and throws; the comment itself concedes "The suspended rewrite still runs when its handler's promise settles". By then begin_suspension already attached the .then, so on_handler_resolve (:1302) → resume_rewrite keeps lexing the rest of the buffered string and invokes the user's handler for every later match — against a Response nobody can reach. A rejection after the throw goes to deliver_body_error (:1200-1228) on the orphan body: not thrown, not on any body, not an unhandledRejection — worse than the pre-PR behavior it replaces, where it surfaced from transform(). A user reads the TypeError as "the transform failed", catches, retries with new Response(html) — and every handler's side effects (fetches, DB writes, counters) run twice. None of this half-state is specified, documented, or tested.
Fix (fail atomically): mark the sink sync_only on the string/ArrayBuffer branch; in handler_callback's Status::Pending arm (:1677) record_handler_error(the TypeError) + return HandlerOutcome::Stop instead of suspending. That reuses the existing poison path (init's take_handler_error at :914 already turns it into the synchronous throw), deletes the out_guard-disarm special case at :508-533, and guarantees no handler code or swallowed rejection outlives the throw — matching workerd.
Should fix
7. O(N_suspensions × doc_size) memmove per resume, and doubled peak RSS, on the exact workload this PR exists to enable
Bun feeds the whole buffered body in ONE write() (:983, :1012). On suspension consumed_byte_count = lexeme_start, so upstream write() (transform_stream/mod.rs:97-102) memcpys the entire document tail into the Arena; each resume() then runs Arena::shift = copy_within(n.., 0) (arena.rs:59) — a memmove of the whole remaining tail, on the JS event-loop thread inside promise reactions. 5 MB page × 20k async-suspending elements ≈ 50 GB of memory traffic. Main's wait_for_promise was ugly but O(D). "Async handler over a broad selector on a real page" is THE workload.
Fix (~15 lines, confined to the patch): Arena never shrinks anyway — make shift(n) a start: usize offset bump, bytes() return &data[start..], and have append() (the only place that needs compaction, on the multi-write streaming path) do a one-time copy_within(start.., 0); start = 0. Bun's buffered path never writes again after a suspension, so it drops to zero memmoves.
8. The suspend decision drains only JSC's microtask queue, not process.nextTick — so await new Promise(r => process.nextTick(r)), which works synchronously today, now throws
html_rewriter.rs:1664 → global.vm().drain_microtasks() → raw JSC::VM::drainMicrotasks() (bindings.cpp:5016), which never touches m_nextTickQueue. Bun's own event-loop checkpoint (ZigGlobalObject::drainMicrotasks, ZigGlobalObject.cpp:3120; used at event_loop.rs:327) drains nextTick THEN microtasks as one unit, and in Node ordering nextTick runs BEFORE promise jobs — so the error message ("did not resolve within a microtask") is wrong on its own terms, and previously-working code (node-stream helpers that complete via nextTick) silently breaks while the queueMicrotask twin keeps working. Neither variant is tested.
Fix: call JSC__JSGlobalObject__drainMicrotasks(global) (already bound safe at event_loop.rs:143; runs no timers/I/O, so it doesn't reintroduce the re-entrancy this PR removes), handling its termination return. Pin transform(string) × {nextTick-await, queueMicrotask-await}.
9. The post-drain scope.clear_exception() at :1667 can only ever swallow a TerminationException
Inside VM::drainMicrotasks, every job's exception is already cleared-and-reported per job (MicrotaskQueue.cpp:58-65), and the drain bails early leaving ONLY a pending termination exception set (VM.cpp:1463-1491). So the line is 100% dead for the case its comment describes and 100% harmful for the one case it can fire: worker.terminate() landing during the handler drain is erased and re-surfaced as a bogus handler error (or the rewrite runs to completion). Bun already has the right tool: clear_exception_except_termination (JSGlobalObject.rs:974).
Fix: replace with if !global.clear_exception_except_termination() { return HandlerOutcome::Stop } (Stop WITHOUT record_handler_error), and fix the comment — or better, adopt the drain from #8, which already returns the typed termination signal.
10. The "no JS runs ⇒ no GC" comment justifying the unrooted heap PendingSuspension.promise / handler_error states the wrong invariant, and the code is strictly LESS rooted than what it replaces
The invariant happens to be true today (I traced the full window), but the comment (:605, :711, :1160) is not JSC's rule — GC is gated on allocation safepoints, not on running JS, and this file allocates JSC cells from native code (create_lolhtml_error at :1113, err.to_js at :969). The load-bearing property is really "no JSC allocation across the whole lol-html unwind", which is owned by a 2850-line vendored patch, not by html_rewriter.rs, and is asserted nowhere. Main protected the same value AND kept it in a conservatively-scanned stack slot; the PR moves it to an unbarriered heap Cell and deletes the protect. The failure mode is a GC-timing-dependent UAF at the .then() call (:1166) that no realistic CI can catch. Rooting costs one gcProtect per suspension.
Fix: root both. Take result.protected() (the RAII ProtectedJSValue this same file already uses at :1354) in the Pending arm and let PendingSuspension own it; same for handler_error in record_handler_error (:1559). Delete or correct the comment.
11. VirtualMachine.html_rewriter_active_sink: Option<NonNull<c_void>> is a feature-specific field on the god object, and its real justification is never stated
The only consumers are two sites in handler_callback (:1554-1561, :1678). All 7 element/document closures are built in build_settings (:254-342) after the sink is allocated (:750) and already capture a raw NonNull<ElementHandler> — capturing the sink alongside is trivial. The one real friction point — Element::on_end_tag_ (:2345-2389) builds its closure at handler-run time from &self with no sink in scope — is never named in the LAYERING: comment (VirtualMachine.rs:187-198) or the PR body. Meanwhile the ambient shape makes "did every entry point install the guard?" a maintained invariant whose violation is a silent dropped exception in release builds (a compiled-out debug_assert! + HandlerOutcome::Stop at :1678-1683).
Fix (either is acceptable, say which): (a) Minimum: state the on_end_tag_ reason in the comment and PR body, and make the missed-guard path loud (a real error, not a compiled-out assert). (b) Preferred: capture sink: *mut BufferOutputSink in the closures and thread it to handler_callback; plumb it to EndTagHandler via a sink field on Element. That deletes the VM field, the cross-crate c_void erasure, and the silent failure mode.
Coverage gaps (all should-fix; none blocking individually, but the aggregate is not shippable)
The PR creates the largest set of new refcount/rooting/state-machine edges HTMLRewriter has ever had and leaves whole quadrants of the matrix dark:
- No leak-regression test, despite the repo already having the dedicated sibling file
test/js/workerd/html-rewriter-leak.test.tswith the exactheapStats().protectedObjectTypeCountspattern needed — the PR never touches it. Five new ownership edges (sink +1 per suspension :1157, ref'd wrapper :1153-1156,response_value: StrongOptional:705, the heap-parked lol-html rewriter :1343-1346, andRequestContext.ref_()at RequestContext.rs:3204-3205), zero counted. Add: ~400-iteration loops of async-handler transform →Bun.gc(true)→ assertobjectTypeCounts.Response/.HTMLRewriter/protectedObjectTypeCounts.Functionreturn to baseline, for both the resolve and reject paths. - The advertised bugfix has no test. "Streaming input + handler exception now surfaces the real error instead of 'The rewriter has been stopped.'" is implemented by the
is_asyncarm ofon_rewriting_error(:1108-1125), reachable only from a streaming/file input — and every handler-throw test uses a string body while every streaming test has a non-throwing handler. Intersection = empty. Add one: throwing handler on aReadableStream-bodied Response,rejects.toThrow("boom"), verified to fail onUSE_SYSTEM_BUN=1. - The headline production shape is untested.
return rewriter.transform(new Response(html))inBun.servewith a live client and a rejecting async handler — the RequestContext.rs hunk (has_marked_pending+ the ref balance indo_render_with_body_locked) exists ONLY for this, and the one shipped serve test aborts the client. Add: with anerror()hook → hook gets the original reason and the client gets its Response; without one → client gets a 500 and a follow-up request on the same server succeeds. - The canonical async-text idiom is untested at every layer. "Suspend on
lastInTextNodeAND mutate it after resume" — the only JS async-text test literally opts out (if (chunk.lastInTextNode) return;, test:213), and the only in-crate test that mutates a suspended chunk contains the same dodge. Add the JS twin with a post-awaitc.after('|')and assert the exact output string. - Chained rewriters (
rw2.transform(rw1.transform(res))where both suspend) — the topologyinit()'s own comment names as a supported consumer ("anothertransform()", :772-780) — has zero tests, and it is the one topology wheredeliver_body_error'sis_pristinecheck (:1213-1226) flips. Add the success and rejection variants. - The mixed drain case — a real-await handler followed by a microtask-only async handler in the same document — is the only path where
handler_callbackon the resume path seesFulfilled, and no test reaches it (all 12 usesetImmediatePromisefor every handler). One test forces it. - The vendored patch's own regression net has holes. (a) The
emission_enabledgate inresume_dispatchstep 2 — the branch handling a handler suspending inside already-removed content — can be mutated toif truewith all 17 in-crate + all JS tests still green; a future lol-html rebase silently shipping removed content back is a sanitization bug. (b)cdata_allowedbookmark restoration and the entire newflush_pending_texthunk inemit_raw_without_token(whose only realistic trigger is<![CDATA[) have zero tests — onlytext_type(<script>) is pinned. Both are one small in-crate test each; add them so the rebase story isn't "hope".
What was checked and held up
These angles were probed hard and did not survive; you don't need to defend them:
ActiveSinkGuardLIFO nesting / concurrent transforms on the same rewriter — the "nested transform" test does exercise two simultaneously-suspended sinks; the discipline holds.RequestContextref balance aroundhas_marked_pending— the "on_receive_value fires exactly once" invariant is not novel to this PR; the accounting is correct.- Client abort not cancelling a suspension — traced end-to-end; not a DoS, not a regression, and consistent with workerd.
- Re-entrant
VM::drainMicrotasks()on the resume path — precedented (process.nextTick's checkpoint hook, and main's ownwait_for_promisealready nested it); JSC's queue tolerates it. Structurally fine — only the coverage gap above remains. done()holding&mutacrossValue::resolve— the aliasing pattern predates this PR; not newly introduced.send-boundUserDatain the C API patch — a semver concern for an unused module; not actionable here.- Handler-ordering across a descendant suspension, selector-VM state across resume, and end-tag/doc-end resume paths — all verified correct with in-crate tests.
Not wrong-approach
To be explicit: I probed "should this be a nested event loop with better scoping instead?", "should suspension live at a different layer than a lol-html fork-by-patch?", and "is the fork maintainable?". The suspend/resume design is the correct one — it removes real re-entrancy bugs (#33261) and matches workerd's model. The 2850-line fork-by-patch is a genuine maintenance liability, but the mitigation is the in-crate test coverage asked for above (so the next rebase has a net), not a different architecture. Everything in this review is fixable within the design as-is.
One extra, non-blocking suggestion: once #33267 lands, the Bun.serve suspension test here is the perfect place to assert getEventLoopStats().nestedDispatchTicks === 0 (bun:internal-for-testing, debug builds) — it pins the very property this PR exists to establish, and it is the regression net for #33261 item 4.
…ke Arena::shift O(1) Two fixes to the vendored suspension patch. The flush hoist landed in `emit_eof`, `emit_current_token`, `emit_tag` and `emit_raw_without_token`, but not in the two composite actions `emit_current_token_and_eof` / `emit_raw_without_token_and_eof`, which inline the lexeme creation (exclusive raw range) rather than delegating. The first is reachable from every comment and doctype EOF state, so an input ending inside an unterminated comment dispatched the comment before the pending last text chunk, inverting handler order and output bytes relative to upstream. The second carries no token so nothing was dispatched in the window, but it gets the same hoist to keep every suspension point uniform: the in-flight lexeme is either fully consumed or not yet built. `Arena::shift` memmoved the whole remaining tail (`copy_within(n.., 0)`). A suspended rewrite re-feeds its unconsumed tail on every resume, so a document with many suspending handlers paid O(suspensions x tail) of memory traffic on the JS thread. `shift` now just advances a `start` offset; the dead prefix is reclaimed in one memmove by the next `append`, which only the multi-write streaming path performs. The buffered path (bun feeds the whole body in one `write`) never appends after a shift, so it pays nothing. Tests: 4 new in-crate tests pin the handler order and output for text followed by an unterminated comment, doctype and tag at EOF, plus the suspending-text variant; all three comment/doctype ones fail before this change. A new arena test covers consecutive shifts with no intervening append. 168 unit + 34 integration tests pass, and pristine + patch still reproduces the fork byte-exactly.
…sform(string) atomically, drain nextTick
Addresses four review findings.
AttributeIterator no longer caches a `slice::Iter` into the element's
attribute buffer. It holds a backref to the `Element` wrapper plus an index
and reads the attributes back on every `next()`, so a suspension (which
re-points the element at lol-html's heap-parked copy) re-points the iterator
too. `Element::retarget` therefore does no iterator work at all, and
`for (const [k, v] of element.attributes) { await ... }` visits every
attribute instead of silently stopping after the first. Mutation still
detaches, as before. The element nulls the backref in `detach()`, which every
path that frees it runs first, so it cannot dangle.
`transform(string)` / `transform(ArrayBuffer)` now fail atomically. The sink
carries a `sync_only_noun`; a handler that would suspend records the TypeError
and stops the rewrite, which `init` rethrows synchronously. Previously the
rewrite was left running as an orphan: later handlers still fired against a
Response nobody could reach, and a post-throw rejection was swallowed. The
`Locked`-body check and its scopeguard disarm are now unreachable and deleted.
The suspend decision ran `JSC::VM::drainMicrotasks()`, which never touches the
nextTick queue, so `await new Promise(r => process.nextTick(r))` wrongly
reported "did not resolve within a microtask". It now runs one full microtask
checkpoint (nextTick, then promise jobs) via `JSGlobalObject::drainMicrotasks`,
which still runs no timers or I/O.
The post-drain `clear_exception()` could only ever swallow a termination
exception (microtask jobs clear and report their own). It is now
`clear_exception_except_termination()`, and a termination stops the rewrite
without being recorded as a handler error.
Tests: attribute iterator survives the await and resumes mid-iteration, for..of
with an await visits all attributes, transform(string) runs no later handlers
after the throw, ArrayBuffer wording, and nextTick/queueMicrotask handlers stay
synchronous. 100 pass / 0 fail across the 5 HTMLRewriter suites.
… closing it empty `Value::resolve` closed an attached readable with `done()` and discarded the value it was resolving to, so every consumer that turns a still-pending body into a stream got an empty result. Its error twin `to_error_instance` already feeds the stream, which is why the failure paths worked and the success path did not. Pre-PR a pending output body was only reachable from a streaming input, a corner case. A suspended HTMLRewriter transform makes it the default state for any async content handler, so `.body`, `new Response(res.body)`, `.blob()`, `.arrayBuffer()` and `Bun.write(file, res)` on a transformed Response all observed it. They now receive the rewritten bytes. Only when the stream is the body's sole consumer: a pending promise or a registered `on_receive_value` means somebody else owns the value, and those paths already deliver it. `res.clone()` on a suspended transform is still broken and is NOT fixed here. It takes `Value::tee`, which builds a ByteStream, tees it, and leaves both branches on a `PendingValue` that the sink never settles (the settled body reports `has_readable=false`). That is a separate defect in the tee path; a fix belongs with whoever owns it, not bolted onto this change. Tests: a consumer matrix over the six working consumers of a pending output body. 106 pass / 0 fail across the 5 HTMLRewriter suites. `fetch-leak.test.ts` has 5 pre-existing failures in this container with and without this change.
…se context
`begin_suspension` attached its continuation with `promise.then(global, sink,
..)`, whose context is a raw pointer. If the handler's promise is collected
without ever settling (`await new Promise(() => {})`, a stalled fetch with no
timeout), the reactions go with it and the `+1` they were going to release is
orphaned: the sink, the boxed lol-html rewriter, the copied document tail, the
Strong on the output Response, and the handler's protected closures all leak
for the life of the process.
The reaction context is now a `NativePromiseContext` cell, the mechanism this
repo already uses for exactly this hazard (`JSValue::then_with_value`'s doc
names it). A collected cell reaches `SuspensionContext::abandon` on the event
loop, which releases the parked wrapper and fails the output body with a clear
message rather than hanging its readers forever. HTMLRewriter was already a
registered consumer of that machinery for its body bufferer, so this adds one
tag, not a new mechanism.
Under `Bun.serve` a never-settling handler still holds the request open until
its idle timeout, the same as any never-resolving `fetch` handler; other
requests are unaffected.
Tests: `html-rewriter-leak.test.ts` gains a protected-object/Response count
regression over 120 suspending rewrites, and a never-settling handler asserted
to reject the body. 108 pass / 0 fail across the 5 HTMLRewriter suites.
…ct, cover the dark quadrants Rooting: `PendingSuspension.promise` and `BufferOutputSink.handler_error` lived in heap cells the conservative stack scan never sees. The window is native-only today, but that property is owned by a 2850-line vendored patch, and JSC's rule is "rooted whenever a safepoint is reachable", not "whenever JS runs". Both now carry a protect for as long as they are held, at one gcProtect per suspension. Docs and types: `docs/runtime/html-rewriter.mdx` still said "async operations block the transformation until they complete" next to a `transform(html)` example that now throws. It describes the real contract: handlers suspend, `transform(response)` returns immediately, handler errors reject the body, and `transform(string | ArrayBuffer)` throws a TypeError when a handler needs the event loop. `html-rewriter.d.ts` documents the `@throws` on both sync overloads. `ActiveSinkGuard` keeps the VM field, but the reason is now stated (the content handlers are built from a `LOLHTMLContext` that predates any sink, and `Element::on_end_tag_` builds its closure with no sink in scope), and a handler reaching `active_sink() == None` records a real error instead of a compiled-out assert plus a silent `Stop`. Coverage added for the quadrants that were dark: - a throwing handler on a *streaming* input, which is the only way to reach the `is_async` arm of `on_rewriting_error`. Fails on the released bun with "The rewriter has been stopped." and passes here with "boom". - `Bun.serve` with a live client: delivery, and a rejecting handler routed to the `error()` hook. - a detached (fire-and-forget) rejection inside a handler reaching `unhandledRejection`, pinning the second, previously unlisted behavior change. - chained suspending transforms, success and rejection. - a microtask-only handler after a suspending one (the resume path's only `Fulfilled` case). - the async-text `lastInTextNode` idiom, mutated after the resume. - in-crate: a suspension inside removed content emits nothing (mutating the `emission_enabled` gate to `if true` makes it fail), and text around `<![CDATA[`. 170 in-crate lol-html tests, 116 pass / 0 fail across the 5 HTMLRewriter suites, bun-types green, clippy clean, and pristine + patch still reproduces the fork byte-exactly.
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
|
Thanks, this is a great review. Every blocking item reproduced, and every fix below is pushed ( Blocking#5 lexer token order at EOF — fixed, you were right. Reproduced exactly: a text handler plus a comment handler on Four in-crate tests pin it (text before an unterminated comment / doctype / tag at EOF, plus the suspending-text variant). The three comment/doctype ones fail before the change. One thing you missed, for the record: #2 attribute iterators — fixed. I flipped the test to assert the iterator survives and resumes mid-iteration, and added the #3 pending-body consumers — fixed, except
#4 leaked parked rewrite — fixed. The reaction context is now a I skipped the per-VM registry. Under Leak tests: a protected-object/Response count regression over 120 suspending rewrites, and the never-settling case asserted to reject. #6 #1 docs and types — done. mdx:116 now states the real contract (handlers suspend, Should-fix#7 arena memmove — fixed. #8 nextTick — fixed. It now runs a full microtask checkpoint ( #9 #10 rooting — fixed. You're right that my comment stated the wrong invariant (safepoints, not "JS runs"), and more importantly that a property owned by a vendored patch is a bad thing to lean on. Both values are protected while held, one gcProtect per suspension. Comments corrected. #11 the VM field — kept, with the reason stated. Your option (a). The closures are built in CoverageAdded: the streaming-input + throwing-handler case (the advertised bugfix — fails on released bun with "The rewriter has been stopped.", passes here with "boom"); Verification170 in-crate lol-html tests (was 163), 116 pass / 0 fail across the five HTMLRewriter suites, bun-types green, clippy clean, and pristine + patch still reproduces the fork byte-exactly through the real |
The message hardcoded "a" before the interpolated noun, so the ArrayBuffer overload read "cannot synchronously return a ArrayBuffer". The noun now carries its own article.
… iterator refactor invalidated The streaming-input test raced the loopback: if the whole fetch body arrived before `transform()` ran, the rewrite finished inline and the handler threw synchronously instead of rejecting the body. Both are correct, but only one is the `is_async` arm the test exists to cover, and CI hit the other on debian-13-x64-baseline. The server now holds the tail back until `transform()` has returned, so the body is provably incomplete at that point. Comment accuracy: `AttributeIterator` no longer boxes a `slice::Iter` into the attribute buffer, so the comments saying a realloc would dangle its pointers described a memory-safety invariant that no longer exists. Detaching on mutation is now about the stored index going stale (skipping or repeating an attribute), and detaching on handler return is about nulling the backref. Left as-is, a maintainer could reasonably have concluded the detach calls were removable. Also drops the import comment's reference to `on_quiet_unhandled_rejection_handler_capture_value`, whose only call site this PR deleted, and fixes a dead `#content-handlers` anchor in the docs.
alii
left a comment
There was a problem hiding this comment.
Re-reviewed every fix commit (6087729..d68e63f) against the round-1 findings, plus the question you asked me. Short version: the hard fixes held — the composite-EOF lexer flush, the attribute-iterator retarget, the Body-layer delivery, the atomic sync-only throw, the arena offset change, and the microtask checkpoint are all correct, tested, and I could not break them. Three of the reply's claims don't hold up, though, and your registry question has a definite answer (you need the drain, but not a new registry — details in #4). Full pass below.
Design Review — HTMLRewriter: suspend async content handlers instead of nesting the event loop (fix-verification round)
Verdict
Request changes. Two of round 1's blocking items are claimed as fixed in the reply but demonstrably are not — the missed-guard error recording is dead code that reproduces the exact silent-Stop it was written to remove (#1), and the docs/types contract is still false, just in a different direction (#2/#3) — and the reply's answer to its own registry question ("the GC'd-cell path already covers worker.terminate()") is provably wrong (#4). The suspension architecture itself is sound; what's not landable is a PR whose fix-round reply makes verification claims that don't hold up.
Surviving concerns
Blocking — claimed fixed in the reply, not actually fixed
1. record_handler_error in the missed-guard path is dead code; the silent Stop is still there
Pushback: The reply's item #11 says the missed-guard path now "records a real error rather than tripping a compiled-out assert." It doesn't. record_handler_error is called from inside the active_sink(global) == None else-arm, and its first action is to re-check active_sink(global) on the same global — which deterministically returns None again. The freshly-allocated TypeError is dropped on the floor and release builds still return HandlerOutcome::Stop silently — the exact failure mode round 1 flagged.
Evidence: src/runtime/api/html_rewriter.rs — active_sink (:686-691) is a pure read of global.bun_vm_ref().html_rewriter_active_sink. The "fixed" branch (:1761-1775) is let Some(sink) = active_sink(global) else { debug_assert!(false, ..); record_handler_error(global, ..); return HandlerOutcome::Stop; }. record_handler_error (:1632-1638) is if let Some(sink) = active_sink(global) { .. } — same predicate, and create_type_error_instance in between doesn't touch html_rewriter_active_sink. Zero test coverage: git grep "ran outside\|outside of a rewrite" -- test/ returns nothing, which is why the no-op went unnoticed.
Recommendation: Pick the honest shape and delete the dead recording:
- If "handlers only run inside a sink's lol-html call" is a true internal invariant (the comment says it is), replace the whole else-arm body with
unreachable!("HTMLRewriter handler ran outside a rewrite"). Adebug_assert!plus dead recovery code is the worst of both. - If it must be recoverable, throw the TypeError on the global (pending JS exception) instead of
record_handler_error— there is by definition no sink to record onto.
Best structural fix either way: change record_handler_error to take the sink it's called with rather than re-deriving it, so this call shape can't silently no-op again.
2. The new docs/d.ts contract ("handler errors reject the body instead of throwing from transform()") is false, and this PR's own tests prove it
Pushback: Round-1 blocking item #1 (docs accuracy) is not resolved — the fix traded an inverted contract for a differently-wrong one. Three places now state unconditionally that a handler error "rejects the response body instead of throwing from transform()":
docs/runtime/html-rewriter.mdx:129-133and:364-372packages/bun-types/html-rewriter.d.ts:167-169(on thetransform(input: Response)overload)
The branch's own tests contradict it: test/js/workerd/html-rewriter.test.js:25 (sync throw in handler, transform(new Response(..)), asserts toThrow) and :51 ("async error without a real await", async element(){ throw }, also toThrow). Both are "an error thrown by an async handler / a Promise it returned that rejects" throwing synchronously from transform() on the Response overload.
The deeper problem (#10): the channel is not decided by the overload at all — it's decided by whether the input body happened to be buffered when transform() ran. on_rewriting_error (:1119-1136) / on_finished_buffering (:974-980) route to set_handler_error (rethrown synchronously by init() at :925-930) when !is_async, else to deliver_body_error. is_async comes from which arm of ValueBufferer::run fired (src/runtime/webcore/Body.rs:2336-2394): in-memory bodies → false; a Locked body whose ByteStream already has has_received_last_chunk → false; only a still-streaming Locked body → true. For rewriter.transform(await fetch(u)) this is a race against the network — a small/local/already-complete upstream throws synchronously from transform(), a larger/slower one rejects the body. No static sentence can describe an error channel selected by unobservable internal buffering state.
Recommendation: Two options; (a) is preferred and makes the docs true as written:
(a) Make the behavior deterministic, keyed on the overload rather than buffering timing (matches workerd): when sync_only_noun == None (any Response input), always route handler errors through deliver_body_error; reserve set_handler_error / the synchronous rethrow for the string/ArrayBuffer overload where it is mandatory. Cheapest shape: in on_rewriting_error (:1119) and the js_err arm of on_finished_buffering, branch on sync_only_noun instead of is_async. Ask yourself first: was the sync throw for an already-buffered Response deliberately preserved, or an accident of the buffering path? If deliberate, say so in the PR.
(b) Fix the prose to be honest in all three places: "A handler failure that transform() can observe before it returns — a synchronous throw, or (for an already-buffered input body) a rejection reachable within one microtask checkpoint — still throws synchronously from transform(). Only a failure that arrives after transform() has returned rejects the returned body." That is testable and true, but it's an ugly contract; (a) is better.
Do not ship the current sentence — it's a public contract statement in the .d.ts jsdoc on the overload everyone uses, and it points users in exactly the wrong direction in the most common failure case (.text().catch(report) never fires; the process gets an uncaught synchronous exception).
3. The transform() JSDoc is wrong for the entire BufferSource third of overload 1, and the corrective @throws sits on an overload TypeScript never selects
Pushback: Bun.BufferSource (packages/bun-types/bun.d.ts:21) already covers every TypedArray, DataView, and ArrayBuffer, so overload 1 transform(input: Response | Blob | Bun.BufferSource): Response wins resolution for transform(new Uint8Array(..)). But at runtime any typed array / ArrayBuffer dispatches to the sync-only path (is_typed_array_or_array_buffer() → ResponseKind::ArrayBuffer, html_rewriter.rs:458-459), which returns an ArrayBuffer (:533-534) and throws on a suspending handler (:490-492). This PR's own test proves it: test/js/workerd/html-rewriter.test.js:550-559 passes new TextEncoder().encode("<div></div>") and asserts it throws "cannot synchronously return an ArrayBuffer". So the new "continues in the background / body rejects" prose is attached to the exact input shape most likely to hit the new TypeError, and the @throws that would save the user is on an unreachable overload.
Recommendation: Restructure so the JSDoc can be true for everything its overload accepts:
transform(input: Response | Blob): Responsekeeps the async prose (verify Blob at runtime first — the RustResponseKind::Otherfall-through looks like it rejects Blob too; pre-existing, but check while you're here).transform(input: Bun.BufferSource): ArrayBuffercarrying the sync-only@throws.
Note the return type also appears to be a lie today (Response declared, ArrayBuffer returned) — worth confirming and fixing in the same edit.
4. worker.terminate() mid-suspension permanently leaks the parked rewrite; the reply's "the GC'd-cell path already covers the real leak" is false
Pushback: Answering the author's registry question directly: YES, you still need per-VM drain, and no, the GC'd-cell path does not cover terminate. The chain, every link verified on the branch:
WebWorker::shutdownstep 2 setsvm.is_shutting_down = true(src/jsc/web_worker.rs:1227) before step 3 callsWebWorker__teardownJSCVM(:1261).- Teardown derefs the JSC::VM to zero (
src/jsc/bindings/webcore/Worker.cpp:628), so~VM→Heap::lastChanceToFinalizesweeps every destructible cell —~NativePromiseContextdoes run on live, un-taken cells. - That dtor (
src/jsc/bindings/NativePromiseContext.cpp:30-34) →Bun__NativePromiseContext__destroy→DeferredDerefTask::schedule(src/runtime/api/NativePromiseContext.rs:181), whose first statement isif vm.is_shutting_down() { return; }(:187), with the comment "Process is dying; the leak no longer matters." At worker teardown the thread-local VM is the worker's andis_shutting_downis already true — soSuspensionContext::abandon(html_rewriter.rs:1355) is a guaranteed no-op, and that comment is false for a worker: the parent process survives.
Result: the +1 taken in begin_suspension (:1148) is never released, BufferOutputSink::Drop never runs, and the boxed suspended lol-html rewriter + its document arena + the SuspensionContext Box leak natively on every worker.terminate() with a parked suspension. A pool of workers doing HTMLRewriter transforms and being recycled via terminate() leaks RSS monotonically until OOM. This is exactly round 1's "permanently leaked parked rewrite"; the fix commits genuinely closed the GC'd-cell path but not this one.
Recommendation: You don't need the per-VM registry field you didn't want — RareData::cleanup_hooks already IS that registry, drained by vm.on_exit() at exactly the point where abandoning is still legal. Whole fix is a push/remove pair plus an extern "C" shim (~30 lines, zero new VM fields):
- In
begin_suspension, alongside creating the cell, register a cleanup hook (ctx= the*mut SuspensionContext,func= anextern "C"shim overSuspensionContext::abandon) via the existingRareData::push_cleanup_hook. - Unregister it in both
SuspensionContext::take()(covers resolve and reject) andabandon(). RareData needs a ~5-lineretain-based remove, mirroringnapi_remove_env_cleanup_hook. on_exit()'s existing drain then abandons survivors on both worker-terminate and main-thread exit.
If you prefer a bespoke list, the constraint is: drain must happen next to cancel_all_timers at web_worker.rs:1238 — after is_shutting_down = true but before WebWorker__teardownJSCVM, because abandon calls deliver_body_error which touches the JSC heap. But the cleanup-hook shape is strictly less code.
While you're there: the NativePromiseContext.rs:185 comment ("Process is dying") should be corrected — for a worker it isn't.
Should-fix — behavior change and disclosure
5. res.clone() on the flagship async-handler pattern now hangs forever, and the disclosure is 1-of-3
Pushback: The Value::tee defect is pre-existing and separable — I verified that on released 1.3.14 a streaming input reproduces the identical two-branch hang, so no, this PR doesn't need to fix .clone(). But the PR turns a niche precondition into the default state of every async-handler transform: on 1.3.14, transform(new Response(html)) with an async handler settled the body inside transform() (nested loop), so res.clone().text() resolved; on this branch the output body is a pristine Locked(PendingValue::new(global)) (html_rewriter.rs:783-798), so the same code hangs forever with no error and no timeout. caches.default.put(req, res.clone()) is the single most idiomatic Workers pattern, and async handlers are exactly when you'd reach for it.
The author conceded the gap in the PR body — but that's the only place. No mdx caveat, no it.todo, and the fix commit's own source comment at Body.rs:1094 falsely lists .clone() as a handled consumer.
Recommendation: Don't block on the tee fix. Do hold the disclosure bar:
- Remove
.clone()from theBody.rs:1094comment list (or invert it: clone is the one consumer NOT handled). - Add an
it.todo(".clone() of an async-handler transform output")— or better, anitthat racesclone().text()against a bounded timer and asserts the timeout — pinned to a filed issue URL. - One sentence in the mdx's async-handlers section: "
.clone()of the returned Response does not yet work when the rewrite is still in progress; both branches will hang. Buffer with.arrayBuffer()first if you need two consumers."
6. The fire-and-forget-rejection behavior change is unpinned by test and undocumented
Pushback: The reply claims this "is pinned by a subprocess test." It isn't — I ran the test's exact -e snippet (test/js/workerd/html-rewriter.test.js:570-585) verbatim against a pre-PR debug build (origin/main HEAD, 1498d7b): output OUT:<p>ok</p> / UNHANDLED:detached / exit 0 — exactly what the test's expects require. It passes with the PR reverted, because its fixture (sync handler + string input) is the one shape where main's nested loop never captured the rejection anyway.
The real change is elsewhere: on pre-PR main, async element(e){ (async()=>{throw new Error("detached")})(); await Bun.sleep(5); e.setInnerContent("ok") } on transform(new Response(..)) inside try/catch prints CAUGHT:Error:detached, exit 0 — main's nested event loop hijacked the detached rejection into transform()'s synchronous throw (via VirtualMachine.rs unhandled_rejection). On this branch the catch gets nothing and, with no listener, the process prints an unhandled-rejection report and exits 1. A Bun.serve worker or CLI script that used to survive now dies. That's fine as a semantic (arguably more correct), but it's a process-exit-code regression documented nowhere users read: the mdx Error Handling section describes exactly two channels and doesn't mention this third one, and the pinning test masks the default outcome with process.on('unhandledRejection') + process.exit(0).
Recommendation:
- mdx: one sentence naming the third channel — "A rejection from a Promise a handler creates but does not return or await is not captured by transform() or the body; it goes to the process-global
unhandledRejectionpath. On earlier versions it could surface fromtransform()itself." - Replace the pinning test's fixture with one that actually changed:
transform(new Response("<p>x</p>"))with a realawaitin the handler, nounhandledRejectionlistener, asserting stderr contains the report and exit code is 1. That test fails on pre-PR main (it exits 0), so it actually pins the change.
Should-fix — verification claims that don't hold
7. Nothing in Bun's CI runs cargo test -p lol_html — the headline "170 in-crate lol-html tests" are invisible to CI
Pushback: The reply cites in-crate tests as proof for four separate fix claims (the two lexer composite-EOF flush tests, the emission_enabled gate test, the Arena::compact() unit test, the suspension drive() matrix) and reports "170 in-crate lol-html tests" as verification. A reviewer reads that as "reverting any of these fails CI." It's false for every one of them: git grep 'cargo test\|nextest' across .buildkite/, .github/workflows/, scripts/, package.json matches only scripts/bench-json-rust.sh (dev-only), and the miri job's MIRI_CRATES (scripts/rust-miri.ts:32-46) doesn't include lol_html. rust:check/rust:clippy type-check but don't execute tests. So you can make compact() do copy_within(self.start.., 1) or revert flush_pending_text out of emit_current_token_and_eof and every CI job stays green. (Separately, Bun's own binary never reaches compact() — every in-tree consumer issues exactly one write() — so the JS suite doesn't guard it either.)
Recommendation: The load-bearing fix is wiring the vendored crate's tests into CI, not adding another test to a suite CI ignores. Add a step running cargo test -p lol_html (lib + the integration_tests target) after the fetch+patch step — natural home is the existing Linux Rust job (the clippy.yml/miri.yml shape), and the vendored source is already materialized by the same ninja clone-lolhtml edge rust-miri.ts:62 uses, so the plumbing exists. This one step is what turns the author's whole in-crate verification story from an unchecked claim into a CI guarantee. Ideally gate it on patches/lolhtml/** / scripts/build/deps/lolhtml.ts changes so it's not on every PR's critical path.
8. The 120-rewrite protected-count leak test guards the wrong path; the abandon fix's only guard is a hang-shaped test that never checks release
Pushback: Both suspension leak tests were added by the fix commit (253c4da78) itself. But the settle path was already correct at the parent commit (the removed lines show on_handler_resolve doing ScopedRef::adopt(sink) — the +1 was released on every settle pre-fix), and the commit message itself scopes the bug to "collected without ever settling." So the 120-iteration test ("suspended rewrites release their parked state once the handler settles") measures a path this commit did not change and passes with the fix reverted. The abandon mechanism's sole guard is the never-settle test — a 5s-timeout assertion gated on collecting the promise graph in exactly 3 forced GCs, which never asserts that the parked native/protected state was actually released. abandon regressing back into a leak (while still delivering the rejection) is invisible to the suite, and the test's failure mode on slow ASAN/debug lanes is a 5s hang: quarantine bait.
Recommendation: Repoint the counting test at the path the fix changed: run N never-settling-handler rewrites, poll (a bounded gc+sleep loop, expectMaxObjectTypeCount-style — not a fixed 3 GCs) until all N .text() promises have rejected with the abandon message, then assert protectedObjectTypeCounts.Function / objectTypeCounts.Response deltas are flat. That single test fails pre-fix (hang), fails if abandon stops releasing the wrapper/Strong/sink, and removes the exactly-3-GCs fragility. Apply the same bounded-poll shape to the existing never-settle test. Keep the 120-settle test as a general canary, but don't cite it as the abandon regression guard.
Should-fix — latent hazard
9. pending_suspension's exactly-once release is positional, not structural, and one currently-unreachable path already violates it
Pushback: The gcProtect + wrapper ref taken in handler_callback's Suspend arm (html_rewriter.rs:1799-1806) is only ever released by begin_suspension (:1152), reached solely from on_rewriting_error's matches!(e, Suspended) arm (:1106-1108). Neither the non-Suspended arm (:1110-1136) nor Drop for BufferOutputSink (:1414-1426) drains a leftover pending_suspension — confirmed by grep, the field is only touched at :723/:772/:1152/:1806. That means correctness rests on an unstated cross-crate invariant ("a Suspend outcome always makes the lol-html call return Err(Suspended)") which the patched TransformStream::write already violates on one path: it puts the is_suspended() check after the pristine tail bookkeeping, whose init_with(unconsumed).map_err(MemoryLimitExceeded)? can return a non-Suspended error with pending_suspension armed. Not reachable today (Bun sizes the arena to the input and the limiter to u32::MAX), but on that path the wrapper was disarmed out of its ScopeGuard and never retargeted or detached — it still points at the dispatcher-local token that the rewriter's Drop frees, and the handler's post-await continuation is still scheduled to touch it. That's a silent UAF, not just a leak, one lol-html rebase away.
Recommendation: Enforce it on the Bun side rather than assuming it (robust across lol-html rebases):
- In
on_rewriting_error's non-Suspended arm, drain a leftoverpending_suspension— unprotect its promise and release/detach the wrapper — plus adebug_assert!that it wasNone. - Same in
Drop for BufferOutputSink(which today only handleshandler_error). - Or, cleanest: give
PendingSuspensionaDropthat unprotects+releases, making the exactly-once release structural rather than positional.
Do not fix it by moving write()'s is_suspended() check — that's the fragile side.
What was checked and held up
For the record, these angles were probed and dismissed, so the author knows they were considered:
- Handler-dispatch matrix ("six sync-only cells"): wrong premise — all 8 handler registrations funnel through the single
handler_callbackdispatch cell, so one test covers them all. No gap. - Composite-EOF with both handlers suspending: the fix delta only touches point A of the two composite actions; the untested point B is not part of this diff.
- The deleted
Lockedcheck replaced by a comment: this is the round-1 reviewer's own prescribed fix; re-litigating it would be self-contradictory. Held. resume()feeding into a cancelled/teed stream: empirically the same on released 1.3.14; genuinely pre-existing and separable.- Sibling suspension resume inside a drain checkpoint / re-entrancy: all four interleave probes came back correct and exception-check clean. No defect.
- The new
Blobmember in the d.ts overload being rejected at runtime: every falsehood there is pre-existing on main; this PR added only prose. (Worth a drive-by runtime check per concern #3, but not this PR's regression.)
Summary of asks
| # | What | Where |
|---|---|---|
| 1 | Delete or make live the dead record_handler_error call; pass the sink in |
html_rewriter.rs:1632,1761-1775 |
| 2 | Make the handler-error channel deterministic (preferred) OR make the prose honest | html_rewriter.rs:1119, mdx:129,364, d.ts:167 |
| 3 | Split BufferSource into its own overload with the sync-only @throws |
html-rewriter.d.ts |
| 4 | Register abandon as a RareData cleanup hook so worker.terminate() drains parked suspensions |
html_rewriter.rs:1148, RareData |
| 5 | Disclose the .clone() hang in mdx + it.todo; fix the false Body.rs:1094 comment |
mdx, test, Body.rs:1094 |
| 6 | Re-fixture the fire-and-forget test so it fails pre-PR; document the third error channel | html-rewriter.test.js:570, mdx |
| 7 | Add cargo test -p lol_html to CI |
.github/workflows/ |
| 8 | Repoint the leak-counting test at the never-settle path; drop the exactly-3-GCs shape | html-rewriter-leak.test.ts |
| 9 | Drain pending_suspension in the non-Suspended error arm and in Drop (or give it a Drop) |
html_rewriter.rs:1110-1136,1414 |
The core design — suspend instead of nesting the event loop — is right, and most of round 1's items were genuinely fixed. But #1, #2, and #4 are round-1 blockers the reply claims are closed and are not, and #7/#8 mean the verification story a reviewer is being asked to trust isn't backed by CI. Fix those and this lands.
…, drain suspensions at worker exit Four findings from review round 2, all reproduced first. The missed-guard recording was dead code. `record_handler_error` re-derived the sink from `global`, and it was called from inside the arm that had just established there is none, so the TypeError was dropped and release builds still returned `Stop` silently. The sink is now read once at the top of `handler_callback` and passed explicitly, so the shape cannot silently no-op again, and the dead recording is gone. The handler-error channel was selected by whether the input body happened to be buffered when `transform()` ran, i.e. by a race against the network for `transform(await fetch(u))`. It is now selected by the overload: `transform(string)` / `transform(ArrayBuffer)` must hand back a value so they throw; every `Response` input rejects its output body. That is the contract the docs and the `.d.ts` already claimed, and it matches workerd. `is_async` drops out of the rewrite-error path entirely. A handler error on a Response that was already buffered (a sync `throw`, a TypeError from `setAttribute`) therefore rejects the body where it used to throw from `transform()`; five tests that pinned the old split are updated. Input-body errors keep their own channel: an already-failed or aborted body still throws synchronously, since `transform()` observes it before returning. Capturing the thrown value rather than the `Exception` cell, and marking the handler's returned promise handled once we route its rejection to the body. Without the first, `JSPromise::reject` asserts; without the second, the rejection is also reported to `unhandledRejection`. `worker.terminate()` with a parked suspension leaked it permanently: teardown sets `is_shutting_down` before sweeping the heap, and `DeferredDerefTask:: schedule` bails on that flag, so the GC'd-cell path never fired. The suspension now also registers a `RareData` cleanup hook, which `vm.on_exit()` drains on worker terminate and main exit while the JSC heap is still alive. No new VM field; `RareData` gains a `remove_cleanup_hook` so the settle paths unregister. `PendingSuspension` gets a `Drop` that releases the wrapper's ref and the promise's protect, so the exactly-once contract is structural. The non-suspension error arm and `BufferOutputSink::Drop` now drain a leftover rather than relying on lol-html's invariant that a `Suspend` outcome always yields `Err(Suspended)`.
…n what the tests claim to pin Round-2 should-fix items. `transform()`'s overloads were lying in two ways, both pre-existing: `Bun.BufferSource` sat on the overload returning `Response` while every typed array / ArrayBuffer actually returns an `ArrayBuffer`, and the corrective `@throws` was therefore on an overload TypeScript never selects. `Blob` was on that overload too, and throws "Expected Response or Body" at runtime. The overloads are now one per real shape: `Response -> Response`, `string -> string`, `Bun.BufferSource -> ArrayBuffer`, with the sync-only `@throws` where it can be seen. Dropping `Blob` turns a runtime throw into a compile error; the types fixture used it and is updated. `.clone()` of a still-pending body hangs (a `Value::tee` defect, separable from this PR). The `Body.rs` comment listed it as a handled consumer, which was false. It is now called out there, pinned by a test that asserts the hang so the day `tee` is fixed the test fails and gets promoted, and warned about in the docs. The fire-and-forget rejection test pinned nothing: its fixture (sync handler, string input) behaves identically before and after this PR. The shape that actually changed is a real-await handler on a Response, where main's nested loop hijacked the detached rejection into `transform()`'s throw (exit 0) and this branch lets it reach `unhandledRejection` (exit 1). The test now uses that shape with no listener and asserts the report and the exit code, so it fails pre-PR. The third error channel is documented. Nothing in CI ran the vendored crate's tests, so the 170 in-crate lol-html tests cited as verification guarded nothing: reverting the composite-EOF flush or breaking `Arena::compact()` left every job green. A new `lolhtml.yml` runs `cargo test` in `vendor/lolhtml` after the fetch+patch edge, gated on `patches/lolhtml/**` and `scripts/build/deps/lolhtml.ts` so it stays off the critical path. The 120-rewrite leak test measured the settle path, which was already correct before the abandon fix, so it passed with the fix reverted. The abandon path now has its own test: N never-settling handlers, a bounded gc+poll loop instead of exactly three forced GCs, asserting both the abandon message and that the parked Responses and protected closures were released.
|
You were right on all three of the "the reply claims this is closed and it isn't" calls. I reproduced each before touching anything. All nine items are addressed in Blocking#1 — dead code, exactly as described. Took your structural suggestion: the sink is read once at the top of #2 — channel is now keyed on the overload. Your option (a). Answering your question directly: the sync throw for an already-buffered Response was an accident of the buffering path, not deliberate. I traced it to Two things worth pricing in before you accept this, because the blast radius is larger than the two tests you found:
Input-body errors keep their own channel: an already-failed or aborted body still throws synchronously, since #3 — overloads split. Verified at runtime first, and it's worse than you thought: transform(input: Response): Response; // async prose
transform(input: string): string; // sync-only @throws
transform(input: Bun.BufferSource): ArrayBuffer; // sync-only @throwsDropping #4 — you're right, and thank you for the registry answer. The chain is exactly as you traced:
Should-fix#5 — disclosed in all three places. The #6 — you're right, the fixture pinned nothing. Confirmed: my #7 — you're right, and this was the most embarrassing one to verify. Nothing ran New #8 — repointed. You're right that the 120-rewrite test measured the settle path, which was already correct at the parent commit, and that the abandon mechanism's only guard was a hang-shaped assertion that never checked release. The abandon path now has its own test: N never-settling handlers, a bounded gc+poll loop instead of exactly three forced GCs, asserting both the abandon message and that the parked Responses and protected closures came back. A regression that keeps rejecting the body but stops releasing now shows up as ~N leaked Responses. The 120-settle test stays as a general canary and I won't cite it as the abandon guard. #9 — made structural. Verification117 pass / 0 fail across the five HTMLRewriter suites, 170 in-crate lol-html tests (now actually run by CI), |
The doc describing what record_handler_error does had landed on exception_value, which records nothing, and the SAFETY line above each of its call sites was duplicated.
… change broke CI caught these on every lane; I had only run the five suites under test/js/workerd and test/js/web, so I missed the regression tests that also assert a handler error throws from transform(new Response(..)). Both now assert the body rejection, keeping the property each was written to protect: #19219 that the error is the descriptive TypeError and not a generic "[native code: Exception]" (its two `string`-input cases still throw, unchanged), and #21680 that a throwing handler does not crash the process. Blast radius of the channel change is therefore seven pre-existing tests, not five. Also adds lolhtml.yml to the toolchain-bump checklist in .github/workflows/CLAUDE.md, which only named clippy.yml and miri.yml.
… and keep cleanup_hooks in order `SuspensionContext::abandon` took the Box (freeing the context) and then handed that pointer to `unregister_cleanup_hook`, contradicting the helper's own doc and diverging from the sibling `take()`, which unregisters first. No ABA window today since nothing in between allocates a `SuspensionContext`, but the contract said not to do it and a future edit could open one. It now reads the sink, unregisters while live, then destroys. `RareData::remove_cleanup_hook` used `swap_remove`, which moves the last entry into the hole. `on_exit()` iterates `cleanup_hooks` in insertion order and N-API also pushes into it, so a general-purpose remover should not reshuffle unrelated survivors. The list holds a handful of entries; `remove`'s shift is free.
…andlers instead of wait_for_promise (oven-sh#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 oven-sh#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 (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`. ### 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 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 -->
…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 -->
What
HTMLRewriter.prototype.transformno longer re-enters the event loop. When a content handler returns a promise that is still pending after one microtask checkpoint, lol-html parks the current rewritable unit on the heap and returns fromwrite(); a.thenreaction resumes the rewrite from the real event loop once the promise settles.Why
handler_callbackcalledvm.wait_for_promise(promise)for async handlers (src/runtime/api/html_rewriter.rs, previously line 1311). That spins the whole event loop (timers, I/O completions, GC, arbitrary user JS) from six native frames deep inside lol-html'swrite(), while theElementthe handler's JS wrapper points at is a stack borrow inside that call. Nested event loops are a known hazard class in this codebase (deadlocks like #14950, the uSockets ready-poll clobbering fixed in a05b9c0, the pending-exception leak fixed in 289cce1), and HTMLRewriter was one of the last users.It could not be removed before because lol-html's 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 itsawait. Now that lol-html is vendored as Rust source (#33048), the token can be heap-allocated.lol-html fork (
patches/lolhtml/content-handler-suspension.patch)Err(SuspensionRequest)to suspend. The in-flight unit is deep-copied onto the heap (boxed, stable address),write()/end()return a new non-poisoningRewritingError::Suspended, andHtmlRewriter::resume()continues from aStateMachineBookmark.Dispatcher::handle_taginto the lexer actions, before the lexeme is built. Every suspension point then has a uniform shape (the in-flight lexeme is either fully consumed or not yet built), the parser never has to save a lexeme, and the resume reuses the crate's existingcontinue_from_bookmarkmachinery. Tree-builder feedback (<script>content model etc.) is applied before the handler runs, so it is never replayed.Arena::shiftadvances astartoffset instead of memmoving the remaining tail. A suspended rewrite re-feeds its unconsumed tail on every resume, so the oldcopy_withincost O(suspensions x tail) on the JS thread; the dead prefix is now reclaimed in one memmove by the nextappend, which only the multi-write streaming path performs.el.remove()across a suspension, re-suspension by a later handler on the same unit, the<script>content model surviving a suspension, multi-chunk input, text ordering around an unterminated comment/doctype/tag at EOF, a suspension inside removed content emitting nothing, and text around<![CDATA[. All existing unit tests and the html5lib integration fixtures still pass.Bun side
handler_callbackreturnsHandlerOutcome::{Continue, Stop, Suspend}. On a pending promise it runs one microtask checkpoint (process.nextTickthen promise jobs, never the event loop), soasync element(el) { el.remove() }and anything else reachable without a macrotask stays synchronous. A genuinely pending promise suspends: the handler's JS wrapper is kept alive, re-pointed at the heap-parked unit so post-awaitmutations land on what gets serialized, and released when the promise settles.PromiseFunctions(Bun__HTMLRewriter__onHandlerResolve/Reject) carry the continuation. Their context is aNativePromiseContextcell, not a raw pointer, so a handler promise collected without ever settling takes the reaction with it andSuspensionContext::abandonfails the output body instead of leaking the parked rewrite forever.AttributeIteratorholds a backref to theElementwrapper plus an index rather than aslice::Iterinto the attribute buffer, so an iterator handed out before anawaitfollows the element onto the heap and keeps working.Cellininit()onto the heap sink, and thevm.on_unhandled_rejection+unhandled_pending_rejection_to_captureoverrides are removed. A newVirtualMachine::html_rewriter_active_sinkfield (saved/restored LIFO around each lol-html call) is how handlers reach their sink. This also fixes a bug where a handler exception on a streaming input surfaced asThe rewriter has been stopped.instead of the real error.init()no longer stampspv.taskon the output body as an identity tag.Bun.servereadslock.task.is_some()as "another consumer owns this body" and piped aReadableStreamthat nothing ever feeds.RequestContext'son_receive_valueregistration for aLockedresponse body now takes a ref and setshas_marked_pending, with the matching release indo_render_with_body_locked. Without it,should_render_missingrendered a 404 and released the context back to the pool while the body's owner still held the callback pointer (heap-use-after-free). That branch was unreachable from JS before this change.Body::Value::resolvehands a pending body's attached readable the bytes it resolved to instead of closing it empty (its error twinto_error_instancealready did). A suspended transform makes a pending output body the default state, so.body,new Response(res.body),.blob(),.arrayBuffer()andBun.write(file, res)all observed the empty result.Behavior changes
transform(string)/transform(ArrayBuffer)must hand back a value, so anything a handler raises throws fromtransform(). EveryResponseinput rejects its output body instead. Previously the channel was selected by whether the input body happened to be buffered whentransform()ran, which fortransform(await fetch(u))is a race against the network. This means a handler error on an already-buffered Response (a synchronousthrow, aTypeErrorfromsetAttribute) now rejects the body where it used to throw; five tests that pinned the old split are updated. Input-body errors keep their own channel: an already-failed or aborted body still throws synchronously, sincetransform()observes it before returning.unhandledRejection, matching sync handlers. The old code captured it and turned it intotransform()'s thrown error, which hijacked unrelated rejections and swallowed them with exit 0. This is a process-exit-code change: a program that used to printCAUGHT:...and exit 0 now reports the rejection and exits 1.transform()'s declared types were wrong and are fixed:Bun.BufferSourcereturns anArrayBuffer, not aResponse, andBlobthrew"Expected Response or Body"at runtime and is no longer in the type. DroppingBlobturns a runtime throw into a compile error.docs/runtime/html-rewriter.mdxandpackages/bun-types/html-rewriter.d.tsare updated for all three.Known gap (not fixed here)
res.clone()on a still-suspended transform hangs.Value::teebuilds aByteStream, tees it, and leaves both branches on aPendingValuethe sink never settles. Teeing a native ByteStream works fine in general, so this is a specific defect intee's interaction with a body whose value arrives later; it predates this PR in mechanism and is better fixed on its own. It is disclosed in the docs, asserted as a hang by a test (so the dayteeis fixed that test fails and gets promoted), and called out in theBody.rscomment. Every other consumer of a pending output body works and is covered by a test matrix.Verification
test/js/workerd/html-rewriter.test.js+ the four sibling suites: 116 pass, 0 fail under the ASAN debug build. New coverage includesBun.gc(true)twice while anElementis heap-parked across anawaitplus a post-awaitmutation (the use-after-free shape this design has to get right), async text/comment/doctype/onEndTag/document-end handlers, thelastInTextNodeidiom mutated after a resume, re-suspension by a second handler on the same element, a nestedtransform()inside a suspended handler, chained suspending transforms, strict document-order sequencing across 8 awaiting handlers,Bun.servewith a live client (delivery and a rejecting handler routed toerror()), a client aborting mid-suspension, and the consumer matrix for a pending output body.The rewriter has been stopped.on the released bun and with the real error here; the two updated error tests and thetransform(string)throw also fail on the released bun.test/js/workerd/html-rewriter-leak.test.tsgains a protected-object/Response count regression over 120 suspending rewrites, and a never-settling handler asserted to reject the body rather than leak it.bun-typesgreen,cargo clippyclean..github/workflows/lolhtml.ymlruns the vendored crate's owncargo test(170 unit + 34 integration) after the fetch+patch edge, gated onpatches/lolhtml/**. Nothing ran it before, so the fork's invariants (the composite-EOF flush,Arena::compact(), the suspension bookkeeping) were unguarded by CI.patches/lolhtmldiff is validated through the real fetch edge:ninja vendor/lolhtml/.refre-extracts the pristinecloudflare/lol-html@77127cd2and applies the patch, producing a byte-identical tree. Note for anyone adding apatches/*file:fetch-cli.tsapplies them withgit applyfrom insidevendor/<dep>, which is a subdirectory of the bun git worktree, so a git-format patch (withdiff --gitandindexheaders) gets its paths resolved against the repo root and is silently skipped with exit 0. The existing patches are all traditional--- a/xunified diffs, and this one is too.