Skip to content

macOS: getSelfAndAncestorPidsSync only walks /proc on Linux, leaving #68451 unfixed for grandchild callers #76254

Description

@GulfStreamOutdoors

Summary

The fix for #68451 (excluding the caller's ancestor chain in cleanStaleGatewayProcessesSync / signalVerifiedGatewayPidSync so a sidecar can't SIGTERM the gateway it lives under) was only implemented for Linux. On macOS, getSelfAndAncestorPidsSync() in src/infra/restart-stale-pids.ts stops at process.ppid and never walks further. Any process more than one level deep that calls these cleanup functions will SIGTERM the live gateway because lsof reports it on the gateway port and the macOS exclusion set doesn't cover the grandparent.

Environment

  • openclaw 2026.4.29
  • macOS Darwin 25.3.0 (Sequoia, arm64)
  • node@22 v22.22.0

The bug

function getSelfAndAncestorPidsSync() {
    const pids = new Set([process.pid]);
    const immediateParent = getParentPid();
    if (!Number.isFinite(immediateParent) || immediateParent <= 0) return pids;
    pids.add(immediateParent);
    if (process.platform !== "linux") return pids;  // <-- macOS exits here
    // ...walk via /proc/<pid>/status, Linux-only...
}

readParentPidFromProc reads /proc/<pid>/status — doesn't exist on macOS. The bug-report-as-comment immediately above this function describes the exact failure mode that's still live on macOS.

Reproduction

  1. Run gateway on macOS under launchctl (managed install)
  2. Send a Telegram message to a configured agent
  3. Gateway receives SIGTERM ~10–15s after the agent CLI child is reaped, exits cleanly through handleStopAfterServerClose, KeepAlive respawns
  4. Direct openclaw agent --message … does NOT reproduce — that path falls back to an embedded in-process agent and doesn't go through whichever sidecar runs cleanup

Evidence

  • exit-trace preload via Node --require consistently captures signal received: SIGTERMSignal.callbackTrampoline (kernel delivery; not internal process.exit)
  • launchctl PATH-shim never fires → killer is process.kill() from a node process, not launchctl kill
  • dtrace's proc:::signal-send and syscall::kill:entry both blocked under default macOS SIP, so the sender process couldn't be identified directly; root cause confirmed via source analysis after ruling out cron / Mission Control / dashboard / startup-healthcheck plists
  • Process-snapshot loop showed: claude-cli agent child becomes zombie at T+0, gateway reaps via waitpid, gateway gets SIGTERM at T+12s — fits "sidecar spawned post-reap → lsof → exclusion-passes-grandparent → kill"

Suggested fix

function readParentPidViaPs(pid) {
    try {
        const res = spawnSync("ps", ["-p", String(pid), "-o", "ppid="], { encoding: "utf8", timeout: 500 });
        if (res.error || res.status !== 0) return null;
        const parsed = Number.parseInt(res.stdout.trim(), 10);
        return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
    } catch { return null; }
}

function getSelfAndAncestorPidsSync() {
    const pids = new Set([process.pid]);
    const immediateParent = getParentPid();
    if (!Number.isFinite(immediateParent) || immediateParent <= 0) return pids;
    pids.add(immediateParent);
    if (process.platform === "win32") return pids;
    let current = immediateParent;
    for (let depth = 0; depth < MAX_ANCESTOR_WALK_DEPTH; depth++) {
        const parent = process.platform === "linux"
            ? readParentPidFromProc(current)
            : readParentPidViaPs(current);
        if (parent == null || parent <= 0 || pids.has(parent)) break;
        pids.add(parent);
        current = parent;
    }
    return pids;
}

ps -p <pid> -o ppid= is portable BSD/macOS, prints only the parent PID with no header. Each step is a spawnSync capped at 500ms; with MAX_ANCESTOR_WALK_DEPTH = 32 the worst case is bounded and the typical cost is small (~50ms to walk 7 levels to launchd in local testing). Bundle patched locally has been stable for ~30 min on the affected install with no recurrence.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal backlog priority with limited blast radius.impact:crash-loopCrash, hang, restart loop, or process-level availability failure.staleMarked as stale due to inactivity

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions