Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ce83eac
spawn: add `maxMemory` to kill a child's whole process tree when it u…
Jarred-Sumner Sep 23, 2026
b19edbd
spawn: add `subprocess.memoryUsage()` for the memory of a child's who…
Jarred-Sumner Sep 23, 2026
ec2f469
spawn: make the memory watcher pass clippy, mordant and the source lints
Jarred-Sumner Sep 23, 2026
758d549
spawn: make the maxMemory grandchild test wait for the killed pid to …
Jarred-Sumner Sep 23, 2026
2de903b
spawn: on Windows, let the kernel report when a maxMemory tree crosse…
Jarred-Sumner Sep 23, 2026
c84e267
spawn: on Linux, put a maxMemory child in a memory cgroup when Bun ma…
Jarred-Sumner Sep 23, 2026
0c7dcd3
spawn: keep the real error from the cgroup file helpers
Jarred-Sumner Sep 23, 2026
ec2f43d
spawn: let the maxMemory test prove which route enforces the limit
Jarred-Sumner Sep 23, 2026
043f406
spawn: fix the maxMemory problems found in review
Jarred-Sumner Sep 24, 2026
25034ce
spawn: make the reparented-descendant maxMemory test work on every pl…
Jarred-Sumner Sep 24, 2026
8b9c422
spawn: fix the second round of maxMemory review findings
Jarred-Sumner Sep 24, 2026
30b9e70
spawn: fix memoryUsage() returning 0 on Linux
Jarred-Sumner Sep 24, 2026
691dad2
spawn: retry the maxMemory kill for a process that was missed or is new
Jarred-Sumner Sep 24, 2026
9235455
spawn: do not use a v1 memory cgroup on kernels that do not report OO…
Jarred-Sumner Sep 24, 2026
0604209
spawn: report a cgroup maxMemory kill only when the cgroup's own limi…
Jarred-Sumner Sep 24, 2026
5ca06e1
spawn: count only an OOM of the cgroup's own limit, and skip a v1 cgr…
Jarred-Sumner Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/runtime/child-process.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,41 @@ therefore exceed `maxBuffer` only by the single read that passed it, never by
whatever the process manages to write before the kill lands. This matches
Node.js.

## Using maxMemory

`maxMemory` limits how many bytes of memory the process **and all of its descendants** may use. When the tree crosses the limit, Bun sends `killSignal` (default `SIGTERM`) to every process in it.

```ts index.ts icon="/icons/typescript.svg"
const proc = Bun.spawn({
cmd: ["sh", "-c", "tsc -p ."],
maxMemory: 2 * 1024 * 1024 * 1024, // 2 GiB for sh + tsc + anything tsc spawns
killSignal: "SIGKILL",
});
await proc.exited;

const result = Bun.spawnSync({ cmd: ["make", "-j8"], maxMemory: 4 * 1024 ** 3 });
result.exitedDueToMaxMemory; // true if the build was killed for exceeding 4 GiB
```

Bun uses the kernel to enforce the limit where the kernel can do it, and samples only as a fallback:

| Platform | How the limit is enforced |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Windows | The child runs in a Job Object. The kernel notifies Bun when the tree commits more than `maxMemory`, and Bun terminates the job. |
| Linux | When Bun can create a memory cgroup (v2 or v1), the child starts inside one with `memory.max` set, and the kernel kills the tree. Otherwise Bun samples. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
| macOS | The kernel has no memory limit for a group of processes, so one background thread samples the tree's physical footprint. |

The sampler runs slowly while every tree is far from its limit and every millisecond once any tree is within 15% of it. `killSignal` applies when Bun does the kill. When the kernel does it (Linux cgroup, Windows job), the tree is killed at once, as with `SIGKILL`.

To read the number yourself, call `proc.memoryUsage()`. It works with or without `maxMemory`:

```ts index.ts icon="/icons/typescript.svg"
const proc = Bun.spawn(["bun", "run", "build"]);
const { current, peak } = proc.memoryUsage(); // bytes, for the whole process tree
```

`current` is `0` after the process exits. `peak` is the highest value Bun has sampled. On Windows without `maxMemory`, only the process itself is counted.

## Inter-process communication (IPC)

Bun supports a direct inter-process communication channel between two `bun` processes. To receive messages from a spawned Bun subprocess, specify an `ipc` handler.
Expand Down
28 changes: 28 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7933,6 +7933,19 @@ declare module "bun" {
* @default undefined (no limit)
*/
maxBuffer?: number;

/**
* The maximum number of bytes of memory the process and its descendants
* may use, combined. If the tree goes over this limit, every process in
* it is killed with `killSignal` (defaults to SIGTERM).
*
* The kernel enforces the limit where it can: a Job Object on Windows and a
* memory cgroup on Linux when Bun may create one. Otherwise Bun samples the
* tree. `killSignal` applies only when Bun does the kill.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
*
* @default undefined (no limit)
*/
maxMemory?: number;
}

