Skip to content
9 changes: 7 additions & 2 deletions src/bun.js/api/server.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1766,7 +1766,13 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
.config = config.*,
.base_url_string_for_joining = base_url,
.vm = jsc.VirtualMachine.get(),
.allocator = Arena.getThreadLocalDefault(),
// Was MimallocArena.getThreadLocalDefault(), but that uses a vtable
// distinct from bun.default_allocator's even though both route
// to mimalloc. Collections that flow between server-owned and
// default-owned code (e.g. BabyList in the response sink) trip
// CheckedAllocator's vtable check in ci_assert builds. There's
// no longer a per-thread heap to bind to, so just use default.
.allocator = bun.default_allocator,
.dev_server = dev_server,
});

Expand Down Expand Up @@ -3787,7 +3793,6 @@ const js_printer = bun.js_printer;
const logger = bun.logger;
const strings = bun.strings;
const uws = bun.uws;
const Arena = bun.allocators.MimallocArena;
const BoringSSL = bun.BoringSSL.c;
const SocketAddress = bun.api.socket.SocketAddress;

Expand Down
48 changes: 29 additions & 19 deletions test/js/bun/http/serve-stream-reject-flush-leak.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,34 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
import { bunEnv, bunExe, isWindows } from "harness";
import { join } from "node:path";

test("handleRejectStream unprotects pending_flush (no Promise GC-root leak)", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), join(import.meta.dir, "serve-stream-reject-flush-leak-fixture.ts")],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// The fixture relies on an 8 MiB tryEnd() hitting socket backpressure on a
// loopback client that never reads. Windows' loopback send buffer auto-tunes
// large enough that the write completes synchronously and pending_flush is
// never created, so the precondition can't be satisfied there. The leak being
// guarded is platform-agnostic Zig (handleRejectStream); POSIX coverage is
// sufficient.
test.skipIf(isWindows)(
"handleRejectStream unprotects pending_flush (no Promise GC-root leak)",
async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), join(import.meta.dir, "serve-stream-reject-flush-leak-fixture.ts")],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stderr).toBe("");
const result = JSON.parse(stdout.trim());
// Sanity: we actually hit the backpressure → pending_flush path.
expect(result.flushPending).toBeGreaterThanOrEqual(result.iterations / 2);
// Without the fix, delta ≈ iterations (one protected Promise leaked per
// request). With the fix, it should be ~0. Allow a small constant for
// unrelated bookkeeping promises.
expect(result.delta).toBeLessThan(result.iterations / 2);
expect(exitCode).toBe(0);
}, 60_000);
expect(stderr).toBe("");
const result = JSON.parse(stdout.trim());
// Sanity: we actually hit the backpressure → pending_flush path.
expect(result.flushPending).toBeGreaterThanOrEqual(result.iterations / 2);
// Without the fix, delta ≈ iterations (one protected Promise leaked per
// request). With the fix, it should be ~0. Allow a small constant for
// unrelated bookkeeping promises.
expect(result.delta).toBeLessThan(result.iterations / 2);
expect(exitCode).toBe(0);
},
60_000,
);
6 changes: 5 additions & 1 deletion test/js/bun/test/parallel/test-integration-rspack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ const cwd = tmpdirSync();
console.log([0, cwd]);

