bug: the client never compares the daemon's recorded version — a stale daemon serves degraded answers silently #83
Labels
No labels
code-review
correctness
dos
performance
security
severity/high
severity/low
severity/medium
tech-debt
Kind/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
h-dv/code-index#83
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found while dogfooding v0.22.3. It cost an hour and produced a confident wrong diagnosis of an already-fixed bug (#82), which is the real argument for fixing it: a silent build mismatch does not merely degrade answers, it sends the reader after the wrong cause.
What happened
A daemon was started for an isolated harness as:
It wrote
/path/to/repo/.code-index/daemon.tomland became the daemon for that root:Hours later the binaries were rebuilt and installed at 0.22.3. The MCP client reconnected, re-attached to that registration, and talked to a 0.22.2 daemon serving a different database — for the rest of the session, with no signal of any kind.
Consequence:
FileOverlap.content_hash(added inc807368) was absent, so the client correctly fell back to the mtime proxy and reported files stale. The degraded path behaved exactly as designed — every three-state contract held. There was simply nothing anywhere saying why the answers were degraded, so a fixed bug looked live.Two separable defects
1. The version is recorded, parsed, and discarded.
Lockfile(crates/daemon/src/lifecycle.rs:67-71) carriesversion: String, andLockfile::readparses it on every attach. Nothing compares it to the client's own version.schema_guardpollsschema_versiononly (schema_guard.rs:89-97) and the schema was identical here, so it never fired — correctly, because the schema had not changed. The code had.This is the I063 shape exactly: a fact that is computed, stored, read, and then thrown away, leaving the consumer to infer a cause it cannot see.
2. Daemon identity is
root, not(root, db).Lockfile.rootexists precisely to "reject a payload that describes a different project than the one being queried" (issue #7,lifecycle.rs:72-80). It did not apply: the rogue daemon had the same root and a different--db. So a daemon serving database B can capture the registration for root A, and every client for A is silently routed to B.The pair
(--root, --db)is what actually determines what a daemon answers, and only half of it participates in identity.Why this is not just operator error
It was operator error in this instance — I passed the real root to a scratchpad daemon. But:
--root X --db Yas a first-class combination; nothing warns that it hijacks X's registration.ps+daemon.tomlinspection revealed it.Proposed fix
A. Compare the version on attach. The client knows its own; the lockfile carries the daemon's. On mismatch, either refuse and respawn (the I027 machinery already spawns when re-attach fails), or attach and disclose it in the payload —
daemon_version_skew: { client, daemon }— so an agent seeing degraded three-state fields can tell whether it is looking at a genuine gap or an old daemon. Silence is the one option that should be off the table.Note the honest subtlety: an older daemon is not automatically wrong, and forcing a respawn on every patch bump would be disruptive for long-lived sessions. Disclosure is the minimum; refusal is a policy choice worth arguing separately.
B. Make identity
(root, db). Record the canonical db path in the lockfile besideroot, and haveLockfile::readreject a payload whose db differs from the one the caller asked for — the same defence-in-depthrootalready provides, applied to the other half of the pair. That alone would have made this impossible.Test shape
versiondiffers from the client's → the reply carries the skew disclosure (or the attach is refused), and — anti-vacuity — a matching version produces neither.(root, dbA); a client asking for(root, dbB)must not attach to it. Mutation: drop the db from the comparison → the test goes red.Related
Same family as the skew work in #75/#78 — the lifecycle analysis there noted there is no channel on the wire for rule-set identity, and the health RPC carries only
(root, schema_version)(rpc_index.rs:665-673). This is that gap, reachable today, without any of the template machinery.Triage 2026-09-06: CLOSING. Both halves shipped — the skew reaches the payload, and attach refuses on a different database.
Verified against master; landed in
d0279c0.A — the version comparison, and it is read at call time
pub enum DaemonBuildwith four states (NoDaemonLeg,Matched,Skewed{client,daemon,pid},Unknown{why}) —crates/daemon/src/access.rs:36;fn daemon_build()onIndexAccessat:73, defaulting toNoDaemonLeg.It is read at call time, not latched at attach —
crates/daemon/src/rpc_index.rs:837-885, with the reasoning written into the doc: an I027 respawn must not be able to leave a stale verdict behind. That is the right call on this repo, where the daemon idle-exits at 30 minutes and respawns mid-session.It reaches the payload rather than a log:
render_daemon_build(call sitecrates/mcp-server/src/server.rs:4470-4500) emitsskew/client_version/daemon_version/daemon_pid/semantics, emits nothing onMatchedandNoDaemonLeg, andavailability: unavailable+whyonUnknown. The previous behaviour wastracing::warn!— a channel no agent reads.B — identity becomes
(root, db)pub db: Option<String>—crates/daemon/src/lifecycle.rs:110, optional withskip_if_none, so absent ⇒DbIdentity::Unrecorded, which is notSame.db_identityat:466.And it is enforced, not merely reported:
crates/mcp-server/src/main.rs:863—if lock.db_identity(db_path) == DbIdentity::Different { bail!(… pid …) }. The error names the recorded db, the asked-for db, and the pid.Runs (exit 0)
incl.
db_identity_has_a_did_not_report_stateandthe_four_payload_states_are_distinct(RUN mutation recorded atpayload_state_e2e.rs:241).Residual, and it is worth reading before assuming this is fully graded
This issue's test shape asked for both halves on the daemon leg. Neither is graded there:
db_identityis exercised only by unit-level calls onLockfile::db_identity. Nothing starts a daemon on(root, dbA)and attaches a client askingdbB, so themain.rs:863refusal path itself is untested.daemon_buildappears in no test file at all — the skew disclosure is graded by arender_daemon_buildunit test, not over the wire.Both mechanisms are real and present; the attach-time halves are ungraded. On this repo that is a familiar shape — an e2e that never crosses the wire is how five RPC arms once stayed deletable-green — so it is worth a follow-up test rather than being forgotten. I am closing on the mechanism shipping and being read at the right time, and recording the gap here rather than in a new issue, since it is a test to add and not a defect to fix.
There is a live illustration of exactly why this matters, from today: the installed binary is
0.26.1 (4555887), older than the tree it indexes, and a probe against it returns pre-fix answers. That is the class this issue exists to make visible.🤖 Triage lane, 2026-09-06, master
45cf6e4