interface SpawnSyncOptions<In extends Writable, Out extends Readable, Err extends Readable>
Expand Down Expand Up @@ -8232,6 +8245,16 @@ declare module "bun" {
* Returns `undefined` until the process has exited
*/
resourceUsage(): ResourceUsage | undefined;

/**
* Memory used right now by this process and all of its descendants, in bytes.
*
* `current` is `0` after the process exits. `peak` is the highest value Bun has
* sampled; with {@link SpawnOptions.maxMemory} set, Bun samples continuously.
*
* On Windows without `maxMemory`, only the process itself is counted.
*/
memoryUsage(): { current: number; peak: number };
}

/**
Expand All @@ -8257,6 +8280,11 @@ declare module "bun" {
signalCode?: NodeJS.Signals | number;
exitedDueToTimeout?: boolean;
exitedDueToMaxBuffer?: boolean;

/**
* `true` if the process tree exceeded {@link SpawnOptions.maxMemory} and was killed for it.
*/
exitedDueToMaxMemory?: boolean;
pid: number;
}

Expand Down
3 changes: 3 additions & 0 deletions src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ export const subprocessInternals = {
subprocess: import("bun").Subprocess,
kind: "stdout" | "stderr",
) => boolean,
memoryLimitRoute: $newRustFunction("subprocess.rs", "TestingAPIs.memoryLimitRoute", 1) as (
subprocess: import("bun").Subprocess,
) => "job" | "cgroup" | "sampler" | undefined,
};

