Repository navigation
Conversation
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.
Collaborator
Author
|
Updated 11:43 PM PT - Oct 19th, 2026
❌ @robobun, your commit 944b639 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 45912That installs a local version of the PR into your bun-45912 --bun |
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).
|
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #43266 (the move to lol-html v3.0.1). Merge that first.
Problem
raw_input.get(read..).unwrap_or_default().to_vec()inTextDecoder::feed_decoder_loop(vendor/lolhtml/src/rewritable_units/text_decoder.rs:193). Every suspension copies the undecoded rest of the text again.Fix
LOLHTML_COMMITfrom 0e6a4b1 to 5f83589: 0e6a4b1 plus that commit.test/js/workerd/html-rewriter.test.js, 7 new cases. On the base one fails: 544 allocated bytes per byte of text, limit 16.Background
HTMLRewriteris the fork oven-sh/lol-html. Bun pins one commit of it.<that opens no tag, and after the first write of a text node.write()).Downsides
.textgrows by 512 bytes.Notes
Why this PR is stacked. Main pins lol-html 725ce49, on the v2.7.2 lineage. The fork's
bunbranch 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 thebunhead. If main stays on v2.7.2, the same commit is onrobobun/463f6c50/text-suspension-linear-v2.7.2(fefbf98 on 725ce49). The other open pins (#41475, #41968, #42234, #42646) each setLOLHTML_COMMITto 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:
await 1does not suspend.<, in one write, is one chunk.One 4 MiB text node in one write, async handler, CPU for the
transform()in release builds (two runs each):<p>, the byte 0xE9, text<p>a <, text (valid UTF-8 string)<script>withi<n, text (valid UTF-8 string)<p>, text with no stray<(2 chunks)On 1.4.x a program can avoid the cost: give the body to
transform()as a pull-basedReadableStreamof 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 awaitssetImmediateon every chunk. User plus system CPU around thetransform(), median of 10 interleaved runs (3 at 16 MiB), one size per process: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_requestedaround thetransform()). 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 infinish(). 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 afterBun.gc(true)): cancel 0 MB on the base and 80.2 MB here, reject 84.7 MB and 165.1 MB.Size.
sizeof the release binary:.text80,631,395 to 80,631,907 bytes. The file is 80,799,264 bytes before and after.bloatyis not installed on the machine that measured.Tests. The 7 cases are in
describe("async text handler on a text node of many chunks").setImmediateon 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.Expected: < 16, Received: 544.src/andpackages/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 cargo testjob. They are described in Copy the rest of a suspended text lexeme once, not at every suspension lol-html#13.