Skip to content

HTMLRewriter: hold a waiting body read in the transform cell instead of a GC root - #46118

Open
robobun wants to merge 4 commits into
mainfrom
robobun/c85bd028/rewriter-pending-body-promise
Open

robobun wants to merge 4 commits into
mainfrom
robobun/c85bd028/rewriter-pending-body-promise

Conversation

@robobun

@robobun robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • .text() and the other body readers of an HTMLRewriter.transform() output stay a GC root when the input stream never ends. That keeps 6 of 6 disposed Bun.ModuleGraphs (root(ProtectedValues) Promise -> FullPromiseReaction -> Object -storage-> ModuleGraph) and one realm per file under bun test --isolate.
  • Cause: PendingValue::set_promise (src/runtime/webcore/Body.rs:456) calls protect() until the producer settles the body. Only the collector keeps a RewriterPipe alive.

Fix

  • set_promise asks the producer first (SourceHandle::hold_read_promise). RewriterPipe holds the promise in the transform cell's outputConsumer slot (was outputStream). It roots it only to settle it, and while a handler is parked.
  • Correct because whatever can still feed a rewrite reaches its cell.
  • Value::resolve releases the root on every exit. Value::reset frees the decoder of an unsettled formData() read.
  • Verified: test/js/workerd/html-rewriter-leak.test.ts, module-graph-gc.test.ts, isolation.test.ts (40 new, 19 fail on main). Self-reviewed: 42 concerns raised, 31 addressed, 4 handed off, 7 not taken.

Background

Downsides

  • fetch() and Bun.serve reads: +2 instructions in set_promise. A waiting rewriter read: +2 slot writes, +1 slot read. Binary text: +624 bytes.
  • Still rooted: those reads after .body was read, and a read whose handler is parked on a promise that the waiting code keeps.
Notes

Report. There is no GitHub issue. A leak fuzzer found it: a Bun.ModuleGraph whose code waits on new HTMLRewriter().transform(response).text() while the input stream is open is never collected after graph.dispose(). The same holds with no graph. Merged #43379 recorded it under "Not changed": "A pending .text() leaves 100 protected Promises (promise_value.protect(), src/runtime/webcore/Body.rs:453)". It is not a regression: 1.4.3 has it.

Who can hit it. Code that rewrites a stream of its own and waits for the whole result, when the stream is dropped before it ends. The weight depends on what the promise's reactions reach: a disposed Bun.ModuleGraph (all of its modules), a retired realm of bun test --isolate or --parallel (one per test file), a generation of bun --hot (not measured here).

The reporter's scripts (disposed graphs, or waiting objects, still alive after 40 forced collections, on release builds with --lto=off):

main 91e7e3b this PR
graph: text(), arrayBuffer(), await text() 6 of 6 each, exit 1 0 or 1 of 6, exit 0
graph: the three controls 0 or 1 of 6 0 or 1 of 6
no graph: text() through a rewriter 6 of 6 0 or 1 of 6
no graph: text(), no rewriter 0 of 6 0 of 6

Those scripts count with WeakRef. The 1 of 6 is the last rewrite that the script made, and no root holds it. Taken inside the no-graph script's last Bun.gc(true), on the release build of this PR, with the script unchanged (a preload wraps Bun.gc):

objects with `big`: 1, of them not reached by a reported root: 1, cells that reach those: 14
  Object, JSLexicalEnvironment, Function, Promise, HTMLRewriterTransform, FullPromiseReaction, Response, Promise,
  ReadStreamIntoSinkOperation, ReadableStreamDefaultReader, ReadRequest, ReadableHTMLRewriterSinkController,
  ReadableStream, ReadableStreamDefaultController          (none reached by a reported root)
live stack [rsp 0x7ffd8f6828c8, base 0x7ffd8f687000): 2 words point at one of those cells
  0x7ffd8f682d70: 0x5be54304888 -> ReadableStreamDefaultController
  0x7ffd8f682de8: 0x5be54304888 -> ReadableStreamDefaultController
      both in frame [0x7ffd8f682d60, 0x7ffd8f682e80) of JSC::runInternalMicrotask(...)
      called from JSC::MicrotaskQueue::drainWithUseCallOnEachMicrotask(...)

The first block is generateHeapSnapshotForDebugging(). The second is a read of the blocked process's stack (/proc/<pid>/mem, frame-pointer walk, llvm-symbolizer). The two words are slots of runInternalMicrotask that the resume of the script's async function does not write: they still hold the controller of the last rewrite's input, an argument of an earlier microtask. The same count reads 0 of 6 on a debug build. The tests here use the heap snapshot (Lifetimes.stillAlive) or a bound of N / 4.

