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
22 changes: 18 additions & 4 deletions test/js/workerd/html-rewriter-leak.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,28 @@ test("HTMLRewriter does not leak element/document handler allocations", async ()
const warmup = haveMimallocStats ? 500 : 4000;
const iterations = haveMimallocStats ? 4000 : 16000;

for (let i = 0; i < warmup; i++) once();
Bun.gc(true);
// GC every batch instead of once at the end. With a single trailing GC
// the lol-html builder allocations (Rust side) for all N rewriters are
// live simultaneously, then freed in one burst. Under ASAN those go
// through the sanitizer allocator, which never returns freed pages to
// the OS, so RSS pins at the peak live set (~230 MB at 16k iterations)
// regardless of whether the Zig-side handler structs leak. Batched GC
// bounds the live set so RSS only tracks the *retained* handler structs
// — exactly the leak being measured.
const batch = 1000;
function spin(n) {
for (let i = 0; i < n; i += batch) {
for (let j = 0; j < batch && i + j < n; j++) once();
Bun.gc(true);
}
}

spin(warmup);

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

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

const afterMi = heapStats().mimalloc.malloc_normal.current;
const afterRss = process.memoryUsage.rss();
Expand Down
Loading