Skip to content

macOS: #71662 regressed in v2026.4.29 — killProcessTree group-kill defaults to true and SIGTERMs the gateway when callers omit { detached: false } #76259

Description

@GulfStreamOutdoors

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

  1. Configure any agent (observed on both claude-cli/claude-sonnet-4-6 and openai-codex/gpt-5.5)
  2. 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
  3. ~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
  4. 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) → handleStopAfterServerCloseexitProcess(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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal backlog priority with limited blast radius.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:crash-loopCrash, hang, restart loop, or process-level availability failure.impact:session-stateSession, memory, transcript, context, or agent state can drift or corrupt.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.maturity:stableIssue affects a taxonomy feature currently scored M4/M5.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions