Gateway Lock: Singleton Guard with File Lock and WebSocket/HTTP Bind

Explains the three-layer startup ownership enforcement for Gateway processes: state ownership lock, config lock, and socket bind. For developers ensuring only one gateway owns a state directory.

Read this when

  • Running or debugging the gateway process
  • Investigating single-instance enforcement

Why

  • Only one gateway process should own a state directory. Run additional gateways with separate profiles, state directories, configs, and ports.
  • Survive crashes or SIGKILL without leaving stale lock files behind.
  • Fail fast with a clear error when another gateway already owns the port.

Three layers

Startup enforces ownership in three sequential steps:

  1. State ownership lock acquires a lock keyed to the canonical state directory. Every Gateway participates, including those started with OPENCLAW_ALLOW_MULTI_GATEWAY=1, so destructive SQLite maintenance cannot race against a live owner.
  2. Config lock acquires the historical per-config lock and records the runtime port. Multi-Gateway mode skips this config singleton but keeps the state ownership lock.
  3. Socket bind binds the HTTP/WebSocket listener (default ws://127.0.0.1:18789) as an exclusive TCP listener.

Each layer can fail independently and throws its own GatewayLockError.

State and config locks

  • Lock liveness comes from the recorded PID, platform process start identity when available, and Gateway process identity. A verified owner remains authoritative during startup before its port starts listening.

  • A dedicated SQLite coordinator serializes metadata inspection, stale-owner reclamation, and lock replacement. Its exclusive transaction is released automatically if the owning process crashes.

  • If a lock file is missing or the recorded owner process is gone, startup reclaims the lock and continues.

  • If either lock is actively held, startup retries for up to 5 seconds (default) before giving up:

    GatewayLockError("gateway already running (pid <pid>); lock timeout after <ms>ms")
    

Socket bind

  • On EADDRINUSE, startup retries the bind for up to 20 attempts at 500ms intervals (roughly 10 seconds total) to ride out a TIME_WAIT window after a recently exited process.

  • If the port is still in use after retries:

    GatewayLockError("another gateway instance is already listening on ws://127.0.0.1:<port>")
    
  • Other bind failures:

    GatewayLockError("failed to bind gateway socket on ws://127.0.0.1:<port>: <cause>")
    

On shutdown, the gateway closes the HTTP/WebSocket server and removes its state and config lock files.

Operational notes

  • If the port is occupied by a different, non-gateway process, the error is the same. Free the port or choose another with openclaw gateway --port <port>.
  • OPENCLAW_ALLOW_MULTI_GATEWAY=1 permits multiple config or runtime instances, not shared mutable state. Each instance still needs a unique OPENCLAW_STATE_DIR.
  • Under a service supervisor, a new gateway process that hits either error above first probes /healthz on the existing process. If that process is healthy, the new process leaves it in control instead of failing. On systemd, it exits with code 78. The unit's RestartPreventExitStatus=78 stops Restart=always from looping on a lock or EADDRINUSE conflict. If the existing process never becomes healthy, the health-probe retry is time-bounded and startup then fails with the lock error above instead of looping forever.
  • The macOS app keeps its own lightweight PID guard before spawning the gateway. The file lock and socket bind above are the actual runtime enforcement.