From 54679c4137fbe72ce80593c562c6ea32c347f020 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:54:57 +0000 Subject: [PATCH 1/8] Bun.color: reuse one mimalloc heap per VM instead of creating one per call A string input and a "css" output each built a fresh Arena, which is mi_heap_new() plus mi_heap_destroy(), for a parser and printer that rarely allocate from it. The arena now comes from a per-VM slot in RareData and goes back after the call. VirtualMachine::destroy drops it on the Worker's own thread, so no heap outlives a Worker. --- src/css_jsc/color_js.rs | 54 ++++++++++++++-- src/jsc/rare_data.rs | 21 ++++++ test/js/bun/css/color.test.ts | 116 +++++++++++++++++++++++++++++++++- 3 files changed, 183 insertions(+), 8 deletions(-) diff --git a/src/css_jsc/color_js.rs b/src/css_jsc/color_js.rs index 86ccdb49e781..ae74081ba9a5 100644 --- a/src/css_jsc/color_js.rs +++ b/src/css_jsc/color_js.rs @@ -210,6 +210,41 @@ fn zero_if_none(component: f32) -> f32 { if component.is_nan() { 0.0 } else { component } } +/// The VM's parked scratch arena, held for one call and handed back on every +/// exit path. The CSS parser and printer want an arena but rarely allocate +/// from it for a bare color, so a fresh `Arena::new()` per call was almost +/// all `mi_heap_new()` + `mi_heap_destroy()`. +struct ScratchArena<'a> { + global: &'a JSGlobalObject, + arena: Option, +} + +impl<'a> ScratchArena<'a> { + fn take(global: &'a JSGlobalObject) -> Self { + let arena = global.bun_vm().as_mut().rare_data().take_scratch_arena(); + Self { + global, + arena: Some(arena), + } + } + + fn get(&self) -> &Arena { + self.arena.as_ref().expect("held until drop") + } +} + +impl Drop for ScratchArena<'_> { + fn drop(&mut self) { + if let Some(arena) = self.arena.take() { + self.global + .bun_vm() + .as_mut() + .rare_data() + .put_back_scratch_arena(arena); + } + } +} + pub fn js_function_color(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { use bun_ast::symbol::Map as SymbolMap; use bun_core::Utf8Bytes; @@ -241,6 +276,9 @@ pub fn js_function_color(global: &JSGlobalObject, frame: &CallFrame) -> JsResult break 'brk OutputColorFormat::Css; }; let input: Utf8Bytes; + // One arena serves the parse and the print of the same call. Taken only + // by the paths that need one. + let mut scratch: Option = None; let parsed_color: css::CssColorParseResult = 'brk: { if args[0].is_number() { @@ -324,10 +362,10 @@ pub fn js_function_color(global: &JSGlobalObject, frame: &CallFrame) -> JsResult input = args[0].to_utf8(global)?; - // MimallocArena::new() calls mi_heap_new(), so defer creation to the - // paths that actually allocate. - let arena = Arena::new(); - let mut parser_input = css::ParserInput::new(input.slice(), &arena); + let arena = scratch + .get_or_insert_with(|| ScratchArena::take(global)) + .get(); + let mut parser_input = css::ParserInput::new(input.slice(), arena); let mut parser = css::Parser::new( &mut parser_input, None, @@ -599,13 +637,15 @@ pub fn js_function_color(global: &JSGlobalObject, frame: &CallFrame) -> JsResult } // Fallback to CSS string output - let arena = Arena::new(); + let arena = scratch + .get_or_insert_with(|| ScratchArena::take(global)) + .get(); let mut dest: Vec = Vec::new(); let symbols = SymbolMap::init_list(Default::default()); let mut printer = css::Printer::new( - &arena, - bun_alloc::ArenaVec::::new_in(&arena), + arena, + bun_alloc::ArenaVec::::new_in(arena), &mut dest, &css::PrinterOptions::default(), None, diff --git a/src/jsc/rare_data.rs b/src/jsc/rare_data.rs index d9bf6f6bd4d6..ccff1a1ce822 100644 --- a/src/jsc/rare_data.rs +++ b/src/jsc/rare_data.rs @@ -273,6 +273,8 @@ pub struct RareData { websocket_inflate_scratch: Option>, /// One libdeflate handle for every JS-thread one-shot inflate; see [`Self::libdeflate_decompressor`]. libdeflate_decompressor: Option, + /// Parked mimalloc heap for short synchronous parses; see [`Self::take_scratch_arena`]. + scratch_arena: Option, // There is intentionally no `aws_signature_cache` field — storage lives in // `bun_s3_signing::credentials::AWS_SIGNATURE_CACHE` (process static; it @@ -334,6 +336,7 @@ impl Default for RareData { compression_scratch: None, websocket_inflate_scratch: None, libdeflate_decompressor: None, + scratch_arena: None, s3_default_client: Strong::empty(), node_quic_callbacks: Strong::empty(), default_csrf_secret: Box::default(), @@ -678,6 +681,24 @@ impl RareData { } } + /// A private mimalloc heap costs about a microsecond to create and destroy, more than a + /// small parse. By value, like [`Self::take_h2_padded_frame_buffer`]: a caller that runs JS + /// can reach this path again before the arena comes back. Owned by the VM, not a + /// thread-local, so `VirtualMachine::destroy` frees the heap on a Worker's own thread. + pub fn take_scratch_arena(&mut self) -> bun_alloc::Arena { + self.scratch_arena.take().unwrap_or_default() + } + + /// Hand a taken arena back; the slot keeps the first one returned. Blocks the caller left + /// behind stay until they pass `KEEP`, then the heap is recycled. + pub fn put_back_scratch_arena(&mut self, mut arena: bun_alloc::Arena) { + const KEEP: usize = 64 * 1024; + if self.scratch_arena.is_none() { + arena.reset_retain_with_limit(KEEP); + self.scratch_arena = Some(arena); + } + } + /// libdeflate keeps no state between calls, so one handle serves every one-shot inflate on /// this thread. `None` when libdeflate cannot allocate it; callers fall back to zlib. pub fn libdeflate_decompressor(&mut self) -> Option<&mut libdeflate::Decompressor> { diff --git a/test/js/bun/css/color.test.ts b/test/js/bun/css/color.test.ts index 567d7e71632b..c822777354cd 100644 --- a/test/js/bun/css/color.test.ts +++ b/test/js/bun/css/color.test.ts @@ -1,6 +1,6 @@ import { color } from "bun"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isDebug, withoutAggressiveGC } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, tempDir, withoutAggressiveGC } from "harness"; const namedColors = ["red", "green", "blue", "yellow", "purple", "orange", "pink", "brown", "gray"]; @@ -572,6 +572,45 @@ describe("input forms", () => { expect(color("#gg0000", "hex")).toBeNull(); }); + // A CSS escape or a NUL byte makes the tokenizer copy the token into the + // arena, which is reused between calls. + const escaped: [string, string | null, string | null, number | null][] = [ + ["\\72 ed", "red", "#ff0000", 0xff0000], + ["\\72\\65\\64", "red", "#ff0000", 0xff0000], + ["r\\65 d", "red", "#ff0000", 0xff0000], + ["#ff\\38 800", "#f80", "#ff8800", 0xff8800], + ["re\0d", null, null, null], + ["\0", null, null, null], + ["\\", null, null, null], + ["r\\", null, null, null], + ]; + + test.each(escaped)("escaped input %j", (input, css, hex, number) => { + expect({ css: color(input, "css"), hex: color(input, "hex"), number: color(input, "number") }).toEqual({ + css, + hex, + number, + }); + }); + + test("escaped and plain inputs give the same answers when interleaved", () => { + const plain: [string, string | null][] = [ + ["red", "red"], + ["#ff8800", "#f80"], + ["hsl(120, 100%, 50%)", "#0f0"], + ["rgb(255, 0, 0)", "red"], + ["notacolor", null], + ]; + const inputs = [...plain, ...escaped.map(([input, css]): [string, string | null] => [input, css])]; + const wrong: string[] = []; + for (let i = 0; i < 2000; i++) { + const [input, expected] = inputs[(i * 7) % inputs.length]; + const actual = color(input, "css"); + if (actual !== expected) wrong.push(`${i}: color(${JSON.stringify(input)}) = ${JSON.stringify(actual)}`); + } + expect(wrong).toEqual([]); + }); + test("alpha survives the object and array forms", () => { expect(color("#f00", "{rgba}")).toEqual({ r: 255, g: 0, b: 0, a: 1 }); expect(color("#f00", "[rgba]")).toEqual([255, 0, 0, 255]); @@ -764,3 +803,78 @@ describe("conversions between color spaces", () => { ); }); }); + +// The CSS parser and printer take an arena. Bun.color reuses one mimalloc heap +// per VM for it; creating and destroying a heap costs more than the conversion. +describe.concurrent("mimalloc heaps", () => { + test("a string to css conversion does not create a heap per call", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { heapStats } = require("bun:jsc"); + const seqs = () => heapStats({ dump: true }).mimallocDump.heaps.map(h => h.seq); + // Heaps are numbered in creation order, and a live Bun.Transpiler owns one, + // so a new Transpiler shows how many heaps the process has created so far. + const keep = []; + const newestHeap = () => { + const before = new Set(seqs()); + const transpiler = new Bun.Transpiler(); + transpiler.transformSync("1"); + keep.push(transpiler); + return seqs().filter(seq => !before.has(seq)); + }; + Bun.color("red", "css"); + const a = newestHeap(); + for (let i = 0; i < 1000; i++) Bun.color("#ff8800", "css"); + const b = newestHeap(); + const observed = a.length > 0 && b.length > 0; + console.log(JSON.stringify({ observed, fewHeaps: observed && Math.max(...b) - Math.max(...a) < 50 })); + `, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ observed: true, fewHeaps: true }); + expect(exitCode).toBe(0); + }); + + test("a Worker that called Bun.color leaves no heap behind when it exits", async () => { + using dir = tempDir("color-worker-heap", { + "color-worker-heap-fixture.js": ` + const { Worker, isMainThread } = require("node:worker_threads"); + if (!isMainThread) { + for (const input of ["red", "#ff8800", "hsl(120, 50%, 50%)", "\\\\72 ed"]) Bun.color(input, "css"); + } else { + const { heapStats } = require("bun:jsc"); + const liveHeaps = () => heapStats({ dump: true }).mimallocDump.heaps.length; + const runWorker = () => + new Promise((resolve, reject) => { + const worker = new Worker(__filename); + worker.on("error", reject); + worker.on("exit", code => (code === 0 ? resolve() : reject(new Error("worker exited with " + code)))); + }); + // Whatever the process sets up lazily for its first Worker is part of the baseline. + await runWorker(); + const before = liveHeaps(); + const workers = 3; + for (let i = 0; i < workers; i++) await runWorker(); + console.log(JSON.stringify({ workers, leaked: liveHeaps() - before })); + } + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "color-worker-heap-fixture.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ workers: 3, leaked: 0 }); + expect(exitCode).toBe(0); + }); +}); From e9060ecbedf62ffbb097e98bc592812e1747f388 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:25:38 +0000 Subject: [PATCH 2/8] test: start one measured Worker in the Bun.color heap test and size its timeout for ASAN Four Worker VMs in sequence took 7.6 s on a loaded debug ASAN build and hit the 5 s default. A heap left behind shows up once per Worker, so one Worker after the warm-up proves the same thing. --- test/js/bun/css/color.test.ts | 42 ++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/test/js/bun/css/color.test.ts b/test/js/bun/css/color.test.ts index c822777354cd..d153a126d3e1 100644 --- a/test/js/bun/css/color.test.ts +++ b/test/js/bun/css/color.test.ts @@ -842,9 +842,11 @@ describe.concurrent("mimalloc heaps", () => { expect(exitCode).toBe(0); }); - test("a Worker that called Bun.color leaves no heap behind when it exits", async () => { - using dir = tempDir("color-worker-heap", { - "color-worker-heap-fixture.js": ` + test( + "a Worker that called Bun.color leaves no heap behind when it exits", + async () => { + using dir = tempDir("color-worker-heap", { + "color-worker-heap-fixture.js": ` const { Worker, isMainThread } = require("node:worker_threads"); if (!isMainThread) { for (const input of ["red", "#ff8800", "hsl(120, 50%, 50%)", "\\\\72 ed"]) Bun.color(input, "css"); @@ -860,21 +862,25 @@ describe.concurrent("mimalloc heaps", () => { // Whatever the process sets up lazily for its first Worker is part of the baseline. await runWorker(); const before = liveHeaps(); - const workers = 3; - for (let i = 0; i < workers; i++) await runWorker(); - console.log(JSON.stringify({ workers, leaked: liveHeaps() - before })); + // A heap left behind shows up once per Worker, so one more Worker is enough. + await runWorker(); + console.log(JSON.stringify({ leaked: liveHeaps() - before })); } `, - }); - await using proc = Bun.spawn({ - cmd: [bunExe(), "color-worker-heap-fixture.js"], - env: bunEnv, - cwd: String(dir), - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual({ workers: 3, leaked: 0 }); - expect(exitCode).toBe(0); - }); + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "color-worker-heap-fixture.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ leaked: 0 }); + expect(exitCode).toBe(0); + // Two Worker VMs start one after the other. On a loaded debug ASAN machine each took + // 1.2 to 1.9 s, which put the test at the 5 s default. Release builds keep the default. + }, + isDebug || isASAN ? 30_000 : undefined, + ); }); From e2ef5fb2e8e37f7f9e0a8206d5ed16b7c10a9ea9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:27:32 +0000 Subject: [PATCH 3/8] Shorten the scratch arena comments Keep the two facts a reader cannot get from the code: the arena is taken by value, and it is per VM because mimalloc keeps a heap whose thread exits. --- src/css_jsc/color_js.rs | 8 ++------ src/jsc/rare_data.rs | 10 ++++------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/css_jsc/color_js.rs b/src/css_jsc/color_js.rs index ae74081ba9a5..24085f562dff 100644 --- a/src/css_jsc/color_js.rs +++ b/src/css_jsc/color_js.rs @@ -210,10 +210,7 @@ fn zero_if_none(component: f32) -> f32 { if component.is_nan() { 0.0 } else { component } } -/// The VM's parked scratch arena, held for one call and handed back on every -/// exit path. The CSS parser and printer want an arena but rarely allocate -/// from it for a bare color, so a fresh `Arena::new()` per call was almost -/// all `mi_heap_new()` + `mi_heap_destroy()`. +/// The VM's scratch arena for one call; `Drop` hands it back on every exit path. struct ScratchArena<'a> { global: &'a JSGlobalObject, arena: Option, @@ -276,8 +273,7 @@ pub fn js_function_color(global: &JSGlobalObject, frame: &CallFrame) -> JsResult break 'brk OutputColorFormat::Css; }; let input: Utf8Bytes; - // One arena serves the parse and the print of the same call. Taken only - // by the paths that need one. + // Shared by the parse and the print of one call. let mut scratch: Option = None; let parsed_color: css::CssColorParseResult = 'brk: { diff --git a/src/jsc/rare_data.rs b/src/jsc/rare_data.rs index ccff1a1ce822..e29c606b0913 100644 --- a/src/jsc/rare_data.rs +++ b/src/jsc/rare_data.rs @@ -681,16 +681,14 @@ impl RareData { } } - /// A private mimalloc heap costs about a microsecond to create and destroy, more than a - /// small parse. By value, like [`Self::take_h2_padded_frame_buffer`]: a caller that runs JS - /// can reach this path again before the arena comes back. Owned by the VM, not a - /// thread-local, so `VirtualMachine::destroy` frees the heap on a Worker's own thread. + /// Reused mimalloc heap for a short synchronous parse. By value, like + /// [`Self::take_h2_padded_frame_buffer`]. Per VM, not per thread: mimalloc keeps a heap + /// whose thread exits, and `VirtualMachine::destroy` frees this one on the Worker's thread. pub fn take_scratch_arena(&mut self) -> bun_alloc::Arena { self.scratch_arena.take().unwrap_or_default() } - /// Hand a taken arena back; the slot keeps the first one returned. Blocks the caller left - /// behind stay until they pass `KEEP`, then the heap is recycled. + /// Hand a taken arena back; the slot keeps the first one returned and recycles it past `KEEP`. pub fn put_back_scratch_arena(&mut self, mut arena: bun_alloc::Arena) { const KEEP: usize = 64 * 1024; if self.scratch_arena.is_none() { From 5a3e833b2978105b1a41edf481b670e61f344e7e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:29:36 +0000 Subject: [PATCH 4/8] rare_data: keep only the ownership fact in the take_scratch_arena doc --- src/jsc/rare_data.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/jsc/rare_data.rs b/src/jsc/rare_data.rs index e29c606b0913..e873cccd76a5 100644 --- a/src/jsc/rare_data.rs +++ b/src/jsc/rare_data.rs @@ -681,9 +681,8 @@ impl RareData { } } - /// Reused mimalloc heap for a short synchronous parse. By value, like - /// [`Self::take_h2_padded_frame_buffer`]. Per VM, not per thread: mimalloc keeps a heap - /// whose thread exits, and `VirtualMachine::destroy` frees this one on the Worker's thread. + /// Per VM, not a thread-local: mimalloc keeps a heap whose thread exits, and + /// `VirtualMachine::destroy` frees this one on the Worker's own thread. pub fn take_scratch_arena(&mut self) -> bun_alloc::Arena { self.scratch_arena.take().unwrap_or_default() } From fa497256a94f503535ffac3ec4740aec151a90f3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:39:17 +0000 Subject: [PATCH 5/8] test: use module-scope imports in the Bun.color heap fixtures The Worker fixture is now an ES module, so it starts the Worker from import.meta.filename. --- test/js/bun/css/color.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/js/bun/css/color.test.ts b/test/js/bun/css/color.test.ts index d153a126d3e1..4109231e7702 100644 --- a/test/js/bun/css/color.test.ts +++ b/test/js/bun/css/color.test.ts @@ -813,7 +813,7 @@ describe.concurrent("mimalloc heaps", () => { bunExe(), "-e", ` - const { heapStats } = require("bun:jsc"); + import { heapStats } from "bun:jsc"; const seqs = () => heapStats({ dump: true }).mimallocDump.heaps.map(h => h.seq); // Heaps are numbered in creation order, and a live Bun.Transpiler owns one, // so a new Transpiler shows how many heaps the process has created so far. @@ -847,15 +847,15 @@ describe.concurrent("mimalloc heaps", () => { async () => { using dir = tempDir("color-worker-heap", { "color-worker-heap-fixture.js": ` - const { Worker, isMainThread } = require("node:worker_threads"); + import { heapStats } from "bun:jsc"; + import { Worker, isMainThread } from "node:worker_threads"; if (!isMainThread) { for (const input of ["red", "#ff8800", "hsl(120, 50%, 50%)", "\\\\72 ed"]) Bun.color(input, "css"); } else { - const { heapStats } = require("bun:jsc"); const liveHeaps = () => heapStats({ dump: true }).mimallocDump.heaps.length; const runWorker = () => new Promise((resolve, reject) => { - const worker = new Worker(__filename); + const worker = new Worker(import.meta.filename); worker.on("error", reject); worker.on("exit", code => (code === 0 ? resolve() : reject(new Error("worker exited with " + code)))); }); From 482e2dc32ddf28553f52de0c5abd4e2be4c2ba33 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:50:47 +0000 Subject: [PATCH 6/8] test: count the heaps Bun.color creates exactly, and start one Worker with no timeout The heap test measures what its own probe creates and expects the 1000 calls to add nothing, instead of allowing up to 50 heaps. The Worker test takes its baseline before any Worker runs. A Worker that does nothing adds no heap, so the warm-up Worker and the per-test timeout are gone. --- test/js/bun/css/color.test.ts | 50 +++++++++++++++-------------------- 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/test/js/bun/css/color.test.ts b/test/js/bun/css/color.test.ts index 4109231e7702..0667c7984421 100644 --- a/test/js/bun/css/color.test.ts +++ b/test/js/bun/css/color.test.ts @@ -823,14 +823,15 @@ describe.concurrent("mimalloc heaps", () => { const transpiler = new Bun.Transpiler(); transpiler.transformSync("1"); keep.push(transpiler); - return seqs().filter(seq => !before.has(seq)); + return Math.max(...seqs().filter(seq => !before.has(seq))); }; Bun.color("red", "css"); const a = newestHeap(); - for (let i = 0; i < 1000; i++) Bun.color("#ff8800", "css"); + // Nothing runs between these two, so the difference is what the probe itself creates. const b = newestHeap(); - const observed = a.length > 0 && b.length > 0; - console.log(JSON.stringify({ observed, fewHeaps: observed && Math.max(...b) - Math.max(...a) < 50 })); + for (let i = 0; i < 1000; i++) Bun.color("#ff8800", "css"); + const c = newestHeap(); + console.log(JSON.stringify({ observed: b > a, heapsPerThousandCalls: c - b - (b - a) })); `, ], env: bunEnv, @@ -838,15 +839,13 @@ describe.concurrent("mimalloc heaps", () => { }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual({ observed: true, fewHeaps: true }); + expect(JSON.parse(stdout)).toEqual({ observed: true, heapsPerThousandCalls: 0 }); expect(exitCode).toBe(0); }); - test( - "a Worker that called Bun.color leaves no heap behind when it exits", - async () => { - using dir = tempDir("color-worker-heap", { - "color-worker-heap-fixture.js": ` + test("a Worker that called Bun.color leaves no heap behind when it exits", async () => { + using dir = tempDir("color-worker-heap", { + "color-worker-heap-fixture.js": ` import { heapStats } from "bun:jsc"; import { Worker, isMainThread } from "node:worker_threads"; if (!isMainThread) { @@ -859,28 +858,21 @@ describe.concurrent("mimalloc heaps", () => { worker.on("error", reject); worker.on("exit", code => (code === 0 ? resolve() : reject(new Error("worker exited with " + code)))); }); - // Whatever the process sets up lazily for its first Worker is part of the baseline. - await runWorker(); const before = liveHeaps(); - // A heap left behind shows up once per Worker, so one more Worker is enough. await runWorker(); console.log(JSON.stringify({ leaked: liveHeaps() - before })); } `, - }); - await using proc = Bun.spawn({ - cmd: [bunExe(), "color-worker-heap-fixture.js"], - env: bunEnv, - cwd: String(dir), - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual({ leaked: 0 }); - expect(exitCode).toBe(0); - // Two Worker VMs start one after the other. On a loaded debug ASAN machine each took - // 1.2 to 1.9 s, which put the test at the 5 s default. Release builds keep the default. - }, - isDebug || isASAN ? 30_000 : undefined, - ); + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "color-worker-heap-fixture.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ leaked: 0 }); + expect(exitCode).toBe(0); + }); }); From ec4202e1778a637381db19ea19c2e12f89948aa0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:37:00 +0000 Subject: [PATCH 7/8] test: cover the scratch arena's recycle limit in the Bun.color heap tests The heap tests now share one probe. A new test parses escaped tokens, which stay in the parked arena after the call: 100 small tokens must create no heap, and each of 5 tokens over the 64 KiB limit must recycle the arena once. Without the reset_retain_with_limit call in put_back_scratch_arena the second count is 0. --- test/js/bun/css/color.test.ts | 81 +++++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 33 deletions(-) diff --git a/test/js/bun/css/color.test.ts b/test/js/bun/css/color.test.ts index 0667c7984421..4ed7bcf231be 100644 --- a/test/js/bun/css/color.test.ts +++ b/test/js/bun/css/color.test.ts @@ -807,40 +807,55 @@ describe("conversions between color spaces", () => { // The CSS parser and printer take an arena. Bun.color reuses one mimalloc heap // per VM for it; creating and destroying a heap costs more than the conversion. describe.concurrent("mimalloc heaps", () => { - test("a string to css conversion does not create a heap per call", async () => { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - ` - import { heapStats } from "bun:jsc"; - const seqs = () => heapStats({ dump: true }).mimallocDump.heaps.map(h => h.seq); - // Heaps are numbered in creation order, and a live Bun.Transpiler owns one, - // so a new Transpiler shows how many heaps the process has created so far. - const keep = []; - const newestHeap = () => { - const before = new Set(seqs()); - const transpiler = new Bun.Transpiler(); - transpiler.transformSync("1"); - keep.push(transpiler); - return Math.max(...seqs().filter(seq => !before.has(seq))); - }; - Bun.color("red", "css"); - const a = newestHeap(); - // Nothing runs between these two, so the difference is what the probe itself creates. - const b = newestHeap(); - for (let i = 0; i < 1000; i++) Bun.color("#ff8800", "css"); - const c = newestHeap(); - console.log(JSON.stringify({ observed: b > a, heapsPerThousandCalls: c - b - (b - a) })); - `, - ], - env: bunEnv, - stderr: "pipe", - }); + // Heaps are numbered in creation order, and a live Bun.Transpiler owns one, + // so a new Transpiler shows how many heaps the process has created so far. + const probe = ` + import { heapStats } from "bun:jsc"; + const seqs = () => heapStats({ dump: true }).mimallocDump.heaps.map(h => h.seq); + const keep = []; + const newestHeap = () => { + const before = new Set(seqs()); + const transpiler = new Bun.Transpiler(); + transpiler.transformSync("1"); + keep.push(transpiler); + return Math.max(...seqs().filter(seq => !before.has(seq))); + }; + // Nothing runs between the first two probes, so their difference is what a probe creates. + const heapsCreatedBy = fn => { + const a = newestHeap(); + const b = newestHeap(); + if (!(b > a)) throw new Error("the probe did not see its own heap"); + fn(); + return newestHeap() - b - (b - a); + }; + Bun.color("red", "css"); + newestHeap(); + `; + + async function run(program: string) { + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", probe + program], env: bunEnv, stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual({ observed: true, heapsPerThousandCalls: 0 }); - expect(exitCode).toBe(0); + return { stderr, stdout: stdout.trim(), exitCode }; + } + + test("a string to css conversion does not create a heap per call", async () => { + const program = ` + console.log(heapsCreatedBy(() => { for (let i = 0; i < 1000; i++) Bun.color("#ff8800", "css"); })); + `; + expect(await run(program)).toEqual({ stderr: "", stdout: "0", exitCode: 0 }); + }); + + // An escaped token is copied into the arena and stays there after the call. + // put_back_scratch_arena keeps an arena that holds at most 64 KiB and recycles a fuller one. + test("the arena is kept after small tokens and recycled after a token over the limit", async () => { + const program = ` + const small = "\\\\72 ed"; + const large = "\\\\72 " + Buffer.alloc(256 * 1024, "a").toString(); + const afterSmall = heapsCreatedBy(() => { for (let i = 0; i < 100; i++) Bun.color(small, "css"); }); + const afterLarge = heapsCreatedBy(() => { for (let i = 0; i < 5; i++) Bun.color(large, "css"); }); + console.log(JSON.stringify({ afterSmall, afterLarge })); + `; + expect(await run(program)).toEqual({ stderr: "", stdout: '{"afterSmall":0,"afterLarge":5}', exitCode: 0 }); }); test("a Worker that called Bun.color leaves no heap behind when it exits", async () => { From d36b26e4f0ea7b9c22c42164531b0af74903a275 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:29:23 +0000 Subject: [PATCH 8/8] test: count heaps with heapStats().mimalloc.heaps.total in the Bun.color heap tests The counter reports every mimalloc heap the process has created, so the tests no longer infer that number from the heap a Bun.Transpiler owns. --- test/js/bun/css/color.test.ts | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/test/js/bun/css/color.test.ts b/test/js/bun/css/color.test.ts index 4ed7bcf231be..7e5a3530bab0 100644 --- a/test/js/bun/css/color.test.ts +++ b/test/js/bun/css/color.test.ts @@ -807,33 +807,20 @@ describe("conversions between color spaces", () => { // The CSS parser and printer take an arena. Bun.color reuses one mimalloc heap // per VM for it; creating and destroying a heap costs more than the conversion. describe.concurrent("mimalloc heaps", () => { - // Heaps are numbered in creation order, and a live Bun.Transpiler owns one, - // so a new Transpiler shows how many heaps the process has created so far. - const probe = ` + const prelude = ` import { heapStats } from "bun:jsc"; - const seqs = () => heapStats({ dump: true }).mimallocDump.heaps.map(h => h.seq); - const keep = []; - const newestHeap = () => { - const before = new Set(seqs()); - const transpiler = new Bun.Transpiler(); - transpiler.transformSync("1"); - keep.push(transpiler); - return Math.max(...seqs().filter(seq => !before.has(seq))); - }; - // Nothing runs between the first two probes, so their difference is what a probe creates. + // heaps.total counts every mimalloc heap the process has created. const heapsCreatedBy = fn => { - const a = newestHeap(); - const b = newestHeap(); - if (!(b > a)) throw new Error("the probe did not see its own heap"); + const before = heapStats().mimalloc.heaps.total; fn(); - return newestHeap() - b - (b - a); + return heapStats().mimalloc.heaps.total - before; }; + // The first call creates the arena that the later calls reuse. Bun.color("red", "css"); - newestHeap(); `; async function run(program: string) { - await using proc = Bun.spawn({ cmd: [bunExe(), "-e", probe + program], env: bunEnv, stderr: "pipe" }); + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", prelude + program], env: bunEnv, stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); return { stderr, stdout: stdout.trim(), exitCode }; }