Skip to content

HTMLRewriter: make an async text handler on a long text node linear - #45912

Open
robobun wants to merge 2 commits into
robobun/31756292/lolhtml-nested-notfrom
robobun/463f6c50/htmlrewriter-text-suspension-linear
Open

robobun wants to merge 2 commits into
robobun/31756292/lolhtml-nested-notfrom
robobun/463f6c50/htmlrewriter-text-suspension-linear

Conversation

@robobun

@robobun robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #43266 (the move to lol-html v3.0.1). Merge that first.

Problem

Fix

  • Copy the rest of a suspended text lexeme once, not at every suspension lol-html#13 copies the rest once and then only moves an offset. This PR moves LOLHTML_COMMIT from 0e6a4b1 to 5f83589: 0e6a4b1 plus that commit.
  • 16 MiB now costs 0.13 s, and 64 MiB 0.33 s.
  • Verified: test/js/workerd/html-rewriter.test.js, 7 new cases. On the base one fails: 544 allocated bytes per byte of text, limit 16.
  • Self-reviewed: 9 concerns raised, 8 addressed. Not done: issues for defects outside this change.

Background

  • HTMLRewriter is the fork oven-sh/lol-html. Bun pins one commit of it.
  • lol-html gives text to a handler in 1024-byte chunks after a byte that is not UTF-8, after a < that opens no tag, and after the first write of a text node.
  • A pending promise from a handler suspends lol-html, which parks the chunk and a copy of the undecoded rest.
  • Considered smaller writes from bun (handlers would see other chunks) and no copy at all (a branch on every write()).

Downsides

  • A rewrite cancelled or rejected while parked keeps the whole parked copy until its Response is collected: 80 MB more for 20 such 4 MiB rewrites.
  • A suspension on one-chunk text costs 122 more instructions (14,288 to 14,410).
  • .text grows by 512 bytes.
Notes

Why this PR is stacked. Main pins lol-html 725ce49, on the v2.7.2 lineage. The fork's bun branch is on v3.0.1, and #43266 moves main there. The fix is a PR against that branch (oven-sh/lol-html#13), so the pin that holds it needs #43266 first. 5f83589 is the pin of #43266 (0e6a4b1, the head of oven-sh/lol-html#11) plus the one commit of oven-sh/lol-html#13: compare. When the fork PRs merge, the pin moves to the bun head. If main stays on v2.7.2, the same commit is on robobun/463f6c50/text-suspension-linear-v2.7.2 (fefbf98 on 725ce49). The other open pins (#41475, #41968, #42234, #42646) each set LOLHTML_COMMIT to a different commit of the v2.7.2 lineage, and this PR does not add another one.

Overlap with #41527. Its pin contains oven-sh/lol-html#3, which keeps a valid UTF-8 text node on lol-html's fast path. With that change, a handler that suspends sees other chunks than a sync handler, and two fork tests of this change fail (details in oven-sh/lol-html#13 and in a comment on oven-sh/lol-html#3). The three "sees the chunks that a sync handler sees" cases here check the same property in bun.

Origin. Found by measurement, and no user reported it. oven-sh/lol-html#12 mentions it under "Found on the way". #36733 (1.4.0) added the suspension of async handlers and, with it, the copy. 1.3.13 ran async handlers without parking and is linear.

Which rewrites are affected. All three conditions must hold:

  • The handler is still pending after the microtask checkpoint, for example it awaits a timer or I/O. A handler that does await 1 does not suspend.
  • The text reaches the handler in 1024-byte chunks (see Background). Valid UTF-8 text with no stray <, in one write, is one chunk.
  • A long part of the text arrives in one write. A string, Buffer, Blob or ArrayBuffer body is one write. A streamed body is quadratic in each write, and the writes that arrive during a suspension are fed as one write.

One 4 MiB text node in one write, async handler, CPU for the transform() in release builds (two runs each):

Body base this PR
<p>, the byte 0xE9, text 408 / 361 ms 41 / 37 ms
<p>a < , text (valid UTF-8 string) 417 / 397 ms 40 / 33 ms
<script> with i<n, text (valid UTF-8 string) 264 / 396 ms 28 / 22 ms
<p>, text with no stray < (2 chunks) 6 / 11 ms 6 / 11 ms

On 1.4.x a program can avoid the cost: give the body to transform() as a pull-based ReadableStream of 16 KiB pieces. 8 MiB of the second body then costs 57 ms on the base, against 1,553 ms as one string.

Time. Release builds of this branch with the pin of #43266 (base) and with this pin. The body is <p>, the byte 0xE9, N bytes of text, </p> in one Buffer. The handler awaits setImmediate on every chunk. User plus system CPU around the transform(), median of 10 interleaved runs (3 at 16 MiB), one size per process:

