zeroclaw-labs/zeroclaw · error · std::io::Error

local IPC endpoint lifecycle is already owned at {}

Error message

local IPC endpoint lifecycle is already owned at {}

What it means

Raised by EndpointLock::acquire (crates/zeroclaw-runtime/src/rpc/local.rs:406) when taking an advisory flock() on the companion '<socket-path>.lock' file returns WouldBlock. The lock serializes ownership of a Unix-socket IPC endpoint's lifecycle, so this error means another live process (typically a second zeroclaw daemon) currently holds the endpoint lock for the same socket path. Because flock is released automatically when the holder exits, a WouldBlock almost always indicates a genuinely running daemon, not a stale leftover.

Source

Thrown at crates/zeroclaw-runtime/src/rpc/local.rs:406

                    // A swap between open and lock would let a second daemon
                    // lock the replacement and re-enter the split-ownership
                    // race this lock exists to prevent.
                    let current = SocketIdentity::read(&lock_path).with_context(|| {
                        format!(
                            "confirming local IPC endpoint lifecycle lock {}",
                            lock_path.display()
                        )
                    })?;
                    if current != SocketIdentity::from_metadata(&metadata) {
                        anyhow::bail!(
                            "local IPC endpoint lock {} was replaced while being \
                             acquired; refusing to share lifecycle ownership",
                            lock_path.display()
                        );
                    }
                    Ok(Self { _file: file })
                }
                Err(TryLockError::WouldBlock) => Err(std::io::Error::new(
                    ErrorKind::AddrInUse,
                    format!(
                        "local IPC endpoint lifecycle is already owned at {}",
                        path.display()
                    ),
                )
                .into()),
                Err(TryLockError::Error(error)) => Err(error).with_context(|| {
                    format!(
                        "locking local IPC endpoint lifecycle at {}",
                        lock_path.display()
                    )
                }),
            }
        }
    }

    /// Removes the bound socket only while the path still names this listener.

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Check whether a daemon is already serving: connect to the socket (`zeroclaw status`, or `socat - UNIX-CONNECT:<path>` / a curl to the gateway) and reuse that instance instead of starting another.
  2. If you truly want a second instance, give it its own data dir or set ZEROCLAW_SOCKET to a distinct path so each daemon owns a different endpoint lock.
  3. Find the lock holder and stop it: `fuser <path>.lock` or `lsof <path>.lock`, then stop that process cleanly (systemctl stop zeroclaw, or kill the PID). Do not just delete the .lock file — flock state lives on the open file description, not the directory entry, and deleting it can create the split-ownership race the lock exists to prevent.
  4. If a supervisor restarts the daemon, add a pre-start check that fails when the socket answers, so restarts wait for the old process to release the lock.

Example fix

// before: blindly start a second daemon on the same socket
let (listener, guard) = rpc::local::bind(Path::new("/data/daemon.sock")).await?; // AddrInUse: lifecycle already owned

// after: detect the live owner first, and only bind when the endpoint is free
let path = Path::new("/data/daemon.sock");
if tokio::net::UnixStream::connect(path).await.is_ok() {
    anyhow::bail!("a zeroclaw daemon is already serving at {}; reuse it or set ZEROCLAW_SOCKET", path.display());
}
let (listener, guard) = rpc::local::bind(path).await?;
Defensive patterns

Strategy: validation

Validate before calling

use std::path::Path;
use tokio::net::UnixStream;

async fn endpoint_free(path: &Path) -> bool {
    // A successful connect means a live daemon owns the endpoint.
    UnixStream::connect(path).await.is_err()
}

// before spawning a daemon:
// assert!(endpoint_free(Path::new(&socket_path)).await, "daemon already running");

Type guard

fn is_endpoint_in_use(err: &anyhow::Error) -> bool {
    err.chain()
        .filter_map(|c| c.downcast_ref::<std::io::Error>())
        .any(|e| e.kind() == std::io::ErrorKind::AddrInUse)
}

Try / catch

match rpc::local::bind(&path).await {
    Ok(bound) => { /* serve */ }
    Err(e) if e.chain().any(|c| matches!(c.downcast_ref::<std::io::Error>(), Some(io_err) if io_err.kind() == std::io::ErrorKind::AddrInUse)) => {
        eprintln!("another daemon already owns {}/.lock; stop it or set ZEROCLAW_SOCKET", path.display());
        std::process::exit(1);
    }
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: Calling bind(path) / EndpointLock::acquire(path) while another process holds the flock on path.with_extension(".lock") (e.g. '<data-dir>/daemon.sock.lock'). Concretely: starting `zeroclaw daemon` a second time with the same data dir or the same ZEROCLAW_SOCKET while the first daemon is still alive (systemd service plus a manual foreground run is the classic case).

Common situations: Running the daemon manually while a systemd/user service instance is already active; two agents pointed at the same data dir; CI or dev shells reusing a shared ZEROCLAW_SOCKET; a supervisor that spawns a new daemon before the old process fully exits; running the daemon twice in two terminals by accident.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/e21bb195713aef29. Report an issue: GitHub.