From 90e9555623072da441d0d1b8cf44d5bac8d76422 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Thu, 22 Oct 2026 11:26:50 +0800 Subject: [PATCH 1/4] fix(daemon): upgrade an outdated index before the daemon starts serving it In the default daemon mode nothing upgraded an index an older extraction version built. A cold `serve --mcp` session defers to its daemon, and the daemon's startup gate admits only a current index, so every tool call answered "outdated" until a manual `codegraph sync`. v0.55.0 moved the extraction version from 21 to 22, so every v0.54.0 index hit this. The daemon now upgrades the index before its startup gate, through a new codegraph-watch entry, upgrade_outdated_index_once. Under one exclusive lease it requires an outdated namespace whose authoritative slot records the current phase, no uninitialized tombstone, and the database file, and only then runs the migration `codegraph sync` runs. The state gate classifies every phase an older version wrote as outdated, so without these checks a daemon would rebuild the database an older `uninit --force` removed. Anything else, and a failed upgrade, is left to the unchanged gate, which refuses it as before. --- crates/codegraph-cli/src/main.rs | 54 +++ .../codegraph-cli/tests/upgrade_from_v054.rs | 430 +++++++++++++++++- crates/codegraph-watch/src/lib.rs | 7 +- crates/codegraph-watch/src/sync.rs | 92 +++- docs/cli.md | 19 +- 5 files changed, 589 insertions(+), 13 deletions(-) diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index 65dc96cf..c18a44a8 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -1689,6 +1689,57 @@ fn authorize_daemon_startup(project_root: &Path) -> Result tracing::info!( + project = %project_root.display(), + files = outcome.files_reindexed, + "upgraded the index to the current extraction version" + ), + Ok(codegraph_watch::OutdatedUpgrade::Skipped(reason)) => tracing::info!( + project = %project_root.display(), + %reason, + "left the outdated index to the startup gate" + ), + Err(error) => tracing::warn!( + error = %format!("{error:#}"), + project = %project_root.display(), + "could not upgrade the outdated index; the startup gate decides" + ), + } +} + /// Ask this project's daemon (if any) to stop accepting, cancel its watcher lease /// loops, drain, remove its own pid/socket, and ACK. `Err(detail)` is the /// fail-closed signal; the pid is only ever reported, never signalled. @@ -2728,6 +2779,9 @@ fn cmd_serve( // startup gate below validates state/owner/tombstone under a // bounded SHARED index lease and RETAINS it across pid/socket // publication, so a concurrent `uninit --force` cannot interleave. + // An index an older extraction version built is upgraded first: + // the gate admits only `Current`, and nothing else upgrades it. + upgrade_outdated_index_for_daemon(&project_root); let gate = |root: &Path| authorize_daemon_startup(root); return codegraph_daemon::run_foreground_gated( &project_root, diff --git a/crates/codegraph-cli/tests/upgrade_from_v054.rs b/crates/codegraph-cli/tests/upgrade_from_v054.rs index 87813375..110d8e1c 100644 --- a/crates/codegraph-cli/tests/upgrade_from_v054.rs +++ b/crates/codegraph-cli/tests/upgrade_from_v054.rs @@ -4,11 +4,12 @@ //! released v0.54.0 binary (extraction version 21) wrote for the mini corpus. //! Each test plants it in a fresh project namespace, stages the state slot that //! release publishes (`phase=current`, extraction version 21), and upgrades it -//! through one of the two paths a user hits: `codegraph sync` from the shell, and -//! the catch-up sync a daemon or MCP session runs when it first opens the -//! project. Both must classify the namespace as outdated, rebuild it from source -//! exactly once, and leave an index at the current schema whose canonical graph -//! equals a fresh `init` of the same tree. +//! through the paths a user hits: `codegraph sync` from the shell, the catch-up +//! sync a daemon or MCP session runs when it first opens the project, and a cold +//! `serve --mcp` session in the default daemon mode, whose daemon must upgrade +//! the index before it starts serving. Each must classify the namespace as +//! outdated, rebuild it from source, and leave an index at the current schema +//! whose canonical graph equals a fresh `init` of the same tree. use std::fs; use std::path::{Path, PathBuf}; @@ -63,6 +64,425 @@ fn catch_up_sync_upgrades_a_v0_54_index_to_the_current_schema_and_extraction() { assert_upgraded_like_a_fresh_init(&project, "catch-up"); } +/// The default daemon mode: a cold `serve --mcp` session answers direct and +/// spawns the project's daemon, which used to refuse an `outdated` index at its +/// startup gate, so nothing ever upgraded it and every tool call failed until a +/// manual `codegraph sync`. The daemon must upgrade the index first, then serve +/// it and keep it live. +#[cfg(unix)] +#[test] +fn a_cold_mcp_session_upgrades_a_v0_54_index_through_its_daemon() { + let dir = TestDir::new("daemon"); + let project = plant_v0_54_index(&dir); + let paths = index_paths(&project); + let mut session = cold::Session::start(&project, dir.path()); + + let deadline = std::time::Instant::now() + cold::UPGRADED_WITHIN; + while Store::extraction_status(&paths) != ExtractionStatus::Current { + assert!( + std::time::Instant::now() < deadline, + "the v0.54.0 index was still {:?} {:?} after the cold session started;\n\ + daemon.log:\n{}\nsession stderr:\n{}", + Store::extraction_status(&paths), + cold::UPGRADED_WITHIN, + fs::read_to_string(paths.daemon_log()).unwrap_or_default(), + session.stderr() + ); + std::thread::sleep(std::time::Duration::from_millis(100)); + } + cold::wait_for_daemon(&project, &session); + assert_upgraded_like_a_fresh_init(&project, "daemon"); + + let marker = "upgradedDaemonSessionMarker"; + fs::write( + project.join("src/upgraded.ts"), + format!("export function {marker}(): number {{\n return 1;\n}}\n"), + ) + .unwrap(); + session.wait_for_symbol(marker, "src/upgraded.ts"); + session.close(); + cold::shutdown_daemon(&project); +} + +/// A daemon never undoes an older `uninit --force`. That leaves an +/// `uninitialized` slot at the older extraction version, which the state gate +/// classifies as `outdated`, a tombstone, and no database; the daemon must +/// leave all of it alone and refuse to start, as it did before. +#[cfg(unix)] +#[test] +fn a_cold_mcp_session_leaves_an_older_uninit_alone() { + let dir = TestDir::new("daemon-uninit"); + let project = dir.path().join("project"); + copy_tree(&mini_fixture(), &project); + let init = cli(&["init", path_str(&project)]); + assert!(init.ok, "init failed: {}", init.stderr); + let uninit = cli(&["uninit", "--force", path_str(&project)]); + assert!(uninit.ok, "uninit failed: {}", uninit.stderr); + let paths = index_paths(&project); + stage_state_slot( + &paths, + 3, + CURRENT_STORAGE_PROTOCOL, + V0_54_EXTRACTION_VERSION, + "uninitialized", + ); + let outdated = ExtractionStatus::Outdated { + built: V0_54_EXTRACTION_VERSION, + }; + assert_eq!(Store::extraction_status(&paths), outdated); + assert!(paths.tombstone().is_file(), "uninit leaves its tombstone"); + assert!(!paths.current_db().exists(), "uninit removes the database"); + let slots = state_slot_bytes(&paths); + + let session = cold::Session::start(&project, dir.path()); + let log = cold::wait_for_daemon_log(&paths, "refusing to start a daemon", &session); + assert!( + !paths.current_db().exists(), + "the daemon recreated the database an uninit removed:\n{log}" + ); + assert!(paths.tombstone().is_file(), "the tombstone must stay"); + assert_eq!(Store::extraction_status(&paths), outdated); + assert_eq!( + state_slot_bytes(&paths), + slots, + "the state slots must not move" + ); + assert!( + !cold::daemon_live(&project), + "no daemon may serve this index" + ); + assert!( + log.contains("left the outdated index to the startup gate"), + "the daemon must say why it left the index alone:\n{log}" + ); + session.close(); +} + +/// A failed upgrade changes nothing, and the startup gate still refuses the +/// index with its own guidance. Here the project config cannot be parsed. +#[cfg(unix)] +#[test] +fn a_daemon_whose_upgrade_fails_still_refuses_the_outdated_index() { + let dir = TestDir::new("daemon-failed-upgrade"); + let project = plant_v0_54_index(&dir); + let paths = index_paths(&project); + fs::write(paths.config_toml(), "[indexing\n").unwrap(); + let database = fs::read(paths.current_db()).unwrap(); + let slots = state_slot_bytes(&paths); + + let session = cold::Session::start(&project, dir.path()); + let log = cold::wait_for_daemon_log(&paths, "refusing to start a daemon", &session); + assert_eq!( + Store::extraction_status(&paths), + ExtractionStatus::Outdated { + built: V0_54_EXTRACTION_VERSION + } + ); + assert_eq!( + fs::read(paths.current_db()).unwrap(), + database, + "a failed upgrade must leave the database as it was" + ); + assert_eq!( + state_slot_bytes(&paths), + slots, + "the state slots must not move" + ); + assert!( + !cold::daemon_live(&project), + "no daemon may serve this index" + ); + assert!( + log.contains("could not upgrade the outdated index"), + "the daemon must log the failed upgrade:\n{log}" + ); + session.close(); +} + +/// The upgrade a daemon runs rebuilds only a complete older index. Each +/// namespace below is one `codegraph sync` would still migrate. +#[test] +fn the_daemon_upgrade_skips_an_interrupted_older_build() { + let dir = TestDir::new("skip-building"); + let project = plant_v0_54_index(&dir); + stage_state_slot( + &index_paths(&project), + 3, + CURRENT_STORAGE_PROTOCOL, + V0_54_EXTRACTION_VERSION, + "building", + ); + assert_upgrade_skipped_untouched(&project, "records phase building"); +} + +#[test] +fn the_daemon_upgrade_skips_an_older_index_with_a_tombstone() { + let dir = TestDir::new("skip-tombstone"); + let project = plant_v0_54_index(&dir); + fs::write(index_paths(&project).tombstone(), b"uninitialized\n").unwrap(); + assert_upgrade_skipped_untouched(&project, "tombstone is present"); +} + +#[test] +fn the_daemon_upgrade_skips_an_older_index_without_its_database() { + let dir = TestDir::new("skip-missing-db"); + let project = plant_v0_54_index(&dir); + fs::remove_file(index_paths(&project).current_db()).unwrap(); + assert_upgrade_skipped_untouched(&project, "database is missing"); +} + +fn assert_upgrade_skipped_untouched(project: &Path, reason: &str) { + let paths = index_paths(project); + let status = Store::extraction_status(&paths); + assert!( + matches!(status, ExtractionStatus::Outdated { .. }), + "each case starts outdated: {status:?}" + ); + let database = fs::read(paths.current_db()).ok(); + let tombstone = fs::read(paths.tombstone()).ok(); + let slots = state_slot_bytes(&paths); + match codegraph_watch::upgrade_outdated_index_once(project).expect("daemon upgrade entry") { + codegraph_watch::OutdatedUpgrade::Skipped(text) => { + assert!(text.contains(reason), "skipped for another reason: {text}"); + } + other => panic!("expected a skip ({reason}), got {other:?}"), + } + assert_eq!(Store::extraction_status(&paths), status); + assert_eq!(fs::read(paths.current_db()).ok(), database); + assert_eq!(fs::read(paths.tombstone()).ok(), tombstone); + assert_eq!(state_slot_bytes(&paths), slots); +} + +/// Both state slots, byte for byte (`None` for an absent slot). +fn state_slot_bytes(paths: &IndexPaths) -> [Option>; 2] { + paths.state_slots().map(|slot| fs::read(slot).ok()) +} + +/// A cold `serve --mcp --path ` over line-delimited JSON-RPC, the +/// harness of `cold_start_live_sync.rs` reduced to what the upgrade case needs. +#[cfg(unix)] +mod cold { + use std::fs; + use std::io::{BufRead, BufReader, Write}; + use std::path::{Path, PathBuf}; + use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + use std::time::{Duration, Instant}; + + use serde_json::{Value, json}; + + /// Upper bound for the daemon to upgrade the mini corpus and publish. + pub const UPGRADED_WITHIN: Duration = Duration::from_secs(60); + /// Upper bound for an edit to reach `codegraph_search` once it serves. + const EDIT_VISIBLE_WITHIN: Duration = Duration::from_secs(10); + + pub struct Session { + child: Child, + stdin: Option, + stdout: BufReader, + stderr_log: PathBuf, + next_id: i64, + } + + impl Session { + pub fn start(project: &Path, scratch: &Path) -> Self { + let stderr_log = scratch.join("serve.stderr.log"); + let mut child = Command::new(env!("CARGO_BIN_EXE_codegraph")) + .args(["serve", "--mcp", "--path"]) + .arg(project) + .env_remove("CODEGRAPH_NO_DAEMON") + .env_remove("CODEGRAPH_NO_WATCH") + .env_remove("CODEGRAPH_DIR") + .env("CODEGRAPH_MCP_REGISTRY_DIR", scratch.join("mcp-registry")) + .env("CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS", "1000") + .env("CODEGRAPH_DAEMON_RETRY_MS", "200") + .env("CODEGRAPH_DAEMON_RETRY_MAX_MS", "1000") + .env("CODEGRAPH_WATCH_DEBOUNCE_MS", "100") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(fs::File::create(&stderr_log).expect("create serve stderr log")) + .spawn() + .expect("spawn cold serve --mcp"); + let stdin = child.stdin.take().expect("serve stdin"); + let stdout = BufReader::new(child.stdout.take().expect("serve stdout")); + let mut session = Self { + child, + stdin: Some(stdin), + stdout, + stderr_log, + next_id: 1, + }; + let id = session.next_id(); + session.write(json!({ + "jsonrpc": "2.0", + "id": id, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "upgrade-from-v054", "version": "0" } + } + })); + let response = session.read_id(id); + assert_eq!( + response["result"]["serverInfo"]["name"], "codegraph", + "{response}" + ); + session.write(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })); + session + } + + fn next_id(&mut self) -> i64 { + let id = self.next_id; + self.next_id += 1; + id + } + + fn write(&mut self, value: Value) { + let stdin = self.stdin.as_mut().expect("serve stdin stays open"); + writeln!(stdin, "{value}").unwrap(); + stdin.flush().unwrap(); + } + + fn read_id(&mut self, want: i64) -> Value { + loop { + let mut line = String::new(); + let read = self.stdout.read_line(&mut line).expect("read serve stdout"); + assert!( + read > 0, + "serve closed stdout before response {want}; stderr:\n{}", + self.stderr() + ); + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let value: Value = serde_json::from_str(trimmed).unwrap_or_else(|error| { + panic!("non-JSON line on serve stdout ({error}): {trimmed:?}") + }); + if value.get("id").and_then(Value::as_i64) == Some(want) { + return value; + } + } + } + + fn search(&mut self, query: &str) -> String { + let id = self.next_id(); + self.write(json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": "codegraph_search", "arguments": { "query": query } } + })); + let response = self.read_id(id); + response["result"]["content"][0]["text"] + .as_str() + .unwrap_or("") + .to_string() + } + + pub fn wait_for_symbol(&mut self, symbol: &str, file: &str) { + let deadline = Instant::now() + EDIT_VISIBLE_WITHIN; + loop { + let text = self.search(symbol); + if text.contains(symbol) && text.contains(file) { + return; + } + assert!( + Instant::now() < deadline, + "`{symbol}` was not searchable within {EDIT_VISIBLE_WITHIN:?}; last result:\n{text}\nstderr:\n{}", + self.stderr() + ); + std::thread::sleep(Duration::from_millis(100)); + } + } + + pub fn stderr(&self) -> String { + fs::read_to_string(&self.stderr_log).unwrap_or_default() + } + + pub fn close(mut self) { + drop(self.stdin.take()); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if self.child.try_wait().unwrap().is_some() { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + panic!("cold serve did not exit after stdin EOF"); + } + } + + impl Drop for Session { + fn drop(&mut self) { + drop(self.stdin.take()); + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } + } + + /// Upper bound for a refusing daemon to write its log lines. + const REFUSED_WITHIN: Duration = Duration::from_secs(30); + + /// Whether a live daemon owns `project`. + pub fn daemon_live(project: &Path) -> bool { + codegraph_daemon::daemon_pid_path(project) + .ok() + .and_then(|path| fs::read_to_string(path).ok()) + .and_then(|raw| codegraph_daemon::decode_lock_info(&raw)) + .is_some_and(|info| info.pid > 0 && codegraph_daemon::is_process_alive(info.pid)) + } + + /// Wait until the project's `daemon.log` contains `needle`; returns the log. + pub fn wait_for_daemon_log( + paths: &codegraph_core::IndexPaths, + needle: &str, + session: &Session, + ) -> String { + let deadline = Instant::now() + REFUSED_WITHIN; + loop { + let log = fs::read_to_string(paths.daemon_log()).unwrap_or_default(); + if log.contains(needle) { + return log; + } + assert!( + Instant::now() < deadline, + "daemon.log did not show {needle:?} within {REFUSED_WITHIN:?}:\n{log}\nsession stderr:\n{}", + session.stderr() + ); + std::thread::sleep(Duration::from_millis(50)); + } + } + + /// Wait until a live daemon owns `project`: it publishes only after the + /// upgrade and its startup gate. + pub fn wait_for_daemon(project: &Path, session: &Session) { + let deadline = Instant::now() + UPGRADED_WITHIN; + loop { + if daemon_live(project) { + return; + } + assert!( + Instant::now() < deadline, + "no live daemon within {UPGRADED_WITHIN:?}; stderr:\n{}", + session.stderr() + ); + std::thread::sleep(Duration::from_millis(25)); + } + } + + pub fn shutdown_daemon(project: &Path) { + let paths = codegraph_core::IndexPaths::resolve(project, None).unwrap(); + match codegraph_daemon::request_daemon_shutdown(project, paths.project_identity()) { + Ok(codegraph_daemon::ShutdownOutcome::NoDaemon) + | Ok(codegraph_daemon::ShutdownOutcome::Drained { .. }) => {} + other => panic!("failed to stop the daemon: {other:?}"), + } + } +} + /// Copy the mini corpus into `dir`, create a namespace with the current binary, /// then replace its database and state slot with what v0.54.0 left behind. fn plant_v0_54_index(dir: &TestDir) -> PathBuf { diff --git a/crates/codegraph-watch/src/lib.rs b/crates/codegraph-watch/src/lib.rs index 30a08311..edaff3ac 100644 --- a/crates/codegraph-watch/src/lib.rs +++ b/crates/codegraph-watch/src/lib.rs @@ -45,9 +45,10 @@ pub use policy::{ CODEGRAPH_NO_WATCH, TooBroadRoot, WatchPolicy, too_broad_root_reason, watch_disabled_reason, }; pub use sync::{ - PendingChanges, SyncCancellation, SyncOutcome, pending_full_inventory, pending_project_changes, - pending_project_changes_detailed, sync_changed_paths, sync_project_once, - sync_project_once_cancellable, sync_project_once_with_progress, + OutdatedUpgrade, PendingChanges, SyncCancellation, SyncOutcome, pending_full_inventory, + pending_project_changes, pending_project_changes_detailed, sync_changed_paths, + sync_project_once, sync_project_once_cancellable, sync_project_once_with_progress, + upgrade_outdated_index_once, }; pub use watcher::{ CODEGRAPH_WATCH_SENTINEL_MS, LOCK_RECOVERY_INTERVAL, PendingFile, ProjectWatcher, WatchHealth, diff --git a/crates/codegraph-watch/src/sync.rs b/crates/codegraph-watch/src/sync.rs index ca2c7c8f..9819f37d 100644 --- a/crates/codegraph-watch/src/sync.rs +++ b/crates/codegraph-watch/src/sync.rs @@ -20,7 +20,9 @@ use codegraph_resolve::ReferenceResolver; use codegraph_resolve::framework::FrameworkExtractionContext; use codegraph_resolve::frameworks::godot_dsl_config::GodotDslConfig; use codegraph_store::queries::{FileReferenceSite, ReferenceSite, ResolutionReplacement}; -use codegraph_store::{IndexLease, Store, StoreWriteOpen, StoreWritePurpose}; +use codegraph_store::{ + ExtractionStatus, IndexLease, StatePhase, Store, StoreWriteOpen, StoreWritePurpose, classify, +}; use rayon::prelude::*; use crate::git_pending::{GitIndexCapture, PendingSource}; @@ -358,6 +360,94 @@ fn sync_project_once_with_scope( } } +/// What [`upgrade_outdated_index_once`] found under its exclusive lease. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OutdatedUpgrade { + /// A complete index an older extraction version built, rebuilt from source + /// into the current namespace. + Upgraded(SyncOutcome), + /// Any other namespace, left untouched; the text says why. + Skipped(String), +} + +/// Rebuild a complete index an older extraction version built, and nothing +/// else: the upgrade a daemon runs before its startup gate. +/// +/// The state gate classifies an older version's every phase as `Outdated`, and +/// `codegraph sync` migrates all of them. An automatic upgrade must not undo an +/// older `uninit --force` or recreate a database someone removed, so this takes +/// ONE exclusive lease, reclassifies under it, and requires an `Outdated` +/// status whose authoritative slot records the `current` phase, no +/// uninitialized tombstone, and the database file. Only then does it run the +/// migration `sync` runs, under the same lease, so no writer can change the +/// namespace between the check and the rebuild. +pub fn upgrade_outdated_index_once(project_root: impl AsRef) -> Result { + let project_root = project_root.as_ref(); + let paths = index_paths(project_root)?; + let scope = ProjectScope::load(project_root, &paths)?; + let started = Instant::now(); + let lease = IndexLease::acquire_exclusive_existing( + &paths, + Instant::now() + SYNC_LEASE_TIMEOUT, + || false, + )?; + if let Some(reason) = not_a_complete_outdated_index(&paths)? { + return Ok(OutdatedUpgrade::Skipped(reason)); + } + match Store::open_for_write(&paths, lease, StoreWritePurpose::IncrementalSync)? { + StoreWriteOpen::FullRebuildRequired(authorization) => crate::migrate::migrate_project( + project_root, + &paths, + authorization, + &scope, + started, + |_, _| {}, + ) + .map(OutdatedUpgrade::Upgraded), + // The check above held this lease, so the gate classifies the same + // `Outdated` namespace; any other answer is refused, not written. + _ => bail!( + "the index state gate did not authorize upgrading {}", + paths.current_root().display() + ), + } +} + +/// Why the namespace is not a complete index an older extraction version +/// built, or `None` when it is. +fn not_a_complete_outdated_index(paths: &IndexPaths) -> Result> { + let classification = classify(paths); + if !matches!(classification.status(), ExtractionStatus::Outdated { .. }) { + return Ok(Some(format!("index state is {}", classification.status()))); + } + let record = classification.authoritative().map(|slot| &slot.record); + if record.and_then(|record| record.phase) != Some(StatePhase::Current) { + return Ok(Some(format!( + "the outdated index records phase {}, not {}", + record.map_or("unknown", |record| record.phase_raw.as_str()), + StatePhase::Current.as_wire() + ))); + } + match fs::symlink_metadata(paths.tombstone()) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Ok(_) => return Ok(Some("its uninitialized tombstone is present".to_string())), + Err(error) => { + return Err(error) + .with_context(|| format!("inspecting {}", paths.tombstone().display())); + } + } + match fs::symlink_metadata(paths.current_db()) { + Ok(metadata) if metadata.file_type().is_file() => Ok(None), + Ok(_) => Ok(Some("its database is not a regular file".to_string())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(Some("its database is missing".to_string())) + } + Err(error) => { + Err(error).with_context(|| format!("inspecting {}", paths.current_db().display())) + } + } +} + /// One project's immutable configuration for ONE sync operation. /// /// Loaded from the addressed project's resolved [`IndexPaths`] — its current-root diff --git a/docs/cli.md b/docs/cli.md index 0243910d..0728d914 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1084,6 +1084,12 @@ attaches again, doubling the wait after each miss up to `CODEGRAPH_DAEMON_RETRY_MAX_MS`. It never starts a daemon for a project whose index is gone, and `CODEGRAPH_DAEMON_RETRY_MS=0` turns the retries off. +A daemon serves only a current index. Before it publishes its socket it upgrades +a complete index an older extraction version built, as +[Extraction-version upgrades](#extraction-version-upgrades) describes, and it +refuses to start on any other index that is not current, logging the reason to +`.codegraph/daemon.log`. + A daemon keeps running the code it started with, so after an upgrade the project's daemon can be an older build than the session that finds it. A session that meets a daemon of another version reads the version from the @@ -1348,10 +1354,15 @@ codegraph index --force /path/to/project # only when the CLI explicitly requires ``` An index built by an older extraction version is a supported `sync` upgrade: -`status` reports it as outdated, and `sync` (or the catch-up a daemon or MCP -session runs when it opens the project) rebuilds it from source into the current -namespace, applying any pending schema migration on the way. The result equals a -fresh `init`. Do not run `index --force` solely because the extraction version +`status` reports it as outdated, and `sync` rebuilds it from source into the +current namespace, applying any pending schema migration on the way. The result +equals a fresh `init`. An MCP session upgrades a complete older index as well: +in the default daemon mode the project's daemon rebuilds it the same way before it +starts serving, and with `CODEGRAPH_NO_DAEMON=1` the session's own catch-up does. +Until the rebuild finishes, a tool call may wait for it, up to 30 seconds, or +fail; a later call succeeds. The daemon leaves an older index alone when it is +not complete (an interrupted build, an `uninit --force`, or a missing database) +and refuses to start; run `codegraph status` for the recovery it needs. Do not run `index --force` solely because the extraction version changed. An older binary refuses an index a newer extraction version wrote and leaves it untouched. From 08668e80ca3f00819bd3b4bedc3fbc89861c4cdb Mon Sep 17 00:00:00 2001 From: sunerpy Date: Thu, 22 Oct 2026 11:26:50 +0800 Subject: [PATCH 2/4] fix(test): give every plain_output project its own temporary directory Two tests that started within one clock tick shared a directory, and the first to finish deleted the other's project mid-run, as in the first CI attempt of #325. The name now carries a process-wide sequence, and the directory is created with create_dir, so a collision fails instead of sharing. --- crates/codegraph-cli/tests/plain_output.rs | 33 ++++++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/codegraph-cli/tests/plain_output.rs b/crates/codegraph-cli/tests/plain_output.rs index 4718b516..ddc611af 100644 --- a/crates/codegraph-cli/tests/plain_output.rs +++ b/crates/codegraph-cli/tests/plain_output.rs @@ -10,6 +10,7 @@ use std::path::PathBuf; use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; const ANSI: &[u8] = b"\x1b["; @@ -27,18 +28,33 @@ struct Project { registry: PathBuf, } +/// Next project directory suffix, unique within this test process. +static NEXT_PROJECT: AtomicU64 = AtomicU64::new(0); + +/// A fresh directory name for one project. The clock alone repeats when two +/// tests start within one tick, and the first test's `Drop` then deleted the +/// other's project mid-run; the process-wide sequence keeps every name distinct. +fn project_base(nanos: u128) -> PathBuf { + std::env::temp_dir().join(format!( + "codegraph-plain-output-{}-{}-{nanos}", + std::process::id(), + NEXT_PROJECT.fetch_add(1, Ordering::Relaxed) + )) +} + impl Project { fn indexed() -> Self { - let base = std::env::temp_dir().join(format!( - "codegraph-plain-output-{}-{}", - std::process::id(), + let base = project_base( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() - .as_nanos() - )); + .as_nanos(), + ); + // `create_dir` fails on an existing directory, so two projects can + // never share one. + std::fs::create_dir(&base).expect("create a fresh project directory"); let root = base.join("project"); - std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir(&root).unwrap(); std::fs::write( root.join("a.ts"), "export function alpha(): number { return beta(); }\n\ @@ -79,6 +95,11 @@ impl Drop for Project { } } +#[test] +fn projects_created_within_one_clock_tick_get_distinct_directories() { + assert_ne!(project_base(7), project_base(7)); +} + fn has(haystack: &[u8], needle: &[u8]) -> bool { haystack .windows(needle.len()) From 8bb6e0af0ebbe6defa3358706fad352f2db4fe00 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Thu, 22 Oct 2026 14:14:06 +0800 Subject: [PATCH 3/4] docs(cli): say what a daemon leaves when its upgrade fails partway The upgrade runs the migration `codegraph sync` runs, which publishes `phase=building` before it removes the old database, so a rebuild that fails partway leaves the index `building`, as an interrupted `sync` does, and the daemon refuses it. The upgrade section said only that a later tool call succeeds. --- docs/cli.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 0728d914..c8cb31d0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1360,9 +1360,11 @@ equals a fresh `init`. An MCP session upgrades a complete older index as well: in the default daemon mode the project's daemon rebuilds it the same way before it starts serving, and with `CODEGRAPH_NO_DAEMON=1` the session's own catch-up does. Until the rebuild finishes, a tool call may wait for it, up to 30 seconds, or -fail; a later call succeeds. The daemon leaves an older index alone when it is -not complete (an interrupted build, an `uninit --force`, or a missing database) -and refuses to start; run `codegraph status` for the recovery it needs. Do not run `index --force` solely because the extraction version +fail; a later call succeeds once it is done. A rebuild that fails partway leaves +the index `building`, as an interrupted `sync` does. The daemon also leaves an +older index alone when it is not complete (an interrupted build, an +`uninit --force`, or a missing database). In both cases it refuses to start; run +`codegraph status` for the recovery it needs. Do not run `index --force` solely because the extraction version changed. An older binary refuses an index a newer extraction version wrote and leaves it untouched. From fd097d4a63a3256e3c4e68a6c97a95037cf033bc Mon Sep 17 00:00:00 2001 From: sunerpy Date: Thu, 22 Oct 2026 15:34:35 +0800 Subject: [PATCH 4/4] fix(daemon): stop a daemon whose upgrade fails before its startup gate A failed upgrade is a failed migration, and a migration failure must stop. The daemon logged the error and went on to its startup gate. The last step of the rebuild publishes `Current`, and the publisher's directory sync can still fail after that, so the gate could serve an index whose upgrade had just reported an error. The daemon now refuses to start with that error, before the gate, and publishes no pid or socket. A later daemon finds the index as the failure left it, and the gate decides as before. docs/cli.md now says what a failed rebuild can leave: the older index untouched if it fails before it begins replacing it, `building` if it fails later, as an interrupted `sync` does. --- crates/codegraph-cli/src/main.rs | 37 ++++++++++++------- .../codegraph-cli/tests/upgrade_from_v054.rs | 37 ++++++++++++++----- docs/cli.md | 18 +++++---- 3 files changed, 60 insertions(+), 32 deletions(-) diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index c18a44a8..eb3724c3 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -1697,23 +1697,25 @@ fn authorize_daemon_startup(project_root: &Path) -> Result Result<()> { let Ok(paths) = index_paths(project_root) else { // The gate reports the same resolution failure. - return; + return Ok(()); }; // Lock-free first look, so a daemon for any other state never queues for // the exclusive lease; the upgrade classifies again under it. let ExtractionStatus::Outdated { built } = Store::extraction_status(&paths) else { - return; + return Ok(()); }; tracing::info!( project = %project_root.display(), @@ -1732,12 +1734,17 @@ fn upgrade_outdated_index_for_daemon(project_root: &Path) { %reason, "left the outdated index to the startup gate" ), - Err(error) => tracing::warn!( - error = %format!("{error:#}"), - project = %project_root.display(), - "could not upgrade the outdated index; the startup gate decides" - ), + Err(error) => { + return Err(error.context(format!( + "refusing to start a daemon for {}: could not upgrade its outdated index. This \ + daemon did not publish a pid or socket. Run `codegraph status \"{}\"` for \ + recovery guidance", + project_root.display(), + project_root.display() + ))); + } } + Ok(()) } /// Ask this project's daemon (if any) to stop accepting, cancel its watcher lease @@ -2781,7 +2788,9 @@ fn cmd_serve( // publication, so a concurrent `uninit --force` cannot interleave. // An index an older extraction version built is upgraded first: // the gate admits only `Current`, and nothing else upgrades it. - upgrade_outdated_index_for_daemon(&project_root); + // A failed upgrade stops this daemon before the gate. + upgrade_outdated_index_for_daemon(&project_root) + .context("running as detached MCP daemon")?; let gate = |root: &Path| authorize_daemon_startup(root); return codegraph_daemon::run_foreground_gated( &project_root, diff --git a/crates/codegraph-cli/tests/upgrade_from_v054.rs b/crates/codegraph-cli/tests/upgrade_from_v054.rs index 110d8e1c..b0218e61 100644 --- a/crates/codegraph-cli/tests/upgrade_from_v054.rs +++ b/crates/codegraph-cli/tests/upgrade_from_v054.rs @@ -135,7 +135,12 @@ fn a_cold_mcp_session_leaves_an_older_uninit_alone() { let slots = state_slot_bytes(&paths); let session = cold::Session::start(&project, dir.path()); - let log = cold::wait_for_daemon_log(&paths, "refusing to start a daemon", &session); + let log = cold::wait_for_daemon_log( + &paths, + "refusing to start a daemon for", + "state-gated Store open rejected", + &session, + ); assert!( !paths.current_db().exists(), "the daemon recreated the database an uninit removed:\n{log}" @@ -158,8 +163,9 @@ fn a_cold_mcp_session_leaves_an_older_uninit_alone() { session.close(); } -/// A failed upgrade changes nothing, and the startup gate still refuses the -/// index with its own guidance. Here the project config cannot be parsed. +/// A failed upgrade is a failed migration: the daemon stops before its startup +/// gate, and nothing changes. Here the project config cannot be parsed, so the +/// upgrade fails before it takes the index lease. #[cfg(unix)] #[test] fn a_daemon_whose_upgrade_fails_still_refuses_the_outdated_index() { @@ -171,7 +177,12 @@ fn a_daemon_whose_upgrade_fails_still_refuses_the_outdated_index() { let slots = state_slot_bytes(&paths); let session = cold::Session::start(&project, dir.path()); - let log = cold::wait_for_daemon_log(&paths, "refusing to start a daemon", &session); + let log = cold::wait_for_daemon_log( + &paths, + "refusing to start a daemon for", + "could not upgrade its outdated index", + &session, + ); assert_eq!( Store::extraction_status(&paths), ExtractionStatus::Outdated { @@ -193,8 +204,8 @@ fn a_daemon_whose_upgrade_fails_still_refuses_the_outdated_index() { "no daemon may serve this index" ); assert!( - log.contains("could not upgrade the outdated index"), - "the daemon must log the failed upgrade:\n{log}" + !log.contains("state-gated Store open"), + "a failed upgrade must stop the daemon before its startup gate:\n{log}" ); session.close(); } @@ -435,21 +446,27 @@ mod cold { .is_some_and(|info| info.pid > 0 && codegraph_daemon::is_process_alive(info.pid)) } - /// Wait until the project's `daemon.log` contains `needle`; returns the log. + /// Wait until one line of the project's `daemon.log` contains both `first` + /// and `then`, in that order; returns the whole log. pub fn wait_for_daemon_log( paths: &codegraph_core::IndexPaths, - needle: &str, + first: &str, + then: &str, session: &Session, ) -> String { let deadline = Instant::now() + REFUSED_WITHIN; loop { let log = fs::read_to_string(paths.daemon_log()).unwrap_or_default(); - if log.contains(needle) { + let shown = log.lines().any(|line| { + line.find(first) + .is_some_and(|at| line[at + first.len()..].contains(then)) + }); + if shown { return log; } assert!( Instant::now() < deadline, - "daemon.log did not show {needle:?} within {REFUSED_WITHIN:?}:\n{log}\nsession stderr:\n{}", + "daemon.log did not show {first:?} … {then:?} within {REFUSED_WITHIN:?}:\n{log}\nsession stderr:\n{}", session.stderr() ); std::thread::sleep(Duration::from_millis(50)); diff --git a/docs/cli.md b/docs/cli.md index c8cb31d0..c4b33830 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1086,9 +1086,9 @@ index is gone, and `CODEGRAPH_DAEMON_RETRY_MS=0` turns the retries off. A daemon serves only a current index. Before it publishes its socket it upgrades a complete index an older extraction version built, as -[Extraction-version upgrades](#extraction-version-upgrades) describes, and it -refuses to start on any other index that is not current, logging the reason to -`.codegraph/daemon.log`. +[Extraction-version upgrades](#extraction-version-upgrades) describes. It +refuses to start when that upgrade fails or the index is otherwise not current, +logging the reason to `.codegraph/daemon.log`. A daemon keeps running the code it started with, so after an upgrade the project's daemon can be an older build than the session that finds it. A @@ -1360,11 +1360,13 @@ equals a fresh `init`. An MCP session upgrades a complete older index as well: in the default daemon mode the project's daemon rebuilds it the same way before it starts serving, and with `CODEGRAPH_NO_DAEMON=1` the session's own catch-up does. Until the rebuild finishes, a tool call may wait for it, up to 30 seconds, or -fail; a later call succeeds once it is done. A rebuild that fails partway leaves -the index `building`, as an interrupted `sync` does. The daemon also leaves an -older index alone when it is not complete (an interrupted build, an -`uninit --force`, or a missing database). In both cases it refuses to start; run -`codegraph status` for the recovery it needs. Do not run `index --force` solely because the extraction version +fail; a later call succeeds once it is done. If the rebuild fails, the daemon +does not start. A failure before the rebuild begins replacing the index leaves +the older index as it was; a later one can leave it `building`, as an +interrupted `sync` does. The daemon also refuses to start on an older index that +is not complete (an interrupted build, an `uninit --force`, or a missing +database), and leaves it alone. Either way, run `codegraph status` for the state +the index is in and the recovery it needs. Do not run `index --force` solely because the extraction version changed. An older binary refuses an index a newer extraction version wrote and leaves it untouched.