daemon lifecycle: stats() full-scans refs, so the 2s handshake fails on any large index — healthy daemons are judged dead and evicted forever #67

Closed
opened 2026-08-18 10:50:10 +02:00 by buildagent · 5 comments
Member

⚠️ READ THIS FIRST — the analysis below is SUPERSEDED

The original report (kept verbatim under the fold, for the reproduction and the measurements) blamed a pending resolve. That was wrong. The resolve had already completed; 21.5% is this codebase's normal resolution rate.

Actual root cause: stats() runs SELECT COUNT(*) FROM refs WHERE reclassified = 0, refs has no index on reclassified, so it is a full table scan — and stats() is the health predicate for the whole daemon lifecycle, bounded at 2s. On a large index it is systematically too slow, so a healthy daemon is judged dead and evicted, forever.

Still valid from the original report: the eviction/respawn livelock is real and reproducible, the two 60s budgets are too tight, and the diagnosability gaps in doctor / link list are genuine.


Original report (superseded — click to expand)

Source: gurofa dogfood, 2026-08-18, v0.13.2 (47e6543), Windows 11.

A project whose daemon startup work exceeds ~60s can never come up. The MCP shim gives up at 60s and respawns; the respawn either exits immediately or evicts the incumbent as "wedged"; the evicted daemon's in-flight transaction rolls back; the replacement starts the same work from zero. Nothing ever completes, so the condition that causes the slow start is never cleared.

This is a livelock, not slowness: the retry pressure is what prevents the completion that would end the retry. It does not self-heal, and waiting does not help.

Related but distinct from:

  • #65 (open) — the slow resolve is the trigger, not the mechanism. Any startup work >60s does this.
  • #66 (closed) — that issue built STARTUP_GRACE_SECS / probe_reachable / rediscovery. This is a defect in that machinery: the safeguard meant to rescue a wedged daemon is what kills every healthy-but-slow one.

Observed

E:\custom\gurofa declares a link to the base product:

[[links]]
name = "timeline"
path = "E:/code/timeline/16.0/Source"
relationship = "base"

Every tool call scoped to project="timeline" returns warming_up, then project_not_available. The counters are frozen across retries.

metric value
files 12,872
symbols 211,747
stale rows (mtime_ns = -1) 0 — parse backlog complete
refs 1,649,171
refs resolved 0 (later shown to be a transient state; see corrections)
index.db ~1.29 GB
index.db-wal 0 MB (not a checkpoint-cost problem)

Client side (gurofa MCP server, driven with a real initialize)

INFO attached to existing daemon root=\\?\E:\custom\gurofa port=50585        <- primary OK
WARN lockfile claims live daemon but handshake failed; will respawn
     error=stats handshake timed out pid=21244 port=50572
WARN re-attach failed; respawning daemon root=...\TimeLine\16.0\Source
     error=no live daemon lockfile (daemon exited and was not restarted)
WARN linked project unavailable; continuing without it name=timeline
     root=\\?\E:\code\TimeLine\16.0\Source
     error=daemon spawned but never became ready within 60s (no lockfile or no TCP handshake)

Repeats indefinitely with a fresh pid and port each round: 21244, 14692, 19460, 10432, ...

Daemon side

Any attempt to start a daemon for that root while the loop is running:

Error: daemon on this root is still starting (within startup grace); not evicting
       (pid=17604, port=65271): daemon already running (pid=17604, port=65271)

Measured generation turnover

10:45:57   pid1692  up 24s
10:46:07   pid1692  up 34s   pid19440 up 5s
10:46:18                     pid19440 up 15s
10:46:28                     pid19440 up 25s
10:46:38   (none)
10:46:48   pid9356  up 7s

Each daemon lives 30-50s, while burning ~2.5 cores continuously across the churn.

Budgets involved

constant value file
DAEMON_READY_TIMEOUT 60s crates/mcp-server/src/main.rs:452
HANDSHAKE_TIMEOUT 2s crates/mcp-server/src/main.rs:475
STARTUP_GRACE_SECS 60 crates/daemon/src/takeover.rs:57
HOLDER_PROBE_TIMEOUT 2s crates/daemon/src/takeover.rs:43

takeover.rs documents the assumption the tuning rests on:

"A freshly-started daemon binds its listener and publishes the lockfile long before its accept loop begins serving — on a large index this gap has been observed at ~13s. STARTUP_GRACE_SECS is a generous floor (well above the observed ~13s)."

Reproduction

  1. Take a project large enough that startup work exceeds 60s.
  2. Declare it as a [[links]] entry from a second project.
  3. Open an MCP session on the second project.
  4. Observe: project_not_available for the link, daemon generations cycling every 30-50s.

Not reproducible on small repos.

Impact

  • A linked project in this state is permanently unavailable. search_symbols / find_callers fan-out silently narrows to primary, so answers are wrong-by-omission rather than failing loudly.
  • Continuous CPU burn (~2.5 cores) and heavy I/O for as long as a session stays open.
  • The same mechanism hits a primary project too — observed independently on E:\code\TimeLine\e3, where a live session drove a connect/abort storm at ~2s intervals plus repeated evictions.
  • code-index doctor has no link check at all. It inspects the primary's root, db, schema, population, daemon lockfile, disk and plugins. A completely dead link reports as "all fine".
  • code-index link list reports "status": "running" for a daemon that cannot serve. lockfile_status() (crates/cli/src/link.rs:408) only calls pid_is_alive() — no TCP probe, no handshake — while the MCP server uses a full authenticated round trip. The two disagree exactly when it matters, and the CLI is the optimistic one.
