After a daemon restart a session can refuse every filter as "daemon predates the filter", and calls during the restart are refused instead of waiting #584
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#584
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?
Reported by a customer on v0.41.50 (client and daemon both v0.41.50, five language packages,
plugin doctorall OK), 2026-11-13.What the user saw
search_symbolswithpath_globwas refused: "the running daemon predates thesearch_symbols.path_globfilter and would silently ignore it … Stop it (or wait out its idle timeout) and retry". Both binaries were the same release. The refusal stayed for the rest of the session.search_textwithpath_globkept working.daemon_restartingand "existing owner … is still starting". They fell back to grep.Part A: a failed capability probe is remembered as "this daemon has no filters" (cause known)
RpcIndex::honors(crates/daemon/src/rpc_index.rs) asks the daemon for its filter capabilities once per handle and keeps the answer in aOnceCell. If that one request fails for ANY reason, the error is logged and an EMPTY set is stored:Only restarting the MCP server clears it. Present in every release that has the capability probe, including v0.41.52.
Expected:
Part B: calls made during a daemon restart are refused instead of answered a moment later (to measure)
After an idle timeout the next call starts a new daemon. Calls from other clients of the same project that arrive in that window are refused (
daemon_restarting, "existing owner … is still starting"). An agent treats a refusal as "tool broken" and leaves for grep, even when the refusal says retryable.To establish before changing anything:
Acceptance
Workaround until released: restart the MCP server (the client session) once the daemon is up.
First data point for Part B, measured on Linux with the release smoke steps (fresh project, seven languages enabled, no daemon running, a copy of a real store with 45 installed packages):
index_incomplete(statewarming_up)reconcilingSo on the reporter's release a start with a filled package store took long enough for several calls to be refused; that part is the startup regression of #581, fixed in v0.41.52. This is the one-shot CLI path, not yet the daemon restart with two MCP clients that the report describes; that measurement is part of the lane.
For the reporter: upgrading to v0.41.52 shortens the window; Part A (the remembered "predates the filter" refusal) is not fixed by it, the workaround stays restarting the MCP server.
Part A is fixed on the next-train branch (lane r34, merged locally as
ab56843c; not released yet).invoke_versioned,health,search_symbols_ex,wait_for_startup, the optional readers (resolve_progress,resolve_costs,partial_sources,lang_spellings,lang_census,declared_profiles,read_epoch,path_claims,outline_source) and theexclude_testsrefusals of references and callers.change_impact.role_filter,resolution_gaps.{exclude_tests,ref_kind,qualifier_captured},search_symbols.path_glob,file_outline.top_level_only,find_references/find_callers.resolution.Part B, measured. Restart after a real idle exit, 300 files, five package-owned languages, warm store and index: first answer after 0.21 s and settled after 1.16 s on v0.41.50; 0.24 s and 1.40 s on the branch in the shipped build shape. So a warm restart is short on both, and the long window in the report needs the large package store of #581 (see the figures above: 19.6 s to a refused first query with 45 packages on v0.41.50, 1.7 s on v0.41.52).
What each client path does today while a daemon starts:
daemon_restarting, noretryablefielddaemon_restarting("existing owner … is still starting"), noretryablefieldindex_incomplete,retryable: truequeryindex_incomplete; answers from a snapshot when no daemon answersThe second row is the report's case. A lane is now running for it: a call that finds a positively identified starting owner waits for it, bounded by 10 s and by the call's own budget, and answers; a refusal after that carries
retryable: true, the measured wait and the owner's startup phase.Part B is on the next-train branch (lane r38, merged locally as
15eab3e5; not released yet).daemon_restartingrefusal now carriesretryable: true,waited_ms(0 when no wait was made) andstartup_phase(the daemon's latest lockfile phase, orunavailable). Example from the renderer:{"error":"daemon_restarting","retryable":true,"waited_ms":80,"startup_phase":"opening index", …}. The lockfile phases describe the time before the listener serves (opening index,resetting index schema,checkpointing write-ahead log); the phases a reader reports instate_detailafterwards are the separate closed list indocs/operations.md.Unchanged and still as in the table above: a newly started MCP server (2 s, then
index_incompletewithretryable: true), the CLIquery(10 s shared startup wait) and the hook (fails open).With Part A and Part B merged, what remains of this ticket is the release.
A gap in Part B found and closed before release (lane r44, merged locally as
cf2d3ee4).A full daemon-suite run under load failed once in
daemon_death_mcp_e2e::a_daemon_killed_mid_call_still_answers_that_call: the call that was in flight when the daemon was killed came back asinternal_error("existing owner … is still starting. No replacement was started").Cause, traced. My first guess (a freshly spawned daemon that has not published its identity yet) was not what happened in that run. The killed daemon itself still held the kernel lock for a moment and still looked alive, with phase
servingand no heartbeat. That shape fell into the older branch of the recovery guard: one more connect attempt, then a plain error, which the server renders asinternal_errorwhen the previous daemon recorded no exit. Under 12 busy loops the test failed 1 time in 20 before the change.Change. One predicate now decides whether recovery may wait for a lock holder (its truth table is in the doc comment of
starting_owner): the lock is held, the process is alive, no exit was announced, the recorded root and identity are compatible, and the heartbeat is not stale. Identity fields that are still unknown may become known during the wait; a known value that changes ends it. A channel is used only after the authenticated root handshake, as before. If the holder releases the lock during the wait (the killed predecessor finishing its teardown), the same call goes on to start the replacement. The older one-attempt branch is gone.A call that gives up because a holder is still starting is now
daemon_restartingin every case, withretryable: true,waited_msandstartup_phase; when the previous daemon was killed, the hint says that its exit was not recorded instead of naming a cause.Checked. After the change the same loop passes 20 of 20 under the same load (worker's run). In my run: 21 unit tests of the reconnect module, 10 reconnect and 8 daemon-death end-to-end tests pass; removing the "start the replacement once the lock is released" arm (mutation) fails the test written for it. The worker reported six further mutations red and the whole daemon suite and both MCP legs green.
Released in v0.41.53 (
59b8bc3d); verified on the installed binaries.Filters no longer stay refused after a transient capability request failure, and an existing session waits for a replacement daemon within its call budget. A restart refusal is retryable and includes
waited_msandstartup_phase.Verified on the installed release:
584_first_filtered_call: a filtered call receives an answer without a false older-daemon refusal. PASS: answered after 1 call; replies: ['ok']584_after_kill: the session answers after its daemon is killed, with valid restart refusals if any. PASS: the session answered 0.2s after its daemon was killedRemaining scope: a newly started MCP server, the CLI
queryand the hook keep their own waiting rules while a daemon starts.