From db9cca960edf559b0977a8c756d82329c74cb662 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:22:16 +0000 Subject: [PATCH 1/5] Bump WebKit (oven-sh/WebKit#567 preview): idle JSC worker threads release their mimalloc heap Each wasm compiler thread kept about 10 MB of freed compile temporaries after a large compile until it timed out and exited 10 s later. Only the owning thread can collect its mimalloc thread-local heap, and an idle AutomaticThread never did. RSS grew with the core count, not with the number of live modules. oven-sh/WebKit#567 makes an AutomaticThread release its heap after 100 ms without work. This pins that PR's preview build and adds a test that compiles a tree-sitter shaped module 8 times on 8 compiler threads and checks that RSS comes back while the threads are still alive. --- scripts/build/deps/webkit.ts | 2 +- test/js/bun/wasm/compile-rss-fixture.mjs | 127 +++++++++++++++++++++++ test/js/bun/wasm/compile-rss.test.ts | 32 ++++++ 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 test/js/bun/wasm/compile-rss-fixture.mjs create mode 100644 test/js/bun/wasm/compile-rss.test.ts diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 7e8ddecce2ac..076f2d6a612d 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "2e2aa2290fac856d6f451ceacb58f7f5b44dd057"; +export const WEBKIT_VERSION = "autobuild-preview-pr-567-e62d118e"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/test/js/bun/wasm/compile-rss-fixture.mjs b/test/js/bun/wasm/compile-rss-fixture.mjs new file mode 100644 index 000000000000..9943cd96adbe --- /dev/null +++ b/test/js/bun/wasm/compile-rss-fixture.mjs @@ -0,0 +1,127 @@ +// Fixture for compile-rss.test.ts. Compiles a generated wasm module 3 times, discards each result, +// then polls RSS until it falls below the target (argv[2], MiB) or the deadline passes. The idle +// compiler threads exit after 10 s and release everything then, so the deadline stays well below +// that. Prints one JSON line with the lowest RSS growth seen, relative to the RSS before the first +// compile. + +class Bytes { + constructor() { + this.buf = new Uint8Array(1 << 20); + this.len = 0; + } + push(...bytes) { + for (const b of bytes) this.byte(b); + } + byte(b) { + if (this.len === this.buf.length) { + const next = new Uint8Array(this.buf.length * 2); + next.set(this.buf); + this.buf = next; + } + this.buf[this.len++] = b; + } + leb(n) { + do { + let b = n & 0x7f; + n >>>= 7; + if (n !== 0) b |= 0x80; + this.byte(b); + } while (n !== 0); + } + append(other) { + for (let i = 0; i < other.len; i++) this.byte(other.buf[i]); + } + section(id, payload) { + this.byte(id); + this.leb(payload.len); + this.append(payload); + } + bytes() { + return this.buf.subarray(0, this.len); + } +} + +// A module shaped like a tree-sitter parser: a few dozen functions of 20 to 30 KB of control-flow +// heavy code, plus one giant function. Compiling it makes each wasm compiler thread allocate and +// free tens of MB of temporaries. Uniform modules of many small functions do not show the +// retention: their pages end up fully free and mimalloc's scavenger purges them on its own. +function makeModule({ functionCount = 35, opsPerFunction = 1400, giantOps = 30000 } = {}) { + const out = new Bytes(); + out.push(0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00); + + // type 0: (i32, i32) -> i32 + const types = new Bytes(); + types.push(1, 0x60, 2, 0x7f, 0x7f, 1, 0x7f); + out.section(1, types); + + const funcs = new Bytes(); + funcs.leb(functionCount); + for (let f = 0; f < functionCount; f++) funcs.byte(0); + out.section(3, funcs); + + const exports = new Bytes(); + exports.push(1, 1, 0x66, 0x00, 0); // export "f" = func 0 + out.section(7, exports); + + const code = new Bytes(); + code.leb(functionCount); + for (let f = 0; f < functionCount; f++) { + const ops = f === 0 ? giantOps : opsPerFunction + ((f * 7919) % 400); + const body = new Bytes(); + body.push(1, 1, 0x7f); // one local group: 1 x i32 + body.push(0x20, 0); // local.get 0 + for (let i = 0; i < ops; i++) { + // block + // local.get 1; i32.const k; i32.add; local.tee 2 + // br_table {0,0,0,0} 0 + // local.get 2; local.get 0; call g; drop + // end + // local.get 2; i32.xor + body.push(0x02, 0x40, 0x20, 1, 0x41); + body.leb((f * 31 + i * 7) & 0x3f); + body.push(0x6a, 0x22, 2, 0x0e, 4, 0, 0, 0, 0, 0, 0x20, 2, 0x20, 0, 0x10); + body.leb((f + 1) % functionCount); + body.push(0x1a, 0x0b, 0x20, 2, 0x73); + } + body.byte(0x0b); // end + code.leb(body.len); + code.append(body); + } + out.section(10, code); + return out.bytes(); +} + +const targetMiB = Number(process.argv[2]); +const sleep = ms => new Promise(r => setTimeout(r, ms)); +const bytes = makeModule(); +Bun.gc(true); +await sleep(50); +Bun.gc(true); + +const base = process.memoryUsage().rss; +for (let i = 0; i < 3; i++) { + let mod = await WebAssembly.compile(bytes); + mod = null; +} +// Free the modules. The main thread frees their metadata into the compiler threads' pages, and only +// those threads can hand that memory back. They do so once they have worked and gone idle again, so +// give each of them one trivial function to compile after the modules are gone. +Bun.gc(true); +await WebAssembly.compile(makeModule({ functionCount: 16, opsPerFunction: 1, giantOps: 1 })); +Bun.gc(true); +const afterCompiles = process.memoryUsage().rss - base; + +const deadline = performance.now() + 3000; +let min = Infinity; +while (performance.now() < deadline && min / 1048576 >= targetMiB) { + await sleep(100); + Bun.gc(true); + min = Math.min(min, process.memoryUsage().rss - base); +} +console.log( + JSON.stringify({ + moduleMiB: bytes.length / 1048576, + afterCompilesDeltaMiB: afterCompiles / 1048576, + idleDeltaMiB: min / 1048576, + }), +); diff --git a/test/js/bun/wasm/compile-rss.test.ts b/test/js/bun/wasm/compile-rss.test.ts new file mode 100644 index 000000000000..8cb00023af51 --- /dev/null +++ b/test/js/bun/wasm/compile-rss.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, isASAN, isDebug } from "harness"; +import path from "node:path"; + +// Unfixed, the 8 idle wasm compiler threads keep 17 to 25 MB of freed compile temporaries until +// they exit after 10 s. Fixed, they release it about 100 ms after the last compile and RSS returns +// to where it started. +const idleTargetMiB = 10; + +// Debug and ASAN builds link a JavaScriptCore that does not allocate through mimalloc, so the +// per-thread retention this test checks for does not exist there. +test.skipIf(isDebug || isASAN)( + "WebAssembly.compile does not retain memory in idle compiler threads (#41438)", + async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), path.join(import.meta.dir, "compile-rss-fixture.mjs"), String(idleTargetMiB)], + env: { + ...bunEnv, + // The retained amount scales with the compiler thread count. Pin it so the test does not + // depend on the core count of the machine. + BUN_JSC_numberOfWasmCompilerThreads: "8", + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const result = JSON.parse(stdout.trim().split("\n").at(-1)!); + expect(result.idleDeltaMiB).toBeLessThan(idleTargetMiB); + expect(exitCode).toBe(0); + }, +); From 658d8a70d19f903289747a9f1fa259cc94cd2d37 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:24:12 +0000 Subject: [PATCH 2/5] test: reject a missing or non-numeric target in compile-rss-fixture.mjs --- test/js/bun/wasm/compile-rss-fixture.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/js/bun/wasm/compile-rss-fixture.mjs b/test/js/bun/wasm/compile-rss-fixture.mjs index 9943cd96adbe..2b622862e99a 100644 --- a/test/js/bun/wasm/compile-rss-fixture.mjs +++ b/test/js/bun/wasm/compile-rss-fixture.mjs @@ -92,6 +92,7 @@ function makeModule({ functionCount = 35, opsPerFunction = 1400, giantOps = 3000 } const targetMiB = Number(process.argv[2]); +if (!Number.isFinite(targetMiB)) throw new Error(`expected the target in MiB as argv[2], got ${process.argv[2]}`); const sleep = ms => new Promise(r => setTimeout(r, ms)); const bytes = makeModule(); Bun.gc(true); From 7e65d357b0eb55e5455aa97b73d1a09b77c22d54 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:39:19 +0000 Subject: [PATCH 3/5] test: measure phys_footprint on Darwin, settle the baseline, assert the compiles grew memory On macOS mimalloc returns memory with MADV_FREE_REUSABLE and RSS keeps counting it, so the test read +41 MB on a fixed build there. Use Bun.unsafe.memoryFootprint on Darwin like the other RSS tests do. The baseline now waits until memory stops falling instead of a fixed 50 ms sleep, and the test asserts that the compiles grew memory by at least twice the target before it checks that the growth came back. --- test/js/bun/wasm/compile-rss-fixture.mjs | 27 ++++++++++++++++++------ test/js/bun/wasm/compile-rss.test.ts | 2 ++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/test/js/bun/wasm/compile-rss-fixture.mjs b/test/js/bun/wasm/compile-rss-fixture.mjs index 2b622862e99a..48562cf38fb0 100644 --- a/test/js/bun/wasm/compile-rss-fixture.mjs +++ b/test/js/bun/wasm/compile-rss-fixture.mjs @@ -93,35 +93,50 @@ function makeModule({ functionCount = 35, opsPerFunction = 1400, giantOps = 3000 const targetMiB = Number(process.argv[2]); if (!Number.isFinite(targetMiB)) throw new Error(`expected the target in MiB as argv[2], got ${process.argv[2]}`); +// On Darwin mimalloc returns memory with MADV_FREE_REUSABLE, which the kernel keeps counted in RSS +// until it reuses the pages. phys_footprint drops at once, so measure that there. +const rss = + process.platform === "darwin" && typeof Bun.unsafe.memoryFootprint === "function" + ? Bun.unsafe.memoryFootprint + : process.memoryUsage.rss; const sleep = ms => new Promise(r => setTimeout(r, ms)); const bytes = makeModule(); -Bun.gc(true); -await sleep(50); -Bun.gc(true); -const base = process.memoryUsage().rss; +// Building the module leaves garbage behind. Wait until RSS stops falling before taking the +// baseline: mimalloc hands freed memory back on its own schedule, and there is no signal for it. +let base = Infinity; +for (const deadline = performance.now() + 2000; performance.now() < deadline; ) { + Bun.gc(true); + await sleep(50); + const now = rss(); + if (now >= base - 1048576) break; + base = now; +} for (let i = 0; i < 3; i++) { let mod = await WebAssembly.compile(bytes); mod = null; } +// Read before anything is freed: the test checks that the compiles grew RSS at all. +const peak = rss() - base; // Free the modules. The main thread frees their metadata into the compiler threads' pages, and only // those threads can hand that memory back. They do so once they have worked and gone idle again, so // give each of them one trivial function to compile after the modules are gone. Bun.gc(true); await WebAssembly.compile(makeModule({ functionCount: 16, opsPerFunction: 1, giantOps: 1 })); Bun.gc(true); -const afterCompiles = process.memoryUsage().rss - base; +const afterCompiles = rss() - base; const deadline = performance.now() + 3000; let min = Infinity; while (performance.now() < deadline && min / 1048576 >= targetMiB) { await sleep(100); Bun.gc(true); - min = Math.min(min, process.memoryUsage().rss - base); + min = Math.min(min, rss() - base); } console.log( JSON.stringify({ moduleMiB: bytes.length / 1048576, + peakDeltaMiB: peak / 1048576, afterCompilesDeltaMiB: afterCompiles / 1048576, idleDeltaMiB: min / 1048576, }), diff --git a/test/js/bun/wasm/compile-rss.test.ts b/test/js/bun/wasm/compile-rss.test.ts index 8cb00023af51..71bf77e16d0f 100644 --- a/test/js/bun/wasm/compile-rss.test.ts +++ b/test/js/bun/wasm/compile-rss.test.ts @@ -26,6 +26,8 @@ test.skipIf(isDebug || isASAN)( const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); const result = JSON.parse(stdout.trim().split("\n").at(-1)!); + // The compiles have to grow RSS well past the target first, or the check below means nothing. + expect(result.peakDeltaMiB).toBeGreaterThan(idleTargetMiB * 2); expect(result.idleDeltaMiB).toBeLessThan(idleTargetMiB); expect(exitCode).toBe(0); }, From 94d09967ca36bbd850b18a0aa27a44cb3c8d5f23 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:50:05 +0000 Subject: [PATCH 4/5] ci: retrigger From aafb03bed62700eb65db48aea8b33d67c9154364 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:39:44 +0000 Subject: [PATCH 5/5] test: compile 6 times for a wider margin, print the whole measurement on failure Folds in the parts of #41452 that this test lacked: the memoryFootprint() undefined fallback and the failure output that carries every number. Six compiles put the unfixed build at 35 to 39 MiB against the 10 MiB bound (was 18 to 27) for about 60 ms more. --- test/js/bun/wasm/compile-rss-fixture.mjs | 15 ++++++++------- test/js/bun/wasm/compile-rss.test.ts | 12 ++++++++---- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/test/js/bun/wasm/compile-rss-fixture.mjs b/test/js/bun/wasm/compile-rss-fixture.mjs index 48562cf38fb0..d2e3905a0cb2 100644 --- a/test/js/bun/wasm/compile-rss-fixture.mjs +++ b/test/js/bun/wasm/compile-rss-fixture.mjs @@ -1,4 +1,4 @@ -// Fixture for compile-rss.test.ts. Compiles a generated wasm module 3 times, discards each result, +// Fixture for compile-rss.test.ts. Compiles a generated wasm module 6 times, discards each result, // then polls RSS until it falls below the target (argv[2], MiB) or the deadline passes. The idle // compiler threads exit after 10 s and release everything then, so the deadline stays well below // that. Prints one JSON line with the lowest RSS growth seen, relative to the RSS before the first @@ -94,11 +94,10 @@ function makeModule({ functionCount = 35, opsPerFunction = 1400, giantOps = 3000 const targetMiB = Number(process.argv[2]); if (!Number.isFinite(targetMiB)) throw new Error(`expected the target in MiB as argv[2], got ${process.argv[2]}`); // On Darwin mimalloc returns memory with MADV_FREE_REUSABLE, which the kernel keeps counted in RSS -// until it reuses the pages. phys_footprint drops at once, so measure that there. -const rss = - process.platform === "darwin" && typeof Bun.unsafe.memoryFootprint === "function" - ? Bun.unsafe.memoryFootprint - : process.memoryUsage.rss; +// until it reuses the pages. phys_footprint drops at once, so measure that there. memoryFootprint() +// returns undefined when task_info fails, so fall back to RSS. +const rss = () => + (process.platform === "darwin" ? Bun.unsafe.memoryFootprint?.() : undefined) ?? process.memoryUsage.rss(); const sleep = ms => new Promise(r => setTimeout(r, ms)); const bytes = makeModule(); @@ -112,7 +111,9 @@ for (const deadline = performance.now() + 2000; performance.now() < deadline; ) if (now >= base - 1048576) break; base = now; } -for (let i = 0; i < 3; i++) { +// Each compile adds to what the unfixed threads keep (8 threads: 18 to 27 MiB after 3 compiles, +// 35 to 39 MiB after 6) and costs about 20 ms. The fixed build returns to the baseline either way. +for (let i = 0; i < 6; i++) { let mod = await WebAssembly.compile(bytes); mod = null; } diff --git a/test/js/bun/wasm/compile-rss.test.ts b/test/js/bun/wasm/compile-rss.test.ts index 71bf77e16d0f..8987962ce8d3 100644 --- a/test/js/bun/wasm/compile-rss.test.ts +++ b/test/js/bun/wasm/compile-rss.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import { bunEnv, bunExe, isASAN, isDebug } from "harness"; import path from "node:path"; -// Unfixed, the 8 idle wasm compiler threads keep 17 to 25 MB of freed compile temporaries until +// Unfixed, the 8 idle wasm compiler threads keep 35 to 39 MiB of freed compile temporaries until // they exit after 10 s. Fixed, they release it about 100 ms after the last compile and RSS returns // to where it started. const idleTargetMiB = 10; @@ -26,9 +26,13 @@ test.skipIf(isDebug || isASAN)( const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); const result = JSON.parse(stdout.trim().split("\n").at(-1)!); - // The compiles have to grow RSS well past the target first, or the check below means nothing. - expect(result.peakDeltaMiB).toBeGreaterThan(idleTargetMiB * 2); - expect(result.idleDeltaMiB).toBeLessThan(idleTargetMiB); + // The whole measurement is in the object so a failure prints it. The compiles have to grow RSS + // well past the target first, or the idle check means nothing. + expect({ + ...result, + grew: result.peakDeltaMiB > idleTargetMiB * 2, + released: result.idleDeltaMiB < idleTargetMiB, + }).toMatchObject({ grew: true, released: true }); expect(exitCode).toBe(0); }, );