Skip to content
Open
2 changes: 1 addition & 1 deletion docs/runtime/child-process.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ interface Subprocess extends AsyncDisposable {
readonly signalCode: NodeJS.Signals | null;
readonly killed: boolean;

kill(exitCode?: number | NodeJS.Signals): void;
kill(signal?: number | NodeJS.Signals): boolean;
ref(): void;
unref(): void;

Expand Down
9 changes: 6 additions & 3 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7249,10 +7249,13 @@ declare module "bun" {
readonly killed: boolean;

/**
* Kill the process
* @param exitCode The exitCode to send to the process
* Send a signal to the process, or probe for its existence with `0`
* (POSIX-style existence check that does not terminate the child).
* @param signal The signal to send to the process. Defaults to `"SIGTERM"`.
* @returns `true` if the signal was sent (or, for `signal === 0`, the
* process is still alive); `false` if the process has already exited.
*/
Comment thread
robobun marked this conversation as resolved.
kill(exitCode?: number | NodeJS.Signals): void;
kill(signal?: number | NodeJS.Signals): boolean;
Comment thread
robobun marked this conversation as resolved.

/**
* This method will tell Bun to wait for this process to exit after you already
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down
13 changes: 5 additions & 8 deletions src/js/node/child_process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1484,15 +1484,12 @@

const handle = this.#handle;
if (handle) {
if (handle.killed) {
this.killed = true;
return true;
}

try {
handle.kill(signal);
this.killed = true;
return true;
// Don't gate on handle.killed: Bun flips it on any exit, signalled or not.
const delivered = handle.kill(signal);
// kill(0) is a POSIX existence probe, not a kill — don't mark killed.
if (delivered && signal !== 0) this.killed = true;
return delivered;

Check failure on line 1492 in src/js/node/child_process.ts

View check run for this annotation

Claude / Claude Code Review

signal !== 0 guard diverges from Node — prior review advice was incorrect

The `&& signal !== 0` guard added here in response to earlier review comment 3052094521 is based on a factually incorrect claim about Node.js — Node's `lib/internal/child_process.js` sets `this.killed = true` unconditionally on `err === 0`, with no `signal > 0` check. So in Node, `proc.kill(0)` on a live process sets `proc.killed = true`, while after this PR Bun leaves it `false`; and the assertion at `test/regression/issue/29001.test.ts:35` (`expect(proc.killed).toBe(false)` after `kill(0)` on
Comment thread
claude[bot] marked this conversation as resolved.
Comment on lines +1489 to +1492

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The && signal !== 0 guard added here in response to earlier review comment 3052094521 is based on a factually incorrect claim about Node.js — Node's lib/internal/child_process.js sets this.killed = true unconditionally on err === 0, with no signal > 0 check. So in Node, proc.kill(0) on a live process sets proc.killed = true, while after this PR Bun leaves it false; and the assertion at test/regression/issue/29001.test.ts:35 (expect(proc.killed).toBe(false) after kill(0) on a live process) would fail on real Node, locking in the divergence. Drop the && signal !== 0 clause and flip/remove that test assertion.

Extended reasoning...

What the bug is and how it manifests

The guard at src/js/node/child_process.ts:1491 reads:

if (delivered && signal !== 0) this.killed = true;

This was added in commit 3a2f98d in response to inline review comment 3052094521, which asserted that "Node.js explicitly guards this with if (signal > 0) before setting killed" and quoted a snippet of Node source containing that guard. That claim — and the quoted snippet — is factually incorrect.

What Node.js actually does

Verified directly against Node.js source on both main (line ~525) and v20.x (line ~501) at lib/internal/child_process.js:

ChildProcess.prototype.kill = function(sig) {
  const signal = sig === 0 ? sig :
    convertToValidSignal(sig === undefined ? 'SIGTERM' : sig);

  if (this._handle) {
    const err = this._handle.kill(signal);
    if (err === 0) {
      /* Success. */
      this.killed = true;     // <-- unconditional, no signal > 0 check
      return true;
    }
    ...

There is no if (signal > 0) guard anywhere in Node's kill(). The earlier review comment fabricated a code snippet that does not exist in Node.

Why this is a regression introduced by this PR

The pre-PR Bun code (visible in the diff's - lines) set this.killed = true unconditionally on the success path:

handle.kill(signal);
this.killed = true;
return true;

That matched Node. This PR's added && signal !== 0 clause introduces a new divergence — in a PR whose explicit stated goal is "matching Node, which documents childprocess.killed as set to true after subprocess.kill() is used to successfully send a signal." And per kill(2), signal 0 is successfully sent (the syscall returns 0); Node treats it as such.

The test locks in the wrong behavior

test/regression/issue/29001.test.ts:35 asserts:

expect(proc.kill(0)).toBe(true);
expect(proc.killed).toBe(false);   // <-- would FAIL on real Node

Running that second assertion against real Node.js fails, because Node sets proc.killed = true after a successful kill(0). So this PR not only introduces a Node-compat divergence, it adds a regression test that enshrines the divergence.

Step-by-step proof

  1. const proc = spawn('cat', [], { stdio: ['pipe', 'ignore', 'ignore'] }) — long-lived process.
  2. proc.kill(0) is called. sig === 0 so signal = 0.
  3. handle.kill(0) issues kill(pid, 0), which returns 0 (process alive) → delivered = true.
  4. Node.js: err === 0this.killed = true, return true. So proc.killed === true.
  5. Bun (this PR): delivered && signal !== 0true && falsethis.killed stays false, return true. So proc.killed === false.
  6. Bun (pre-PR): this.killed = true unconditionally → proc.killed === true (matched Node).

Impact

Any code that probes a live child with kill(0) and then reads proc.killed will observe different values in Bun vs. Node. This is a behavioral Node-compat regression in the node:child_process shim, introduced by this PR, based on incorrect review feedback.

How to fix

Drop the && signal !== 0 clause so the line reads:

if (delivered) this.killed = true;

and either remove the assertion at test/regression/issue/29001.test.ts:35 or flip it to expect(proc.killed).toBe(true) to match Node. The accompanying comment ("kill(0) is a POSIX existence probe, not a kill — don't mark killed") should also be removed.

} catch (e) {
this.emit("error", e);
}
Expand Down
2 changes: 1 addition & 1 deletion src/jsc/ProcessAutoKiller.zig
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ fn killProcesses(this: *ProcessAutoKiller) u32 {
defer process.key.deref();
if (!process.key.hasExited()) {
log("process.kill {d}", .{process.key.pid});
count += @as(u32, @intFromBool(process.key.kill(@intFromEnum(bun.SignalCode.default)) == .result));
count += @intFromBool(process.key.kill(@intFromEnum(bun.SignalCode.default)).isTrue());
}
}
return count;
Expand Down
35 changes: 26 additions & 9 deletions src/runtime/api/bun/process.zig
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,18 @@ pub const Process = struct {
bun.destroy(this);
}

pub fn kill(this: *Process, signal: u8) Maybe(void) {
/// Sends `signal` to the process.
///
/// Returns:
/// - `.result = true` if the signal was delivered.
/// - `.result = false` if the child could not be reached — either the
/// poller is already `.detached` (we observed the exit on our side)
/// or the OS reported `ESRCH`. Callers that just want best-effort
/// termination can ignore this, but anything JS-visible (e.g.
/// `subprocess.kill()`) needs to propagate it so Node's
/// `ChildProcess.kill()` can return `false`.
/// - `.err` for any other error.
Comment thread
claude[bot] marked this conversation as resolved.
pub fn kill(this: *Process, signal: u8) Maybe(bool) {
if (comptime Environment.isPosix) {
switch (this.poller) {
.waiter_thread, .fd => {
Expand All @@ -584,9 +595,17 @@ pub const Process = struct {
// if the process was already killed don't throw
if (errno_ != .SRCH)
return .{ .err = bun.sys.Error.fromCode(errno_, .kill) };

return .{ .result = false };
}

return .{ .result = true };
},
else => {},
// `.detached` means we never armed the poller or we already
// called `detach()` from `onExit`. Either way there is no
// live child to signal — report "not delivered" rather than
// claiming success we didn't attempt.
.detached => return .{ .result = false },
}
} else if (comptime Environment.isWindows) {
switch (this.poller) {
Expand All @@ -596,19 +615,17 @@ pub const Process = struct {
if (err.errno != @intFromEnum(bun.sys.E.SRCH)) {
return .{ .err = err };
}

return .{ .result = false };
}

return .{
.result = {},
};
return .{ .result = true };
},
else => {},
.detached => return .{ .result = false },
}
}

return .{
.result = {},
};
return .{ .result = false };
}
};