> ## ⚠️ READ THIS FIRST — the analysis below is SUPERSEDED > > The original report (kept verbatim under the fold, for the reproduction and the measurements) blamed a **pending resolve**. That was wrong. The resolve had already completed; 21.5% is this codebase's normal resolution rate. > > **Actual root cause:** `stats()` runs `SELECT COUNT(*) FROM refs WHERE reclassified = 0`, `refs` has no index on `reclassified`, so it is a full table scan — and `stats()` is the health predicate for the whole daemon lifecycle, bounded at 2s. On a large index it is systematically too slow, so a healthy daemon is judged dead and evicted, forever. > > - Corrected analysis + measurements: **[comment 5169](https://git.h-dv.de/h-dv/code-index/issues/67#issuecomment-5169)** > - Concrete fix options + tradeoffs: **[comment 5173](https://git.h-dv.de/h-dv/code-index/issues/67#issuecomment-5173)** > - [comment 5165](https://git.h-dv.de/h-dv/code-index/issues/67#issuecomment-5165) is **retracted in full** — disregard it. > > Still valid from the original report: the eviction/respawn livelock is real and reproducible, the two 60s budgets are too tight, and the diagnosability gaps in `doctor` / `link list` are genuine. --- <details> <summary>Original report (superseded — click to expand)</summary> **Source:** gurofa dogfood, 2026-08-18, v0.13.2 (47e6543), Windows 11. A project whose daemon startup work exceeds ~60s can **never** come up. The MCP shim gives up at 60s and respawns; the respawn either exits immediately or evicts the incumbent as "wedged"; the evicted daemon's in-flight transaction rolls back; the replacement starts the same work from zero. Nothing ever completes, so the condition that causes the slow start is never cleared. This is a livelock, not slowness: **the retry pressure is what prevents the completion that would end the retry.** It does not self-heal, and waiting does not help. Related but distinct from: - **#65** (open) — the slow resolve is the *trigger*, not the mechanism. Any startup work >60s does this. - **#66** (closed) — that issue built `STARTUP_GRACE_SECS` / `probe_reachable` / rediscovery. This is a defect *in* that machinery: the safeguard meant to rescue a wedged daemon is what kills every healthy-but-slow one. ## Observed `E:\custom\gurofa` declares a link to the base product: ```toml [[links]] name = "timeline" path = "E:/code/timeline/16.0/Source" relationship = "base" ``` Every tool call scoped to `project="timeline"` returns `warming_up`, then `project_not_available`. The counters are frozen across retries. ### Link state (base product, `E:\code\TimeLine\16.0\Source`) | metric | value | |---|---| | files | 12,872 | | symbols | 211,747 | | stale rows (`mtime_ns = -1`) | 0 — parse backlog complete | | refs | 1,649,171 | | refs resolved | 0 *(later shown to be a transient state; see corrections)* | | index.db | ~1.29 GB | | index.db-wal | 0 MB (not a checkpoint-cost problem) | ### Client side (gurofa MCP server, driven with a real `initialize`) ``` INFO attached to existing daemon root=\\?\E:\custom\gurofa port=50585 <- primary OK WARN lockfile claims live daemon but handshake failed; will respawn error=stats handshake timed out pid=21244 port=50572 WARN re-attach failed; respawning daemon root=...\TimeLine\16.0\Source error=no live daemon lockfile (daemon exited and was not restarted) WARN linked project unavailable; continuing without it name=timeline root=\\?\E:\code\TimeLine\16.0\Source error=daemon spawned but never became ready within 60s (no lockfile or no TCP handshake) ``` Repeats indefinitely with a fresh pid and port each round: 21244, 14692, 19460, 10432, ... ### Daemon side Any attempt to start a daemon for that root while the loop is running: ``` Error: daemon on this root is still starting (within startup grace); not evicting (pid=17604, port=65271): daemon already running (pid=17604, port=65271) ``` ### Measured generation turnover ``` 10:45:57 pid1692 up 24s 10:46:07 pid1692 up 34s pid19440 up 5s 10:46:18 pid19440 up 15s 10:46:28 pid19440 up 25s 10:46:38 (none) 10:46:48 pid9356 up 7s ``` **Each daemon lives 30-50s**, while burning ~2.5 cores continuously across the churn. ## Budgets involved | constant | value | file | |---|---|---| | `DAEMON_READY_TIMEOUT` | 60s | `crates/mcp-server/src/main.rs:452` | | `HANDSHAKE_TIMEOUT` | 2s | `crates/mcp-server/src/main.rs:475` | | `STARTUP_GRACE_SECS` | 60 | `crates/daemon/src/takeover.rs:57` | | `HOLDER_PROBE_TIMEOUT` | 2s | `crates/daemon/src/takeover.rs:43` | `takeover.rs` documents the assumption the tuning rests on: > "A freshly-started daemon binds its listener and publishes the lockfile long before its accept loop begins serving — on a large index this gap **has been observed at ~13s**. `STARTUP_GRACE_SECS` is a generous floor (well above the observed ~13s)." ## Reproduction 1. Take a project large enough that startup work exceeds 60s. 2. Declare it as a `[[links]]` entry from a second project. 3. Open an MCP session on the second project. 4. Observe: `project_not_available` for the link, daemon generations cycling every 30-50s. Not reproducible on small repos. ## Impact - A linked project in this state is **permanently** unavailable. `search_symbols` / `find_callers` fan-out silently narrows to primary, so answers are wrong-by-omission rather than failing loudly. - Continuous CPU burn (~2.5 cores) and heavy I/O for as long as a session stays open. - The same mechanism hits a **primary** project too — observed independently on `E:\code\TimeLine\e3`, where a live session drove a connect/abort storm at ~2s intervals plus repeated evictions. ## Related diagnosability gaps (possibly a separate issue) - **`code-index doctor` has no link check at all.** It inspects the primary's root, db, schema, population, daemon lockfile, disk and plugins. A completely dead link reports as "all fine". - **`code-index link list` reports `"status": "running"` for a daemon that cannot serve.** `lockfile_status()` (`crates/cli/src/link.rs:408`) only calls `pid_is_alive()` — no TCP probe, no handshake — while the MCP server uses a full authenticated round trip. The two disagree exactly when it matters, and the CLI is the optimistic one. </details>
Author
Member

