Skip to content

Free port 18789 before gateway spawn, fail /healthz when dead#219

Open
kbhuw wants to merge 1 commit into
vignesh07:mainfrom
kbhuw:fix/zombie-gateway-port-free
Open

Free port 18789 before gateway spawn, fail /healthz when dead#219
kbhuw wants to merge 1 commit into
vignesh07:mainfrom
kbhuw:fix/zombie-gateway-port-free

Conversation

@kbhuw

@kbhuw kbhuw commented Apr 19, 2026

Copy link
Copy Markdown

Summary

  • When the embedded openclaw gateway hits its 10 min run timeout (or otherwise dies unexpectedly), the wrapper's gatewayProc handle is lost but the OS-level listener on port 18789 stays held by a zombie. The next gateway run spawn fails with EADDRINUSE, but the wrapper process keeps running — so the container stays "up", WebSocket accepts connections, and chat.send fires an instant empty chat:final. Every message looks stuck from the user's side.
  • Runtime image was missing lsof / procps / psmisc, so openclaw's own "port in use" recovery path (which shells out to find the owning PID) had nothing to call — logs read Port is in use but process details are unavailable (install lsof or run as an admin user).
  • /healthz always returned 200, so Railway's restartPolicyType=on_failure never recycled the container even when the gateway was dead.

Changes

  • Dockerfile: install lsof, procps, psmisc in the runtime stage.
  • src/server.js:
    • New tryFreePort(port, { signal }) helper — probes with lsof -t -i:PORT -sTCP:LISTEN (falls back to fuser), kills any matching PIDs.
    • startGateway() now calls it (SIGTERM, then escalates to SIGKILL) right before childProcess.spawn, so zombies from a previous container life can't EADDRINUSE the new spawn.
    • restartGateway() calls it after the SIGTERM + 750ms grace so a stuck child that escaped SIGTERM doesn't block the next ensureGatewayRunning().
    • /healthz returns 503 when isConfigured() but probeGateway() fails, so Railway's healthcheck can trigger a restart.
  • railway.toml: point healthcheckPath at /healthz (the gateway-aware one) instead of /setup/healthz (always 200).

Test plan

  • Fresh deploy: container boots, /healthz returns 200 with gateway.reachable: true.
  • Simulate zombie: exec into container, spawn a dummy listener on port 18789, call restartGateway via /setup/api/console/run — confirm the supervisor kills the dummy listener and the new gateway spawn succeeds.
  • Force the embedded run timeout, wait ~30s — confirm /healthz flips to 503 and Railway restarts the container.

🤖 Generated with Claude Code

The wrapper-supervised openclaw gateway occasionally hits its embedded run
timeout (10 min) and the subsequent restart fails because port 18789 is
still held by a zombie openclaw process — the supervisor's handle is null
(fresh container life) so childProcess.kill can't reach it, and
lsof/procps were missing from the runtime image so openclaw's own "port
in use" check couldn't recover either. Net effect: the container stays
up, WebSocket accepts connections, but chat.send fires an instant empty
chat:final and every message looks stuck.

- Install lsof + procps + psmisc in the runtime stage so PID-by-port
  lookups work.
- Add a tryFreePort helper that probes with lsof (then fuser) and sends
  SIGTERM / SIGKILL to any pid holding the target port, before the
  supervisor spawns a new gateway or inside restartGateway.
- /healthz now returns 503 when the gateway is configured but the
  internal port is unreachable, and railway.toml points the
  healthcheckPath at it instead of the always-200 /setup/healthz — so
  restartPolicyType=on_failure can actually recycle the container when
  the agent dies.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant