ref:d8b808ec535327b082a52f6e4d9e14392d9bfd8d

fix(runner): guard against duplicate daemons for the same runner_id (#46) (#49)

Closes half of #46 (the runner-side duplicate guard). A runner's single-instance check was keyed on the `--pid-file` path, so two daemons for the **same runner_id** but different `--pid-file` both started — and the host ran the runner twice, exceeding its `parallel` config. Found live on `carl` (parallel:1) running two CI jobs at once (a stale `anvil-runner.service` next to `anvil-runner-default.service`, using `runner.pid` vs `runner-default.pid`), which thrashed the box into CI timeouts. **Fix:** a lock keyed on the **runner_id** (`pid_file::identity_path`), acquired at startup independent of `--pid-file`, so any second daemon for the same runner is refused with a clear message. Companion server-side fix (heartbeat capacity telemetry) is in fangorn/anvil. Mitigation already applied on carl (legacy unit disabled). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
SHA: d8b808ec535327b082a52f6e4d9e14392d9bfd8d
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-07-24 04:18
Parents: b14d6e9
2 files changed +43 -0
Type
src/runner/loop_runner.rs +18 −0
@@ -68,6 +68,24 @@
let shutdown_pid_path = pid_path.clone();
let _pid_guard = pid_file::Guard::acquire(pid_path, std::process::id() as i32)?;
// Identity lock: the pid-file check above is per-instance (keyed on the
// --pid-file path), so two daemons for the SAME runner_id but different
// --pid-file (e.g. a stale service unit next to a new one) both slip
// through, and the host runs the runner twice — exceeding its `parallel`
// config. This lock is keyed on runner_id, so the second daemon is refused.
// (fangorn/anvil-cli#46)
let id_path = pid_file::identity_path(&config.runner_id);
if let Existing::Live(pid) = pid_file::inspect(&id_path) {
return Err(format!(
"another anvil runner is already running as this runner (id {}, PID {pid}); \
refusing to start a duplicate. If it isn't, remove {}",
config.runner_id,
id_path.display()
)
.into());
}
let _id_guard = pid_file::Guard::acquire(id_path, std::process::id() as i32)?;
// Validate connection
eprintln!("Connecting to {}...", config.server_url);
heartbeat::send_once(&config).await?;
src/runner/pid_file.rs +25 −0
@@ -35,6 +35,18 @@
}
}
/// Identity lock path, keyed on the **runner_id** rather than the instance /
/// `--pid-file`. Two daemons for the same runner_id (e.g. a stale service unit
/// alongside a new one, each with its own `--pid-file`) collide here and the
/// second is refused — otherwise one host runs the runner twice, exceeding its
/// `parallel` config. See fangorn/anvil-cli#46.
pub fn identity_path(runner_id: &str) -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".anvil-runner")
.join(format!("id-{runner_id}.pid"))
}
/// Result of inspecting an existing PID file.
#[derive(Debug)]
pub enum Existing {
@@ -138,6 +150,19 @@
));
std::fs::create_dir_all(&dir).unwrap();
dir.join("runner.pid")
}
#[test]
fn identity_path_is_keyed_on_runner_id_not_instance() {
// Two runner_ids must never collide; and the identity lock must be a
// different file from the per-instance pid file, so a stale service
// unit with a different --pid-file still trips the identity guard.
let a = identity_path("11111111-1111-1111-1111-111111111111");
let b = identity_path("22222222-2222-2222-2222-222222222222");
assert_ne!(a, b);
assert_ne!(a, instance_path("default"));
assert_ne!(a, instance_path("default-worker"));
assert!(a.to_string_lossy().contains("id-11111111"));
}
#[test]