Shapes (N = 6 each, judged with the heap snapshot):

shape main this PR
text(), json(), blob(), bytes(), arrayBuffer(), formData() 6 of 6 each 0
text(), the callback also holds the output Response 6 of 6 0
async-iterator input, and a rewriter over a rewriter 6 of 6 0
a handler promise that nothing holds 0 0
second read() pending on out.body 6 of 6 6 of 6
new Response(out.body).text() 6 of 6 6 of 6
Bun.write(file, out) 6 of 6 6 of 6

Protected Promises per 1,000 pending reads, after a full collection: main +1000 for each of the six readers, this PR +0. With a handler parked on a promise that script keeps: +1000 on both. After those handlers resolve, with the input still open: main +1000, this PR +0.

How the promise moves. hold_read_promise puts it in the cell unless the rewrite is over or a handler is parked. The body is then Locked with its action set and no promise, so bodyUsed, a second read, .body, clone() and Bun.write answer as before. root_read_promise moves it into PendingValue.promise and protects it in three places: at the start of fail(), in finish() before the body is replaced, and in begin_suspension(). The last one exists because abandon_suspension() has to reject the read after the cell is collected. unroot_read_promise moves it back when the handler has resumed and no other handler is parked.

Why the cell is alive whenever the rewrite can end. A native input (a fetch body, a file) roots its stream wrapper while it has a sink, and the wrapper's sinkOwner slot holds the cell. The JS pump's reaction holds the cell. A queued background pull protects it. The context of a parked handler's promise holds it, and in that window the promise is a root anyway.

Measurements (release builds of main 91e7e3b and of this PR, --lto=off, linux-x64, with llvm-objdump, llvm-size, and gdb hit counts on the stripped binary). They are from before the merge with main 2d6d445 and were not taken again. That merge moved the cell's declaration to html_rewriter.bind.ts (#46128), where a slot read is an inline load and not a call:

main this PR
PendingValue::set_promise, instructions 210 243
of those, executed for a fetch or Bun.serve body +2 (cmpb, jne on the producer tag)
Value::resolve, instructions 451 453
Value::to_error_instance 341 341, identical
Bun.serve await req.text() x 10,000: protect / unprotect 10,000 / 10,000 10,000 / 10,000
begin_transform (a transform() that nothing reads) 1,440 1,440, identical
RewriterPipe allocation 304 B 304 B
HTMLRewriterTransform cell (snapshot stride) 96 B 96 B
RewriterPipe::on_start_buffering 5 5, identical
Response::destroy, body not pending same instructions
a waiting rewriter read, x 1,000: protect / unprotect / slot writes / slot reads 1000 / 1000 / 0 / 0 1000 / 1000 / 2000 / 1000
the same with a handler that parks after the read began and resumes 1000 / 1000 / 0 / 0 2000 / 2000 / 4000 / 2000
RewriterPipe::fail / finish, instructions 311 / 154 360 / 200
binary text 101,810,298 101,810,922

Data and bss do not change. valgrind, perf, strace and bloaty are not installed on the machine that measured, so there are no instruction counts of whole runs.

Tests. 40 new. On main's source 19 fail (a release build of 91e7e3b, and a debug build of 2d6d445): the 11 "is not a GC root" rows (six readers, a handler that parked and resumed, four reads begun inside the rewrite's own handlers), the node:vm timeout row, the Bun.ModuleGraph test (13 graphs, each with root(ProtectedValues) Promise), and the six bun test --isolate rows (8 live globals, the bound is 4). The other 21 guard what must stay and pass before and after: a read is still settled when only the input is held, by a handler that parked and resumed, by the abandonment of a handler that parked after the read began or after another one resumed, by a failure whose input cancel() begins the read, by a file, by a fetch in flight, when one of the rewrite's own handlers begins it, and through the stream once .body was read.

The node:vm row reads with json(), so the parse makes the settle most of the read, and it sets the timeout to land in the middle of the settle. A run counts only when the document's end handler ran after the script began and json() did not return: between those two there is only the settle. With the guard in Value::resolve removed and the rest of this PR in place, the row fails as on main (Expected 0, Received 4), and 8 of 8 runs of the same code are cut short in the settle.

