Runner can exceed its `parallel` config: duplicate daemons + dropped capacity telemetry #46
Links
No links yet.
Summary
A single runner host can silently run more concurrent jobs than its parallel config, because two independent daemons for the same runner_id can both run — and the server can’t even detect it, because it discards the capacity telemetry the runner sends.
Found on carl (a 4-core Pi, parallel: 1): two enabled systemd –user units, anvil-runner.service (legacy) and anvil-runner-default.service (current), were both active for the same runner_id, each with 1 slot → 2 concurrent jobs. With run-affinity pinning a whole run to one host, this put test + coverage (two heavy Rust compiles) on the same Pi at once and tipped the test job past its timeout.
Bug 1 — duplicate-runner guard is keyed on the pid-file path, not the runner_id (anvil-cli)
loop_runner acquires pid_file::Guard on the --pid-file path. The two units used different pid-files (runner.pid vs runner-default.pid), so neither guard saw the other and both started as the same runner. The service install also leaves a prior unit (different instance name) enabled instead of replacing it.
Fix:
- Acquire a lock keyed on
runner_idat a fixed, config-derived path, independent of--pid-file, so any second daemon for the same runner refuses to start with a clear message. runner service installshould disable/remove a pre-existinganvil-runner*unit for the same runner (or at least warn).
Bug 2 — the server drops runner capacity telemetry (anvil / server)
The runner heartbeats {"parallel": N, "slots_busy": M} (top-level) every 30s, but the heartbeat controller does Map.take(params, ["status", "version", "metadata"]) — so parallel and slots_busy are silently discarded. ci_runners has no slots columns. The server therefore has zero visibility into runner capacity/busyness and can’t detect over-subscription or do capacity-aware scheduling.
Fix: persist parallel/slots_busy from the heartbeat (columns or metadata), surface them on the runner API/dashboard, and let the scheduler use them.
Impact
Silent over-subscription of a runner → CPU thrash → CI job timeouts that look like flakes. Immediate mitigation applied on carl (legacy unit disabled); this issue tracks the code fixes so it can’t recur.
Fixed by #49 (runner side, merged) and fangorn/anvil#213 (server side, merged).
Bug 1 — duplicate daemons. loop_runner::start now acquires a second lock keyed on the runner_id (pid_file::identity_path), independent of --pid-file, so a second daemon for the same runner is refused with a clear message regardless of which pid-file or instance name it was started with.
Bug 2 — dropped capacity telemetry. The heartbeat controller no longer discards the runner’s top-level parallel / slots_busy; they are merged into the runner’s metadata (preserving existing keys, no migration) and surfaced in the runner admin API serializer, so a host’s configured slots and current busyness are finally visible.
Immediate mitigation was applied on carl at diagnosis time: the stray legacy anvil-runner.service user unit was disabled (systemctl --user disable --now), leaving one daemon at --parallel 1.
Residual not covered: the ticket also asked that runner service install disable/remove (or at least warn about) a pre-existing anvil-runner* unit for the same runner. That is not implemented — the identity lock now makes the duplicate fail loudly at startup instead of silently double-running, but install still leaves an old unit enabled.