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