Suites run on the debug (ASAN) build, again after the merge with main 2d6d445:

  • html-rewriter-leak.test.ts (82 pass, 1 skip), module-graph-gc.test.ts (38) and the leaked-handles group of test/cli/test/isolation.test.ts (15), each under the environment of the ASAN lane (detect_leaks=1, BUN_DESTRUCT_VM_ON_EXIT=1, BUN_JSC_validateExceptionChecks=1).
  • html-rewriter.test.js, html-rewriter-end-error.test.ts, html-rewriter-doctype.test.ts, htmlrewriter-additional-bugs.test.ts, text-chunk-null-access.test.ts (212 together).
  • fetch/body.test.ts, body-mixin-errors.test.ts, body-clone.test.ts, body-async-iterator.test.ts, FormData.test.ts (1100 together), body-stream.test.ts (9086), serve-stream-body-error.test.ts (34).
  • The HTMLRewriter cases of module-graph-isolation.test.ts and worker-terminate-lifetime.test.ts, and test/internal/source-lints (182).
  • With the default 5 s timeout, the debug build on the machine that ran these is too slow for some tests that this PR does not add. Examples: three subprocess tests of html-rewriter-leak.test.ts (6.6 s to 7.1 s each), and now and then one test of module-graph-gc.test.ts. They pass with a longer timeout, as the ASAN lane sets.

The two changes in Body.rs beside set_promise.

  • Value::resolve took the promise and unprotected it only on its last line, after eight ?. A termination takes one of them, and a node:vm { timeout } termination is withdrawn afterwards, so the VM lives on with the root. On main, 4 of 4 reads that a timeout cut short stay protected. The guard is jsc::js_value::Protected::adopt, as in streams.rs.
  • Response::destroy resets the body and frees the Response without dropping the body, so the decoder of a formData() read that was never settled was leaked (LeakSanitizer: 640 bytes in 40 objects for the new formData() row). Value::reset now frees it. The release is a cold function, so reset is still inlined into Response::destroy.

The first version had a second half, which is removed. It armed an AbortHandle for a read that Bun.ModuleGraph code started, and ended the rewrite when that graph's context stopped. A review of it found, and reruns confirmed:

  • A context also stops when its graph is collected without dispose(). A host that kept only the promise of graph.run(() => out.text()) then got its input stream cancelled and the promise pending for ever. Main fulfils it.
  • The stop cancelled the rewrite's input, so a cancel() callback of the graph's own stream ran inside dispose() on this path.
  • A narrowed tail of fail() made a rewrite that failed twice report its first error. Main reports the second. The tail of fail() is main's text again.
  • A read begun by the input's cancel() inside fail() went to the cell after fail() had taken the promise, and stayed pending (8 of 8). hold_read_promise now refuses once the rewrite is over, and 8 tests pin it.

Not changed.

  • A read that waits while a handler is parked on a promise that the waiting code or the handler keeps: the promise is a root in that window, as on main. With no graph that is 6 of 6 in 2 of 5 parked shapes. In a disposed Bun.ModuleGraph it keeps the graph (6 of 6 in 4 of 9 shapes). Ending such a read with its graph belongs to the read in Body.rs, for fetch and Bun.serve bodies too, and only for dispose(). That is a separate change.
  • The same six readers once .body was read, a pending read(), new Response(out.body).text() and Bun.readableStreamToText(out.body): their roots are the output stream's pull promise and BufferAction.promise in ByteStream. Fetch relies on the first.
  • Bun.write(file, out): the root is the file sink's controller, and it reproduces with no rewriter.
  • The pump's drain promise is a JSPromiseStrong.
  • A handler that ends the rewrite from the document's end has its outcome replaced by finish(). This is on main and does not need a pending read.

The second, third and last of these are handed off as separate work.

Self-review, what was not taken. Holding the promise in place through a weak handle, and asking the producer at settle time in Value::resolve, were both proposed to close the read-inside-fail() window. The phase == Done test in hold_read_promise closes it with no new handle and no change to the settle path. A debug_assert for that invariant was not added. A Body-level stop for all producers and a reader context on PendingValue are the separate change named above. The review also noted that a host can wrap the output as new Response(out.body, out) today to avoid the graph form, and that open PRs touch the same lines (below).

Overlap with open PRs. #44259 makes PendingValue.producer a private type: self.producer.hold_read_promise(..) in set_promise and the matches!(locked.producer, ..) in root_read_promise need a small rebase in whichever lands second. The rewrite of begin_suspension from #44759 reached main in #46021. This branch is merged with it: the one call that this PR adds there follows main's debug_assert. The tail of fail(), which #43521 and #44259 rewrite, is untouched here.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/workerd/html-rewriter-leak.test.ts

…form cell, not as a GC root

