Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 75 additions & 3 deletions test/js/workerd/html-rewriter-leak.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

// `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
Expand Down
Loading