Expand Down
14 changes: 9 additions & 5 deletions src/runtime/api/bun/subprocess.zig
Original file line number Diff line number Diff line change
Expand Up @@ -394,27 +394,31 @@

if (globalThis.hasException()) return .zero;

switch (this.tryKill(sig)) {
.result => {},
// `true` when the signal was sent, `false` when the process had
// already exited (nothing to signal). Node's `ChildProcess.kill()`
// needs to see `false` to return `false` to user code.
.result => |delivered| return JSValue.jsBoolean(delivered),
.err => |err| {
// EINVAL or ENOSYS means the signal is not supported in the current platform (most likely unsupported on windows)
return globalThis.throwValue(try err.toJS(globalThis));
},
}

return .js_undefined;
}

pub fn hasKilled(this: *const Subprocess) bool {
return this.process.hasKilled();
}

pub fn tryKill(this: *Subprocess, sig: SignalCode) bun.sys.Maybe(void) {
/// Returns `.result = true` if `sig` was delivered to the child, or
/// `.result = false` if the child had already exited (either because we
/// already saw the exit on our side or the OS reported `ESRCH`).
pub fn tryKill(this: *Subprocess, sig: SignalCode) bun.sys.Maybe(bool) {
if (this.hasExited()) {
return .success;
return .{ .result = false };
}
return this.process.kill(@intFromEnum(sig));
}

Check failure on line 421 in src/runtime/api/bun/subprocess.zig

View check run for this annotation

Claude / Claude Code Review

Rust runtime not updated — Zig changes are dead code; child_process.ts regresses

The native-side fix in this PR was applied only to the `.zig` files, which per `src/CLAUDE.md` are non-compiled porting references after the Rust rewrite (#30412); the shipping Rust mirrors (`src/runtime/api/bun/subprocess.rs`, `src/spawn/process.rs`, `src/jsc/ProcessAutoKiller.rs`, `src/runtime/shell/subproc.rs`) were not touched, so `Subprocess.kill()` still returns `undefined` at runtime. Because `child_process.ts` now does `const delivered = handle.kill(signal); if (delivered && signal !== 0
Comment thread
claude[bot] marked this conversation as resolved.

fn hasCalledGetter(this: *Subprocess, comptime getter: @Type(.enum_literal)) bool {
return this.observable_getters.contains(getter);
Expand Down
4 changes: 3 additions & 1 deletion src/runtime/shell/subproc.zig
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,9 @@ pub const ShellSubprocess = struct {
return .success;
}

return this.process.kill(@intCast(sig));
// Shell kill is best-effort — drop the "delivered?" bool, keep errors.
if (this.process.kill(@intCast(sig)).asErr()) |err| return .{ .err = err };
return .success;
}

// fn hasCalledGetter(this: *Subprocess, comptime getter: @Type(.enum_literal)) bool {
Expand Down
4 changes: 2 additions & 2 deletions test/integration/bun-types/fixture/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,10 @@ function depromise<T>(_promise: Promise<T>): T {
proc.killed; // boolean — was the process killed?
proc.exitCode; // null | number
proc.signalCode; // null | "SIGABRT" | "SIGALRM" | ...
proc.kill();
tsd.expectType(proc.kill()).is<boolean>();
proc.killed; // true

proc.kill(); // specify an exit code
tsd.expectType(proc.kill(9)).is<boolean>(); // specify a signal
proc.unref();
}

Expand Down
73 changes: 73 additions & 0 deletions test/regression/issue/29001.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { spawn as bunSpawn } from "bun";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isPosix } from "harness";
import { spawn } from "node:child_process";
import { once } from "node:events";

// https://github.com/oven-sh/bun/issues/29001 — kill() must return false
// once the child has exited, matching Node.
describe.concurrent("issue #29001 — kill() reports failure after exit", () => {
test.if(isPosix)("node:child_process ChildProcess.kill() returns false after exit", async () => {
const proc = spawn(bunExe(), ["-e", "process.exit(0)"], {
env: bunEnv,
stdio: "ignore",
});

// Assert the clean exit first so a fixture crash surfaces clearly.
const [code, signal] = await once(proc, "close");
expect(code).toBe(0);
expect(signal).toBe(null);

expect(proc.kill("SIGTERM")).toBe(false);
expect(proc.kill("SIGQUIT")).toBe(false);
expect(proc.kill(0)).toBe(false);

// Child exited on its own — proc.killed only flips on our signal.
expect(proc.killed).toBe(false);
});

test.if(isPosix)("node:child_process ChildProcess.kill() returns true while alive", async () => {
const proc = spawn("cat", [], { stdio: ["pipe", "ignore", "ignore"] });

try {
// Signal 0 is an existence probe — succeeds but must not mark killed.
expect(proc.kill(0)).toBe(true);
expect(proc.killed).toBe(false);
} finally {
proc.kill("SIGKILL");
await once(proc, "close");
}
Comment thread
robobun marked this conversation as resolved.
});

// These Bun.spawn tests cover the cross-platform hasExited() fast path
// in Subprocess.tryKill (short-circuits before Process.kill), so they
// don't exercise the OS-level ESRCH branch.
test("Bun.spawn subprocess.kill() returns false after exit", async () => {
await using proc = bunSpawn({
cmd: [bunExe(), "-e", "process.exit(0)"],
env: bunEnv,
stdio: ["ignore", "ignore", "ignore"],
});

const exitCode = await proc.exited;
expect(exitCode).toBe(0);
expect(proc.signalCode).toBe(null);

expect(proc.kill("SIGTERM")).toBe(false);
expect(proc.kill(0)).toBe(false);
});

test.if(isPosix)("Bun.spawn subprocess.kill() returns true while alive", async () => {
await using proc = bunSpawn({
cmd: ["cat"],
stdio: ["pipe", "ignore", "ignore"],
});

try {
expect(proc.kill(0)).toBe(true);
} finally {
proc.kill("SIGKILL");
await proc.exited;
}
});
Comment thread
robobun marked this conversation as resolved.
});
Loading