docir — design documents Graph

Documents / Issues / issue-aaa512e9c58f

The daemon keeps serving the code it started with, so a fix silently does not take effect

Nothing compares the running daemon's version against the installed one; after an upgrade or a source edit every command is answered by the old code, and the wrong answer looks normal.

issue-aaa512e9c58fissueresolved#daemon#material
View as Markdown◉ View in graph

Class: missing · Severity: material Flow: arch-0a3c2d6d54a6 · Step: any command, after an upgrade or an edit to src/

Finding

The daemon loads docir's code once and lives on (idle timeout, default 900s). Nothing compares the running process's version against the installed one, so after an upgrade — uv sync, a pip install -U, or any edit to src/ during development — the client keeps routing every command to a process running the old code, and the answer looks entirely normal.

What happens today

OBSERVED, during the fix for issue-44875a5a6ca6. _find_cycles was corrected and its unit tests passed. docir check then reported 117 cycle findings; docir --no-daemon check reported 0. The difference was a daemon started before the edit. Nothing in either output indicated which code produced it, and the plausible reading of 117 findings is that the fix is wrong — the failure imitates a real result rather than announcing itself.

docir daemon status prints the socket path and says nothing about the version being served, so the state is not even inspectable after the fact. The workaround, once you suspect it, is docir daemon stop.

Impact

Every read is answered by the stale process, so a corrected check, a changed ranking or a new schema rule silently does not take effect. The blast radius is bounded by the idle timeout, which is exactly long enough to cover an edit-test cycle. It hits developers of docir hardest and users of docir on the release where the behaviour changed.

Proposed direction

Stamp the daemon's docir.__version__ (and, in a development install, the mtime of the loaded package) into the pid file at startup. The client already respawns on a refused connect; have it also treat a version mismatch as a reason to stop and respawn, which makes the recovery automatic rather than a thing you have to know. Failing that, print the served version in docir daemon status so the state is at least visible.

Resolution

FIXED. The pid file now carries a code stampdocir.__version__ plus the newest mtime across the package's .py sources — and ensure_running stops and replaces a live daemon whose stamp is not the client's. Recovery is automatic rather than a thing you have to suspect and then fix with docir daemon stop.

The mtime half is not decoration: nothing bumps __version__ between commits, so a source edit during development — the case that produced the 117 cycles above — is invisible to a version comparison. An installed wheel stamps its files at install time, so the same pair also moves on uv sync / pip install -U; one mechanism covers both. The walk costs ~10ms against a ~800ms CLI startup.

current_stamp() is cached per process, and that is what makes the daemon's answer honest: it must report the build it started with, not whatever is on disk when someone asks. A pid file written before the stamp existed holds a bare integer — an unknown build, which never matches. That is correct rather than lenient: such a daemon predates the check and is exactly what it exists to replace.

docir daemon status now prints the served build (serving 0.9.0) and marks a stale one, so the state is inspectable after the fact instead of only inferable from an answer that looks wrong.

One thing the fix needed that the proposed direction did not name: stop() had to start waiting for the process to actually exit. Its teardown clears the pid file and unlinks the socket, so a replacement spawned while it was still winding down could have both removed out from under it — a healthy daemon that no client can find. Stop-then-immediately-spawn is a much tighter race than the paths that existed before.

Verified by injecting the bug: reverting ensure_running to its old body fails four tests, including TestRealDaemon::test_a_live_daemon_on_other_code_is_replaced, which spawns a real detached daemon, doctors its recorded stamp and asserts the next command answers from a different pid. Also confirmed by hand: start a daemon, touch src/docir/entry_points/cli/app.py, and daemon status reports stale code, after which any command replaces the process.

To amend: Re-verify: