From 40e53ad269ef5519a5b4d0883dbf35b64630e21c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:39:43 +0000 Subject: [PATCH 1/3] HTMLRewriter: make element.onEndTag() on nested elements linear Pin lol-html to oven-sh/lol-html@77a9728 (oven-sh/lol-html#5). Each captured end tag walked every registered end-tag handler, and every still-open element that called onEndTag() has one, so n nested elements cost n^2/2 visits: 100k nested (700 KB) took 5.3 s. The walk now stops once the handlers that end tag activated have run. The new pin sits on the fork bun head ea6125b, so it also carries the selector VM change from oven-sh/lol-html#2. Adds a nested/multi-pop ordering test and a 400k-deep onEndTag test that the released bun cannot finish inside the spawn timeout. --- scripts/build/deps/lolhtml.ts | 2 +- test/js/workerd/html-rewriter.test.js | 57 +++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/scripts/build/deps/lolhtml.ts b/scripts/build/deps/lolhtml.ts index 499ddf47ec7e..e13a25f1bc63 100644 --- a/scripts/build/deps/lolhtml.ts +++ b/scripts/build/deps/lolhtml.ts @@ -26,7 +26,7 @@ import type { Dependency } from "../source.ts"; // base commit is recorded here so a rebase onto a new upstream tag is // `git rebase --onto bun` in the fork. const LOLHTML_UPSTREAM_BASE = "77127cd2b8545998756e8d64e36ee2313c4bb312"; // v2.7.2 -const LOLHTML_COMMIT = "725ce499aa9b71e38b7a2d0a9fbb6d7294a4079e"; +const LOLHTML_COMMIT = "77a97289c845d4d71824c10a865dedc02b9f50eb"; void LOLHTML_UPSTREAM_BASE; export const lolhtml: Dependency = { diff --git a/test/js/workerd/html-rewriter.test.js b/test/js/workerd/html-rewriter.test.js index 1c775108f01d..1013bda40ec8 100644 --- a/test/js/workerd/html-rewriter.test.js +++ b/test/js/workerd/html-rewriter.test.js @@ -3314,3 +3314,60 @@ describe("GC pressure mid-rewrite", () => { }); }); }); + +describe("end tag handler dispatch", () => { + const annotate = () => + new HTMLRewriter().on("*", { + element(el) { + const id = el.getAttribute("id"); + el.onEndTag(end => { + end.before(``, { html: true }); + }); + }, + }); + + it("runs nested handlers innermost first, including when one end tag closes several elements", () => { + expect([ + annotate().transform('x'), + // pops , and
at once: all three handlers run on it. + annotate().transform('
x

y

'), + ]).toEqual([ + 'x', + '
x

y

', + ]); + }); + + // Every open element that called onEndTag() keeps a pending handler, and + // each end tag used to scan all of them: n nested elements cost n^2/2 + // visits (n = 100k, a 700 KB document, took 5.3 s in a release build). + // The scan now stops once the handlers that end tag activated have run. + // The release size cannot finish inside the spawn timeout with the old + // scan and takes well under a second without it. A debug build spends + // ~0.2 ms of JS per element, so it gets a size that only keeps it short. + it("stays linear when every nested element registers onEndTag", async () => { + const n = isDebug ? 40_000 : 400_000; + const fixture = /* js */ ` + const n = ${n}; + const doc = Buffer.alloc(n * 3, "").toString() + "x" + Buffer.alloc(n * 4, "").toString(); + let ends = 0; + const onEnd = () => { ends++; }; + const out = new HTMLRewriter().on("a", { element(el) { el.onEndTag(onEnd); } }).transform(doc); + console.log(JSON.stringify({ ends, same: out === doc })); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 30_000, + killSignal: "SIGKILL", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode, signal: proc.signalCode }).toEqual({ + stdout: JSON.stringify({ ends: n, same: true }), + stderr: "", + exitCode: 0, + signal: null, + }); + }, 60_000); +}); From a8bb8d8b7274bfa675ce5292a96734b1d9c9c60d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:51:10 +0000 Subject: [PATCH 2/3] test: smaller debug size, default per-test timeout, only surface error-looking stderr --- test/js/workerd/html-rewriter.test.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/js/workerd/html-rewriter.test.js b/test/js/workerd/html-rewriter.test.js index 1013bda40ec8..304a11c9e077 100644 --- a/test/js/workerd/html-rewriter.test.js +++ b/test/js/workerd/html-rewriter.test.js @@ -3345,7 +3345,7 @@ describe("end tag handler dispatch", () => { // scan and takes well under a second without it. A debug build spends // ~0.2 ms of JS per element, so it gets a size that only keeps it short. it("stays linear when every nested element registers onEndTag", async () => { - const n = isDebug ? 40_000 : 400_000; + const n = isDebug ? 10_000 : 400_000; const fixture = /* js */ ` const n = ${n}; const doc = Buffer.alloc(n * 3, "").toString() + "x" + Buffer.alloc(n * 4, "").toString(); @@ -3363,11 +3363,16 @@ describe("end tag handler dispatch", () => { killSignal: "SIGKILL", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout: stdout.trim(), stderr, exitCode, signal: proc.signalCode }).toEqual({ + expect({ + stdout: stdout.trim(), + stderr: /error|panic|assert|abort/i.test(stderr) ? stderr : "", + exitCode, + signal: proc.signalCode, + }).toEqual({ stdout: JSON.stringify({ ends: n, same: true }), stderr: "", exitCode: 0, signal: null, }); - }, 60_000); + }); }); From 41fe1d24b03e64ab96607bbe39cecaae6da678ee Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:36:52 +0000 Subject: [PATCH 3/3] ci: retrigger