mirror of
https://github.com/oven-sh/bun
synced 2026-02-16 22:01:47 +00:00
Compare commits
30 Commits
taylor.fis
...
claude/aut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9188779dd8 | ||
|
|
34da4d9979 | ||
|
|
a5af52f565 | ||
|
|
db221a17f9 | ||
|
|
9961acf579 | ||
|
|
e0e6ac6a92 | ||
|
|
6273180892 | ||
|
|
88e1f84317 | ||
|
|
a8b8294cb4 | ||
|
|
dc728ebeea | ||
|
|
961676a7ef | ||
|
|
b511d93fe2 | ||
|
|
58d2abc593 | ||
|
|
3217bd9447 | ||
|
|
fe61519b49 | ||
|
|
21a1a4bfcd | ||
|
|
3a809203a3 | ||
|
|
09e62f6877 | ||
|
|
58c3b6bcd9 | ||
|
|
6f72ad90ae | ||
|
|
a1458ca9d8 | ||
|
|
3f6ca7271a | ||
|
|
8f2b66242a | ||
|
|
84bcb5fe4b | ||
|
|
193ec0ace1 | ||
|
|
f8f971d3e6 | ||
|
|
8b83b72883 | ||
|
|
48afc83936 | ||
|
|
c35689d1d1 | ||
|
|
f56e220148 |
@@ -101,11 +101,19 @@ pub fn isExiting() bool {
|
||||
return is_exiting.load(.monotonic);
|
||||
}
|
||||
|
||||
// Global variable to track if autokill is enabled
|
||||
pub var autokill_enabled: bool = false;
|
||||
|
||||
/// Flushes stdout and stderr (in exit/quick_exit callback) and exits with the given code.
|
||||
pub fn exit(code: u32) noreturn {
|
||||
is_exiting.store(true, .monotonic);
|
||||
_ = @atomicRmw(usize, &bun.analytics.Features.exited, .Add, 1, .monotonic);
|
||||
|
||||
// Kill all child processes if autokill is enabled
|
||||
if (autokill_enabled) {
|
||||
bun.sys.autokill.killAllChildProcesses();
|
||||
}
|
||||
|
||||
// If we are crashing, allow the crash handler to finish it's work.
|
||||
bun.crash_handler.sleepForeverIfAnotherThreadIsCrashing();
|
||||
|
||||
|
||||
@@ -162,6 +162,11 @@ pub const Run = struct {
|
||||
js_ast.Stmt.Data.Store.create();
|
||||
const arena = Arena.init();
|
||||
|
||||
// Set the global autokill flag if enabled
|
||||
if (ctx.runtime_options.autokill) {
|
||||
Global.autokill_enabled = true;
|
||||
}
|
||||
|
||||
run = .{
|
||||
.vm = try VirtualMachine.init(
|
||||
.{
|
||||
|
||||
@@ -822,6 +822,12 @@ pub fn setEntryPointEvalResultCJS(this: *VirtualMachine, value: JSValue) callcon
|
||||
}
|
||||
|
||||
pub fn onExit(this: *VirtualMachine) void {
|
||||
// Kill all child processes if autokill is enabled (main thread only)
|
||||
// This must happen before cleanup hooks to ensure children are still tracked
|
||||
if (this.isMainThread() and bun.Global.autokill_enabled) {
|
||||
bun.sys.autokill.killAllChildProcesses();
|
||||
}
|
||||
|
||||
this.exit_handler.dispatchOnExit();
|
||||
this.is_shutting_down = true;
|
||||
|
||||
|
||||
@@ -1626,6 +1626,12 @@ pub fn reloadProcess(
|
||||
__reload_in_progress__.store(true, .monotonic);
|
||||
__reload_in_progress__on_current_thread = true;
|
||||
|
||||
// Kill all child processes before reloading to ensure clean state
|
||||
// Skip during crash handler (may_return=true) to avoid allocations/syscalls in compromised state
|
||||
if (!may_return and Global.autokill_enabled) {
|
||||
sys.autokill.killAllChildProcesses();
|
||||
}
|
||||
|
||||
if (clear_terminal) {
|
||||
Output.flush();
|
||||
Output.disableBuffering();
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
|
||||
#if DARWIN
|
||||
#include <copyfile.h>
|
||||
#include <libproc.h>
|
||||
#include <sys/proc_info.h>
|
||||
#include <mach/mach_host.h>
|
||||
#include <mach/processor_info.h>
|
||||
#include <net/if.h>
|
||||
|
||||
@@ -385,6 +385,7 @@ pub const Command = struct {
|
||||
expose_gc: bool = false,
|
||||
preserve_symlinks_main: bool = false,
|
||||
console_depth: ?u16 = null,
|
||||
autokill: bool = false,
|
||||
};
|
||||
|
||||
var global_cli_ctx: Context = undefined;
|
||||
|
||||
@@ -112,6 +112,7 @@ pub const runtime_params_ = [_]ParamType{
|
||||
clap.parseParam("--no-addons Throw an error if process.dlopen is called, and disable export condition \"node-addons\"") catch unreachable,
|
||||
clap.parseParam("--unhandled-rejections <STR> One of \"strict\", \"throw\", \"warn\", \"none\", or \"warn-with-error-code\"") catch unreachable,
|
||||
clap.parseParam("--console-depth <NUMBER> Set the default depth for console.log object inspection (default: 2)") catch unreachable,
|
||||
clap.parseParam("--autokill Recursively kill all child processes on exit (macOS only)") catch unreachable,
|
||||
clap.parseParam("--user-agent <STR> Set the default User-Agent header for HTTP requests") catch unreachable,
|
||||
};
|
||||
|
||||
@@ -782,6 +783,10 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
|
||||
if (args.flag("--zero-fill-buffers")) {
|
||||
Bun__Node__ZeroFillBuffers = true;
|
||||
}
|
||||
if (args.flag("--autokill")) {
|
||||
ctx.runtime_options.autokill = true;
|
||||
}
|
||||
|
||||
const use_system_ca = args.flag("--use-system-ca");
|
||||
const use_openssl_ca = args.flag("--use-openssl-ca");
|
||||
const use_bundled_ca = args.flag("--use-bundled-ca");
|
||||
|
||||
@@ -1364,6 +1364,12 @@ pub const TestCommand = struct {
|
||||
|
||||
js_ast.Expr.Data.Store.create();
|
||||
js_ast.Stmt.Data.Store.create();
|
||||
|
||||
// Set the global autokill flag if enabled
|
||||
if (ctx.runtime_options.autokill) {
|
||||
Global.autokill_enabled = true;
|
||||
}
|
||||
|
||||
var vm = try jsc.VirtualMachine.init(
|
||||
.{
|
||||
.allocator = ctx.allocator,
|
||||
|
||||
@@ -300,6 +300,7 @@ pub const Tag = enum(u8) {
|
||||
};
|
||||
|
||||
pub const Error = @import("./sys/Error.zig");
|
||||
pub const autokill = @import("./sys/autokill.zig");
|
||||
pub const PosixStat = @import("./sys/PosixStat.zig").PosixStat;
|
||||
|
||||
pub fn Maybe(comptime ReturnTypeT: type) type {
|
||||
|
||||
248
src/sys/autokill.zig
Normal file
248
src/sys/autokill.zig
Normal file
@@ -0,0 +1,248 @@
|
||||
const KillPass = enum {
|
||||
sigterm, // Send SIGTERM for graceful shutdown
|
||||
sigstop, // Send SIGSTOP to freeze processes
|
||||
sigkill, // Send SIGKILL for forced termination
|
||||
};
|
||||
|
||||
/// Kill all child processes using a three-pass strategy:
|
||||
///
|
||||
/// 1. SIGTERM: Graceful shutdown - allows cleanup handlers to run (500μs delay)
|
||||
/// 2. SIGSTOP: Freeze survivors - prevents reparenting races
|
||||
/// 3. SIGKILL: Force termination - ensures nothing survives
|
||||
///
|
||||
/// Each pass freshly enumerates children to catch any spawned during the sequence.
|
||||
/// Early bailout if no children remain after any pass.
|
||||
///
|
||||
/// This is more graceful than SIGSTOP→SIGKILL (allows cleanup) and more thorough
|
||||
/// than SIGTERM→SIGKILL (SIGSTOP prevents races). Most processes exit from SIGTERM,
|
||||
/// making this faster in practice despite being three passes.
|
||||
pub fn killAllChildProcesses() void {
|
||||
if (Environment.isWindows) {
|
||||
// Windows already uses Job Objects which automatically kill children on exit
|
||||
// This is a no-op
|
||||
return;
|
||||
}
|
||||
|
||||
const current_pid = std.c.getpid();
|
||||
|
||||
// Walk the process tree and kill only child processes
|
||||
// Do NOT kill the entire process group with kill(-pid) as that would
|
||||
// kill the Bun process itself before it can finish shutting down
|
||||
|
||||
// Pass 1: SIGTERM to allow graceful cleanup
|
||||
// Give processes a chance to handle cleanup work before forced termination
|
||||
{
|
||||
const children = getChildPids(current_pid, current_pid) catch &[_]c_int{};
|
||||
defer if (children.len > 0) bun.default_allocator.free(children);
|
||||
|
||||
if (children.len > 0) {
|
||||
var seen = std.AutoHashMap(c_int, void).init(bun.default_allocator);
|
||||
defer seen.deinit();
|
||||
for (children) |child| {
|
||||
killProcessTreeRecursive(child, &seen, current_pid, .sigterm) catch {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Brief delay to allow processes to handle SIGTERM
|
||||
// Use longer delay on musl due to slower syscalls and /proc inconsistencies
|
||||
const delay_us = if (Environment.isMusl) 2000 else 500;
|
||||
std.time.sleep(delay_us * std.time.ns_per_us);
|
||||
|
||||
// Pass 2: SIGSTOP to freeze entire tree and minimize reparenting races
|
||||
// Get fresh child list in case some exited from SIGTERM
|
||||
{
|
||||
const children = getChildPids(current_pid, current_pid) catch &[_]c_int{};
|
||||
defer if (children.len > 0) bun.default_allocator.free(children);
|
||||
|
||||
if (children.len > 0) {
|
||||
var seen = std.AutoHashMap(c_int, void).init(bun.default_allocator);
|
||||
defer seen.deinit();
|
||||
for (children) |child| {
|
||||
killProcessTreeRecursive(child, &seen, current_pid, .sigstop) catch {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: SIGKILL to force termination of any remaining processes
|
||||
// Get fresh child list in case some exited from SIGSTOP
|
||||
{
|
||||
const children = getChildPids(current_pid, current_pid) catch &[_]c_int{};
|
||||
defer if (children.len > 0) bun.default_allocator.free(children);
|
||||
|
||||
if (children.len > 0) {
|
||||
var seen = std.AutoHashMap(c_int, void).init(bun.default_allocator);
|
||||
defer seen.deinit();
|
||||
for (children) |child| {
|
||||
killProcessTreeRecursive(child, &seen, current_pid, .sigkill) catch {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn getChildPids(parent: c_int, current_pid: c_int) ![]c_int {
|
||||
if (Environment.isLinux) {
|
||||
// Try /proc/{pid}/task/{tid}/children first (most efficient, requires kernel 3.5+)
|
||||
// If it fails for any reason (older kernel, musl quirks, etc), fall back to /proc scanning
|
||||
const children_path = std.fmt.allocPrint(
|
||||
bun.default_allocator,
|
||||
"/proc/{d}/task/{d}/children",
|
||||
.{ parent, parent },
|
||||
) catch {
|
||||
// Allocation failed; fall back to /proc scanning
|
||||
return getChildPidsFallback(parent, current_pid);
|
||||
};
|
||||
defer bun.default_allocator.free(children_path);
|
||||
|
||||
const file = std.fs.openFileAbsolute(children_path, .{}) catch {
|
||||
// File doesn't exist (older kernel or /proc not mounted properly)
|
||||
// Fall back to scanning /proc
|
||||
return getChildPidsFallback(parent, current_pid);
|
||||
};
|
||||
defer file.close();
|
||||
|
||||
const contents = file.readToEndAlloc(bun.default_allocator, 4096) catch {
|
||||
// File unreadable or too large; fall back to /proc scanning
|
||||
return getChildPidsFallback(parent, current_pid);
|
||||
};
|
||||
defer bun.default_allocator.free(contents);
|
||||
|
||||
var list = std.ArrayList(c_int).init(bun.default_allocator);
|
||||
var iter = std.mem.tokenizeAny(u8, contents, " \n");
|
||||
while (iter.next()) |pid_str| {
|
||||
const pid = std.fmt.parseInt(c_int, pid_str, 10) catch continue;
|
||||
list.append(pid) catch continue;
|
||||
}
|
||||
|
||||
// If we successfully read the file but it gave us no children,
|
||||
// trust that result - don't fall back
|
||||
return list.toOwnedSlice();
|
||||
} else if (Environment.isMac) {
|
||||
// Use proc_listpids with PROC_PPID_ONLY
|
||||
// Note: 2048 is a reasonable limit for most scenarios. If a process has more
|
||||
// than 2048 direct children, the list will be truncated. This is acceptable
|
||||
// for autokill's use case as processes with thousands of children are rare.
|
||||
var pids: [2048]c_int = undefined;
|
||||
const bytes = bun.c.proc_listpids(bun.c.PROC_PPID_ONLY, @as(u32, @intCast(parent)), &pids, @sizeOf(@TypeOf(pids)));
|
||||
|
||||
if (bytes <= 0) return &[_]c_int{};
|
||||
|
||||
const count = @as(usize, @intCast(bytes)) / @sizeOf(c_int);
|
||||
var list = std.ArrayList(c_int).init(bun.default_allocator);
|
||||
|
||||
for (pids[0..count]) |pid| {
|
||||
if (pid > 0 and pid != current_pid) {
|
||||
list.append(pid) catch continue;
|
||||
}
|
||||
}
|
||||
|
||||
return list.toOwnedSlice();
|
||||
}
|
||||
|
||||
return &[_]c_int{};
|
||||
}
|
||||
|
||||
fn getChildPidsFallback(parent: c_int, current_pid: c_int) ![]c_int {
|
||||
// Fallback for older Linux kernels: scan /proc
|
||||
var list = std.ArrayList(c_int).init(bun.default_allocator);
|
||||
|
||||
var proc_dir = std.fs.openDirAbsolute("/proc", .{ .iterate = true }) catch return list.toOwnedSlice();
|
||||
defer proc_dir.close();
|
||||
|
||||
var iter = proc_dir.iterate();
|
||||
while (try iter.next()) |entry| {
|
||||
const pid = std.fmt.parseInt(c_int, entry.name, 10) catch continue;
|
||||
if (pid <= 0 or pid == parent or pid == current_pid) continue;
|
||||
|
||||
// Read /proc/{pid}/stat to get ppid
|
||||
const stat_path = std.fmt.allocPrint(
|
||||
bun.default_allocator,
|
||||
"/proc/{d}/stat",
|
||||
.{pid},
|
||||
) catch continue;
|
||||
defer bun.default_allocator.free(stat_path);
|
||||
|
||||
const stat_file = std.fs.openFileAbsolute(stat_path, .{}) catch continue;
|
||||
defer stat_file.close();
|
||||
|
||||
const stat_contents = stat_file.readToEndAlloc(bun.default_allocator, 4096) catch continue;
|
||||
defer bun.default_allocator.free(stat_contents);
|
||||
|
||||
// Parse: pid (comm) state ppid ...
|
||||
// Find the last ')' to skip the comm field
|
||||
const last_paren = std.mem.lastIndexOf(u8, stat_contents, ")") orelse continue;
|
||||
const after_comm = stat_contents[last_paren + 1 ..];
|
||||
|
||||
// Parse: " state ppid ..."
|
||||
var parts = std.mem.tokenizeAny(u8, after_comm, " ");
|
||||
_ = parts.next(); // skip state
|
||||
const ppid_str = parts.next() orelse continue;
|
||||
const ppid = std.fmt.parseInt(c_int, ppid_str, 10) catch continue;
|
||||
|
||||
if (ppid == parent) {
|
||||
list.append(pid) catch continue;
|
||||
}
|
||||
}
|
||||
|
||||
return list.toOwnedSlice();
|
||||
}
|
||||
|
||||
fn killProcessTreeRecursive(pid: c_int, killed: *std.AutoHashMap(c_int, void), current_pid: c_int, pass: KillPass) !void {
|
||||
// Avoid cycles and killing ourselves
|
||||
if (killed.contains(pid) or pid == current_pid or pid <= 0) {
|
||||
return;
|
||||
}
|
||||
try killed.put(pid, {});
|
||||
|
||||
// Get children first to avoid race conditions where killing the parent
|
||||
// might prevent us from finding the children
|
||||
// If enumeration fails, treat as having no children and continue to kill this process
|
||||
const children = getChildPids(pid, current_pid) catch &[_]c_int{};
|
||||
defer if (children.len > 0) bun.default_allocator.free(children);
|
||||
|
||||
// Process children first (depth-first)
|
||||
for (children) |child| {
|
||||
if (child > 0) {
|
||||
killProcessTreeRecursive(child, killed, current_pid, pass) catch {};
|
||||
}
|
||||
}
|
||||
|
||||
// Use std.posix.SIG for platform-portable signal constants
|
||||
// (SIGSTOP=17 on macOS, 19 on Linux)
|
||||
// Use direct syscall on Linux to avoid musl libc issues
|
||||
switch (pass) {
|
||||
.sigterm => {
|
||||
// Pass 1: SIGTERM for graceful shutdown
|
||||
if (comptime Environment.isLinux) {
|
||||
_ = std.os.linux.kill(pid, std.posix.SIG.TERM);
|
||||
} else {
|
||||
_ = std.c.kill(pid, std.posix.SIG.TERM);
|
||||
}
|
||||
},
|
||||
.sigstop => {
|
||||
// Pass 2: SIGSTOP to freeze the process
|
||||
if (comptime Environment.isLinux) {
|
||||
_ = std.os.linux.kill(pid, std.posix.SIG.STOP);
|
||||
} else {
|
||||
_ = std.c.kill(pid, std.posix.SIG.STOP);
|
||||
}
|
||||
},
|
||||
.sigkill => {
|
||||
// Pass 3: SIGKILL to force termination
|
||||
if (comptime Environment.isLinux) {
|
||||
_ = std.os.linux.kill(pid, std.posix.SIG.KILL);
|
||||
} else {
|
||||
_ = std.c.kill(pid, std.posix.SIG.KILL);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export fn Bun__autokillChildProcesses() void {
|
||||
killAllChildProcesses();
|
||||
}
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const bun = @import("../bun.zig");
|
||||
const Environment = bun.Environment;
|
||||
741
test/cli/autokill.test.ts
Normal file
741
test/cli/autokill.test.ts
Normal file
@@ -0,0 +1,741 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bunEnv, bunExe, isWindows, tempDirWithFiles } from "harness";
|
||||
|
||||
// Helper to wait for a process to die, polling with a timeout
|
||||
async function waitForProcessDeath(pid: number, timeoutMs: number = 1000): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
// Still alive, wait a bit
|
||||
await Bun.sleep(10);
|
||||
} catch {
|
||||
// Process is dead
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
describe.skipIf(isWindows)("--autokill", () => {
|
||||
test("basic autokill flag works", async () => {
|
||||
const dir = tempDirWithFiles("autokill-basic", {
|
||||
"simple.js": `
|
||||
console.log("Hello from autokill test");
|
||||
process.exit(0);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "--autokill", "simple.js"],
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
});
|
||||
|
||||
const [output, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.trim()).toBe("Hello from autokill test");
|
||||
});
|
||||
|
||||
test("autokill flag kills single child process", async () => {
|
||||
const dir = tempDirWithFiles("autokill-single", {
|
||||
"spawn_one.js": `
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const child = spawn('sleep', ['30']);
|
||||
console.log(child.pid);
|
||||
|
||||
setTimeout(() => process.exit(0), 50);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "--autokill", "spawn_one.js"],
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
});
|
||||
|
||||
const [output, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const childPid = parseInt(output.trim());
|
||||
expect(childPid).toBeGreaterThan(0);
|
||||
|
||||
// Wait for autokill to take effect (polling with timeout)
|
||||
const died = await waitForProcessDeath(childPid, 1000);
|
||||
expect(died).toBe(true);
|
||||
|
||||
// Clean up if somehow still alive
|
||||
try {
|
||||
process.kill(childPid, "SIGKILL");
|
||||
} catch {
|
||||
// Expected - process should be dead
|
||||
}
|
||||
});
|
||||
|
||||
test("autokill flag kills multiple child processes", async () => {
|
||||
const dir = tempDirWithFiles("autokill-multiple", {
|
||||
"spawn_many.js": `
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const children = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const child = spawn('sleep', ['30']);
|
||||
children.push(child.pid);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(children));
|
||||
setTimeout(() => process.exit(0), 50);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "--autokill", "spawn_many.js"],
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
});
|
||||
|
||||
const [output, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const childPids = JSON.parse(output.trim());
|
||||
expect(childPids).toBeArray();
|
||||
expect(childPids.length).toBe(5);
|
||||
|
||||
// Wait for all processes to die (polling with timeout)
|
||||
for (const pid of childPids) {
|
||||
const died = await waitForProcessDeath(pid, 1000);
|
||||
expect(died).toBe(true);
|
||||
// Clean up if somehow still alive
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// Expected - process should be dead
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("autokill handles nested processes (shell with background job)", async () => {
|
||||
const dir = tempDirWithFiles("autokill-shell", {
|
||||
"shell_bg.js": `
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// Spawn a shell with background sleep and capture the background job PID
|
||||
const shell = spawn('sh', ['-c', 'sleep 30 & echo $!; wait']);
|
||||
shell.stdout.setEncoding('utf8');
|
||||
shell.stdout.once('data', data => {
|
||||
const bgPid = Number.parseInt(data.trim(), 10);
|
||||
console.log(JSON.stringify({ shell: shell.pid, background: bgPid }));
|
||||
setTimeout(() => process.exit(0), 50);
|
||||
});
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "--autokill", "shell_bg.js"],
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
});
|
||||
|
||||
const [output, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const { shell: shellPid, background: bgPid } = JSON.parse(output.trim());
|
||||
expect(shellPid).toBeGreaterThan(0);
|
||||
expect(bgPid).toBeGreaterThan(0);
|
||||
|
||||
// Wait for both shell and background process to die
|
||||
for (const pid of [shellPid, bgPid]) {
|
||||
const died = await waitForProcessDeath(pid, 1000);
|
||||
expect(died).toBe(true);
|
||||
// Clean up if somehow still alive
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// Expected - process should be dead
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("autokill handles deeply nested process tree", async () => {
|
||||
const dir = tempDirWithFiles("autokill-deep", {
|
||||
"spawn_nested.sh": `#!/bin/sh
|
||||
# Level 2 shell: spawn sleep and record its PID
|
||||
sleep 30 & echo "level3=$!" >> "$1"
|
||||
# Wait for the background job so we don't exit and reparent level3 to init
|
||||
wait
|
||||
`,
|
||||
"deep_tree.js": `
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Create a 3-level deep process tree and capture all PIDs
|
||||
const pidFile = path.join(__dirname, 'deep-pids.json');
|
||||
const nestedScript = path.join(__dirname, 'spawn_nested.sh');
|
||||
|
||||
// Make script executable
|
||||
fs.chmodSync(nestedScript, 0o755);
|
||||
|
||||
// Write level1 PID first (to file that will be appended to)
|
||||
fs.writeFileSync(pidFile, '');
|
||||
|
||||
// Level 1: outer shell that spawns Level 2 (the nested script)
|
||||
const shellCmd = nestedScript + ' ' + pidFile + ' & echo level2=$! >> ' + pidFile + '; sleep 0.3; wait';
|
||||
const level1 = spawn('sh', ['-c', shellCmd]);
|
||||
|
||||
// Append level1 PID
|
||||
fs.appendFileSync(pidFile, 'level1=' + level1.pid + '\\n');
|
||||
|
||||
// Wait for child processes to start and write their PIDs
|
||||
setTimeout(() => {
|
||||
console.log('done');
|
||||
setTimeout(() => process.exit(0), 50);
|
||||
}, 400);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "--autokill", "deep_tree.js"],
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
});
|
||||
|
||||
const [output, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Read PIDs from file
|
||||
const pidFile = `${dir}/deep-pids.json`;
|
||||
try {
|
||||
const pidData = await Bun.file(pidFile).text();
|
||||
const pids: Record<string, number> = {};
|
||||
// Match all level#=PID patterns
|
||||
const matches = pidData.matchAll(/level(\d+)=(\d+)/g);
|
||||
for (const match of matches) {
|
||||
const key = `level${match[1]}`;
|
||||
const value = Number.parseInt(match[2], 10);
|
||||
pids[key] = value;
|
||||
}
|
||||
|
||||
// Verify we captured all three levels
|
||||
expect(pids.level1).toBeGreaterThan(0);
|
||||
expect(pids.level2).toBeGreaterThan(0);
|
||||
expect(pids.level3).toBeGreaterThan(0);
|
||||
|
||||
// Wait for all processes to die
|
||||
for (const [name, pid] of Object.entries(pids)) {
|
||||
if (pid && pid > 0) {
|
||||
const died = await waitForProcessDeath(pid, 1000);
|
||||
expect(died).toBe(true);
|
||||
// Clean up if somehow still alive
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// Expected - process should be dead
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// If we can't read the file, at least verify something happened
|
||||
expect(output.trim()).toContain("done");
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
test("autokill handles mix of process types", async () => {
|
||||
const dir = tempDirWithFiles("autokill-mixed", {
|
||||
"mixed_processes.js": `
|
||||
const { spawn, exec } = require('child_process');
|
||||
|
||||
const pids = [];
|
||||
|
||||
// Direct sleep process
|
||||
const sleep1 = spawn('sleep', ['30']);
|
||||
pids.push(sleep1.pid);
|
||||
|
||||
// Shell with sleep
|
||||
const shell = spawn('sh', ['-c', 'sleep 30']);
|
||||
pids.push(shell.pid);
|
||||
|
||||
// exec sleep (creates intermediate shell)
|
||||
const execChild = exec('sleep 30');
|
||||
pids.push(execChild.pid);
|
||||
|
||||
console.log(JSON.stringify(pids));
|
||||
setTimeout(() => process.exit(0), 100);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "--autokill", "mixed_processes.js"],
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
});
|
||||
|
||||
const [output, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const pids = JSON.parse(output.trim());
|
||||
expect(pids).toBeArray();
|
||||
expect(pids.length).toBe(3);
|
||||
|
||||
// Wait for all processes to die (polling with timeout)
|
||||
for (const pid of pids) {
|
||||
const died = await waitForProcessDeath(pid, 1000);
|
||||
expect(died).toBe(true);
|
||||
// Clean up if somehow still alive
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// Expected - process should be dead
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("autokill works on uncaught exception", async () => {
|
||||
const dir = tempDirWithFiles("autokill-crash", {
|
||||
"crash.js": `
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const child = spawn('sleep', ['30']);
|
||||
console.log(child.pid);
|
||||
|
||||
// Cause an uncaught exception after spawning
|
||||
setTimeout(() => {
|
||||
throw new Error("Intentional crash for testing");
|
||||
}, 50);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "--autokill", "crash.js"],
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
});
|
||||
|
||||
const [output, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
// Should exit with non-zero due to uncaught exception
|
||||
expect(exitCode).not.toBe(0);
|
||||
|
||||
const childPid = parseInt(output.trim());
|
||||
expect(childPid).toBeGreaterThan(0);
|
||||
|
||||
// Wait for autokill to take effect (polling with timeout)
|
||||
const died = await waitForProcessDeath(childPid, 1000);
|
||||
expect(died).toBe(true);
|
||||
|
||||
// Clean up if somehow still alive
|
||||
try {
|
||||
process.kill(childPid, "SIGKILL");
|
||||
} catch {
|
||||
// Expected - process should be dead
|
||||
}
|
||||
});
|
||||
|
||||
test("autokill works on process.exit(non-zero)", async () => {
|
||||
const dir = tempDirWithFiles("autokill-exit-code", {
|
||||
"exit_code.js": `
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const child = spawn('sleep', ['30']);
|
||||
console.log(child.pid);
|
||||
|
||||
setTimeout(() => process.exit(42), 50);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "--autokill", "exit_code.js"],
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
});
|
||||
|
||||
const [output, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(42);
|
||||
|
||||
const childPid = parseInt(output.trim());
|
||||
expect(childPid).toBeGreaterThan(0);
|
||||
|
||||
// Wait for autokill to take effect (polling with timeout)
|
||||
const died = await waitForProcessDeath(childPid, 1000);
|
||||
expect(died).toBe(true);
|
||||
|
||||
// Clean up if somehow still alive
|
||||
try {
|
||||
process.kill(childPid, "SIGKILL");
|
||||
} catch {
|
||||
// Expected - process should be dead
|
||||
}
|
||||
});
|
||||
|
||||
test("without autokill flag, child processes remain alive", async () => {
|
||||
const dir = tempDirWithFiles("no-autokill", {
|
||||
"no_kill.js": `
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const child = spawn('sleep', ['5']);
|
||||
console.log(child.pid);
|
||||
|
||||
setTimeout(() => process.exit(0), 50);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "no_kill.js"], // No --autokill flag
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
});
|
||||
|
||||
const [output, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const childPid = parseInt(output.trim());
|
||||
expect(childPid).toBeGreaterThan(0);
|
||||
|
||||
// Without autokill, child should remain alive
|
||||
// Poll to verify it stays alive for at least 100ms
|
||||
let alive = false;
|
||||
const deadline = Date.now() + 100;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
process.kill(childPid, 0);
|
||||
alive = true;
|
||||
await Bun.sleep(10);
|
||||
} catch {
|
||||
// Process died prematurely
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
try {
|
||||
process.kill(childPid, "SIGKILL");
|
||||
} catch {
|
||||
// Process might have exited
|
||||
}
|
||||
|
||||
// Without autokill, the child should have been alive
|
||||
expect(alive).toBe(true);
|
||||
});
|
||||
|
||||
test("autokill handles rapid process spawning", async () => {
|
||||
const dir = tempDirWithFiles("autokill-rapid", {
|
||||
"rapid_spawn.js": `
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const pids = [];
|
||||
|
||||
// Rapidly spawn processes
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const child = spawn('sleep', ['30']);
|
||||
pids.push(child.pid);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(pids));
|
||||
|
||||
// Exit immediately after spawning
|
||||
process.exit(0);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "--autokill", "rapid_spawn.js"],
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
});
|
||||
|
||||
const [output, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const pids = JSON.parse(output.trim());
|
||||
expect(pids).toBeArray();
|
||||
expect(pids.length).toBe(10);
|
||||
|
||||
// Wait for all processes to die (polling with timeout)
|
||||
for (const pid of pids) {
|
||||
const died = await waitForProcessDeath(pid, 1000);
|
||||
expect(died).toBe(true);
|
||||
// Clean up if somehow still alive
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// Expected - process should be dead
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("autokill preserves exit code", async () => {
|
||||
const dir = tempDirWithFiles("autokill-exit-preserve", {
|
||||
"preserve_exit.js": `
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
spawn('sleep', ['30']);
|
||||
|
||||
setTimeout(() => process.exit(123), 50);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "--autokill", "preserve_exit.js"],
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
});
|
||||
|
||||
const [output, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
// Exit code should be preserved even with autokill
|
||||
expect(exitCode).toBe(123);
|
||||
});
|
||||
|
||||
test("autokill handles processes that spawn during tree walking", async () => {
|
||||
const dir = tempDirWithFiles("autokill-concurrent", {
|
||||
"concurrent_spawn.js": `
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// Spawn a shell that continuously spawns children
|
||||
const spawner = spawn('sh', ['-c', \`
|
||||
for i in 1 2 3 4 5; do
|
||||
sleep 30 &
|
||||
sleep 0.01
|
||||
done
|
||||
wait
|
||||
\`]);
|
||||
|
||||
console.log(spawner.pid);
|
||||
|
||||
// Exit after a short delay to trigger autokill during spawning
|
||||
setTimeout(() => process.exit(0), 100);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "--autokill", "concurrent_spawn.js"],
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
});
|
||||
|
||||
const [output, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const spawnerPid = parseInt(output.trim());
|
||||
expect(spawnerPid).toBeGreaterThan(0);
|
||||
|
||||
// Wait for autokill to handle concurrent spawning (polling with timeout)
|
||||
const died = await waitForProcessDeath(spawnerPid, 1000);
|
||||
expect(died).toBe(true);
|
||||
|
||||
// Clean up if somehow still alive
|
||||
try {
|
||||
process.kill(spawnerPid, "SIGKILL");
|
||||
} catch {
|
||||
// Expected - process should be dead
|
||||
}
|
||||
});
|
||||
|
||||
test("autokill works with different signal handlers", async () => {
|
||||
const dir = tempDirWithFiles("autokill-signals", {
|
||||
"signal_handlers.js": `
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// Set up signal handlers
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('Got SIGTERM');
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('Got SIGINT');
|
||||
});
|
||||
|
||||
const child = spawn('sleep', ['30']);
|
||||
console.log(child.pid);
|
||||
|
||||
setTimeout(() => process.exit(0), 50);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "--autokill", "signal_handlers.js"],
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
});
|
||||
|
||||
const [output, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const lines = output.trim().split("\n");
|
||||
const childPid = parseInt(lines[lines.length - 1]);
|
||||
expect(childPid).toBeGreaterThan(0);
|
||||
|
||||
// Wait for autokill to take effect (polling with timeout)
|
||||
const died = await waitForProcessDeath(childPid, 1000);
|
||||
expect(died).toBe(true);
|
||||
|
||||
// Clean up if somehow still alive
|
||||
try {
|
||||
process.kill(childPid, "SIGKILL");
|
||||
} catch {
|
||||
// Expected - process should be dead
|
||||
}
|
||||
});
|
||||
|
||||
test("autokill handles nested bun processes with delays", async () => {
|
||||
const dir = tempDirWithFiles("autokill-nested-bun", {
|
||||
"nested_child.js": `
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Write our PIDs to a file so the test can verify them
|
||||
const pidFile = path.join(__dirname, 'nested-pids.json');
|
||||
|
||||
// Spawn a long-running sleep process
|
||||
const sleep = spawn('sleep', ['30']);
|
||||
const pids = {
|
||||
childBun: process.pid,
|
||||
sleep: sleep.pid
|
||||
};
|
||||
|
||||
fs.writeFileSync(pidFile, JSON.stringify(pids));
|
||||
console.log('nested child ready');
|
||||
|
||||
// Keep this Bun process alive
|
||||
setTimeout(() => {}, 10000);
|
||||
`,
|
||||
"nested_parent.js": `
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// Spawn a nested Bun process with --autokill that spawns its own children
|
||||
const bunExe = process.argv[0];
|
||||
const childBun = spawn(bunExe, ['--autokill', 'nested_child.js'], {
|
||||
cwd: __dirname
|
||||
});
|
||||
|
||||
console.log('parent-bun-pid:', childBun.pid);
|
||||
|
||||
// Exit after a delay, triggering autokill on parent and nested child
|
||||
setTimeout(() => process.exit(0), 200);
|
||||
`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "--autokill", "nested_parent.js"],
|
||||
cwd: dir,
|
||||
env: bunEnv,
|
||||
});
|
||||
|
||||
const [output, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Parse parent PID from output
|
||||
const lines = output.trim().split("\n");
|
||||
const parentBunPid = parseInt(
|
||||
lines
|
||||
.find(l => l.includes("parent-bun-pid:"))
|
||||
?.split(":")[1]
|
||||
?.trim() || "0",
|
||||
);
|
||||
|
||||
expect(parentBunPid).toBeGreaterThan(0);
|
||||
|
||||
// Wait for autokill to complete all three passes (polling with timeout)
|
||||
const parentDied = await waitForProcessDeath(parentBunPid, 1000);
|
||||
expect(parentDied).toBe(true);
|
||||
|
||||
// Clean up if somehow still alive
|
||||
try {
|
||||
process.kill(parentBunPid, "SIGKILL");
|
||||
} catch {
|
||||
// Expected - process should be dead
|
||||
}
|
||||
|
||||
// Check if we can read the nested PIDs file and verify those processes are dead too
|
||||
const pidFile = `${dir}/nested-pids.json`;
|
||||
try {
|
||||
const pidData = await Bun.file(pidFile).text();
|
||||
const pids = JSON.parse(pidData);
|
||||
|
||||
// Verify nested child Bun and its sleep are both dead
|
||||
for (const [name, pid] of Object.entries(pids)) {
|
||||
const died = await waitForProcessDeath(pid as number, 1000);
|
||||
expect(died).toBe(true);
|
||||
// Clean up if somehow still alive
|
||||
try {
|
||||
process.kill(pid as number, "SIGKILL");
|
||||
} catch {
|
||||
// Expected - process should be dead
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// PID file might not exist if timing was off, but parent being dead is sufficient
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user