Summary
#71662 was closed via PR #71681, which made killProcessTreeUnix in src/process/kill-tree.ts skip the process.kill(-pid, "SIGTERM") group-kill only when callers explicitly pass { detached: false }. That's an opt-in fix: any caller that omits the option still triggers the original bug.
On v2026.4.29 (today's published build), reproducing the original #71662 scenario on macOS still SIGTERMs the gateway. The doc comment in kill-tree-BV5lbQ9R.js describes the failure mode correctly but the implementation expresses the fix as a contract for callers to follow rather than enforcing it on the kill side.
Environment
- openclaw
2026.4.29
- macOS Darwin 25.3.0 (Sequoia, arm64)
- node@22 v22.22.0
- launchd-managed gateway (
KeepAlive=true)
Reproduction
- Configure any agent (observed on both
claude-cli/claude-sonnet-4-6 and openai-codex/gpt-5.5)
- Send a Telegram message that causes a hung dispatch — e.g. when the runtime stalls without producing output and the agent CLI child eventually exits
- ~10–15s after the agent CLI child becomes a zombie and the gateway reaps it, the gateway receives SIGTERM, exits via
handleStopAfterServerClose, and KeepAlive respawns
- Direct
openclaw agent --message … does NOT reproduce — different dispatch path; falls back to the embedded in-process agent
Evidence
- exit-trace preload (
process.on("SIGTERM", …)) captures every kill: Signal.callbackTrampoline (kernel-delivered) → handleStopAfterServerClose → exitProcess(0)
- A PATH-resolved
launchctl shim that logs every invocation never fires during any kill cycle — rules out external launchctl paths
- dtrace under default macOS SIP can't open
proc:::signal-send or syscall::kill:entry, so the sender pid wasn't directly identifiable; root cause inferred from source-code analysis after ruling out cron, dashboard, mission-control, and startup-healthcheck plists
- Process-snapshot loop captured the timing: claude-cli child becomes zombie at T+0 → gateway reaps via waitpid → gateway SIGTERM'd at T+12s — fits "killProcessTree on a non-leader child does
process.kill(-pid, …) which targets the gateway's group"
Why #71681 didn't fully fix it
function killProcessTree(pid, opts) {
if (!Number.isFinite(pid) || pid <= 0) return;
const graceMs = normalizeGraceMs(opts?.graceMs);
if (process.platform === "win32") { … return; }
killProcessTreeUnix(pid, graceMs, opts?.detached !== false); // <-- defaults to group-kill
}
useGroupKill defaults to true. The only way to suppress it is { detached: false }. But process.kill(-pid, …) is unsafe for any non-group-leader child regardless of how it was spawned: if pid is not its own group leader, the negated form targets whatever group pid belongs to — typically the gateway's, when the gateway is launchd-managed.
Suggested fix: enforce the invariant on the kill side
process.kill(-pid, …) is only meaningful when pid is the leader of its own process group. That precondition is checkable directly:
import { spawn, spawnSync } from "node:child_process";
function isProcessGroupLeader(pid) {
try {
const res = spawnSync("ps", ["-p", String(pid), "-o", "pgid="], {
encoding: "utf8",
timeout: 500,
});
if (res.error || res.status !== 0) return false;
const pgid = Number.parseInt(res.stdout.trim(), 10);
return Number.isFinite(pgid) && pgid === pid;
} catch {
return false;
}
}
function killProcessTree(pid, opts) {
if (!Number.isFinite(pid) || pid <= 0) return;
const graceMs = normalizeGraceMs(opts?.graceMs);
if (process.platform === "win32") {
killProcessTreeWindows(pid, graceMs);
return;
}
const callerOptedOut = opts?.detached === false;
const useGroupKill = !callerOptedOut && isProcessGroupLeader(pid);
killProcessTreeUnix(pid, graceMs, useGroupKill);
}
Behavior:
- A child spawned with
detached: true becomes its own group leader → pgid === pid → group-killed (descendants included), unchanged from today.
- A child spawned with default
detached: false shares its parent's group → pgid !== pid → falls back to single-pid kill, gateway untouched.
{ detached: false } is preserved as an explicit caller escape hatch (some callers may want to force single-pid kill even on group leaders).
This way the kill-tree module no longer relies on caller cooperation. Any of bash-tools, pi-bundle-mcp-runtime, pi-bundle-lsp-runtime, supervisor, restart-health, schtasks, etc. that currently omit the option would stop being booby traps.
Patched bundle has been running ~5 minutes on the affected install with no recurrence.
Related
Summary
#71662 was closed via PR #71681, which made
killProcessTreeUnixinsrc/process/kill-tree.tsskip theprocess.kill(-pid, "SIGTERM")group-kill only when callers explicitly pass{ detached: false }. That's an opt-in fix: any caller that omits the option still triggers the original bug.On v2026.4.29 (today's published build), reproducing the original #71662 scenario on macOS still SIGTERMs the gateway. The doc comment in
kill-tree-BV5lbQ9R.jsdescribes the failure mode correctly but the implementation expresses the fix as a contract for callers to follow rather than enforcing it on the kill side.Environment
2026.4.29KeepAlive=true)Reproduction
claude-cli/claude-sonnet-4-6andopenai-codex/gpt-5.5)handleStopAfterServerClose, and KeepAlive respawnsopenclaw agent --message …does NOT reproduce — different dispatch path; falls back to the embedded in-process agentEvidence
process.on("SIGTERM", …)) captures every kill:Signal.callbackTrampoline(kernel-delivered) →handleStopAfterServerClose→exitProcess(0)launchctlshim that logs every invocation never fires during any kill cycle — rules out externallaunchctlpathsproc:::signal-sendorsyscall::kill:entry, so the sender pid wasn't directly identifiable; root cause inferred from source-code analysis after ruling out cron, dashboard, mission-control, and startup-healthcheck plistsprocess.kill(-pid, …)which targets the gateway's group"Why #71681 didn't fully fix it
useGroupKilldefaults totrue. The only way to suppress it is{ detached: false }. Butprocess.kill(-pid, …)is unsafe for any non-group-leader child regardless of how it was spawned: ifpidis not its own group leader, the negated form targets whatever grouppidbelongs to — typically the gateway's, when the gateway is launchd-managed.Suggested fix: enforce the invariant on the kill side
process.kill(-pid, …)is only meaningful whenpidis the leader of its own process group. That precondition is checkable directly:Behavior:
detached: truebecomes its own group leader →pgid === pid→ group-killed (descendants included), unchanged from today.detached: falseshares its parent's group →pgid !== pid→ falls back to single-pid kill, gateway untouched.{ detached: false }is preserved as an explicit caller escape hatch (some callers may want to force single-pid kill even on group leaders).This way the
kill-treemodule no longer relies on caller cooperation. Any ofbash-tools,pi-bundle-mcp-runtime,pi-bundle-lsp-runtime,supervisor,restart-health,schtasks, etc. that currently omit the option would stop being booby traps.Patched bundle has been running ~5 minutes on the affected install with no recurrence.
Related
restart-stale-pids(also valid but not the killer in this scenario)