text(), json(), bytes(), arrayBuffer(), blob() and formData() on the
Response that transform() returns, before its body is read, waited on a
promise that PendingValue::set_promise protected until the rewrite ended.
Nothing but its input keeps a rewrite alive, so when script dropped an
input that had not ended, the promise stayed a root with all that its
reactions reached: the waiting closure, a disposed Bun.ModuleGraph, a
retired realm of bun test --isolate.

set_promise now asks the body's producer before it roots the promise
(SourceHandle::hold_read_promise). RewriterPipe holds it in the transform
cell's outputConsumer slot (the renamed outputStream slot, which such a
body never uses) and roots it only to settle it and while a handler is
parked.

Value::resolve releases the root on every exit: a node:vm timeout that
landed in the settle left it protected. Value::reset frees the decoder of
a formData() read that was never settled.
@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 9d5b357e-c186-43bf-b8f7-9bb8aa38a47b
📥 Commits

Reviewing files that changed from the base of the PR and between 2d6d445 and eef6208.

📒 Files selected for processing: 7
  • src/runtime/api/html_rewriter.bind.ts
  • src/runtime/api/html_rewriter.rs
  • src/runtime/webcore/Body.rs
  • src/runtime/webcore/streams.rs
  • test/cli/test/isolation.test.ts
  • test/js/bun/module-graph/module-graph-gc.test.ts
  • test/js/workerd/html-rewriter-leak.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.

Walkthrough

HTMLRewriter now coordinates pending response-body read promises with the rewrite lifecycle. Body and source handling update promise ownership and cleanup. New tests cover promise collection, settlement, interruption, and response body state.

Changes

Pending HTMLRewriter read promise ownership

Layer / File(s) Summary
Body promise ownership handoff
src/runtime/webcore/streams.rs, src/runtime/webcore/Body.rs
Body asks the source to retain pending read promises. It protects the promise itself when the source does not retain it, adopts protection during resolution, and releases pending action resources on reset.
HTMLRewriter cache and lifecycle
src/runtime/api/html_rewriter.bind.ts, src/runtime/api/html_rewriter.rs
outputConsumer represents the output stream or a pending read promise. HTMLRewriter roots pending promises while finishing, suspending, or failing, and restores them after handler resolution.
Promise collection and settlement coverage
test/cli/test/isolation.test.ts, test/js/bun/module-graph/module-graph-gc.test.ts, test/js/workerd/html-rewriter-leak.test.ts
Tests cover collection of pending reads and captured objects, settlement after completion or failure, timeout interruption, and transformed response body state.

Suggested reviewers: dylan-conway

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to eef62

This change stops pending HTMLRewriter body reads from keeping objects such as disposed module graphs alive. It also releases protected promises on every resolve exit and frees unsettled formData decoders. No merge-blocking issue was identified, and regression tests cover collection, settlement, timeout and body-state behavior.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly identifies the main change: pending HTMLRewriter body reads move into the transform cell instead of remaining GC roots.
Description check Passed The description provides a detailed problem statement, implementation summary, verification results, test coverage, known limitations, and follow-up work. It does not use the exact template headings, …
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description provides a detailed problem statement, implementation summary, verification results, test coverage, known limitations, and follow-up work. It does not use the exact template headings, but it contains the required information and is substantially complete.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:13 PM PT - Oct 21st, 2026

✅ @robobun, your commit 4d4441f2bf24de6a9fac5e29c97f3ac0f11531bc passed in Build #130203! 🎉


🧪   To try this PR locally:

bunx bun-pr 46118

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

bun-46118 --bun

@robobun

robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

Status

How the leak was reproduced:

  • A Bun.ModuleGraph whose code does new HTMLRewriter().transform(new Response(stream)).text().then(..), where stream is its own JS ReadableStream that is still open, is disposed. On release builds of main (5900354 and 91e7e3b) and on 1.4.3-canary, 6 of 6 such graphs are still alive after 40 forced collections. With no graph, the closure that waits is held the same way (6 of 6). The control without the rewriter is 0 of 6.
  • A debugging heap snapshot names the root: root(ProtectedValues) Promise -> FullPromiseReaction -> Object -storage-> ModuleGraph.
  • With this PR the same scripts read 0 or 1 of 6 by WeakRef count and 0 of 6 by heap snapshot. The PR notes say what the 1 is.

PR: #46118

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked two ways the outputConsumer slot could lose the parked promise and found neither reachable: reading .body while a .text() is pending returns the in-use stream from locked_to_native_stream (has_consumer() is true from action), so on_readable_stream_available never overwrites the slot; and is_output_locked on a promise in the slot is a dynamicDowncast<JSReadableStream> miss, so it answers false rather than misbehaving. The protect/unprotect moves across root_read_promise/unroot_read_promise/fail/finish/Value::resolve pair up on each interleaving I traced (suspended-at-read, finish-or-fail during resume_rewrite, .body read first).

