ref:main

Runner can exceed its `parallel` config: duplicate daemons + dropped capacity telemetry #46

closed Opened by cole.christensen@gmail.com

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_id at 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 install should disable/remove a pre-existing anvil-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.

colechristensen cole.christensen@gmail.com commented 2026-07-26 20:08

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.