let proc = Bun.spawn({
cmd: [bunExe(), "create", "rsbuild@latest", "app", "--template", "solid-ts"],
// Pinned: rsbuild 2.0.x bundles mimalloc v3 inside @rspack/binding-win32-arm64-msvc.
// Two static mimalloc instances in one process deterministically segfault in ntdll
// during ExitProcess on Windows arm64 (FLS / process-detach cleanup). Tracked
// separately; this test exists to guard the napi TSFN finalizer, not rsbuild HEAD.
cmd: [bunExe(), "create", "rsbuild@1", "app", "--template", "solid-ts"],
stdio: ["ignore", "inherit", "inherit"],
cwd,
env: bunEnv,
Expand Down
4 changes: 3 additions & 1 deletion test/js/bun/websocket/websocket-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1136,7 +1136,9 @@ it("server.upgrade() with Sec-WebSocket-Protocol in options.headers does not use
// the value passed to resp.upgrade(), so the expected response protocol is
// `part`, not the combined "part, tail".
const expected = JSON.stringify({ status: 101, protocol: part, custom: "hello" });
expect({ stdout: stdout.trim(), stderr: stderr.split("\n", 3).join("\n").trim() }).toEqual({
// Don't truncate stderr — when this previously crashed on Windows ci_assert
// builds the panic line was past line 3, leaving "" and a misleading diff.
expect({ stdout: stdout.trim(), stderr: stderr.trim() }).toEqual({
stdout: expected,
stderr: "",
});
Expand Down
126 changes: 57 additions & 69 deletions test/js/workerd/html-rewriter-leak.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
import { bunEnv, bunExe, isDebug } from "harness";

// Each .on() / .onDocument() call heap-allocates an ElementHandler / DocumentHandler
// struct via bun.default_allocator. When the HTMLRewriter is garbage-collected,
// LOLHTMLContext.deinit() must destroy those allocations. Previously it only
// unprotected the held JSValues and leaked the struct memory.
//
// Measuring the leak:
// - The handler structs live in mimalloc (bun.default_allocator), so in debug
// builds (where mimalloc stats are compiled in) we read the live-heap counter
// from `heapStats().mimalloc.malloc_normal.current`. This is exact and
// unaffected by ASAN quarantine / page retention.
// - In release builds mimalloc stats are compiled out (all zeros), so we fall
// back to RSS. RSS carries allocator-arena retention noise (notably on
// Windows), so the release path uses a much bigger warmup + workload to make
// the actual leak dominate that noise. Release is fast enough that 20k
// iterations still finish in well under a second.
test("HTMLRewriter does not leak element/document handler allocations", async () => {
const code = /* js */ `
const { heapStats } = require("bun:jsc");
// RSS is a high-water mark — Bun.gc(true) collects every wrapper and its
// lol-html builder, but the allocators don't promptly hand pages back to the
// OS. So warmup runs the *same* workload as the measured phase: the allocator
// footprint is established before the baseline, and any growth past that is
// what's actually retained.
//
// 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.
test.skipIf(isDebug)(
"HTMLRewriter does not leak element/document handler allocations",
async () => {
const code = /* js */ `
const noop = { element() {}, comments() {}, text() {} };
const docNoop = { doctype() {}, comments() {}, text() {}, end() {} };

Expand All @@ -28,66 +28,54 @@ test("HTMLRewriter does not leak element/document handler allocations", async ()
for (let i = 0; i < 32; i++) rw.onDocument(docNoop);
}

// Probe whether mimalloc stats are being collected (debug builds only).
once();
Bun.gc(true);
const haveMimallocStats = heapStats().mimalloc.malloc_normal.total > 0;

// In release (no mimalloc stats) use a much larger workload so the
// handler leak dwarfs RSS noise from allocator arena retention.
const warmup = haveMimallocStats ? 500 : 4000;
const iterations = haveMimallocStats ? 4000 : 16000;

for (let i = 0; i < warmup; i++) once();
Bun.gc(true);

const beforeMi = heapStats().mimalloc.malloc_normal.current;
const beforeRss = process.memoryUsage.rss();

for (let i = 0; i < iterations; i++) once();
Bun.gc(true);
const N = 4000;
function pass() {
for (let i = 0; i < N; i++) once();
Bun.gc(true);
return process.memoryUsage.rss();
}

const afterMi = heapStats().mimalloc.malloc_normal.current;
const afterRss = process.memoryUsage.rss();
pass(); pass();
const before = pass();
pass(); pass();
const after = pass();

const miDeltaMB = (afterMi - beforeMi) / 1024 / 1024;
const rssDeltaMB = (afterRss - beforeRss) / 1024 / 1024;
process.stdout.write(JSON.stringify({ haveMimallocStats, miDeltaMB, rssDeltaMB }) + "\\n");
process.stdout.write(
JSON.stringify({ before, after, deltaMB: (after - before) / 1024 / 1024 }) + "\\n",
);
`;

await using proc = Bun.spawn({
cmd: [bunExe(), "--smol", "-e", code],
env: {
...bunEnv,
// ASAN's freed-block quarantine inflates RSS with transient lol-html
// builder allocations; it is irrelevant to what we're measuring.
ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "quarantine_size_mb=0", "thread_local_quarantine_size_kb=0"]
.filter(Boolean)
.join(":"),
},
stdout: "pipe",
stderr: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
await using proc = Bun.spawn({
cmd: [bunExe(), "--smol", "-e", code],
env: {
...bunEnv,
// Don't inherit the runner's GC_LEVEL=1 — it changes the per-pass live set.
BUN_GARBAGE_COLLECTOR_LEVEL: "0",
// ASAN's freed-block quarantine is exactly the thing that pins RSS at
// peak; disable it so freed lol-html builders get reused across passes.
ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "quarantine_size_mb=0", "thread_local_quarantine_size_kb=0"]
.filter(Boolean)
.join(":"),
},
stdout: "pipe",
stderr: "pipe",
});

const filteredStderr = stderr
.split("\n")
.filter(line => !line.startsWith("WARNING: ASAN interferes"))
.join("\n")
.trim();
expect(filteredStderr).toBe("");
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

const { haveMimallocStats, miDeltaMB, rssDeltaMB } = JSON.parse(stdout.trim());
const filteredStderr = stderr
.split("\n")
.filter(line => !line.startsWith("WARNING: ASAN interferes"))
.join("\n")
.trim();
expect(filteredStderr).toBe("");

if (haveMimallocStats) {
// 4000 * 64 handlers * ~48 bytes each => ~12-20 MB when leaking; ~0 MB when fixed.
expect(miDeltaMB).toBeLessThan(4);
} else {
// Release: 16000 * 64 handlers * ~48 bytes each => ~49 MB of leaked handler
// structs (plus overhead) when leaking; a few MB of arena churn when fixed.
expect(rssDeltaMB).toBeLessThan(30);
}
const { deltaMB } = JSON.parse(stdout.trim());

expect(exitCode).toBe(0);
}, 120_000);
// Unfixed: ~50 MB over 3 measured passes. Fixed: ±1 MB plateau.
// Threshold sits at ~half the unfixed signal.
expect(deltaMB).toBeLessThan(25);
expect(exitCode).toBe(0);
},
15_000,
);
Loading