Extended reasoning...

The change reroutes the pending-body read promise of an HTMLRewriter output from a GC root into the Transform cell's visited slot, touching Body.rs, streams.rs and html_rewriter.rs plus three leak tests; no security-sensitive surface. Two findings were posted inline, so a human look is already signalled; this note records the slot-overwrite and double-protect paths that were traced and ruled out.

Comment thread src/runtime/webcore/streams.rs Resolved
…iter-pending-body-promise

# Conflicts:
#	src/runtime/api/html_rewriter.classes.ts
#	src/runtime/api/html_rewriter.rs
#	test/cli/test/isolation.test.ts
The row read with text() and took its label from bodyUsed, which is true
from the moment the read begins. It now reads with json(), so the parse
makes the settle most of the read, and it sets the timeout to land in the
middle of the settle. A run counts only when the document's end handler
ran after the script began and json() did not return.
Comment thread src/runtime/api/html_rewriter.rs Resolved
Comment thread src/runtime/api/html_rewriter.rs Resolved
Comment thread src/runtime/webcore/Body.rs Resolved
Comment thread src/runtime/webcore/streams.rs Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment on lines +764 to +797
while (settled < 40 && kilobytes < 256 << 10) {
kilobytes *= 2;
rewritten = 0;
settled = Infinity;
for (let sample = 0; sample < 2; sample++) {
const { at, output } = rewrite(kilobytes);
const began = performance.now();
const read = output.json();
const returned = performance.now();
rewritten = Math.max(rewritten, at.end - began);
settled = Math.min(settled, returned - at.end);
await read;
}
}

const before = protectedCount("Promise");
const runs: string[] = [];
for (let i = 0; i < 4; i++) {
const cut = ((globalThis as any).rewriteCutShort = { ...rewrite(kilobytes), returned: false });
const began = performance.now();
try {
vm.runInThisContext("rewriteCutShort.output.json(); rewriteCutShort.returned = true;", {
timeout: Math.ceil(rewritten + settled / 2),
});
runs.push("returned");
} catch (error: any) {
if (error.code !== "ERR_SCRIPT_EXECUTION_TIMEOUT") throw error;
// Between the call of `end` and the return of json() there is only the settle.
runs.push(`cut short ${cut.returned ? "after" : cut.at.end > began ? "in" : "before"} the settle`);
} finally {
delete (globalThis as any).rewriteCutShort;
}
}
expect(runs).toContain("cut short in the settle");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 nit (optional): maintainers can get a spurious CI failure from this test, because its pass condition is a wall-clock race rather than an observable signal. The assertion at test/js/workerd/html-rewriter-leak.test.ts:797 needs at least one of 4 runs where the vm.runInThisContext deadline lands inside the settle window. That deadline is Math.ceil(rewritten + settled / 2) (line 786), measured from two warm-up samples, while each timed run first rewrites a multi-megabyte document whose duration shifts with GC pauses from the previous run's parsed array. Fix: make the classification independent of timing, e.g. loop the timed run until one lands "in" (bounded by attempts, not by a fixed 4) or drive the termination from a deterministic hook.

Why this was flagged

The test at test/js/workerd/html-rewriter-leak.test.ts:740-799 grows a JSON document until settled (the time between the document-end handler and json() returning) is at least 40 ms, then runs vm.runInThisContext 4 times with timeout: Math.ceil(rewritten + settled / 2) (line 786) and requires runs to contain "cut short in the settle" (line 797). Whether a run is classified "in" depends on where the watchdog deadline falls relative to at.end (line 791), which depends on how long the rewrite of the same document took in that run. Each timed run follows a run that parsed an array of millions of numbers; a full collection or a slower rewrite in any of the 4 runs moves the deadline out of the settle window ("before" or "after"), and if all 4 miss the assertion fails although the code under test is correct. The base branch does not have this test; the prior comment at line 799 (resolved) asked for a classification that proves the termination landed in the settle, and this timing loop is how it was answered, so the race is a consequence of that remedy that the comment did not weigh.

Verification: nit. The pass condition at line 797 (expect(runs).toContain("cut short in the settle")) requires at least one of the 4 runs (line 781) to have the watchdog deadline fire after the end handler ran and before json() returned. The deadline is a wall-clock timer: vm.runInThisContext(..., { timeout: Math.ceil(rewritten + settled / 2) }) at line 786.

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants