You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Behavior bug (correctness) — silent no-op in a safety-critical cleanup path, observable as launchd respawn churn and log-file bloat.
Summary
On macOS, findGatewayPidsOnPortSync in src/infra/restart-stale-pids.ts never recognizes live openclaw gateway processes. parsePidsFromLsofOutput filters candidate PIDs by checking whether the lsof -Fpc command-name token contains "openclaw", but the openclaw gateway rewrites its argv[0] to "openclaw-gateway" after exec while the kernel's p_comm field (what lsof reports) stays "node". The filter therefore drops every real gateway PID, and cleanStaleGatewayProcessesSync silently no-ops even when there is a verifiable gateway listening on the port.
The result is a self-sustaining launchd KeepAlive respawn loop any time the managed PID changes: the new spawn can't detect the existing listener, fails EADDRINUSE, exits, and launchd spawns another one under ThrottleInterval. With the default plist written by openclaw doctor --repair (KeepAlive=true, RunAtLoad=true, ThrottleInterval=1), the cadence is one failed respawn every ~18 seconds, indefinitely.
Impact
Every macOS install running the gateway under launchd with KeepAlive=true is vulnerable. The bug is latent: the managed gateway itself keeps serving traffic, so functionality isn't visibly broken — the observable damage is respawn churn and log-file growth.
gateway.err.log grows quickly (a few MB/day under normal conditions, tens of MB/day once the loop is sustained). After a few days of uptime the file routinely hits tens to hundreds of MB, each EADDRINUSE cycle emitting ~7 lines.
The same filter is shared with pollPortOnceUnix, so waitForPortFreeSync returns {free: true} while the port is genuinely busy. This additionally masks the condition from any diagnostic that relies on that poll.
launchctl list reports runs=N but N is misleadingly low because failed spawns that exit in under MinimumRuntime=10 don't increment the counter. The orphan loop is therefore invisible to a casual launchctl print check — you have to watch ps directly, or see the log growth.
Root cause
In src/infra/restart-stale-pids.ts, parsePidsFromLsofOutput:
currentCmd comes from lsof -Fpc output — the c field is the kernel p_comm / BSD_COMM field. On macOS (and Linux), that field is the basename of the exec'd binary as recorded at execve time, truncated to MAXCOMLEN. For a node-based gateway it is "node". The gateway sets its own process title via process.title = "openclaw-gateway" (or equivalent argv[0] rewrite) after startup — ps reads this rewritten argv[0] and prints "openclaw-gateway", but lsof does not; lsof stays with p_comm.
Because "node".includes("openclaw") is false, every real gateway PID is dropped. The outer filter pid !== process.pid is then irrelevant because the set is already empty.
The Windows code path in the same file (filterVerifiedWindowsGatewayPids / isGatewayArgv) does not rely on a comm filter — it inspects the full process argv via PowerShell and matches against openclaw entry-point patterns. That's the correct abstraction; the Unix path was missing the equivalent step.
Reproduction
Install openclaw on macOS.
openclaw doctor --repair to install the default LaunchAgent with KeepAlive=true, RunAtLoad=true, ThrottleInterval=1.
openclaw gateway start.
Trigger any scenario where launchd's currently-tracked gateway PID exits briefly (e.g., the gateway's own in-process restart path, a /restart command, a direct kill <launchd-tracked-pid> where the actual listening PID is a different orphan).
Tail ~/.openclaw/logs/gateway.err.log. Every ~18 seconds a fresh spawn reports:
[gateway] ⚠️ Gateway is binding to a non-loopback address. …
Gateway failed to start: another gateway instance is already listening on ws://0.0.0.0:18789 | listen EADDRINUSE: address already in use 0.0.0.0:18789
If the gateway is supervised, stop it with: openclaw gateway stop
Port 18789 is already in use.
- pid <N> <user>: openclaw-gateway (*:18789)
- Gateway already running locally. …
Tail ~/.openclaw/logs/gateway.log for service-mode: cleared N stale gateway pid(s) before bind on port — the line never appears, because cleanStaleGatewayProcessesSync silently returned [].
Optional definitive proof: launchctl bootout gui/$UID/ai.openclaw.gateway for ~60s and observe that no new orphan spawns occur. Re-bootstrap; the cycle resumes. This rules out any non-launchd source.
Proposed fix
Remove the lsof-comm-based filter and add a ps-based argv verifier on Unix, mirroring the Windows path's structure.
parsePidsFromLsofOutput returns every listening PID (minus process.pid). No gateway-vs-other classification at the parse layer.
New verifyGatewayPidByArgvSync(pid) helper: runs ps -ww -p <pid> -o command= and matches the result against the openclaw-gateway argv[0] rewrite and common entry-file patterns (/dist/index.js gateway, /openclaw.mjs gateway, /openclaw gateway, openclaw_repo…gateway for dev-mode invocations).
findGatewayPidsOnPortSync on Unix runs lsof, then filters the returned PIDs through verifyGatewayPidByArgvSync.
Optional, separable hardening:
Bump PORT_FREE_TIMEOUT_MS from 2000 to 30000. Under load the kernel can take multiple seconds to release a TCP socket after SIGKILL (TIME_WAIT + teardown), and the current 2 s window doesn't leave useful slack.
I have a working patch against current main. Gateway.err.log growth halted, service-mode: cleared 1 stale gateway pid(s) began appearing correctly, and the EADDRINUSE respawn cycle was broken. Happy to open a PR.
Environment
macOS (Apple Silicon, Darwin 25.x)
openclaw 2026.4.x
Node 25.x
Default LaunchAgent as written by openclaw doctor --repair
PR fix: stale pid cleaner race condition (SIGTERM cascade) #61948 — fix: stale pid cleaner race condition (SIGTERM cascade). Adjacent fix in the same file, different bug (selfPid vs callerPid passthrough). Complementary — the PR's fix is load-bearing once the comm-filter bug is fixed, because real gateway PIDs will start being returned.
commit 8aadca4c3e (fix(infra/restart): exclude ancestor pids from stale-gateway cleanup) — already in main. Extends the self-pid filter to self+ancestors. Complementary — it assumes findGatewayPidsOnPortSync returns correct PIDs; on macOS, today, it doesn't.
This bug is orthogonal to all of the above. It's been silently present on every macOS install since the comm-filter was introduced, and no issue on the tracker names it. Filing this to get it on the record.
Bug type
Behavior bug (correctness) — silent no-op in a safety-critical cleanup path, observable as launchd respawn churn and log-file bloat.
Summary
On macOS,
findGatewayPidsOnPortSyncinsrc/infra/restart-stale-pids.tsnever recognizes live openclaw gateway processes.parsePidsFromLsofOutputfilters candidate PIDs by checking whether the lsof-Fpccommand-name token contains"openclaw", but the openclaw gateway rewrites itsargv[0]to"openclaw-gateway"after exec while the kernel'sp_commfield (what lsof reports) stays"node". The filter therefore drops every real gateway PID, andcleanStaleGatewayProcessesSyncsilently no-ops even when there is a verifiable gateway listening on the port.The result is a self-sustaining launchd KeepAlive respawn loop any time the managed PID changes: the new spawn can't detect the existing listener, fails EADDRINUSE, exits, and launchd spawns another one under
ThrottleInterval. With the default plist written byopenclaw doctor --repair(KeepAlive=true,RunAtLoad=true,ThrottleInterval=1), the cadence is one failed respawn every ~18 seconds, indefinitely.Impact
KeepAlive=trueis vulnerable. The bug is latent: the managed gateway itself keeps serving traffic, so functionality isn't visibly broken — the observable damage is respawn churn and log-file growth.gateway.err.loggrows quickly (a few MB/day under normal conditions, tens of MB/day once the loop is sustained). After a few days of uptime the file routinely hits tens to hundreds of MB, each EADDRINUSE cycle emitting ~7 lines.pollPortOnceUnix, sowaitForPortFreeSyncreturns{free: true}while the port is genuinely busy. This additionally masks the condition from any diagnostic that relies on that poll.launchctl listreportsruns=Nbut N is misleadingly low because failed spawns that exit in underMinimumRuntime=10don't increment the counter. The orphan loop is therefore invisible to a casuallaunchctl printcheck — you have to watchpsdirectly, or see the log growth.Root cause
In
src/infra/restart-stale-pids.ts,parsePidsFromLsofOutput:currentCmdcomes from lsof-Fpcoutput — thecfield is the kernelp_comm/BSD_COMMfield. On macOS (and Linux), that field is the basename of the exec'd binary as recorded atexecvetime, truncated toMAXCOMLEN. For a node-based gateway it is"node". The gateway sets its own process title viaprocess.title = "openclaw-gateway"(or equivalent argv[0] rewrite) after startup —psreads this rewritten argv[0] and prints"openclaw-gateway", but lsof does not; lsof stays withp_comm.Because
"node".includes("openclaw")is false, every real gateway PID is dropped. The outer filterpid !== process.pidis then irrelevant because the set is already empty.Confirmed locally with a stock install:
The Windows code path in the same file (
filterVerifiedWindowsGatewayPids/isGatewayArgv) does not rely on a comm filter — it inspects the full process argv via PowerShell and matches against openclaw entry-point patterns. That's the correct abstraction; the Unix path was missing the equivalent step.Reproduction
Install openclaw on macOS.
openclaw doctor --repairto install the default LaunchAgent withKeepAlive=true,RunAtLoad=true,ThrottleInterval=1.openclaw gateway start.Trigger any scenario where launchd's currently-tracked gateway PID exits briefly (e.g., the gateway's own in-process restart path, a
/restartcommand, a directkill <launchd-tracked-pid>where the actual listening PID is a different orphan).Tail
~/.openclaw/logs/gateway.err.log. Every ~18 seconds a fresh spawn reports:Tail
~/.openclaw/logs/gateway.logforservice-mode: cleared N stale gateway pid(s) before bind on port— the line never appears, becausecleanStaleGatewayProcessesSyncsilently returned[].Optional definitive proof:
launchctl bootout gui/$UID/ai.openclaw.gatewayfor ~60s and observe that no new orphan spawns occur. Re-bootstrap; the cycle resumes. This rules out any non-launchd source.Proposed fix
Remove the lsof-comm-based filter and add a
ps-based argv verifier on Unix, mirroring the Windows path's structure.parsePidsFromLsofOutputreturns every listening PID (minusprocess.pid). No gateway-vs-other classification at the parse layer.verifyGatewayPidByArgvSync(pid)helper: runsps -ww -p <pid> -o command=and matches the result against the openclaw-gateway argv[0] rewrite and common entry-file patterns (/dist/index.js gateway,/openclaw.mjs gateway,/openclaw gateway,openclaw_repo…gatewayfor dev-mode invocations).findGatewayPidsOnPortSyncon Unix runs lsof, then filters the returned PIDs throughverifyGatewayPidByArgvSync.Optional, separable hardening:
PORT_FREE_TIMEOUT_MSfrom2000to30000. Under load the kernel can take multiple seconds to release a TCP socket after SIGKILL (TIME_WAIT + teardown), and the current 2 s window doesn't leave useful slack.I have a working patch against current
main. Gateway.err.log growth halted,service-mode: cleared 1 stale gateway pid(s)began appearing correctly, and the EADDRINUSE respawn cycle was broken. Happy to open a PR.Environment
openclaw doctor --repairRelated (checked before filing)
selfPidvscallerPidpassthrough). Complementary — the PR's fix is load-bearing once the comm-filter bug is fixed, because real gateway PIDs will start being returned.8aadca4c3e(fix(infra/restart): exclude ancestor pids from stale-gateway cleanup) — already in main. Extends the self-pid filter to self+ancestors. Complementary — it assumesfindGatewayPidsOnPortSyncreturns correct PIDs; on macOS, today, it doesn't.This bug is orthogonal to all of the above. It's been silently present on every macOS install since the comm-filter was introduced, and no issue on the tracker names it. Filing this to get it on the record.