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
- Run gateway on macOS under
launchctl (managed install)
- Send a Telegram message to a configured agent
- Gateway receives SIGTERM ~10–15s after the agent CLI child is reaped, exits cleanly through
handleStopAfterServerClose, KeepAlive respawns
- 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: SIGTERM → Signal.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
Summary
The fix for #68451 (excluding the caller's ancestor chain in
cleanStaleGatewayProcessesSync/signalVerifiedGatewayPidSyncso a sidecar can't SIGTERM the gateway it lives under) was only implemented for Linux. On macOS,getSelfAndAncestorPidsSync()insrc/infra/restart-stale-pids.tsstops atprocess.ppidand 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
2026.4.29The bug
readParentPidFromProcreads/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
launchctl(managed install)handleStopAfterServerClose, KeepAlive respawnsopenclaw agent --message …does NOT reproduce — that path falls back to an embedded in-process agent and doesn't go through whichever sidecar runs cleanupEvidence
--requireconsistently capturessignal received: SIGTERM→Signal.callbackTrampoline(kernel delivery; not internalprocess.exit)process.kill()from a node process, notlaunchctl killdtrace'sproc:::signal-sendandsyscall::kill:entryboth 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 plistsSuggested fix
ps -p <pid> -o ppid=is portable BSD/macOS, prints only the parent PID with no header. Each step is aspawnSynccapped at 500ms; withMAX_ANCESTOR_WALK_DEPTH = 32the 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
lsof p_comm vs argv[0] mismatch)