export const iniInternals = {
Expand Down
15 changes: 15 additions & 0 deletions src/js/node/child_process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ function execFile(file, args, options?, callback?) {
uid: options.uid,
gid: options.gid,
cgroup: options.cgroup,
maxMemory: options.maxMemory,
windowsHide: options.windowsHide,
windowsVerbatimArguments: options.windowsVerbatimArguments,
shell: options.shell,
Expand Down Expand Up @@ -583,6 +584,7 @@ function spawnSync(file, args, options?): SpawnSyncResult {
signalCode,
exitedDueToTimeout,
exitedDueToMaxBuffer,
exitedDueToMaxMemory,
pid,
}: Omit<Bun.SyncSubprocess, "exitCode" | "stdout" | "stderr"> & {
exitCode: number | null;
Expand All @@ -603,6 +605,7 @@ function spawnSync(file, args, options?): SpawnSyncResult {
uid: options.uid,
gid: options.gid,
cgroup: options.cgroup,
maxMemory: options.maxMemory,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
windowsVerbatimArguments: options.windowsVerbatimArguments,
windowsHide: options.windowsHide,
argv0: options.args[0],
Expand Down Expand Up @@ -664,6 +667,15 @@ function spawnSync(file, args, options?): SpawnSyncResult {
"ETIMEDOUT",
);
}
if (exitedDueToMaxMemory && error == null) {
result.error = new SystemError(
"spawnSync " + options.file + " ENOMEM (process tree reached maxMemory limit)",
options.file,
"spawnSync " + options.file,
enomemErrorCode(),
"ENOMEM",
Comment thread
Jarred-Sumner marked this conversation as resolved.
Resolved
);
}
if (exitedDueToMaxBuffer && error == null) {
result.error = new SystemError(
"spawnSync " + options.file + " ENOBUFS (stdout or stderr buffer reached maxBuffer size limit)",
Expand All @@ -684,6 +696,7 @@ function spawnSync(file, args, options?): SpawnSyncResult {
}
const etimedoutErrorCode = $newRustFunction("node_util_binding.rs", "etimedoutErrorCode", 0);
const enobufsErrorCode = $newRustFunction("node_util_binding.rs", "enobufsErrorCode", 0);
const enomemErrorCode = $newRustFunction("node_util_binding.rs", "enomemErrorCode", 0);

/**
* Spawns a file as a shell synchronously.
Expand Down Expand Up @@ -1473,6 +1486,8 @@ class ChildProcess extends EventEmitter {
uid: options.uid,
gid: options.gid,
cgroup: options.cgroup,
maxMemory: options.maxMemory,
Comment thread
Jarred-Sumner marked this conversation as resolved.
Resolved
killSignal: options.killSignal,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
Jarred-Sumner marked this conversation as resolved.
Resolved
onExit: (handle, exitCode, signalCode, err) => {
this.#handle = handle;
this.pid = this.#handle.pid;
Expand Down
4 changes: 4 additions & 0 deletions src/runtime/api/BunObject.classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ export default [
fn: "resourceUsage",
length: 0,
},
memoryUsage: {
fn: "memoryUsage",
length: 0,
},
send: {
fn: "doSend",
length: 1,
Expand Down
50 changes: 50 additions & 0 deletions src/runtime/api/bun/js_bun_spawn_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ fn spawn_maybe_sync(
let mut gid: Option<u32> = None;
let mut kill_signal: SignalCode = SignalCode::DEFAULT;
let mut max_buffer: Option<i64> = None;
let mut max_memory: Option<u64> = None;
#[cfg(any(target_os = "linux", target_os = "android"))]
let mut cgroup: Option<CgroupTarget> = None;

Expand Down Expand Up @@ -766,6 +767,25 @@ fn spawn_maybe_sync(
}
}

if let Some(val) = args.get(cx.global(), "maxMemory")? {
if !val.is_undefined_or_null()
&& !(val.is_number() && val.as_number().is_infinite() && val.as_number() > 0.0)
Comment thread
Jarred-Sumner marked this conversation as resolved.
Resolved
{
let bytes = cx.global().validate_integer_range::<u64>(
val,
0,
bun_sql_jsc::jsc::IntegerRange {
min: 0,
field_name: b"maxMemory",
..Default::default()
},
)?;
if bytes > 0 {
max_memory = Some(bytes);
}
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if let Some(val) = args.get(cx.global(), "maxBuffer")? {
if val.is_number() && val.is_finite() {
// 'Infinity' does not set maxBuffer
Expand Down Expand Up @@ -1096,6 +1116,16 @@ fn spawn_maybe_sync(

let loop_handle = EventLoopHandle::init(event_loop.cast::<()>());

#[cfg(any(target_os = "linux", target_os = "android"))]
let mut memory_cgroup = match (max_memory, &cgroup) {
(Some(limit), None) => bun_spawn::memory_watcher::cgroup::Cgroup::create(limit),
_ => None,
};
#[cfg(any(target_os = "linux", target_os = "android"))]
if let Some(created) = &memory_cgroup {
cgroup = Some(CgroupTarget::Path(ZBox::from_bytes(created.path())));
Comment thread
Jarred-Sumner marked this conversation as resolved.
Resolved
}

#[cfg(any(target_os = "linux", target_os = "android"))]
let cgroup_dir = match &cgroup {
Some(target) => Some(target.open(cx.global())?),
Expand Down Expand Up @@ -1330,6 +1360,10 @@ fn spawn_maybe_sync(
crate::timer::EventLoopTimerTag::SubprocessTimeout,
)),
exited_due_to_maxbuf: Cell::new(None),
memory_watch: JsCell::new(None),
exited_due_to_max_memory: Cell::new(false),
memory_peak: Cell::new(0),
memory_route: Cell::new(None),
}));
// SAFETY: subprocess_ptr is a freshly-boxed Subprocess; we hold the only reference.
let subprocess = unsafe { &mut *subprocess_ptr };
Expand Down Expand Up @@ -1626,6 +1660,14 @@ fn spawn_maybe_sync(
subprocess.update_has_pending_activity();
}

if let Some(limit) = max_memory {
subprocess.watch_memory(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
limit,
#[cfg(any(target_os = "linux", target_os = "android"))]
memory_cgroup.take(),
);
}

let mut send_exit_notification = false;

if !is_sync {
Expand Down Expand Up @@ -2009,6 +2051,7 @@ fn spawn_maybe_sync(
});
let exited_due_to_timeout = did_timeout;
let exited_due_to_max_buffer = subprocess.exited_due_to_maxbuf.get();
let exited_due_to_max_memory = subprocess.exited_due_to_max_memory.get();
let result_pid = JSValue::js_number_from_int32(subprocess.pid());
// SAFETY: `subprocess_ptr` was produced by `heap::into_raw(Box::new(...))`
// above (spawnSync path: never handed to a JS wrapper); do what the
Expand Down Expand Up @@ -2066,6 +2109,13 @@ fn spawn_maybe_sync(
},
);
}
if max_memory.is_some() {
sync_value.put(
cx.global(),
b"exitedDueToMaxMemory",
JSValue::from(exited_due_to_max_memory),
);
}
sync_value.put(cx.global(), b"pid", result_pid);

Ok(sync_value)
Expand Down
Loading
Loading