From 87dfe487bde62dc5ecd6d0ee5997c6782903e080 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:34:09 +0000 Subject: [PATCH 1/3] Fit the startup address-space reservations to RLIMIT_AS Under `ulimit -v` / systemd LimitAS= below about 2.3 GB, JSC's fixed 1 GiB JIT pool did not fit next to mimalloc's 1 GiB first arena, so bun ran with no JIT and said nothing. At some limits the Structure heap did not fit either and bun aborted at startup with a crash report. - WebKit oven-sh/WebKit#586 (preview build): the JIT pool and the Structure heap are capped at RLIMIT_AS / 16, the JIT pool is halved when the kernel refuses it, and the Structure heap survives a reservation that mimalloc rejects. - mimalloc oven-sh/mimalloc#35: each arena reservation is capped at RLIMIT_AS / 4. - test/js/bun/jsc/heapStats-mimalloc.test.ts runs bun under ulimit -v. - docs: LimitAS= and MemoryDenyWriteExecute= in the systemd guide. --- docs/guides/ecosystem/systemd.mdx | 7 ++ scripts/build/deps/mimalloc.ts | 2 +- scripts/build/deps/webkit.ts | 2 +- test/js/bun/jsc/heapStats-mimalloc.test.ts | 75 +++++++++++++++++++++- 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/docs/guides/ecosystem/systemd.mdx b/docs/guides/ecosystem/systemd.mdx index 48e245f0d230..df56422a35cc 100644 --- a/docs/guides/ecosystem/systemd.mdx +++ b/docs/guides/ecosystem/systemd.mdx @@ -61,6 +61,13 @@ The command attaches the capability to the `bun` binary itself. Replacing the bi --- +Two of systemd's resource-control and sandboxing options change how Bun runs: + +- `LimitAS=` caps the address space of the service, like `ulimit -v`. Bun reserves more address space than the memory it uses, and it sizes those reservations to fit the limit. Set `LimitAS=` to 512M or more. To cap memory, use `MemoryMax=`. +- `MemoryDenyWriteExecute=yes` forbids memory that is writable and executable at the same time, and forbids making memory executable later. A JIT compiler needs one of the two, so Bun runs JavaScript and WebAssembly in the interpreter. Compute-heavy code runs several times slower. + +--- + With the service file configured, _enable_ the service. Once enabled, it starts automatically on reboot. Enabling the service requires `sudo` permissions. ```bash terminal icon="terminal" diff --git a/scripts/build/deps/mimalloc.ts b/scripts/build/deps/mimalloc.ts index 2984efa98b8a..324bbf130c2f 100644 --- a/scripts/build/deps/mimalloc.ts +++ b/scripts/build/deps/mimalloc.ts @@ -12,7 +12,7 @@ import type { Dependency, DirectBuild } from "../source.ts"; -const MIMALLOC_COMMIT = "eab09015a5850ae18fc43ccfaa5bbe8272992314"; +const MIMALLOC_COMMIT = "3dd6c1ba4e2aff964076caa4a3d952d30ca88d3e"; export const mimalloc: Dependency = { name: "mimalloc", diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index c6e0193955dc..1af6d623c82a 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 = "fb1167ebf2cb9edc1f6771a2c11771b024693ae0"; +export const WEBKIT_VERSION = "autobuild-preview-pr-586-d4aa2237"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/test/js/bun/jsc/heapStats-mimalloc.test.ts b/test/js/bun/jsc/heapStats-mimalloc.test.ts index 1d859fdc4d2e..feeea5b32747 100644 --- a/test/js/bun/jsc/heapStats-mimalloc.test.ts +++ b/test/js/bun/jsc/heapStats-mimalloc.test.ts @@ -1,6 +1,6 @@ import { heapStats } from "bun:jsc"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isLinux, isMacOS, tempDir } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, isLinux, isMacOS, tempDir } from "harness"; describe("heapStats() mimalloc integration", () => { test("mimalloc aggregate stats are present", () => { @@ -294,3 +294,76 @@ describe("heapStats() mimalloc integration", () => { }, ); }); + +// RLIMIT_AS (`ulimit -v`, systemd `LimitAS=`) caps the address space a process may reserve, committed or not. At +// startup bun reserves a mimalloc arena, the JSC structure heap and the JIT pool. Each had a fixed size (1 GiB, up to +// 4 GiB, and 1 GiB on x64): under a limit of a couple of GiB the JIT pool did not fit and bun ran interpreter-only +// with no message, and at some limits the structure heap did not fit either and bun aborted at startup. Not ASAN or +// debug: the shadow memory and the unoptimized code do not fit these limits. +describe.skipIf(!isLinux || isASAN || isDebug)("under an address-space limit (ulimit -v)", () => { + const jitScript = /* js */ ` + function hot(n) { let s = 0; for (let i = 0; i < n; i++) s = (s + i * 7) % 1000003; return s; } + for (let i = 0; i < 30; i++) hot(10000); + const { numberOfDFGCompiles } = require("bun:jsc"); + // numberOfDFGCompiles() reports 1000000 for any function while the JIT tiers are unavailable. + process.stdout.write(JSON.stringify({ jit: numberOfDFGCompiles(hot) !== 1000000 })); + `; + // Allocates untouched 16 MB buffers until the address space runs out and reports how far it got. + const heapScript = /* js */ ` + const buffers = []; + let mb = 0; + try { + for (;;) { buffers.push(new Uint8Array(16 << 20)); mb += 16; } + } catch (e) { + process.stdout.write(JSON.stringify({ error: e.constructor.name, mb })); + } + `; + // 200_000 objects with distinct shapes: each takes a Structure from the structure heap. + const structureScript = /* js */ ` + const objects = []; + for (let i = 0; i < 200_000; i++) objects.push({ ["k" + i]: i }); + process.stdout.write(JSON.stringify({ structures: objects.length })); + `; + + async function run(limitMB: number | undefined, script: string) { + const cmd = + limitMB === undefined + ? [bunExe(), "-e", script] + : ["sh", "-c", `ulimit -v ${limitMB * 1024} && exec "$0" -e "$1"`, bunExe(), script]; + await using proc = Bun.spawn({ cmd, env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode, signalCode: proc.signalCode }; + } + + test("without a limit the JIT is available", async () => { + const { stdout, exitCode, signalCode } = await run(undefined, jitScript); + expect({ stdout, exitCode, signalCode }).toEqual({ stdout: `{"jit":true}`, exitCode: 0, signalCode: null }); + }); + + test.concurrent.each([768, 1024, 1200, 1536, 2048, 2250])( + "ulimit -v %dM: bun starts and keeps the JIT", + async limitMB => { + const { stdout, stderr, exitCode, signalCode } = await run(limitMB, jitScript); + // stderr first: on failure it carries the crash banner or the allocation error. + expect({ stderr, stdout }).toEqual({ stderr: expect.any(String), stdout: `{"jit":true}` }); + expect({ exitCode, signalCode }).toEqual({ exitCode: 0, signalCode: null }); + }, + ); + + // The startup reservations have to leave room for the program: under 1.5 GB it gets a catchable out-of-memory + // error, and not before it has allocated a quarter of the limit (about 600 MB is what fits). + test.concurrent("ulimit -v 1536M: the reservations leave room for the heap", async () => { + const { stdout, stderr, exitCode, signalCode } = await run(1536, heapScript); + expect(stderr).toBe(""); + const { error, mb } = JSON.parse(stdout); + expect(error).toBe("RangeError"); + expect(mb).toBeGreaterThanOrEqual(384); + expect({ exitCode, signalCode }).toEqual({ exitCode: 0, signalCode: null }); + }); + + test.concurrent("ulimit -v 512M: the structure heap still holds 200k shapes", async () => { + const { stdout, stderr, exitCode, signalCode } = await run(512, structureScript); + expect({ stderr, stdout }).toEqual({ stderr: expect.any(String), stdout: `{"structures":200000}` }); + expect({ exitCode, signalCode }).toEqual({ exitCode: 0, signalCode: null }); + }); +}); From 89874ad693492fc86d8f2e507ec084ba281a3a74 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:47:35 +0000 Subject: [PATCH 2/3] test: the address-space limit tests assert an empty stderr --- test/js/bun/jsc/heapStats-mimalloc.test.ts | 28 +++++++++++++++------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/test/js/bun/jsc/heapStats-mimalloc.test.ts b/test/js/bun/jsc/heapStats-mimalloc.test.ts index feeea5b32747..bc62a87c630d 100644 --- a/test/js/bun/jsc/heapStats-mimalloc.test.ts +++ b/test/js/bun/jsc/heapStats-mimalloc.test.ts @@ -336,17 +336,24 @@ describe.skipIf(!isLinux || isASAN || isDebug)("under an address-space limit (ul } test("without a limit the JIT is available", async () => { - const { stdout, exitCode, signalCode } = await run(undefined, jitScript); - expect({ stdout, exitCode, signalCode }).toEqual({ stdout: `{"jit":true}`, exitCode: 0, signalCode: null }); + expect(await run(undefined, jitScript)).toEqual({ + stdout: `{"jit":true}`, + stderr: "", + exitCode: 0, + signalCode: null, + }); }); test.concurrent.each([768, 1024, 1200, 1536, 2048, 2250])( "ulimit -v %dM: bun starts and keeps the JIT", async limitMB => { - const { stdout, stderr, exitCode, signalCode } = await run(limitMB, jitScript); - // stderr first: on failure it carries the crash banner or the allocation error. - expect({ stderr, stdout }).toEqual({ stderr: expect.any(String), stdout: `{"jit":true}` }); - expect({ exitCode, signalCode }).toEqual({ exitCode: 0, signalCode: null }); + // One object: a failure shows the crash banner or the allocation error on stderr beside the exit code. + expect(await run(limitMB, jitScript)).toEqual({ + stdout: `{"jit":true}`, + stderr: "", + exitCode: 0, + signalCode: null, + }); }, ); @@ -362,8 +369,11 @@ describe.skipIf(!isLinux || isASAN || isDebug)("under an address-space limit (ul }); test.concurrent("ulimit -v 512M: the structure heap still holds 200k shapes", async () => { - const { stdout, stderr, exitCode, signalCode } = await run(512, structureScript); - expect({ stderr, stdout }).toEqual({ stderr: expect.any(String), stdout: `{"structures":200000}` }); - expect({ exitCode, signalCode }).toEqual({ exitCode: 0, signalCode: null }); + expect(await run(512, structureScript)).toEqual({ + stdout: `{"structures":200000}`, + stderr: "", + exitCode: 0, + signalCode: null, + }); }); }); From e403e4699fba650776f80f530a4cde49d37e2c1f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:34:40 +0000 Subject: [PATCH 3/3] Repin the WebKit preview (no JIT pool below a 512 MB limit), test a service from a script file under a limit The WebKit change no longer reserves a JIT pool when RLIMIT_AS is below 512 MB: with the pool and the compiler threads, a busy process ran out of address space there. The tests now also run a service-like script from a file under 512 MB and 768 MB, where bun starts the concurrent JIT and the work pool, and check that 384 MB runs without the JIT. --- docs/guides/ecosystem/systemd.mdx | 2 +- scripts/build/deps/webkit.ts | 2 +- test/js/bun/jsc/heapStats-mimalloc.test.ts | 69 ++++++++++++++-------- 3 files changed, 46 insertions(+), 27 deletions(-) diff --git a/docs/guides/ecosystem/systemd.mdx b/docs/guides/ecosystem/systemd.mdx index df56422a35cc..376c3a446fc1 100644 --- a/docs/guides/ecosystem/systemd.mdx +++ b/docs/guides/ecosystem/systemd.mdx @@ -63,7 +63,7 @@ The command attaches the capability to the `bun` binary itself. Replacing the bi Two of systemd's resource-control and sandboxing options change how Bun runs: -- `LimitAS=` caps the address space of the service, like `ulimit -v`. Bun reserves more address space than the memory it uses, and it sizes those reservations to fit the limit. Set `LimitAS=` to 512M or more. To cap memory, use `MemoryMax=`. +- `LimitAS=` caps the address space of the service, like `ulimit -v`. Bun reserves more address space than the memory it uses, and it sizes those reservations to fit the limit. Set `LimitAS=` to 512M or more. Below 512M, Bun runs JavaScript in the interpreter. To cap memory, use `MemoryMax=`. - `MemoryDenyWriteExecute=yes` forbids memory that is writable and executable at the same time, and forbids making memory executable later. A JIT compiler needs one of the two, so Bun runs JavaScript and WebAssembly in the interpreter. Compute-heavy code runs several times slower. --- diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 1af6d623c82a..5dfd3fc1a9b1 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 = "autobuild-preview-pr-586-d4aa2237"; +export const WEBKIT_VERSION = "autobuild-preview-pr-586-06f4ac64"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/test/js/bun/jsc/heapStats-mimalloc.test.ts b/test/js/bun/jsc/heapStats-mimalloc.test.ts index bc62a87c630d..ed95b153d693 100644 --- a/test/js/bun/jsc/heapStats-mimalloc.test.ts +++ b/test/js/bun/jsc/heapStats-mimalloc.test.ts @@ -1,6 +1,7 @@ import { heapStats } from "bun:jsc"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isASAN, isDebug, isLinux, isMacOS, tempDir } from "harness"; +import { join } from "node:path"; describe("heapStats() mimalloc integration", () => { test("mimalloc aggregate stats are present", () => { @@ -302,9 +303,9 @@ describe("heapStats() mimalloc integration", () => { // debug: the shadow memory and the unoptimized code do not fit these limits. describe.skipIf(!isLinux || isASAN || isDebug)("under an address-space limit (ulimit -v)", () => { const jitScript = /* js */ ` + import { numberOfDFGCompiles } from "bun:jsc"; function hot(n) { let s = 0; for (let i = 0; i < n; i++) s = (s + i * 7) % 1000003; return s; } for (let i = 0; i < 30; i++) hot(10000); - const { numberOfDFGCompiles } = require("bun:jsc"); // numberOfDFGCompiles() reports 1000000 for any function while the JIT tiers are unavailable. process.stdout.write(JSON.stringify({ jit: numberOfDFGCompiles(hot) !== 1000000 })); `; @@ -324,43 +325,66 @@ describe.skipIf(!isLinux || isASAN || isDebug)("under an address-space limit (ul for (let i = 0; i < 200_000; i++) objects.push({ ["k" + i]: i }); process.stdout.write(JSON.stringify({ structures: objects.length })); `; + // What a service does: it answers requests and keeps the work pool busy, and the stack of each thread counts + // against the limit. + const serviceScript = /* js */ ` + import { numberOfDFGCompiles } from "bun:jsc"; + import { pbkdf2 } from "node:crypto"; + import { readFile } from "node:fs/promises"; + import { promisify } from "node:util"; + const server = Bun.serve({ port: 0, fetch: async request => new Response((await request.text()) + "!") }); + let answered = 0; + for (let i = 0; i < 20; i++) { + const response = await fetch("http://127.0.0.1:" + server.port + "/", { method: "POST", body: "x" }); + if ((await response.text()) === "x!") answered++; + } + await Promise.all(Array.from({ length: 32 }, (_, i) => promisify(pbkdf2)("pw" + i, "salt", 2000, 32, "sha256"))); + await Promise.all(Array.from({ length: 32 }, () => readFile(import.meta.path))); + function hot(n) { let s = 0; for (let i = 0; i < n; i++) s = (s + i * 7) % 1000003; return s; } + for (let i = 0; i < 30; i++) hot(10000); + process.stdout.write(JSON.stringify({ answered, jit: numberOfDFGCompiles(hot) !== 1000000 })); + await server.stop(true); + `; - async function run(limitMB: number | undefined, script: string) { + // Runs `bun ...args`, under `ulimit -v` when there is a limit. + async function run(limitMB: number | undefined, ...args: string[]) { const cmd = limitMB === undefined - ? [bunExe(), "-e", script] - : ["sh", "-c", `ulimit -v ${limitMB * 1024} && exec "$0" -e "$1"`, bunExe(), script]; + ? [bunExe(), ...args] + : ["sh", "-c", `ulimit -v ${limitMB * 1024} && exec "$0" "$@"`, bunExe(), ...args]; await using proc = Bun.spawn({ cmd, env: bunEnv, stdout: "pipe", stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); return { stdout, stderr, exitCode, signalCode: proc.signalCode }; } + // One object: a failure shows the crash banner or the allocation error on stderr beside the exit code. + const ok = (stdout: string) => ({ stdout, stderr: "", exitCode: 0, signalCode: null }); - test("without a limit the JIT is available", async () => { - expect(await run(undefined, jitScript)).toEqual({ - stdout: `{"jit":true}`, - stderr: "", - exitCode: 0, - signalCode: null, - }); + test.concurrent("without a limit the JIT is available", async () => { + expect(await run(undefined, "-e", jitScript)).toEqual(ok(`{"jit":true}`)); }); test.concurrent.each([768, 1024, 1200, 1536, 2048, 2250])( "ulimit -v %dM: bun starts and keeps the JIT", async limitMB => { - // One object: a failure shows the crash banner or the allocation error on stderr beside the exit code. - expect(await run(limitMB, jitScript)).toEqual({ - stdout: `{"jit":true}`, - stderr: "", - exitCode: 0, - signalCode: null, - }); + expect(await run(limitMB, "-e", jitScript)).toEqual(ok(`{"jit":true}`)); }, ); + // `bun -e` is a one-shot start: no concurrent JIT, one GC marker. A script file gets the threads of a service. + test.concurrent.each([512, 768])("ulimit -v %dM: a service in a file runs with the JIT", async limitMB => { + using dir = tempDir("address-space-limit", { "service.mjs": serviceScript }); + expect(await run(limitMB, join(String(dir), "service.mjs"))).toEqual(ok(`{"answered":20,"jit":true}`)); + }); + + // Below 512 MB the JIT pool and its compiler threads would take address space that the program needs. + test.concurrent("ulimit -v 384M: bun runs without the JIT", async () => { + expect(await run(384, "-e", jitScript)).toEqual(ok(`{"jit":false}`)); + }); + // The startup reservations have to leave room for the program: under 1.5 GB it gets a catchable out-of-memory // error, and not before it has allocated a quarter of the limit (about 600 MB is what fits). test.concurrent("ulimit -v 1536M: the reservations leave room for the heap", async () => { - const { stdout, stderr, exitCode, signalCode } = await run(1536, heapScript); + const { stdout, stderr, exitCode, signalCode } = await run(1536, "-e", heapScript); expect(stderr).toBe(""); const { error, mb } = JSON.parse(stdout); expect(error).toBe("RangeError"); @@ -369,11 +393,6 @@ describe.skipIf(!isLinux || isASAN || isDebug)("under an address-space limit (ul }); test.concurrent("ulimit -v 512M: the structure heap still holds 200k shapes", async () => { - expect(await run(512, structureScript)).toEqual({ - stdout: `{"structures":200000}`, - stderr: "", - exitCode: 0, - signalCode: null, - }); + expect(await run(512, "-e", structureScript)).toEqual(ok(`{"structures":200000}`)); }); });