Follow-up: a client restart buys partial progress, then the livelock resumes

New observation that corrects suggested direction #4 in the report ("every kill costs the entire resolve"). That is too strong — some resolve work does commit and survive eviction.

The reporting session was restarted. refs.target_id went from 0 to 354,084 of 1,649,171 (21%), and index.db grew 1.29 GB → 1.38 GB. So a generation lived long enough to commit a real chunk of the resolve.

It then re-entered the livelock at exactly that point. Measured over 75s with no client interaction:

t0        files/symbols/refs/resolved: (12872, 211747, 1649171, 354084)
t1 (+75s) files/symbols/refs/resolved: (12872, 211747, 1649171, 354084)
delta:                                 [0, 0, 0, 0]

Concurrent daemon state — churn ongoing, generations still replacing each other:

pid 18948  up 33s   CPU 68s   (~2 cores)   root=...\TimeLine\16.0\Source
pid  3384  up  4s   CPU  2s                root=...\TimeLine\16.0\Source
lockfile -> pid 3384, port 59556

index.db last written 10:51:19, i.e. writes stopped several minutes before this sample while two daemons kept burning CPU.

Why this matters for the fix

  1. Progress is partially durable, so an incremental-commit strategy (direction #4) is a smaller change than the report implied — some of that machinery already works. The gap is that whatever unit commits is large enough that a 30-50s generation lifetime usually misses it.
  2. It also strengthens direction #1/#2. refs_resolved moved 0 → 354,084 across generations, so a monotonic progress counter would have distinguished "busy" from "wedged" here — and would have prevented the eviction that stalled it at 21%.
  3. A restart is not a workaround. It advances the index by one partial commit and then re-enters the same loop. Users will read the jump from 0 to 21% as "it's working now" and wait indefinitely.

Caller-visible symptom (unchanged)

The agent on the reporting side saw byte-identical counters across four consecutive tool calls (search_symbols ×3, project_overview ×1) and correctly concluded it could not distinguish a wedged daemon from a slow one from the tool surface alone — the exact gap #66 (a) describes, now reachable through a second path.

## Follow-up: a client restart buys partial progress, then the livelock resumes New observation that **corrects suggested direction #4** in the report ("every kill costs the entire resolve"). That is too strong — some resolve work *does* commit and survive eviction. The reporting session was restarted. `refs.target_id` went from **0** to **354,084** of 1,649,171 (21%), and `index.db` grew 1.29 GB → 1.38 GB. So a generation lived long enough to commit a real chunk of the resolve. It then re-entered the livelock at exactly that point. Measured over 75s with no client interaction: ``` t0 files/symbols/refs/resolved: (12872, 211747, 1649171, 354084) t1 (+75s) files/symbols/refs/resolved: (12872, 211747, 1649171, 354084) delta: [0, 0, 0, 0] ``` Concurrent daemon state — churn ongoing, generations still replacing each other: ``` pid 18948 up 33s CPU 68s (~2 cores) root=...\TimeLine\16.0\Source pid 3384 up 4s CPU 2s root=...\TimeLine\16.0\Source lockfile -> pid 3384, port 59556 ``` `index.db` last written 10:51:19, i.e. writes stopped several minutes before this sample while two daemons kept burning CPU. ### Why this matters for the fix 1. **Progress is partially durable**, so an incremental-commit strategy (direction #4) is a smaller change than the report implied — some of that machinery already works. The gap is that whatever unit commits is large enough that a 30-50s generation lifetime usually misses it. 2. **It also strengthens direction #1/#2.** `refs_resolved` moved 0 → 354,084 across generations, so a monotonic progress counter would have distinguished "busy" from "wedged" here — and would have prevented the eviction that stalled it at 21%. 3. **A restart is not a workaround.** It advances the index by one partial commit and then re-enters the same loop. Users will read the jump from 0 to 21% as "it's working now" and wait indefinitely. ### Caller-visible symptom (unchanged) The agent on the reporting side saw byte-identical counters across four consecutive tool calls (`search_symbols` ×3, `project_overview` ×1) and correctly concluded it could not distinguish a wedged daemon from a slow one from the tool surface alone — the exact gap #66 (a) describes, now reachable through a second path.
Author
Member

CORRECTION — the trigger is stats() latency, not a pending resolve

Retracting a significant part of the original report and all of the first follow-up comment. The livelock is real and reproducible, but I misidentified what pushes a project into it.

What I got wrong

I claimed the index was "stuck at 21%" with a pending resolve that never completed. It is not. The resolve had already finished:

  • All three pending tables (stale_outline_names, resolve_scope_files, stale_path_evidence) are empty.
  • apply_resolution correctly returns Skipped — there is genuinely no work.
  • 354,084 / 1,649,171 = 21.5% is the normal resolution rate here, not a partial result. E:\code\TimeLine\e3 sits at 22.2% and is healthy; this repo's own index is 15.5%.

So the frozen counters both reporting agents saw were a completed index, not a stalled one. The first follow-up comment ("progress is partially durable", "re-entered the livelock at 21%") is wrong — please disregard it. A one-shot code-index index confirms it, in 25s total:

index stage complete: reference resolution elapsed_ms=0 resolved=0 invalidated=0 scope=Skipped
index complete seen=14959 parsed=0 stat_skipped=12872 ... resolve_scope=Skipped

The actual trigger

A daemon started with no client attached comes up fine and stays up:

09:21:55.62  listener bound; lockfile published
09:22:15.71  startup WAL checkpoint complete      <- 20.1s gap = open(): migrations + PRAGMA quick_check
09:22:15.78  RPC accept loop starting
09:22:19.17  daemon confirmed serving             <- 23.5s, well inside DAEMON_READY_TIMEOUT
09:22:42.83  initial reconciliation complete; watcher armed

That daemon (pid 14836) was then idle, warm and serving. A client attaching 22 seconds later still failed:

09:23:04  WARN lockfile claims live daemon but handshake failed;
          error=stats handshake timed out pid=14836 port=55473

try_handshake (mcp-server/src/main.rs:494) bounds rpc.stats() at HANDSHAKE_TIMEOUT = 2s. And stats() (daemon/src/local_index.rs:616) runs:

SELECT COUNT(*) FROM refs WHERE reclassified = 0

refs has indexes on name, target_id and file_id — none on reclassified. EXPLAIN QUERY PLAN confirms:

SCAN refs

A full table scan of 1,649,171 rows in a 1.38 GB database. Measured 0.372s warm on a dedicated read connection; the daemon's read-pool connections start cold, and on Windows with on-access AV scanning this is what misses the 2s bound. Every other stats() query is 2-6 ms:

   0.372s  SELECT COUNT(*) FROM refs WHERE reclassified = 0     = 1197920
   0.034s  SELECT COUNT(*) FROM refs                            = 1649171
   0.018s  SELECT COUNT(*) FROM refs WHERE target_id IS NOT NULL = 354084
   0.006s  SELECT COUNT(*) FROM symbols                         = 211747
   0.002s  SELECT COUNT(*) FROM files                           = 12872
   0.002s  SELECT COUNT(*) FROM imports                         = 65271

Note the fallback on line 617 (.or_else(|_| scalar_u64(&conn, "SELECT COUNT(*) FROM refs"))) only fires on a missing column, not on slowness — so a post-m0030 DB always pays the scan.

Why this makes the livelock permanent

stats() is the health predicate for the entire lifecycle. It gates try_handshake (attach), the takeover wedge probe (HOLDER_PROBE_TIMEOUT, also 2s), and the daemon's own probe_reachable self-check. On a large index it is systematically too slow, so:

  • a perfectly healthy daemon is classified unreachable,
  • the client respawns, the newcomer evicts the incumbent,
  • the replacement is equally "unhealthy" by the same measure,
  • forever, with no state that a rebuild or a completed resolve can clear.

This is worse than the original report suggested. There is no self-healing path and no user action fixes it — I completed the resolve, verified the index is healthy, pre-warmed a serving daemon, and the link still fails at exactly 60s. Size alone determines whether a project works.

Revised fix priority

The cheapest correct fix is at the query, not the timeouts:

  1. Make stats() O(1)-ish. Either add an index covering reclassified, or drop the filter from the health path and report the reclassified-adjusted count only where it is actually needed (project_overview), or maintain the count incrementally. A liveness probe must not full-scan the largest table in the database.
  2. Split liveness from statistics. The handshake needs "are you answering?", not six aggregate counts. A trivial ping RPC would make health independent of index size — and would make HOLDER_PROBE_TIMEOUT / probe_reachable meaningful again.
  3. Timeout tuning (the original report's focus) is then a secondary hardening measure rather than the fix.

Suggested directions #1 and #2 from the original report still stand — progress-based readiness and never evicting a holder that is advancing — but they would not have fixed this case on their own, because the daemon here was idle and finished, with no progress left to show.

## CORRECTION — the trigger is `stats()` latency, not a pending resolve Retracting a significant part of the original report and all of the first follow-up comment. The livelock is real and reproducible, but I misidentified what pushes a project into it. ### What I got wrong I claimed the index was "stuck at 21%" with a pending resolve that never completed. **It is not.** The resolve had already finished: - All three pending tables (`stale_outline_names`, `resolve_scope_files`, `stale_path_evidence`) are **empty**. - `apply_resolution` correctly returns `Skipped` — there is genuinely no work. - 354,084 / 1,649,171 = **21.5% is the normal resolution rate here**, not a partial result. `E:\code\TimeLine\e3` sits at 22.2% and is healthy; this repo's own index is 15.5%. So the frozen counters both reporting agents saw were a **completed** index, not a stalled one. The first follow-up comment ("progress is partially durable", "re-entered the livelock at 21%") is wrong — please disregard it. A one-shot `code-index index` confirms it, in 25s total: ``` index stage complete: reference resolution elapsed_ms=0 resolved=0 invalidated=0 scope=Skipped index complete seen=14959 parsed=0 stat_skipped=12872 ... resolve_scope=Skipped ``` ### The actual trigger A daemon started with **no client attached** comes up fine and stays up: ``` 09:21:55.62 listener bound; lockfile published 09:22:15.71 startup WAL checkpoint complete <- 20.1s gap = open(): migrations + PRAGMA quick_check 09:22:15.78 RPC accept loop starting 09:22:19.17 daemon confirmed serving <- 23.5s, well inside DAEMON_READY_TIMEOUT 09:22:42.83 initial reconciliation complete; watcher armed ``` That daemon (pid 14836) was then **idle, warm and serving**. A client attaching 22 seconds later still failed: ``` 09:23:04 WARN lockfile claims live daemon but handshake failed; error=stats handshake timed out pid=14836 port=55473 ``` `try_handshake` (`mcp-server/src/main.rs:494`) bounds `rpc.stats()` at `HANDSHAKE_TIMEOUT` = **2s**. And `stats()` (`daemon/src/local_index.rs:616`) runs: ```sql SELECT COUNT(*) FROM refs WHERE reclassified = 0 ``` `refs` has indexes on `name`, `target_id` and `file_id` — **none on `reclassified`**. `EXPLAIN QUERY PLAN` confirms: ``` SCAN refs ``` A full table scan of 1,649,171 rows in a 1.38 GB database. Measured **0.372s warm** on a dedicated read connection; the daemon's read-pool connections start cold, and on Windows with on-access AV scanning this is what misses the 2s bound. Every other `stats()` query is 2-6 ms: ``` 0.372s SELECT COUNT(*) FROM refs WHERE reclassified = 0 = 1197920 0.034s SELECT COUNT(*) FROM refs = 1649171 0.018s SELECT COUNT(*) FROM refs WHERE target_id IS NOT NULL = 354084 0.006s SELECT COUNT(*) FROM symbols = 211747 0.002s SELECT COUNT(*) FROM files = 12872 0.002s SELECT COUNT(*) FROM imports = 65271 ``` Note the fallback on line 617 (`.or_else(|_| scalar_u64(&conn, "SELECT COUNT(*) FROM refs"))`) only fires on a *missing column*, not on slowness — so a post-m0030 DB always pays the scan. ### Why this makes the livelock permanent `stats()` is the **health predicate for the entire lifecycle**. It gates `try_handshake` (attach), the takeover wedge probe (`HOLDER_PROBE_TIMEOUT`, also 2s), and the daemon's own `probe_reachable` self-check. On a large index it is systematically too slow, so: - a perfectly healthy daemon is classified unreachable, - the client respawns, the newcomer evicts the incumbent, - the replacement is equally "unhealthy" by the same measure, - forever, with no state that a rebuild or a completed resolve can clear. This is worse than the original report suggested. There is no self-healing path and **no user action fixes it** — I completed the resolve, verified the index is healthy, pre-warmed a serving daemon, and the link still fails at exactly 60s. Size alone determines whether a project works. ### Revised fix priority The cheapest correct fix is at the query, not the timeouts: 1. **Make `stats()` O(1)-ish.** Either add an index covering `reclassified`, or drop the filter from the health path and report the reclassified-adjusted count only where it is actually needed (`project_overview`), or maintain the count incrementally. A liveness probe must not full-scan the largest table in the database. 2. **Split liveness from statistics.** The handshake needs "are you answering?", not six aggregate counts. A trivial `ping` RPC would make health independent of index size — and would make `HOLDER_PROBE_TIMEOUT` / `probe_reachable` meaningful again. 3. Timeout tuning (the original report's focus) is then a secondary hardening measure rather than the fix. Suggested directions #1 and #2 from the original report still stand — progress-based readiness and never evicting a holder that is advancing — but they would not have fixed this case on their own, because the daemon here was idle and finished, with no progress left to show.
buildagent changed title from daemon lifecycle: fixed 60s readiness + 60s takeover grace livelock a slow-starting daemon — the client's own retries destroy the work that would end the wait to daemon lifecycle: stats() full-scans refs, so the 2s handshake fails on any large index — healthy daemons are judged dead and evicted forever 2026-08-18 11:46:23 +02:00
Author
Member

Fix options and tradeoffs

Written up rather than implemented, by request. No code has been changed.

What the handshake actually needs

try_handshake (crates/mcp-server/src/main.rs:477-538) calls rpc.stats() and then uses exactly two fields:

  • stats.root — identity check, "does this daemon serve the root I asked for?" (issue #7 defense)
  • stats.schema_version — skew advisory, never fatal

It uses none of files, symbols, refs, refs_resolved, imports, parse_errors. The expensive part of the call is entirely unused by the caller that its 2s budget applies to.


Add a lightweight RPC returning only identity + schema version, and use it for the three health paths: try_handshake, the takeover wedge probe (HOLDER_PROBE_TIMEOUT), and the daemon's own probe_reachable self-check.

NEW:  RpcRequest::Health -> { root, schema_version }
      no aggregate queries, O(1), independent of index size

FALLBACK: an older daemon answers "unknown method" -> fall back to stats()
          (same wire-compat pattern already used for `index_health`
           in project_overview, which degrades gracefully today)
  • Fixes the root cause structurally. Health stops scaling with index size, so no timeout constant needs to be right for every repo. HOLDER_PROBE_TIMEOUT and probe_reachable become meaningful again instead of being size-dependent coin flips.
  • Cost: touches the protocol enum, the daemon handler, the client, and the three call sites. Needs a wire-skew test (new client vs old daemon), which crates/daemon/tests/wire_skew_e2e.rs already has a pattern for.
  • Does not fix project_overview, which still pays the scan on every call.

Option B — make stats() cheap

Keep stats() as the health probe, remove the scan.

-- today: EXPLAIN QUERY PLAN -> SCAN refs   (1,649,171 rows, 0.372s warm)
SELECT COUNT(*) FROM refs WHERE reclassified = 0

Three sub-options:

  1. Partial index + migration. CREATE INDEX idx_refs_reclassified ON refs(reclassified) WHERE reclassified != 0. Note the selectivity here is poor — 451,251 of 1,649,171 rows are reclassified (27%) — so this is not a small index, and it adds write amplification to the hottest insert path in the indexer.
  2. Derive instead of scan. (SELECT COUNT(*) FROM refs) - (count of reclassified), where the plain count is satisfiable from the smallest existing index (measured 0.034s vs 0.372s).
  3. Maintain the count incrementally in a counters table updated by the writer.
  • Smallest diff (option 2 especially — no migration at all).
  • Leaves health coupled to statistics, so the next aggregate added to stats() silently re-introduces the bug. That coupling is the actual design smell.
  • Also fixes project_overview, which calls the same code path.

Option C — raise the timeouts

Band-aid. HANDSHAKE_TIMEOUT 2s → 10s would unblock these repos today and fail again at 5x the index size. Worth doing only alongside A or B as hardening, never alone.


Recommendation

A + B(2) together. A makes the lifecycle correct by construction; B(2) is a two-line change that also speeds up project_overview and needs no migration. C only as a follow-up hardening pass.

Suggested regression test

The failure mode is size-dependent, which is why no existing test catches it. A test that pins the shape rather than the wall-clock would not flake (cf. #55, where wall-clock-bounded resolver tests were found to flake under load):

assert EXPLAIN QUERY PLAN for every query on the health path
contains no "SCAN <large table>" step

That directly encodes the invariant "a liveness probe must not full-scan the largest table in the database", and it fails today.


Environment state at time of writing

For anyone reproducing: the affected index is healthy and complete and needs no rebuild.

files=12872  symbols=211747  refs=1649171  resolved=354084 (21.5%)
stale_outline_names=0  resolve_scope_files=0  stale_path_evidence=0
index.db = 1.38 GB

A one-shot code-index index over it finishes in 25s with resolve_scope=Skipped — correctly, there is no pending work. A daemon started with no client attached reaches "confirmed serving" in 23.5s and stays up indefinitely. It is only the arrival of a client, and its 2s stats() bound, that starts the eviction loop.

## Fix options and tradeoffs Written up rather than implemented, by request. No code has been changed. ### What the handshake actually needs `try_handshake` (`crates/mcp-server/src/main.rs:477-538`) calls `rpc.stats()` and then uses exactly two fields: - `stats.root` — identity check, "does this daemon serve the root I asked for?" (issue #7 defense) - `stats.schema_version` — skew advisory, never fatal It uses **none** of `files`, `symbols`, `refs`, `refs_resolved`, `imports`, `parse_errors`. The expensive part of the call is entirely unused by the caller that its 2s budget applies to. --- ### Option A — split liveness from statistics (recommended) Add a lightweight RPC returning only identity + schema version, and use it for the three health paths: `try_handshake`, the takeover wedge probe (`HOLDER_PROBE_TIMEOUT`), and the daemon's own `probe_reachable` self-check. ``` NEW: RpcRequest::Health -> { root, schema_version } no aggregate queries, O(1), independent of index size FALLBACK: an older daemon answers "unknown method" -> fall back to stats() (same wire-compat pattern already used for `index_health` in project_overview, which degrades gracefully today) ``` - **Fixes the root cause structurally.** Health stops scaling with index size, so no timeout constant needs to be right for every repo. `HOLDER_PROBE_TIMEOUT` and `probe_reachable` become meaningful again instead of being size-dependent coin flips. - **Cost:** touches the protocol enum, the daemon handler, the client, and the three call sites. Needs a wire-skew test (new client vs old daemon), which `crates/daemon/tests/wire_skew_e2e.rs` already has a pattern for. - **Does not** fix `project_overview`, which still pays the scan on every call. ### Option B — make `stats()` cheap Keep `stats()` as the health probe, remove the scan. ```sql -- today: EXPLAIN QUERY PLAN -> SCAN refs (1,649,171 rows, 0.372s warm) SELECT COUNT(*) FROM refs WHERE reclassified = 0 ``` Three sub-options: 1. **Partial index + migration.** `CREATE INDEX idx_refs_reclassified ON refs(reclassified) WHERE reclassified != 0`. Note the selectivity here is poor — 451,251 of 1,649,171 rows are reclassified (27%) — so this is not a small index, and it adds write amplification to the hottest insert path in the indexer. 2. **Derive instead of scan.** `(SELECT COUNT(*) FROM refs) - (count of reclassified)`, where the plain count is satisfiable from the smallest existing index (measured 0.034s vs 0.372s). 3. **Maintain the count incrementally** in a counters table updated by the writer. - **Smallest diff** (option 2 especially — no migration at all). - **Leaves health coupled to statistics**, so the next aggregate added to `stats()` silently re-introduces the bug. That coupling is the actual design smell. - **Also fixes `project_overview`**, which calls the same code path. ### Option C — raise the timeouts Band-aid. `HANDSHAKE_TIMEOUT` 2s → 10s would unblock these repos today and fail again at 5x the index size. Worth doing only *alongside* A or B as hardening, never alone. --- ### Recommendation **A + B(2) together.** A makes the lifecycle correct by construction; B(2) is a two-line change that also speeds up `project_overview` and needs no migration. C only as a follow-up hardening pass. ### Suggested regression test The failure mode is size-dependent, which is why no existing test catches it. A test that pins the *shape* rather than the wall-clock would not flake (cf. #55, where wall-clock-bounded resolver tests were found to flake under load): ``` assert EXPLAIN QUERY PLAN for every query on the health path contains no "SCAN <large table>" step ``` That directly encodes the invariant "a liveness probe must not full-scan the largest table in the database", and it fails today. --- ### Environment state at time of writing For anyone reproducing: the affected index is healthy and complete and needs no rebuild. ``` files=12872 symbols=211747 refs=1649171 resolved=354084 (21.5%) stale_outline_names=0 resolve_scope_files=0 stale_path_evidence=0 index.db = 1.38 GB ``` A one-shot `code-index index` over it finishes in 25s with `resolve_scope=Skipped` — correctly, there is no pending work. A daemon started with no client attached reaches "confirmed serving" in 23.5s and stays up indefinitely. It is only the arrival of a client, and its 2s `stats()` bound, that starts the eviction loop.
Author
Member

Fixed on hotfix-67-health-probe (unpushed) — and it is worse than the corrected analysis said

Your Option A, implemented. But the diagnosis needs one more correction, in the direction of severity.

stats() scans refs three times, not once

The COUNT(*) WHERE reclassified = 0 you measured at 0.372s is the cheapest of three full scans. lang_resolution (a JOIN over refs × files) and kind_resolution (a GROUP BY over refs) each scan the same table again. All three are in stats(); all three were on the health path.

Confirmed by EXPLAIN QUERY PLAN on our own index — three separate SCAN refs / SCAN r steps.

Measured on a 2.08M-ref index — Linux, warm cache, 205 MB (i.e. easier than your 1.38 GB Windows case)

refs count (reclassified)     204.8 ms
lang_resolution              1026.6 ms
kind_resolution              1621.2 ms
SUBTOTAL                     2852.5 ms   = 143% of the 2s HANDSHAKE_TIMEOUT

new health probe                0.3 ms   =   0.01% of the budget

This matters for how the bug is understood. Your write-up framed it as marginal — 0.372s against a 2s budget, tipped over by cold connections and AV scanning. It is not marginal. The health predicate is deterministically over budget on any sufficiently large index, on fast hardware, with a warm cache. Windows and AV made it visible; they did not cause it.

It also rules out your Option B. Fixing the COUNT (B2, the two-line derive) removes 205ms of 2853ms — 7%. The livelock survives. B was the smallest diff, but it could not have worked.

What shipped

Option A as you specified it:

  • new health RPC → {root, schema_version}, no aggregates, SELECT COALESCE(MAX(version),0) FROM schema_version and nothing else
  • try_handshake, probe_reachable (the takeover wedge probe) and the readiness poll all switch to it
  • old daemon answers "unknown method" → client falls back to stats(). No worse than today, not better until the daemon binary upgrades too

One detail worth recording: probe_reachable needs no fallback. An unknown method returns an error response carrying the same id, and that probe asks "did anything answer?" — which an error answers affirmatively. So an old incumbent reads as REACHABLE and is not evicted. The skew window fails safe.

Health errors still propagate. A probe that swallowed failures would report a dead daemon as alive, which is the opposite bug and a worse one.

Your suggested regression test, with one change

You proposed asserting no SCAN <large table> on the health path. Shipped — but reading the SQL out of LocalIndex::health's own source via include_str! (the idiom the RPC_METHODS anti-drift gate already uses) rather than a hand-maintained query list in the test. The failure mode was a health path quietly growing expensive; a test needing manual updating to notice growth is the wrong shape.

Plus an anti-vacuity assertion that the pre-fix query still scans, so the guard cannot silently become decoration if the schema changes.

Mutation-proven both ways: restoring the scan inside health() fails the shape gate; removing the skew fallback fails the wire-skew leg.

Not fixed, deliberately

project_overview still pays all three scans on every call — ~2.9s on a 2M-ref index. Real, and it will be felt, but it is slowness rather than a livelock and it deserves its own issue rather than being smuggled into a hotfix. Filing separately.

Also confirmed from your report, unfixed here

The doctor link check and link list optimism (lockfile_status() calling only pid_is_alive() while the MCP server does a full authenticated round trip) are genuine and separate. Worth noting the new health RPC is exactly the primitive link list should be using — that gap is now cheap to close.

Timeout tuning (Option C) is deliberately not included. With an O(1) probe the 2s bound is no longer size-dependent, so raising it would only mask a future regression that the shape gate is there to catch.

## Fixed on `hotfix-67-health-probe` (unpushed) — and it is worse than the corrected analysis said Your Option A, implemented. But the diagnosis needs one more correction, in the direction of severity. ### `stats()` scans `refs` three times, not once The `COUNT(*) WHERE reclassified = 0` you measured at 0.372s is the **cheapest** of three full scans. `lang_resolution` (a JOIN over `refs × files`) and `kind_resolution` (a GROUP BY over `refs`) each scan the same table again. All three are in `stats()`; all three were on the health path. Confirmed by `EXPLAIN QUERY PLAN` on our own index — three separate `SCAN refs` / `SCAN r` steps. ### Measured on a 2.08M-ref index — Linux, warm cache, 205 MB (i.e. *easier* than your 1.38 GB Windows case) ``` refs count (reclassified) 204.8 ms lang_resolution 1026.6 ms kind_resolution 1621.2 ms SUBTOTAL 2852.5 ms = 143% of the 2s HANDSHAKE_TIMEOUT new health probe 0.3 ms = 0.01% of the budget ``` **This matters for how the bug is understood.** Your write-up framed it as marginal — 0.372s against a 2s budget, tipped over by cold connections and AV scanning. It is not marginal. The health predicate is deterministically over budget on any sufficiently large index, on fast hardware, with a warm cache. Windows and AV made it visible; they did not cause it. It also **rules out your Option B**. Fixing the `COUNT` (B2, the two-line derive) removes 205ms of 2853ms — 7%. The livelock survives. B was the smallest diff, but it could not have worked. ### What shipped Option A as you specified it: - new `health` RPC → `{root, schema_version}`, no aggregates, `SELECT COALESCE(MAX(version),0) FROM schema_version` and nothing else - `try_handshake`, `probe_reachable` (the takeover wedge probe) and the readiness poll all switch to it - old daemon answers "unknown method" → client falls back to `stats()`. No worse than today, not better until the daemon binary upgrades too One detail worth recording: **`probe_reachable` needs no fallback.** An unknown method returns an error *response* carrying the same id, and that probe asks "did anything answer?" — which an error answers affirmatively. So an old incumbent reads as REACHABLE and is not evicted. The skew window fails safe. Health errors still propagate. A probe that swallowed failures would report a dead daemon as alive, which is the opposite bug and a worse one. ### Your suggested regression test, with one change You proposed asserting no `SCAN <large table>` on the health path. Shipped — but reading the SQL out of `LocalIndex::health`'s **own source** via `include_str!` (the idiom the `RPC_METHODS` anti-drift gate already uses) rather than a hand-maintained query list in the test. The failure mode was a health path quietly growing expensive; a test needing manual updating to notice growth is the wrong shape. Plus an anti-vacuity assertion that the pre-fix query *still* scans, so the guard cannot silently become decoration if the schema changes. Mutation-proven both ways: restoring the scan inside `health()` fails the shape gate; removing the skew fallback fails the wire-skew leg. ### Not fixed, deliberately `project_overview` still pays all three scans on every call — ~2.9s on a 2M-ref index. Real, and it will be felt, but it is slowness rather than a livelock and it deserves its own issue rather than being smuggled into a hotfix. Filing separately. ### Also confirmed from your report, unfixed here The `doctor` link check and `link list` optimism (`lockfile_status()` calling only `pid_is_alive()` while the MCP server does a full authenticated round trip) are genuine and separate. Worth noting the new `health` RPC is exactly the primitive `link list` should be using — that gap is now cheap to close. Timeout tuning (Option C) is deliberately **not** included. With an O(1) probe the 2s bound is no longer size-dependent, so raising it would only mask a future regression that the shape gate is there to catch.
dhoyer referenced this issue from a commit 2026-08-18 13:31:31 +02:00
dhoyer referenced this issue from a commit 2026-08-18 14:08:21 +02:00
Author
Member

Shipped in v0.13.3 — closing

Released and verified from the published artifact, not from a green workflow:

VERSION.txt                    v0.13.3+269
code-index-daemon 0.13.3 (df75018)
SHA256                         MATCH

Merged as 3cd8a92, tagged at df75018, four platforms published with checksums.

What landed

  • new health RPC → {root, schema_version}, no aggregate queries
  • try_handshake, probe_reachable (takeover wedge probe) and the readiness poll all use it
  • older daemons answer "unknown method" → client falls back to stats(), so the mixed-version window degrades to the previous behaviour rather than breaking
  • regression guard: crates/daemon/tests/health_probe_e2e.rs asserts, via EXPLAIN QUERY PLAN over SQL read out of LocalIndex::health's own source, that nothing on the health path scans a large table — with an anti-vacuity check that the pre-fix query still does

Measured effect on the health predicate: 2853 ms → 0.3 ms on a 2.08M-ref index.

Two corrections to the report, for the record

Both in the direction of severity, and neither changes that your Option A was the right call:

  1. stats() scans refs three times, not once. The COUNT(*) WHERE reclassified = 0 you measured at 0.372s is the cheapest of them.
  2. It was therefore never marginal or Windows-specific. At 143% of the 2s budget on Linux with a warm cache, any sufficiently large index fails deterministically. Windows and AV made it visible.

Consequence: your Option B could not have worked. Fixing the COUNT alone removes 7% of the cost and leaves the livelock intact.

Still open, carved off

  • #73 — project_overview still pays all three scans (~2.9s on a 2M-ref index). Real, but slowness rather than livelock.
  • The doctor link check and link list optimism you flagged (lockfile_status() calling only pid_is_alive() while the MCP server does a full authenticated round trip) remain unfixed. The new health RPC is exactly the primitive link list should use, so that gap is now cheap to close — worth its own issue if you want it tracked.

Timeout tuning (Option C) deliberately not included: with an O(1) probe the 2s bound no longer scales with index size, so raising it would only mask a future regression the shape gate exists to catch.

Thank you for the write-up — the retraction and re-diagnosis in particular. The EXPLAIN QUERY PLAN output in comment 5169 is what turned this from "large repos feel slow" into a one-day fix.

## Shipped in v0.13.3 — closing Released and verified from the published artifact, not from a green workflow: ``` VERSION.txt v0.13.3+269 code-index-daemon 0.13.3 (df75018) SHA256 MATCH ``` Merged as `3cd8a92`, tagged at `df75018`, four platforms published with checksums. ### What landed - new `health` RPC → `{root, schema_version}`, no aggregate queries - `try_handshake`, `probe_reachable` (takeover wedge probe) and the readiness poll all use it - older daemons answer "unknown method" → client falls back to `stats()`, so the mixed-version window degrades to the previous behaviour rather than breaking - regression guard: `crates/daemon/tests/health_probe_e2e.rs` asserts, via `EXPLAIN QUERY PLAN` over SQL read out of `LocalIndex::health`'s own source, that nothing on the health path scans a large table — with an anti-vacuity check that the pre-fix query still does Measured effect on the health predicate: **2853 ms → 0.3 ms** on a 2.08M-ref index. ### Two corrections to the report, for the record Both in the direction of severity, and neither changes that your Option A was the right call: 1. `stats()` scans `refs` **three times**, not once. The `COUNT(*) WHERE reclassified = 0` you measured at 0.372s is the cheapest of them. 2. It was therefore never marginal or Windows-specific. At 143% of the 2s budget on Linux with a warm cache, any sufficiently large index fails deterministically. Windows and AV made it *visible*. Consequence: your Option B could not have worked. Fixing the `COUNT` alone removes 7% of the cost and leaves the livelock intact. ### Still open, carved off - **#73** — `project_overview` still pays all three scans (~2.9s on a 2M-ref index). Real, but slowness rather than livelock. - The `doctor` link check and `link list` optimism you flagged (`lockfile_status()` calling only `pid_is_alive()` while the MCP server does a full authenticated round trip) remain unfixed. The new `health` RPC is exactly the primitive `link list` should use, so that gap is now cheap to close — worth its own issue if you want it tracked. Timeout tuning (Option C) deliberately not included: with an O(1) probe the 2s bound no longer scales with index size, so raising it would only mask a future regression the shape gate exists to catch. Thank you for the write-up — the retraction and re-diagnosis in particular. The `EXPLAIN QUERY PLAN` output in [comment 5169](https://git.h-dv.de/h-dv/code-index/issues/67#issuecomment-5169) is what turned this from "large repos feel slow" into a one-day fix.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
h-dv/code-index#67
No description provided.