diff --git a/test/js/workerd/html-rewriter-leak.test.ts b/test/js/workerd/html-rewriter-leak.test.ts
index 7c6ff001fc86..b3bc87adc5e6 100644
--- a/test/js/workerd/html-rewriter-leak.test.ts
+++ b/test/js/workerd/html-rewriter-leak.test.ts
@@ -1,6 +1,6 @@
import { heapStats } from "bun:jsc";
import { describe, expect, test } from "bun:test";
-import { bunEnv, bunExe, expectRssDeltaBelow, isASAN, isDebug, tempDir } from "harness";
+import { bunEnv, bunExe, expectRssDeltaBelow, isASAN, isDebug, isWindows, tempDir } from "harness";
import { join } from "node:path";
// `wire_input`'s materialized-body path transfers the body's `+1` (a
@@ -176,6 +176,9 @@ test("onEndTag callbacks are released after the rewrite", () => {
// Skipped in debug: at this N a debug pass is ~40s and the extra debug-build
// allocation tracking adds enough RSS noise to drown the signal. CI has no
// debug test lane; release + ASAN cover the regression.
+//
+// ASAN builds run a quarter of the registrations. One costs about 7 µs on the
+// ASAN lane, so the full count took 11.8 to 14.5 s of the 15 s limit there.
test.skipIf(isDebug)(
"HTMLRewriter does not leak element/document handler allocations",
async () => {
@@ -190,7 +193,7 @@ test.skipIf(isDebug)(
for (let i = 0; i < 32; i++) rw.onDocument(docNoop);
}
- const N = 4000;
+ const N = ${isASAN ? 1000 : 4000};
function pass() {
for (let i = 0; i < N; i++) once();
Bun.gc(true);
@@ -237,12 +240,81 @@ test.skipIf(isDebug)(
// Unfixed: ~50 MB over 3 measured passes. Fixed: a plateau, but RSS is a
// high-water mark and allocator jitter has been observed to reach ~30 MB
// on release lanes, so the bound sits between that and the unfixed signal.
- expect(deltaMB).toBeLessThan(35);
+ //
+ // ASAN, at a quarter of the count and with the quarantine off: 11 to 14 MB
+ // unfixed, -1.5 to 2 MB fixed.
+ expect(deltaMB).toBeLessThan(isASAN ? 6 : 35);
expect(exitCode).toBe(0);
},
15_000,
);
+// The same regression, counted exactly on sanitizer builds. That includes
+// debug builds, which skip the RSS test. LeakSanitizer fails the run for every
+// handler struct that is still allocated at exit with nothing pointing to it,
+// so one leaked struct is enough.
+test.skipIf(!isASAN || isWindows)(
+ "HTMLRewriter does not leak element/document handler allocations (LeakSanitizer)",
+ async () => {
+ const ROUNDS = 4;
+ const REWRITERS_PER_ROUND = 16;
+ const code = /* js */ `
+ const { heapStats } = require("bun:jsc");
+ const noop = { element() {}, comments() {}, text() {} };
+ const docNoop = { doctype() {}, comments() {}, text() {}, end() {} };
+
+ function once() {
+ const rw = new HTMLRewriter();
+ for (let i = 0; i < 32; i++) rw.on("div", noop);
+ for (let i = 0; i < 32; i++) rw.onDocument(docNoop);
+ }
+
+ // From a macrotask on purpose: leaksan.supp has entries for module
+ // evaluation, and they hide every allocation made while the module
+ // body is on the stack.
+ setImmediate(() => {
+ for (let round = 0; round < ${ROUNDS}; round++) {
+ for (let i = 0; i < ${REWRITERS_PER_ROUND}; i++) once();
+ Bun.gc(true);
+ }
+ process.stdout.write(String(heapStats().objectTypeCounts.HTMLRewriter ?? 0));
+ });
+ `;
+
+ await using proc = Bun.spawn({
+ cmd: [bunExe(), "-e", code],
+ env: {
+ ...bunEnv,
+ // Every cell that is still alive at exit gets finalized, so what
+ // LeakSanitizer then finds is a struct that a finalizer did not free.
+ BUN_DESTRUCT_VM_ON_EXIT: "1",
+ ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "detect_leaks=1"].filter(Boolean).join(":"),
+ LSAN_OPTIONS: `print_suppressions=0:suppressions=${join(import.meta.dirname, "../../leaksan.supp")}`,
+ },
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+
+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+
+ // Unfixed: "SUMMARY: AddressSanitizer: 180224 byte(s) leaked in 4096
+ // allocation(s)" on stderr and a non-zero exit code.
+ expect({ stdout, stderr: withoutAsanWarning(stderr), exitCode }).toEqual({
+ stdout: expect.stringMatching(/^\d+$/),
+ stderr: "",
+ exitCode: 0,
+ });
+
+ // stdout is the count of rewriters that are still alive. The collector
+ // freed the others: they did not wait for the VM to be torn down.
+ expect(Number(stdout)).toBeLessThan((ROUNDS * REWRITERS_PER_ROUND) / 4);
+ },
+ // A pass takes about 0.3 s on a release ASAN build and 2 s on a debug build.
+ // A failure takes over 6 s, more than the default limit: LeakSanitizer
+ // symbolizes its report before the child exits.
+ 90_000,
+);
+
// `fail()` / `cancel_from_output()` on a native ByteStream/FileReader input
// must detach the source's sink backref and let the Transform cell become
// unreachable, or every rewrite pins its output Response. Covers the