zeroclaw-labs/zeroclaw · error · std::io::Error
local IPC endpoint is already serving at {}
Error message
local IPC endpoint is already serving at {} What it means
Raised by remove_stale (crates/zeroclaw-runtime/src/rpc/local.rs:474, called from bind_locked before UnixListener::bind) when the socket path exists and a probe UnixStream::connect(path) succeeds. A successful connect proves a live server is accepting connections on that endpoint, so zeroclaw refuses to treat the socket as stale debris and reports ErrorKind::AddrInUse rather than unlinking a working endpoint out from under its owner.
Source
Thrown at crates/zeroclaw-runtime/src/rpc/local.rs:474
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
.await
.ok();
}
Ok(())
}
pub async fn remove_stale(path: &Path) -> Result<()> {
let observed = match tokio::fs::symlink_metadata(path).await {
Ok(metadata) => SocketIdentity::from_metadata(&metadata),
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error).context("inspecting local IPC endpoint"),
};
match UnixStream::connect(path).await {
Ok(_) => Err(std::io::Error::new(
ErrorKind::AddrInUse,
format!(
"local IPC endpoint is already serving at {}",
path.display()
),
)
.into()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
Err(error) if error.kind() != ErrorKind::ConnectionRefused => {
Err(error).context("probing existing local IPC endpoint")
}
Err(_) => {
let current = match tokio::fs::symlink_metadata(path).await {
Ok(metadata) => SocketIdentity::from_metadata(&metadata),
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(error).context("rechecking stale local IPC endpoint");
}View on GitHub (pinned to 88bb9c8533)
Solutions
- Identify and stop the existing listener: `ss -xlp | grep <path>` or `lsof <path>` shows the PID; stop that process (or reuse it as your endpoint).
- If both services are legitimately needed, move one to a different socket path (own data dir or unique ZEROCLAW_SOCKET).
- Prefer the default per-data-dir socket (data_dir/daemon.sock) whose lifecycle lock already gives single-ownership, instead of hand-picked paths in shared directories.
- If the other listener is orphaned, kill its PID; once it exits, connect probes fail with ECONNREFUSED and remove_stale will clean the path automatically.
Example fix
// before: bind onto a path another live server is using
let (listener, guard) = rpc::local::bind(Path::new("/tmp/shared.sock")).await?; // AddrInUse: already serving
// after: probe first and fail with a clear diagnosis (or pick another path)
let path = Path::new("/tmp/shared.sock");
if tokio::net::UnixStream::connect(path).await.is_ok() {
anyhow::bail!("another server is already serving at {}; stop it or choose a different ZEROCLAW_SOCKET", path.display());
}
let (listener, guard) = rpc::local::bind(path).await?; Defensive patterns
Strategy: validation
Validate before calling
use tokio::net::UnixStream;
async fn socket_has_live_server(path: &std::path::Path) -> bool {
// remove_stale uses exactly this probe: connect() success == live listener.
UnixStream::connect(path).await.is_ok()
}
// if socket_has_live_server(&path).await { /* reuse it or bail before bind() */ } Type guard
fn is_addr_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 is_addr_in_use(&e) => {
eprintln!("a live server is already accepting at {}; reuse it or pick another path", path.display());
std::process::exit(1);
}
Err(e) => return Err(e),
} Prevention
- Prefer the default per-data-dir daemon.sock, whose lifecycle lock makes this conflict impossible, over hand-picked paths in shared directories.
- Do not point multiple deployments at one ZEROCLAW_SOCKET; namespace sockets per service or per user.
- Before binding, probe the socket with a connect and reuse the existing daemon when it answers.
- When reusing external socket dirs like /tmp, include an instance-unique name (pid/user/app) to avoid collisions.
When it happens
Trigger: Calling bind(path) (via bind_locked -> remove_stale) when some process is actively listening on the path and accepting connections. Typical when the socket lives in a shared/external directory (e.g. ZEROCLAW_SOCKET pointing into /tmp) where the lifecycle lock does not confer exclusive ownership, or when a non-cooperating process (another service, a leftover agent of a different version) bound the same path.
Common situations: Pointing two different daemon deployments at the same ZEROCLAW_SOCKET in a world-writable dir like /tmp; a socket path colliding with another application's Unix socket; running against a socket served by a container/host process the caller does not manage; leftover socket from a daemon started outside systemd while configuring a new unit.
Related errors
- local IPC endpoint lifecycle is already owned at {}
- local IPC endpoint changed while being probed at {}
- purge_agent not supported by this memory backend
- rename_agent not supported by this memory backend
- memory backend '{}' does not support StoreOptions kind/pinne
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/2de90102356ba715.
Report an issue: GitHub.