Text 1 MiB 2 MiB 4 MiB 8 MiB 16 MiB 32 MiB 64 MiB
base 42.5 ms 127.6 ms 398.8 ms 1,575.5 ms 8,968.9 ms
this PR 12.0 ms 23.7 ms 32.3 ms 61.1 ms 125.7 ms 168.6 ms 333.2 ms

This PR costs 5 to 12 microseconds per suspension at every size. A sync handler on 16 MiB costs 22.2 ms on the base and 22.6 ms here, which is inside the spread of the runs (19.9 to 24.3 ms).

Rewrites that never suspend. User-mode instructions of the process (perf_event_open, instructions:u), release builds, JIT off, as the slope between two sizes: 5,371.03 per small text node on the base and 5,368.69 here, 2,563.78 per 1024-byte chunk on the base and 2,560.07 here. The spread of the runs is about 1 and 9 instructions. The crate allocates the same bytes with a sync handler (numbers in oven-sh/lol-html#13).

Memory. The allocator is asked for 543.91 bytes per byte of a 1 MiB text node on the base and for 6.2 here (heapStats().mimalloc.malloc_requested around the transform()). While a handler is parked in the middle of a text, lol-html now holds the whole rest as it was at the first suspension, and no longer a copy that shrinks: for a 4 MiB text the mean is 4.2 MB (was 2.1 MB) and the peak is 4.2 MB (was 8.4 MB). A normal rewrite frees it when the text ends. A rewrite that is cancelled or rejected while parked is freed only when its Response is collected, because bun drops the rewriter only in finish(). Measured with 20 rewrites of a 4 MiB text node, each parked at 97 percent of the text, with the 20 Responses kept alive (live mimalloc bytes after Bun.gc(true)): cancel 0 MB on the base and 80.2 MB here, reject 84.7 MB and 165.1 MB.

Size. size of the release binary: .text 80,631,395 to 80,631,907 bytes. The file is 80,799,264 bytes before and after. bloaty is not installed on the machine that measured.

Tests. The 7 cases are in describe("async text handler on a text node of many chunks").

  • 3 cases compare the chunks and the output of a handler that awaits setImmediate on every chunk with those of a sync handler, for the three kinds of text in the table. The text has a period of 37 bytes, so each chunk has its own content.
  • 3 cases replace every chunk after the await and check the output.
  • "copies the rest of the text once, not at every suspension" runs a 1 MiB text node in a child process and checks the bytes that the allocator was asked for. It is skipped on ASAN builds, which do not allocate through mimalloc.
  • Debug ASAN build: 218 pass, 1 skipped. Release build: 219 pass. Release build of the base: 218 pass, and the allocator case fails with Expected: < 16, Received: 544.
  • The gate strips src/ and packages/ for its fail-before run. This fix is in the pinned commit, so that run already has it. The release build of the base is the fail-before proof.
  • lol-html's own tests for this change run in the lol-html cargo test job. They are described in Copy the rest of a suspended text lexeme once, not at every suspension lol-html#13.

Move LOLHTML_COMMIT from 0e6a4b1 to 5f83589, which adds
oven-sh/lol-html#13. When a text handler suspends on each chunk of one
long text node, lol-html now copies the undecoded rest of the text once,
and not at every suspension.

Add tests for an async text handler on a text node that arrives in many
chunks. The handler sees the chunks that a sync handler sees. A mutation
after each await lands on its own chunk. The allocator is asked for less
than 16 bytes per byte of text.
@robobun

robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:43 PM PT - Oct 19th, 2026

❌ @robobun, your commit 944b639 has 2 failures in Build #129200 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 45912

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

bun-45912 --bun

@robobun

robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The diff is green. CI is red on two tests that this change does not touch.

Reproduced with this script on release builds:

const run = async n => {
  const body = Buffer.concat([Buffer.from("<p>\xE9", "latin1"), Buffer.alloc(n, "a"), Buffer.from("</p>")]);
  const rewriter = new HTMLRewriter().on("p", {
    async text() {
      await new Promise(resolve => setImmediate(resolve));
    },
  });
  const before = process.cpuUsage();
  await rewriter.transform(new Response(body)).arrayBuffer();
  const { user, system } = process.cpuUsage(before);
  console.log(n >> 20, "MiB", Math.round((user + system) / 1000), "ms");
};
for (const mb of [4, 8, 16]) await run(mb << 20);

CPU for the 16 MiB text node: 10.2 s on 1.4.2, 9.0 s on the base of this PR, 0.08 s on 1.3.13, and 0.13 s with this PR.

The fix is oven-sh/lol-html#13. This PR is stacked on #43266 and moves its lol-html pin by that one commit.

CI ran twice: build 128320 (183 of 185 jobs passed) and build 129200 (179 of 181).

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

Code review found no issues

No high-confidence issues detected in this change.

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.

1 participant