diff --git a/test/js/bun/http/serve-stream-reject-flush-leak-fixture.ts b/test/js/bun/http/serve-stream-reject-flush-leak-fixture.ts index 46f607e7cc91..a898af14d2ef 100644 --- a/test/js/bun/http/serve-stream-reject-flush-leak-fixture.ts +++ b/test/js/bun/http/serve-stream-reject-flush-leak-fixture.ts @@ -116,9 +116,7 @@ console.log( // The test must actually exercise the backpressure → pending_flush path. if (flushPending < ITERATIONS / 2) { - console.error( - `insufficient backpressure: only ${flushPending}/${ITERATIONS} end() calls returned a pending promise`, - ); + console.error(`insufficient backpressure: only ${flushPending}/${ITERATIONS} end() calls returned a pending promise`); process.exit(2); } // Before the fix: delta ≈ ITERATIONS (every pending_flush stays protected). diff --git a/test/js/bun/http/serve-stream-reject-flush-leak.test.ts b/test/js/bun/http/serve-stream-reject-flush-leak.test.ts index f3066c605f5a..3d1a615b44d1 100644 --- a/test/js/bun/http/serve-stream-reject-flush-leak.test.ts +++ b/test/js/bun/http/serve-stream-reject-flush-leak.test.ts @@ -1,24 +1,37 @@ 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 needs tryEnd() to park under socket backpressure so +// pending_flush is set when the stream rejects. On Windows that can't happen: +// Winsock's non-blocking send() never returns a partial write — it either +// copies the entire buffer into the AFD queue (which grows to fit) or returns +// WSAEWOULDBLOCK only when the queue is already full from a *previous* send. +// tryEnd() is the first write on a fresh socket, so it always reports full +// success regardless of payload size, and pending_flush is never created. +// The leak being guarded (handleRejectStream not unprotecting pending_flush) +// is therefore POSIX-only by construction. +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, +); diff --git a/test/js/bun/test/parallel/test-integration-rspack.ts b/test/js/bun/test/parallel/test-integration-rspack.ts index 7ee282c65d75..ea32820ae55c 100644 --- a/test/js/bun/test/parallel/test-integration-rspack.ts +++ b/test/js/bun/test/parallel/test-integration-rspack.ts @@ -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, diff --git a/test/js/bun/websocket/websocket-server.test.ts b/test/js/bun/websocket/websocket-server.test.ts index 338500f77478..2eb5e822cf85 100644 --- a/test/js/bun/websocket/websocket-server.test.ts +++ b/test/js/bun/websocket/websocket-server.test.ts @@ -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: "", }); diff --git a/test/js/workerd/html-rewriter-leak.test.ts b/test/js/workerd/html-rewriter-leak.test.ts index 4ed76b83c009..18c362862b03 100644 --- a/test/js/workerd/html-rewriter-leak.test.ts +++ b/test/js/workerd/html-rewriter-leak.test.ts @@ -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() {} }; @@ -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, +);