1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2use std::io::{self, BufWriter};
3use std::path::{Component, Path, PathBuf};
4use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
5use std::sync::{mpsc, Arc, Mutex, RwLock, TryLockError, Weak};
6use std::time::{Duration, Instant, SystemTime};
7
8use lsp_types::FileChangeType;
9use notify::RecommendedWatcher;
10use rusqlite::Connection;
11use serde::Serialize;
12
13use crate::alert_state::{
14 AcceptedObservationBatch, AcceptedObservationResult, AlertDeltaState, ObservationError,
15};
16use crate::artifact_owner::{
17 ArtifactOwnerLease, ArtifactOwnerLeaseRegistration, ArtifactOwnerMode, ArtifactOwnerStatus,
18};
19use crate::backup::hash_session;
20use crate::backup::BackupStore;
21use crate::bash_background::{BgCompletion, BgTaskHealthCounts, BgTaskRegistry};
22use crate::callgraph_store::{CallGraphStore, CallGraphStoreError, ReadonlyCallGraphStore};
23use crate::checkpoint::CheckpointStore;
24use crate::config::Config;
25use crate::harness::Harness;
26use crate::inspect::{
27 InspectCategory, InspectManager, InspectSnapshot, Tier2RefreshScheduler, Tier2TriggerReason,
28};
29use crate::language::LanguageProvider;
30use crate::lsp::manager::{LspManager, StaleDiagnosticsMark};
31use crate::lsp::registry::is_config_file_path_with_custom;
32use crate::parser::{SharedSymbolCache, SymbolCache, TreeSitterProvider};
33use crate::protocol::{
34 ConfigureWarningsFrame, ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload,
35};
36use crate::watcher_filter::WatcherJoinOutcome;
37use crate::watcher_filter::{SharedGitignore, WatcherDispatchEvent, WatcherThreadHandle};
38
39pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
40pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
41pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
42const STATUS_DEBOUNCE_MS: u64 = 1_000;
43
44fn canonicalize_lenient(path: &Path) -> Option<PathBuf> {
71 use std::path::Component;
72 if let Ok(canonical) = std::fs::canonicalize(path) {
73 return Some(canonical);
74 }
75 let mut resolved = PathBuf::new();
76 let mut missing: Vec<std::ffi::OsString> = Vec::new();
77 for component in path.components() {
78 match component {
79 Component::Prefix(_) | Component::RootDir => {
80 resolved.push(component.as_os_str());
81 if let Ok(canonical_anchor) = std::fs::canonicalize(&resolved) {
85 resolved = canonical_anchor;
86 }
87 }
88 Component::CurDir => {}
89 Component::ParentDir => {
90 if missing.pop().is_none() {
91 if !resolved.as_os_str().is_empty() && !resolved.is_dir() {
92 return None;
94 }
95 resolved.pop();
96 }
97 }
98 Component::Normal(name) => {
99 if missing.is_empty() {
100 let candidate = resolved.join(name);
101 match std::fs::canonicalize(&candidate) {
102 Ok(canonical) => resolved = canonical,
103 Err(_) => match std::fs::symlink_metadata(&candidate) {
104 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
107 missing.push(name.to_owned())
108 }
109 _ => return None,
112 },
113 }
114 } else {
115 missing.push(name.to_owned());
116 }
117 }
118 }
119 }
120 for name in missing {
121 resolved.push(name);
122 }
123 Some(resolved)
124}
125
126fn pending_path_in_roots(path: &Path, roots: &[PathBuf]) -> bool {
136 if path.is_relative() {
137 let has_prefix_or_root = path.components().next().is_some_and(|component| {
142 matches!(
143 component,
144 std::path::Component::Prefix(_) | std::path::Component::RootDir
145 )
146 });
147 if has_prefix_or_root {
148 return false;
149 }
150 return roots.iter().any(|root| {
153 let joined = root.join(path);
154 match (canonicalize_lenient(&joined), canonicalize_lenient(root)) {
155 (Some(path), Some(root)) => path.starts_with(&root),
156 _ => false,
157 }
158 });
159 }
160 let Some(canonical_path) = canonicalize_lenient(path) else {
161 return false;
162 };
163 roots.iter().any(|root| {
164 canonicalize_lenient(root)
165 .is_some_and(|canonical_root| canonical_path.starts_with(&canonical_root))
166 })
167}
168
169#[derive(Clone, Default)]
173pub(crate) struct SubcLifecycleAdmission {
174 unbound: Arc<parking_lot::Mutex<bool>>,
175}
176
177impl SubcLifecycleAdmission {
178 fn mark_bound(&self) {
179 *self.unbound.lock() = false;
180 }
181
182 fn mark_unbound(&self, configure_generation: &AtomicU64) {
183 let mut unbound = self.unbound.lock();
184 if !*unbound {
185 *unbound = true;
186 configure_generation.fetch_add(1, Ordering::SeqCst);
187 }
188 }
189
190 pub(crate) fn is_current(&self, generation: &AtomicU64, expected: u64) -> bool {
191 let unbound = self.unbound.lock();
192 !*unbound && generation.load(Ordering::SeqCst) == expected
193 }
194
195 fn advance_generation(&self, generation: &AtomicU64) -> u64 {
196 let _unbound = self.unbound.lock();
197 generation.fetch_add(1, Ordering::SeqCst).wrapping_add(1)
198 }
199
200 pub(crate) fn run_if_current<R>(
201 &self,
202 generation: &AtomicU64,
203 expected: u64,
204 action: impl FnOnce() -> R,
205 ) -> Option<R> {
206 let unbound = self.unbound.lock();
207 if *unbound || generation.load(Ordering::SeqCst) != expected {
208 return None;
209 }
210 Some(action())
211 }
212
213 pub(crate) fn is_bound(&self) -> bool {
214 !*self.unbound.lock()
215 }
216
217 fn try_is_bound(&self) -> Option<bool> {
218 self.unbound.try_lock().map(|unbound| !*unbound)
219 }
220
221 fn is_unbound(&self) -> bool {
222 !self.is_bound()
223 }
224
225 fn run_if_unbound<R>(&self, action: impl FnOnce() -> R) -> Option<R> {
226 let unbound = self.unbound.lock();
227 if !*unbound {
228 return None;
229 }
230 Some(action())
231 }
232}
233
234const GRACEFUL_SHUTDOWN_SEARCH_BUILD_WAIT: Duration = Duration::from_secs(5);
235const GRACEFUL_SHUTDOWN_SEARCH_BUILD_POLL: Duration = Duration::from_millis(10);
236
237#[derive(Debug, Clone, Default, PartialEq, Eq)]
241pub struct StatusBarCounts {
242 pub errors: usize,
243 pub warnings: usize,
244 pub dead_code: usize,
245 pub unused_exports: usize,
246 pub duplicates: usize,
247 pub todos: usize,
248 pub tier2_stale: bool,
249}
250
251#[derive(Debug, Clone, Default, PartialEq, Eq)]
254pub struct StatusBarCountValues {
255 pub errors: Option<usize>,
256 pub warnings: Option<usize>,
257 pub dead_code: Option<usize>,
258 pub unused_exports: Option<usize>,
259 pub duplicates: Option<usize>,
260 pub todos: Option<usize>,
261 pub tier2_stale: bool,
262}
263
264impl StatusBarCountValues {
265 fn legacy_projection(&self) -> Option<StatusBarCounts> {
266 let [Some(dead_code), Some(unused_exports), Some(duplicates)] =
267 [self.dead_code, self.unused_exports, self.duplicates]
268 else {
269 return None;
270 };
271
272 Some(StatusBarCounts {
273 errors: self.errors.unwrap_or_default(),
274 warnings: self.warnings.unwrap_or_default(),
275 dead_code,
276 unused_exports,
277 duplicates,
278 todos: self.todos.unwrap_or_default(),
279 tier2_stale: self.tier2_stale,
280 })
281 }
282}
283
284#[derive(Debug, Clone, Default)]
293struct StatusBarTier2 {
294 dead_code: Option<usize>,
295 unused_exports: Option<usize>,
296 duplicates: Option<usize>,
297 todos: Option<usize>,
298 stale: bool,
299 generation: u64,
300 dead_code_blocked_on_callgraph: bool,
307}
308
309#[derive(Debug, Clone, Default)]
310struct StatusBarCache {
311 valid: bool,
312 diagnostics_generation: u64,
313 tier2_generation: u64,
314 tsconfig_generation: u64,
315 counts: Option<StatusBarCountValues>,
316}
317
318#[derive(Debug, Default)]
322struct LegacyStatusBarEmission(RwLock<Option<StatusBarCounts>>);
323
324impl LegacyStatusBarEmission {
325 fn should_emit(&self, counts: &StatusBarCounts) -> bool {
326 let mut last = self
327 .0
328 .write()
329 .unwrap_or_else(std::sync::PoisonError::into_inner);
330 if last.as_ref() == Some(counts) {
331 return false;
332 }
333 *last = Some(counts.clone());
334 true
335 }
336
337 fn clear(&self) {
338 *self
339 .0
340 .write()
341 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
342 }
343}
344
345#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
346#[serde(rename_all = "snake_case")]
347pub enum RootHealthState {
348 Ready,
349 Busy,
350}
351
352#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
353pub struct HealthComponentSnapshot {
354 pub status: &'static str,
355}
356
357#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
358pub struct Tier2HealthSnapshot {
359 pub status: &'static str,
360}
361
362#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
363pub struct RootHealthSnapshot {
364 pub project_root: String,
365 pub actor_count: usize,
366 pub state: RootHealthState,
367 #[serde(skip_serializing_if = "Option::is_none")]
368 pub search_index: Option<HealthComponentSnapshot>,
369 #[serde(skip_serializing_if = "Option::is_none")]
370 pub semantic_index: Option<HealthComponentSnapshot>,
371 #[serde(skip_serializing_if = "Option::is_none")]
372 pub callgraph_store: Option<HealthComponentSnapshot>,
373 #[serde(skip_serializing_if = "Option::is_none")]
374 pub callgraph_repair_entries_60s: Option<u64>,
375 #[serde(skip_serializing_if = "Option::is_none")]
376 pub callgraph_commits_60s: Option<u64>,
377 #[serde(skip_serializing_if = "Option::is_none")]
378 pub callgraph_pages_or_bytes_written_60s: Option<u64>,
379 #[serde(skip_serializing_if = "Option::is_none")]
380 pub tier2: Option<Tier2HealthSnapshot>,
381 #[serde(skip_serializing_if = "Option::is_none")]
382 pub bash: Option<BgTaskHealthCounts>,
383}
384
385#[derive(Debug, Clone, PartialEq, Eq)]
386pub(crate) struct RootHealthSummary {
387 state: RootHealthState,
388 search_index_status: Option<&'static str>,
389 semantic_index_status: Option<&'static str>,
390 callgraph_store_status: Option<&'static str>,
391 tier2_status: Option<&'static str>,
392 bash: Option<BgTaskHealthCounts>,
393}
394
395impl RootHealthSummary {
396 fn busy() -> Self {
397 Self {
398 state: RootHealthState::Busy,
399 search_index_status: None,
400 semantic_index_status: None,
401 callgraph_store_status: None,
402 tier2_status: None,
403 bash: None,
404 }
405 }
406
407 pub(crate) fn is_busy(&self) -> bool {
408 matches!(self.state, RootHealthState::Busy)
409 }
410
411 pub(crate) fn is_fully_ready(&self) -> bool {
412 let component_is_satisfied = |status: &str| matches!(status, "ready" | "disabled");
413 matches!(self.state, RootHealthState::Ready)
414 && self.search_index_status.is_some_and(component_is_satisfied)
415 && self
416 .semantic_index_status
417 .is_some_and(component_is_satisfied)
418 && self
419 .callgraph_store_status
420 .is_some_and(component_is_satisfied)
421 && self.tier2_status.is_some_and(component_is_satisfied)
422 }
423
424 pub(crate) fn into_snapshot(self, project_root: &Path) -> RootHealthSnapshot {
425 if self.is_busy() {
426 return RootHealthSnapshot::busy(project_root);
427 }
428 let callgraph_write_metrics =
432 crate::search_index::artifact_cache_key_memoized_only(project_root)
433 .map(|key| crate::callgraph_store::callgraph_write_metrics_for_project(&key));
434 let (callgraph_commits_60s, callgraph_pages_or_bytes_written_60s) =
435 match callgraph_write_metrics {
436 Some(metrics)
437 if metrics.commits_60s > 0 || metrics.pages_or_bytes_written_60s > 0 =>
438 {
439 (
440 Some(metrics.commits_60s),
441 Some(metrics.pages_or_bytes_written_60s),
442 )
443 }
444 _ => (None, None),
445 };
446 RootHealthSnapshot {
447 project_root: project_root.display().to_string(),
448 actor_count: 1,
449 state: self.state,
450 search_index: self
451 .search_index_status
452 .map(|status| HealthComponentSnapshot { status }),
453 semantic_index: self
454 .semantic_index_status
455 .map(|status| HealthComponentSnapshot { status }),
456 callgraph_store: self
457 .callgraph_store_status
458 .map(|status| HealthComponentSnapshot { status }),
459 callgraph_repair_entries_60s: None,
460 callgraph_commits_60s,
461 callgraph_pages_or_bytes_written_60s,
462 tier2: self
463 .tier2_status
464 .map(|status| Tier2HealthSnapshot { status }),
465 bash: self.bash,
466 }
467 }
468}
469
470impl RootHealthSnapshot {
471 fn busy(project_root: &Path) -> Self {
472 Self {
473 project_root: project_root.display().to_string(),
474 actor_count: 1,
475 state: RootHealthState::Busy,
476 search_index: None,
477 semantic_index: None,
478 callgraph_store: None,
479 callgraph_repair_entries_60s: None,
480 callgraph_commits_60s: None,
481 callgraph_pages_or_bytes_written_60s: None,
482 tier2: None,
483 bash: None,
484 }
485 }
486
487 pub fn is_fully_ready(&self) -> bool {
488 let component_is_satisfied =
489 |status: &HealthComponentSnapshot| matches!(status.status, "ready" | "disabled");
490 let tier2_is_satisfied =
491 |tier2: &Tier2HealthSnapshot| matches!(tier2.status, "ready" | "disabled");
492
493 matches!(self.state, RootHealthState::Ready)
494 && self
495 .search_index
496 .as_ref()
497 .is_some_and(component_is_satisfied)
498 && self
499 .semantic_index
500 .as_ref()
501 .is_some_and(component_is_satisfied)
502 && self
503 .callgraph_store
504 .as_ref()
505 .is_some_and(component_is_satisfied)
506 && self.tier2.as_ref().is_some_and(tier2_is_satisfied)
507 }
508}
509
510pub struct StatusEmitter {
511 latest: Arc<Mutex<Option<StatusPayload>>>,
512 notify: mpsc::Sender<()>,
513}
514
515#[derive(Clone, Debug, Default)]
516struct ConfigureWarmState {
517 generation: u64,
518 key: Option<String>,
519}
520
521#[derive(Debug)]
522struct ConfigurePhaseTiming {
523 phase: &'static str,
524 started_at: Instant,
525 completed: Vec<(&'static str, Duration)>,
526}
527
528impl Default for ConfigurePhaseTiming {
529 fn default() -> Self {
530 Self {
531 phase: "idle",
532 started_at: Instant::now(),
533 completed: Vec::new(),
534 }
535 }
536}
537
538#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
539pub(crate) enum WatcherDrainApplyPhase {
540 #[default]
541 PendingTier2,
542 PendingIndexes,
543 SymbolCache,
544 Callgraph,
545 SearchIndex,
546 SemanticIndex,
547 LspDiagnostics,
548 Complete,
549}
550
551#[derive(Debug, Default)]
552pub(crate) enum WatcherDrainPhase {
553 #[default]
554 Collect,
555 Apply {
556 stage: WatcherDrainApplyPhase,
557 paths: VecDeque<PathBuf>,
558 remaining: usize,
559 oversized_inline_batch: bool,
560 },
561}
562
563#[derive(Debug)]
564pub(crate) struct WatcherDrainSliceState {
565 pub(crate) configure_generation: u64,
566 pub(crate) configure_content_generation: u64,
572 pub(crate) phase: WatcherDrainPhase,
573 pub(crate) pending_paths: VecDeque<PathBuf>,
574 pub(crate) ignore_changed: bool,
575 pub(crate) rescan_required: bool,
576 pub(crate) status_changed: bool,
577 pub(crate) scheduler_changed_path_count: usize,
578 pub(crate) semantic_refresh_paths: Vec<PathBuf>,
579 pub(crate) path_slice_count: usize,
580}
581
582pub(crate) struct PendingReconciliationState {
586 search: BTreeSet<PathBuf>,
587 callgraph: BTreeSet<PathBuf>,
588 tier2: BTreeSet<PathBuf>,
589 semantic: BTreeSet<PathBuf>,
590 corpus_refresh: bool,
591}
592
593impl WatcherDrainSliceState {
594 pub(crate) fn new(configure_generation: u64, configure_content_generation: u64) -> Self {
595 Self {
596 configure_generation,
597 configure_content_generation,
598 phase: WatcherDrainPhase::Collect,
599 pending_paths: VecDeque::new(),
600 ignore_changed: false,
601 rescan_required: false,
602 status_changed: false,
603 scheduler_changed_path_count: 0,
604 semantic_refresh_paths: Vec::new(),
605 path_slice_count: 0,
606 }
607 }
608
609 pub(crate) fn has_pending_work(&self) -> bool {
610 !matches!(self.phase, WatcherDrainPhase::Collect)
611 || !self.pending_paths.is_empty()
612 || self.ignore_changed
613 || self.rescan_required
614 }
615}
616
617#[doc(hidden)]
618pub enum CallGraphStoreBuildEvent {
619 Ready {
620 store: CallGraphStore,
621 fulfilled_force_token: Option<u64>,
622 publication_epoch: u64,
623 },
624 Denied {
625 reason: String,
626 },
627 Settled,
628}
629
630struct CallGraphStoreBuildSettlement {
631 tx: crossbeam_channel::Sender<CallGraphStoreBuildEvent>,
632 sent: bool,
633 force_token: Option<u64>,
634 publication_epoch: u64,
635}
636
637impl CallGraphStoreBuildSettlement {
638 fn new(
639 tx: crossbeam_channel::Sender<CallGraphStoreBuildEvent>,
640 force_token: Option<u64>,
641 publication_epoch: u64,
642 ) -> Self {
643 Self {
644 tx,
645 sent: false,
646 force_token,
647 publication_epoch,
648 }
649 }
650
651 fn ready(&mut self, store: CallGraphStore) {
652 let _ = self.tx.send(CallGraphStoreBuildEvent::Ready {
653 store,
654 fulfilled_force_token: self.force_token,
655 publication_epoch: self.publication_epoch,
656 });
657 self.sent = true;
658 }
659
660 fn denied(&mut self, reason: String) {
661 let _ = self.tx.send(CallGraphStoreBuildEvent::Denied { reason });
662 self.sent = true;
663 }
664}
665
666impl Drop for CallGraphStoreBuildSettlement {
667 fn drop(&mut self) {
668 if !self.sent {
669 let _ = self.tx.send(CallGraphStoreBuildEvent::Settled);
670 }
671 }
672}
673
674#[derive(Clone, Debug)]
675pub(crate) struct ConfigureMaintenanceJob {
676 pub(crate) generation: u64,
677 pub(crate) root_path: PathBuf,
678 pub(crate) canonical_cache_root: PathBuf,
679 pub(crate) harness: Harness,
680 pub(crate) storage_root: PathBuf,
681 pub(crate) harness_dir: PathBuf,
682 pub(crate) session_id: String,
683 pub(crate) home_match: bool,
684 pub(crate) format_tool_cache_clear_needed: bool,
685 pub(crate) run_bash_replay: bool,
686 pub(crate) refresh_project_runtime: bool,
687 pub(crate) sync_bash_compress_flag: bool,
688 pub(crate) reset_filter_registry: bool,
689 pub(crate) clear_failed_spawns: bool,
690 pub(crate) warm_callgraph_store: bool,
691 pub(crate) supersede_artifact_persistence: bool,
694 pub(crate) artifact_load_starts: Vec<crossbeam_channel::Sender<()>>,
697}
698
699impl StatusEmitter {
700 fn new(progress_sender: SharedProgressSender) -> Self {
701 let (notify, rx) = mpsc::channel();
702 let latest = Arc::new(Mutex::new(None));
703 let latest_for_thread = Arc::clone(&latest);
704 std::thread::spawn(move || {
705 status_debounce_loop(rx, latest_for_thread, progress_sender);
706 });
707 Self { latest, notify }
708 }
709
710 pub fn signal(&self, snapshot: StatusPayload) {
711 if let Ok(mut latest) = self.latest.lock() {
712 *latest = Some(snapshot);
713 }
714 let _ = self.notify.send(());
715 }
716}
717
718fn status_debounce_loop(
719 rx: mpsc::Receiver<()>,
720 latest: Arc<Mutex<Option<StatusPayload>>>,
721 progress_sender: SharedProgressSender,
722) {
723 while rx.recv().is_ok() {
724 let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
725 while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
726 match rx.recv_timeout(remaining) {
727 Ok(()) => continue,
728 Err(mpsc::RecvTimeoutError::Timeout) => break,
729 Err(mpsc::RecvTimeoutError::Disconnected) => return,
730 }
731 }
732
733 let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
734 let Some(snapshot) = snapshot else { continue };
735 let sender = progress_sender
736 .lock()
737 .ok()
738 .and_then(|sender| sender.clone());
739 if let Some(sender) = sender {
740 sender(PushFrame::StatusChanged(StatusChangedFrame::new(
741 None, snapshot,
742 )));
743 }
744 }
745}
746use crate::cache_freshness::FileFreshness;
747use crate::search_index::SearchIndex;
748use crate::semantic_index::{EmbeddingEntry, SemanticIndex};
749
750#[derive(Debug, Default, Clone)]
754#[doc(hidden)]
755pub struct SemanticRefreshAccounting {
756 #[doc(hidden)]
757 pub pending: usize,
758 #[doc(hidden)]
759 pub in_flight: usize,
760}
761
762#[derive(Debug, Default)]
763struct SemanticRefreshCircuit {
764 consecutive_transient_failures: AtomicUsize,
765 open: AtomicBool,
766 probe_in_flight: AtomicBool,
767 probe_ready: AtomicBool,
768 probe_token: AtomicU64,
769}
770
771#[derive(Clone, Copy, Debug, Default)]
772pub(crate) struct SemanticColdSeedResume {
773 request_tier2: bool,
774 warm_callgraph: bool,
775}
776
777fn ensure_refreshing_path(refreshing: &mut Vec<PathBuf>, path: PathBuf) {
778 if !refreshing.iter().any(|existing| existing == &path) {
779 refreshing.push(path);
780 refreshing.sort();
781 }
782}
783
784fn remove_refreshing_path(refreshing: &mut Vec<PathBuf>, path: &Path) {
785 refreshing.retain(|existing| existing != path);
786}
787
788#[derive(Debug, Clone)]
789pub enum SemanticIndexStatus {
790 Disabled,
791 Building {
792 stage: String,
794 files: Option<usize>,
795 entries_done: Option<usize>,
796 entries_total: Option<usize>,
797 },
798 Ready {
799 refreshing: Vec<PathBuf>,
802 #[doc(hidden)]
806 accounting: BTreeMap<PathBuf, SemanticRefreshAccounting>,
807 },
808 Failed(String),
809}
810
811impl SemanticIndexStatus {
812 pub fn ready() -> Self {
813 Self::Ready {
814 refreshing: Vec::new(),
815 accounting: BTreeMap::new(),
816 }
817 }
818
819 pub fn add_refreshing_file(&mut self, path: PathBuf) {
820 if let Self::Ready {
821 refreshing,
822 accounting,
823 } = self
824 {
825 let state = accounting.entry(path.clone()).or_default();
826 state.pending = state.pending.saturating_add(1);
827 ensure_refreshing_path(refreshing, path);
828 }
829 }
830
831 pub fn start_refreshing_file(&mut self, path: PathBuf) {
832 if let Self::Ready {
833 refreshing,
834 accounting,
835 } = self
836 {
837 let state = accounting.entry(path.clone()).or_default();
838 if state.pending == 0 {
839 state.pending = 1;
840 }
841 if state.in_flight == 0 {
842 state.in_flight = state.pending;
843 }
844 ensure_refreshing_path(refreshing, path);
845 }
846 }
847
848 pub fn cancel_refreshing_file(&mut self, path: &Path) {
849 self.finish_refreshing_file(path, false);
850 }
851
852 pub fn take_refreshing_files(&mut self) -> Vec<PathBuf> {
856 if let Self::Ready {
857 refreshing,
858 accounting,
859 } = self
860 {
861 accounting.clear();
862 std::mem::take(refreshing)
863 } else {
864 Vec::new()
865 }
866 }
867
868 pub fn corpus_refresh_in_flight(&self) -> bool {
870 matches!(self, Self::Building { stage, .. } if stage == "refreshing_corpus")
871 }
872
873 pub fn complete_refreshing_file(&mut self, path: &Path) {
874 self.finish_refreshing_file(path, true);
875 }
876
877 pub fn remove_refreshing_file(&mut self, path: &Path) {
878 self.complete_refreshing_file(path);
879 }
880
881 fn finish_refreshing_file(&mut self, path: &Path, complete_in_flight: bool) {
882 if let Self::Ready {
883 refreshing,
884 accounting,
885 } = self
886 {
887 let mut keep_refreshing = false;
888 if let Some(state) = accounting.get_mut(path) {
889 let finished = if complete_in_flight {
890 state.in_flight.max(1)
891 } else {
892 1
893 };
894 state.pending = state.pending.saturating_sub(finished);
895 if complete_in_flight {
896 state.in_flight = 0;
897 } else {
898 state.in_flight = state.in_flight.min(state.pending);
899 }
900 keep_refreshing = state.pending > 0;
901 if !keep_refreshing {
902 accounting.remove(path);
903 }
904 }
905
906 if !keep_refreshing {
907 remove_refreshing_path(refreshing, path);
908 }
909 }
910 }
911
912 pub fn refreshing_count(&self) -> usize {
913 match self {
914 Self::Ready { refreshing, .. } => refreshing.len(),
915 _ => 0,
916 }
917 }
918}
919
920pub enum SemanticIndexEvent {
921 Progress {
922 stage: String,
923 files: Option<usize>,
924 entries_done: Option<usize>,
925 entries_total: Option<usize>,
926 },
927 ColdSeedGateCleared,
932 Ready(SemanticIndex),
933 Failed(String),
934}
935
936#[derive(Debug, Clone)]
937pub enum SemanticRefreshRequest {
938 Files {
939 paths: Vec<PathBuf>,
940 },
941 Corpus,
945}
946
947#[derive(Debug)]
948pub enum SemanticRefreshEvent {
949 Started {
950 paths: Vec<PathBuf>,
951 },
952 CorpusStarted {
953 files: usize,
954 },
955 Completed {
956 added_entries: Vec<EmbeddingEntry>,
957 updated_metadata: Vec<(PathBuf, FileFreshness)>,
958 completed_paths: Vec<PathBuf>,
959 },
960 CorpusCompleted {
961 index: SemanticIndex,
962 changed: usize,
963 added: usize,
964 deleted: usize,
965 total_processed: usize,
966 },
967 Failed {
968 paths: Vec<PathBuf>,
969 error: String,
970 },
971 CorpusFailed {
972 error: String,
973 },
974}
975
976pub(crate) struct ReceiverTerminalGuard {
977 terminal_epoch: Arc<AtomicU64>,
978 epoch: u64,
979}
980
981impl ReceiverTerminalGuard {
982 fn new(terminal_epoch: Arc<AtomicU64>, epoch: u64) -> Self {
983 Self {
984 terminal_epoch,
985 epoch,
986 }
987 }
988}
989
990impl Drop for ReceiverTerminalGuard {
991 fn drop(&mut self) {
992 self.terminal_epoch.fetch_max(self.epoch, Ordering::SeqCst);
993 }
994}
995
996pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
997
998struct PathRestrictionContext {
999 raw_root: PathBuf,
1000 resolved_root: PathBuf,
1001 path_for_resolution: PathBuf,
1002}
1003
1004struct PathRestrictionRootMemo {
1009 configured_root: PathBuf,
1010 resolved_root: PathBuf,
1011}
1012
1013fn normalize_path(path: &Path) -> PathBuf {
1017 let mut result = PathBuf::new();
1018 for component in path.components() {
1019 match component {
1020 Component::ParentDir => {
1021 if !result.pop() {
1023 result.push(component);
1024 }
1025 }
1026 Component::CurDir => {} _ => result.push(component),
1028 }
1029 }
1030 result
1031}
1032
1033fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
1034 let mut existing = path.to_path_buf();
1035 let mut tail_segments = Vec::new();
1036
1037 while !existing.exists() {
1038 if let Some(name) = existing.file_name() {
1039 tail_segments.push(name.to_owned());
1040 } else {
1041 break;
1042 }
1043
1044 existing = match existing.parent() {
1045 Some(parent) => parent.to_path_buf(),
1046 None => break,
1047 };
1048 }
1049
1050 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
1051 for segment in tail_segments.into_iter().rev() {
1052 resolved.push(segment);
1053 }
1054
1055 resolved
1056}
1057
1058fn path_error_response(
1059 req_id: &str,
1060 path: &Path,
1061 resolved_root: &Path,
1062) -> crate::protocol::Response {
1063 crate::protocol::Response::error(
1064 req_id,
1065 "path_outside_root",
1066 format!(
1067 "path '{}' is outside the project root '{}'",
1068 path.display(),
1069 resolved_root.display()
1070 ),
1071 )
1072}
1073
1074fn reject_escaping_symlink(
1084 req_id: &str,
1085 original_path: &Path,
1086 candidate: &Path,
1087 resolved_root: &Path,
1088 raw_root: &Path,
1089) -> Result<(), crate::protocol::Response> {
1090 let mut current = PathBuf::new();
1091
1092 for component in candidate.components() {
1093 current.push(component);
1094
1095 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
1096 continue;
1097 };
1098
1099 if !metadata.file_type().is_symlink() {
1100 continue;
1101 }
1102
1103 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
1112 if !inside_root {
1113 continue;
1114 }
1115
1116 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
1117 }
1118
1119 Ok(())
1120}
1121
1122fn iterative_follow_chain(
1125 req_id: &str,
1126 original_path: &Path,
1127 start: &Path,
1128 resolved_root: &Path,
1129) -> Result<(), crate::protocol::Response> {
1130 let mut link = start.to_path_buf();
1131 let mut depth = 0usize;
1132
1133 loop {
1134 if depth > 40 {
1135 return Err(path_error_response(req_id, original_path, resolved_root));
1136 }
1137
1138 let target = match std::fs::read_link(&link) {
1139 Ok(t) => t,
1140 Err(_) => {
1141 return Err(path_error_response(req_id, original_path, resolved_root));
1143 }
1144 };
1145
1146 let resolved_target = if target.is_absolute() {
1147 normalize_path(&target)
1148 } else {
1149 let parent = link.parent().unwrap_or_else(|| Path::new(""));
1150 normalize_path(&parent.join(&target))
1151 };
1152
1153 let canonical_target =
1157 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
1158
1159 if !canonical_target.starts_with(resolved_root)
1160 && !resolved_target.starts_with(resolved_root)
1161 {
1162 return Err(path_error_response(req_id, original_path, resolved_root));
1163 }
1164
1165 match std::fs::symlink_metadata(&resolved_target) {
1167 Ok(meta) if meta.file_type().is_symlink() => {
1168 link = resolved_target;
1169 depth += 1;
1170 }
1171 _ => break, }
1173 }
1174
1175 Ok(())
1176}
1177
1178pub type LanguageProviderFactory = fn() -> Box<dyn LanguageProvider>;
1179
1180pub fn default_language_provider_factory() -> Box<dyn LanguageProvider> {
1181 Box::new(TreeSitterProvider::new())
1182}
1183
1184fn database_path_key(path: &Path) -> PathBuf {
1185 if let Ok(canonical) = std::fs::canonicalize(path) {
1186 return canonical;
1187 }
1188 let Some(parent) = path.parent() else {
1189 return path.to_path_buf();
1190 };
1191 let canonical_parent = std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf());
1192 path.file_name()
1193 .map(|name| canonical_parent.join(name))
1194 .unwrap_or_else(|| canonical_parent.join(path))
1195}
1196
1197pub struct App {
1202 db: parking_lot::Mutex<Option<(PathBuf, Arc<Mutex<Connection>>)>>,
1206 active_watchers: AtomicUsize,
1207 active_actor_roots: AtomicUsize,
1208 open_routes: AtomicUsize,
1209 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
1210 stdout_writer: SharedStdoutWriter,
1211 provider_factory: LanguageProviderFactory,
1212 memory_contexts: parking_lot::Mutex<BTreeMap<PathBuf, Weak<AppContext>>>,
1215}
1216
1217impl App {
1218 pub fn new(provider_factory: LanguageProviderFactory) -> Self {
1219 Self {
1220 db: parking_lot::Mutex::new(None),
1221 active_watchers: AtomicUsize::new(0),
1222 active_actor_roots: AtomicUsize::new(0),
1223 open_routes: AtomicUsize::new(0),
1224 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry::new(),
1225 stdout_writer: Arc::new(Mutex::new(BufWriter::new(io::stdout()))),
1226 provider_factory,
1227 memory_contexts: parking_lot::Mutex::new(BTreeMap::new()),
1228 }
1229 }
1230
1231 pub fn shared(provider_factory: LanguageProviderFactory) -> Arc<Self> {
1233 Arc::new(Self::new(provider_factory))
1234 }
1235
1236 pub fn default_shared() -> Arc<Self> {
1237 Self::shared(default_language_provider_factory)
1238 }
1239
1240 pub fn create_provider(&self) -> Box<dyn LanguageProvider> {
1241 (self.provider_factory)()
1242 }
1243
1244 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
1245 self.lsp_child_registry.clone()
1246 }
1247
1248 pub fn stdout_writer(&self) -> SharedStdoutWriter {
1249 Arc::clone(&self.stdout_writer)
1250 }
1251
1252 pub(crate) fn register_memory_context(&self, root: PathBuf, ctx: &Arc<AppContext>) {
1253 let mut contexts = self.memory_contexts.lock();
1254 contexts.retain(|_, context| context.strong_count() > 0);
1255 contexts.insert(root, Arc::downgrade(ctx));
1256 }
1257
1258 pub(crate) fn unregister_memory_context(&self, root: &Path, ctx: &Arc<AppContext>) {
1259 let mut contexts = self.memory_contexts.lock();
1260 let removes_current = contexts
1261 .get(root)
1262 .and_then(Weak::upgrade)
1263 .is_some_and(|registered| Arc::ptr_eq(®istered, ctx));
1264 if removes_current {
1265 contexts.remove(root);
1266 }
1267 }
1268
1269 pub(crate) fn try_memory_contexts(&self) -> Option<Vec<(PathBuf, Arc<AppContext>)>> {
1272 let contexts = self.memory_contexts.try_lock()?;
1273 Some(
1274 contexts
1275 .iter()
1276 .filter_map(|(root, context)| {
1277 context.upgrade().map(|context| (root.clone(), context))
1278 })
1279 .collect(),
1280 )
1281 }
1282
1283 pub fn open_db(&self, path: &Path) -> Result<Arc<Mutex<Connection>>, crate::db::OpenError> {
1288 let key = database_path_key(path);
1289 let mut slot = self.db.lock();
1290 if let Some((existing_path, conn)) = slot.as_ref() {
1291 if existing_path == &key {
1292 return Ok(Arc::clone(conn));
1293 }
1294 }
1295
1296 let conn = Arc::new(Mutex::new(crate::db::open(path)?));
1297 *slot = Some((key, Arc::clone(&conn)));
1298 Ok(conn)
1299 }
1300
1301 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1302 *self.db.lock() = Some((PathBuf::new(), conn));
1303 }
1304
1305 pub fn clear_db(&self) {
1306 *self.db.lock() = None;
1307 }
1308
1309 pub fn clear_db_for_path(&self, path: &Path) {
1313 let key = database_path_key(path);
1314 let mut slot = self.db.lock();
1315 if slot.as_ref().is_some_and(|(existing_path, _)| {
1316 existing_path.as_os_str().is_empty() || existing_path == &key
1317 }) {
1318 *slot = None;
1319 }
1320 }
1321
1322 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1323 self.db.lock().as_ref().map(|(_, conn)| Arc::clone(conn))
1324 }
1325
1326 pub(crate) fn watcher_started(&self) {
1327 self.active_watchers.fetch_add(1, Ordering::SeqCst);
1328 }
1329
1330 pub(crate) fn watcher_stopped(&self) {
1331 self.active_watchers
1332 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
1333 Some(count.saturating_sub(1))
1334 })
1335 .ok();
1336 }
1337
1338 pub fn watcher_count(&self) -> usize {
1341 self.active_watchers.load(Ordering::SeqCst)
1342 }
1343
1344 pub(crate) fn actor_root_registered(&self) {
1345 self.active_actor_roots.fetch_add(1, Ordering::SeqCst);
1346 }
1347
1348 pub(crate) fn actor_root_unregistered(&self) {
1349 self.active_actor_roots
1350 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
1351 Some(count.saturating_sub(1))
1352 })
1353 .ok();
1354 }
1355
1356 pub fn actor_root_count(&self) -> usize {
1357 self.active_actor_roots.load(Ordering::SeqCst)
1358 }
1359
1360 pub(crate) fn set_open_route_count(&self, count: usize) {
1361 self.open_routes.store(count, Ordering::SeqCst);
1362 }
1363
1364 pub fn open_route_count(&self) -> usize {
1365 self.open_routes.load(Ordering::SeqCst)
1366 }
1367}
1368
1369impl Default for App {
1370 fn default() -> Self {
1371 Self::new(default_language_provider_factory)
1372 }
1373}
1374
1375const _: fn() = || {
1376 fn assert_send_sync<T: Send + Sync>() {}
1377 fn assert_send<T: Send>() {}
1378
1379 assert_send_sync::<App>();
1380 assert_send_sync::<AppContext>();
1381 assert_send::<crate::lsp::manager::LspManager>();
1382 assert_send::<crate::semantic_index::EmbeddingModel>();
1383};
1384
1385#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1386enum GitEntryKind {
1387 Missing,
1388 File,
1389 Directory,
1390 Other,
1391}
1392
1393#[derive(Clone, Debug, PartialEq, Eq)]
1394struct GitEntrySignature {
1395 kind: GitEntryKind,
1396 modified: Option<SystemTime>,
1397}
1398
1399#[derive(Clone, Debug)]
1400struct WorktreeBridgeCacheEntry {
1401 git_entry: GitEntrySignature,
1402 is_worktree_bridge: bool,
1403 git_common_dir: Option<PathBuf>,
1404}
1405
1406pub(crate) const BORROWED_INDEX_CACHE_CAPACITY: usize = 4;
1407
1408#[derive(Clone, Debug, PartialEq, Eq)]
1409struct BorrowedIndexCacheKey {
1410 canonical_root: PathBuf,
1411 artifact: crate::readonly_artifacts::BorrowedArtifactGeneration,
1412}
1413
1414#[derive(Clone, Debug)]
1415enum BorrowedIndexCacheValue {
1416 Search(crate::readonly_artifacts::ReadOnlyArtifact<Arc<SearchIndex>>),
1417 Semantic(crate::readonly_artifacts::ReadOnlyArtifact<Arc<SemanticIndex>>),
1418}
1419
1420#[derive(Debug, Default)]
1421struct BorrowedIndexCache {
1422 entries: VecDeque<(BorrowedIndexCacheKey, BorrowedIndexCacheValue)>,
1423 resolved_roots: VecDeque<(PathBuf, GitEntrySignature)>,
1424}
1425
1426impl BorrowedIndexCache {
1427 fn search(
1428 &mut self,
1429 key: &BorrowedIndexCacheKey,
1430 ) -> Option<crate::readonly_artifacts::ReadOnlyArtifact<Arc<SearchIndex>>> {
1431 let position = self.entries.iter().position(|(candidate, value)| {
1432 candidate == key && matches!(value, BorrowedIndexCacheValue::Search(_))
1433 })?;
1434 let entry = self.entries.remove(position)?;
1435 let BorrowedIndexCacheValue::Search(index) = &entry.1 else {
1436 return None;
1437 };
1438 let index = (*index).clone();
1439 self.entries.push_back(entry);
1440 Some(index)
1441 }
1442
1443 fn semantic(
1444 &mut self,
1445 key: &BorrowedIndexCacheKey,
1446 ) -> Option<crate::readonly_artifacts::ReadOnlyArtifact<Arc<SemanticIndex>>> {
1447 let position = self.entries.iter().position(|(candidate, value)| {
1448 candidate == key && matches!(value, BorrowedIndexCacheValue::Semantic(_))
1449 })?;
1450 let entry = self.entries.remove(position)?;
1451 let BorrowedIndexCacheValue::Semantic(index) = &entry.1 else {
1452 return None;
1453 };
1454 let index = (*index).clone();
1455 self.entries.push_back(entry);
1456 Some(index)
1457 }
1458
1459 fn insert(&mut self, key: BorrowedIndexCacheKey, value: BorrowedIndexCacheValue) {
1460 self.entries.retain(|(candidate, _)| {
1461 candidate.canonical_root != key.canonical_root
1462 || candidate.artifact.path != key.artifact.path
1463 });
1464 self.entries.push_back((key, value));
1465 while self.entries.len() > BORROWED_INDEX_CACHE_CAPACITY {
1466 self.entries.pop_front();
1467 }
1468 }
1469
1470 fn resolved_root(&mut self, requested_root: &Path) -> Option<PathBuf> {
1471 let position = self
1472 .resolved_roots
1473 .iter()
1474 .position(|(candidate, _)| candidate == requested_root)?;
1475 let entry = self.resolved_roots.remove(position)?;
1476 if entry.1 != git_entry_signature(requested_root) {
1477 return None;
1478 }
1479 let root = entry.0.clone();
1480 self.resolved_roots.push_back(entry);
1481 Some(root)
1482 }
1483
1484 fn remember_resolved_root(&mut self, root: PathBuf) {
1485 self.resolved_roots
1486 .retain(|(candidate, _)| candidate != &root);
1487 let signature = git_entry_signature(&root);
1488 self.resolved_roots.push_back((root, signature));
1489 while self.resolved_roots.len() > BORROWED_INDEX_CACHE_CAPACITY {
1490 self.resolved_roots.pop_front();
1491 }
1492 }
1493
1494 fn clear(&mut self) {
1495 self.entries.clear();
1496 self.resolved_roots.clear();
1497 }
1498}
1499
1500fn git_entry_signature(project_root: &Path) -> GitEntrySignature {
1501 match std::fs::symlink_metadata(project_root.join(".git")) {
1502 Ok(metadata) => GitEntrySignature {
1503 kind: if metadata.file_type().is_file() {
1504 GitEntryKind::File
1505 } else if metadata.file_type().is_dir() {
1506 GitEntryKind::Directory
1507 } else {
1508 GitEntryKind::Other
1509 },
1510 modified: metadata.modified().ok(),
1511 },
1512 Err(error) if error.kind() == io::ErrorKind::NotFound => GitEntrySignature {
1513 kind: GitEntryKind::Missing,
1514 modified: None,
1515 },
1516 Err(_) => GitEntrySignature {
1517 kind: GitEntryKind::Other,
1518 modified: None,
1519 },
1520 }
1521}
1522
1523pub struct AppContext {
1535 app: Arc<App>,
1536 provider: Box<dyn LanguageProvider>,
1537 backup: parking_lot::Mutex<BackupStore>,
1538 checkpoint: parking_lot::Mutex<CheckpointStore>,
1539 config: RwLock<Arc<Config>>,
1540 path_restriction_root_memo: parking_lot::Mutex<Option<PathRestrictionRootMemo>>,
1544 #[cfg(test)]
1545 path_restriction_root_canonicalizations: AtomicUsize,
1546 force_restrict_requests: parking_lot::Mutex<BTreeMap<String, usize>>,
1547 pub harness: parking_lot::Mutex<Option<Harness>>,
1548 canonical_cache_root: parking_lot::Mutex<Option<PathBuf>>,
1549 is_worktree_bridge: parking_lot::Mutex<bool>,
1550 git_common_dir: parking_lot::Mutex<Option<PathBuf>>,
1551 shared_artifacts_read_only: AtomicBool,
1552 callgraph_writer: AtomicBool,
1553 inspect_writer: AtomicBool,
1554 artifact_owner_status: parking_lot::Mutex<Option<ArtifactOwnerStatus>>,
1555 artifact_owner_lease: parking_lot::Mutex<Option<ArtifactOwnerLeaseRegistration>>,
1556 degraded_reasons: parking_lot::Mutex<Vec<String>>,
1563 heavy_root_work_allowed: Arc<AtomicBool>,
1568 cold_build_limiter: RwLock<Arc<crate::cold_build_limiter::ColdBuildLimiter>>,
1569 callgraph_store: Arc<RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
1570 callgraph_store_force_requested: AtomicU64,
1571 callgraph_store_force_fulfilled: AtomicU64,
1572 callgraph_store_rx:
1573 parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>>,
1574 callgraph_store_rx_generation: AtomicU64,
1575 callgraph_store_rx_epoch: AtomicU64,
1576 callgraph_store_build_denied: parking_lot::Mutex<Option<(u64, String)>>,
1577 callgraph_persist_epoch: crate::root_cache::ArtifactPublishEpoch,
1578 callgraph_legacy_migration_summary_logged: Arc<AtomicBool>,
1579 pending_callgraph_store_paths: crate::callgraph_store::PendingCallGraphStorePaths,
1580 search_index: RwLock<Option<SearchIndex>>,
1581 search_index_rx: RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>>,
1582 search_index_rx_generation: AtomicU64,
1583 search_index_rx_epoch: AtomicU64,
1584 search_index_rx_terminal_epoch: Arc<AtomicU64>,
1585 search_index_disconnect_reschedule: parking_lot::Mutex<(u64, u32)>,
1591 search_persist_epoch: crate::root_cache::ArtifactPublishEpoch,
1592 pending_search_index_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
1593 symbol_cache: SharedSymbolCache,
1594 inspect_manager: Arc<InspectManager>,
1595 tier2_refresh_scheduler: parking_lot::Mutex<Tier2RefreshScheduler>,
1596 pending_tier2_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
1597 semantic_index: RwLock<Option<SemanticIndex>>,
1598 semantic_index_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
1599 semantic_index_rx_generation: AtomicU64,
1600 semantic_index_rx_epoch: AtomicU64,
1601 semantic_index_rx_terminal_epoch: Arc<AtomicU64>,
1602 semantic_persist_epoch: crate::root_cache::ArtifactPublishEpoch,
1603 semantic_persist_lock: Arc<parking_lot::Mutex<()>>,
1604 semantic_index_status: RwLock<SemanticIndexStatus>,
1605 artifact_reload_lock: parking_lot::Mutex<()>,
1608 semantic_cold_seed_active: Arc<AtomicBool>,
1612 semantic_cold_seed_generation: Arc<AtomicU64>,
1615 semantic_fingerprint_generation: Arc<AtomicU64>,
1616 semantic_callgraph_warm_deferred: AtomicBool,
1617 pending_semantic_index_paths: Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>,
1618 pending_semantic_corpus_refresh: parking_lot::Mutex<bool>,
1619 semantic_refresh_tx:
1620 Arc<parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>>,
1621 semantic_refresh_event_rx:
1622 parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
1623 semantic_refresh_generation: AtomicU64,
1624 semantic_refresh_epoch: AtomicU64,
1625 semantic_refresh_build_epoch: AtomicU64,
1626 semantic_refresh_worker: parking_lot::Mutex<Option<SemanticRefreshWorkerSlot>>,
1627 semantic_refresh_retry_attempts: parking_lot::Mutex<BTreeMap<PathBuf, usize>>,
1628 semantic_refresh_circuit: Arc<SemanticRefreshCircuit>,
1629 semantic_embedding_model: parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>>,
1630 watcher_runtime_lock: parking_lot::Mutex<()>,
1631 watcher: parking_lot::Mutex<Option<RecommendedWatcher>>,
1632 watcher_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>>,
1633 watcher_drain_slice: parking_lot::Mutex<Option<WatcherDrainSliceState>>,
1634 watcher_thread: parking_lot::Mutex<Option<WatcherThreadHandle>>,
1635 lsp_manager: parking_lot::Mutex<LspManager>,
1636 configure_generation: Arc<AtomicU64>,
1637 configure_content_generation: Arc<AtomicU64>,
1641 subc_lifecycle: SubcLifecycleAdmission,
1644 configure_warm_state: parking_lot::Mutex<ConfigureWarmState>,
1645 configure_phase_timing: parking_lot::Mutex<ConfigurePhaseTiming>,
1646 configured_session_roots: parking_lot::Mutex<BTreeSet<(PathBuf, String)>>,
1647 hashline_bindings: crate::hashline::integration::BindingRegistry,
1648 configure_maintenance_jobs: parking_lot::Mutex<VecDeque<ConfigureMaintenanceJob>>,
1649 artifact_cache_keys: parking_lot::Mutex<BTreeMap<PathBuf, String>>,
1650 artifact_cache_key_derivations: AtomicU64,
1651 borrowed_index_cache: parking_lot::Mutex<BorrowedIndexCache>,
1652 worktree_bridge_cache: parking_lot::Mutex<BTreeMap<PathBuf, WorktreeBridgeCacheEntry>>,
1655 #[cfg(test)]
1656 worktree_bridge_probe_spawns: AtomicU64,
1657 #[cfg(test)]
1658 force_worktree_bridge_reprobe: AtomicBool,
1659 last_seen_reuse_completions: AtomicU64,
1663 configure_warnings_tx: crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)>,
1664 configure_warnings_rx: crossbeam_channel::Receiver<(u64, ConfigureWarningsFrame)>,
1665 progress_sender: SharedProgressSender,
1668 status_emitter: StatusEmitter,
1669 fleet_status_client: RwLock<Option<crate::fleet_status::FleetStatusClient>>,
1672 status_bar_last_emitted: LegacyStatusBarEmission,
1675 status_bar_cached: RwLock<StatusBarCache>,
1678 alert_state: parking_lot::Mutex<AlertDeltaState>,
1681 compression_aggregates: Arc<crate::db::compression_events::CompressionAggregateCache>,
1682 bash_background: BgTaskRegistry,
1683 #[cfg(unix)]
1684 escalation_grants: parking_lot::Mutex<crate::sandbox_spawn::EscalationGrantStore>,
1685 filter_registry: crate::compress::SharedFilterRegistry,
1692 filter_registry_rebuild_count: AtomicU64,
1693 filter_registry_loaded: std::sync::atomic::AtomicBool,
1696 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
1701 gitignore: SharedGitignore,
1708 gitignore_generation: Arc<AtomicU64>,
1709 status_bar_tier2: RwLock<StatusBarTier2>,
1713 tsconfig_membership:
1720 parking_lot::Mutex<crate::lsp::tsconfig_membership::TsconfigMembershipCache>,
1721}
1722
1723pub struct ForceRestrictGuard<'a> {
1729 ctx: &'a AppContext,
1730 req_id: String,
1731}
1732
1733impl Drop for ForceRestrictGuard<'_> {
1734 fn drop(&mut self) {
1735 self.ctx.release_force_restrict(&self.req_id);
1736 }
1737}
1738
1739impl Drop for AppContext {
1740 fn drop(&mut self) {
1741 self.artifact_owner_lease.get_mut().take();
1742 if let Some(runtime) = self.watcher_thread.get_mut().take() {
1743 let root = self
1744 .canonical_cache_root
1745 .get_mut()
1746 .clone()
1747 .or_else(|| {
1748 self.config
1749 .get_mut()
1750 .unwrap_or_else(std::sync::PoisonError::into_inner)
1751 .project_root
1752 .clone()
1753 })
1754 .unwrap_or_else(|| PathBuf::from("<unconfigured>"));
1755 Self::spawn_watcher_shutdown(Arc::clone(&self.app), root, runtime);
1756 }
1757 }
1758}
1759
1760pub enum CallgraphStoreAccess {
1768 Ready(Arc<ReadonlyCallGraphStore>),
1770 Building,
1772 Unavailable,
1774 Error(CallGraphStoreError),
1776}
1777
1778#[derive(Clone, Copy)]
1779enum CallgraphBackgroundWork {
1780 Ensure,
1781 ForceRebuild(u64),
1782 LegacyMigration,
1783}
1784
1785#[cfg(test)]
1786struct CallgraphBuildStartGate {
1787 root: PathBuf,
1788 reached: crossbeam_channel::Sender<()>,
1789 release: crossbeam_channel::Receiver<()>,
1790}
1791
1792#[cfg(test)]
1793static CALLGRAPH_BUILD_START_GATE: std::sync::OnceLock<
1794 parking_lot::Mutex<Option<CallgraphBuildStartGate>>,
1795> = std::sync::OnceLock::new();
1796
1797#[cfg(test)]
1798fn install_callgraph_build_start_gate(
1799 root: PathBuf,
1800) -> (
1801 crossbeam_channel::Receiver<()>,
1802 crossbeam_channel::Sender<()>,
1803) {
1804 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
1805 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1806 *CALLGRAPH_BUILD_START_GATE
1807 .get_or_init(|| parking_lot::Mutex::new(None))
1808 .lock() = Some(CallgraphBuildStartGate {
1809 root,
1810 reached: reached_tx,
1811 release: release_rx,
1812 });
1813 (reached_rx, release_tx)
1814}
1815
1816#[cfg(test)]
1817fn wait_on_callgraph_build_start_gate(root: &Path) {
1818 let mut slot = CALLGRAPH_BUILD_START_GATE
1819 .get_or_init(|| parking_lot::Mutex::new(None))
1820 .lock();
1821 if !slot.as_ref().is_some_and(|gate| gate.root == root) {
1822 return;
1823 }
1824 let gate = slot.take();
1825 drop(slot);
1826 if let Some(gate) = gate {
1827 let _ = gate.reached.send(());
1828 let _ = gate.release.recv_timeout(Duration::from_secs(5));
1829 }
1830}
1831
1832#[cfg(not(test))]
1833fn wait_on_callgraph_build_start_gate(_root: &Path) {}
1834
1835#[cfg(test)]
1836static REMOVE_CALLGRAPH_POINTER_BEFORE_INLINE_REOPEN: AtomicBool = AtomicBool::new(false);
1837
1838#[cfg(test)]
1839struct RemoveCallgraphPointerBeforeInlineReopenGuard;
1840
1841#[cfg(test)]
1842impl Drop for RemoveCallgraphPointerBeforeInlineReopenGuard {
1843 fn drop(&mut self) {
1844 REMOVE_CALLGRAPH_POINTER_BEFORE_INLINE_REOPEN.store(false, Ordering::SeqCst);
1845 }
1846}
1847
1848#[cfg(test)]
1849fn remove_callgraph_pointer_before_inline_reopen_for_test(
1850 callgraph_dir: &Path,
1851 store: &CallGraphStore,
1852) {
1853 if REMOVE_CALLGRAPH_POINTER_BEFORE_INLINE_REOPEN.swap(false, Ordering::SeqCst) {
1854 let pointer = callgraph_dir.join(format!("{}.current", store.project_key()));
1855 std::fs::remove_file(pointer).expect("remove callgraph pointer before inline reopen");
1856 }
1857}
1858
1859#[cfg(not(test))]
1860fn remove_callgraph_pointer_before_inline_reopen_for_test(
1861 _callgraph_dir: &Path,
1862 _store: &CallGraphStore,
1863) {
1864}
1865
1866fn callgraph_build_wait_window() -> Duration {
1871 std::env::var("AFT_CALLGRAPH_BUILD_WAIT_MS")
1872 .ok()
1873 .and_then(|raw| raw.parse::<u64>().ok())
1874 .map(Duration::from_millis)
1875 .unwrap_or(Duration::ZERO)
1876}
1877
1878static CALLGRAPH_COLD_BUILD_SPAWN_COUNT: AtomicUsize = AtomicUsize::new(0);
1879
1880#[doc(hidden)]
1881pub fn reset_callgraph_cold_build_spawn_count_for_test() {
1882 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
1883}
1884
1885#[doc(hidden)]
1886pub fn callgraph_cold_build_spawn_count_for_test() -> usize {
1887 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst)
1888}
1889
1890impl AppContext {
1891 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
1892 Self::with_app_and_provider(App::default_shared(), provider, config)
1893 }
1894
1895 pub fn from_app(app: Arc<App>, config: Config) -> Self {
1896 let provider = app.create_provider();
1897 Self::with_app_and_provider(app, provider, config)
1898 }
1899
1900 pub fn with_app_and_provider(
1901 app: Arc<App>,
1902 provider: Box<dyn LanguageProvider>,
1903 config: Config,
1904 ) -> Self {
1905 let bash_compress_enabled = config.experimental_bash_compress;
1906 let (configure_warnings_tx, configure_warnings_rx) = crossbeam_channel::unbounded();
1907 let progress_sender: SharedProgressSender = Arc::new(Mutex::new(None));
1908 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
1909 let heavy_root_work_allowed = Arc::new(AtomicBool::new(true));
1910 let symbol_cache = provider
1911 .as_any()
1912 .downcast_ref::<TreeSitterProvider>()
1913 .map(|provider| provider.symbol_cache())
1914 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
1915 let mut lsp_manager = LspManager::new();
1916 lsp_manager.set_child_registry(app.lsp_child_registry());
1917 lsp_manager.set_diagnostic_capacity(config.diagnostic_cache_size);
1920 let bash_background = BgTaskRegistry::new(Arc::clone(&progress_sender));
1921 let compression_aggregates = bash_background.compression_aggregate_cache();
1922 let context = AppContext {
1923 app: Arc::clone(&app),
1924 provider,
1925 backup: parking_lot::Mutex::new(BackupStore::new()),
1926 checkpoint: parking_lot::Mutex::new(CheckpointStore::new()),
1927 config: RwLock::new(Arc::new(config)),
1928 path_restriction_root_memo: parking_lot::Mutex::new(None),
1929 #[cfg(test)]
1930 path_restriction_root_canonicalizations: AtomicUsize::new(0),
1931 force_restrict_requests: parking_lot::Mutex::new(BTreeMap::new()),
1932 harness: parking_lot::Mutex::new(None),
1933 canonical_cache_root: parking_lot::Mutex::new(None),
1934 is_worktree_bridge: parking_lot::Mutex::new(false),
1935 git_common_dir: parking_lot::Mutex::new(None),
1936 shared_artifacts_read_only: AtomicBool::new(false),
1937 callgraph_writer: AtomicBool::new(true),
1938 inspect_writer: AtomicBool::new(true),
1939 artifact_owner_status: parking_lot::Mutex::new(None),
1940 artifact_owner_lease: parking_lot::Mutex::new(None),
1941 degraded_reasons: parking_lot::Mutex::new(Vec::new()),
1942 heavy_root_work_allowed: Arc::clone(&heavy_root_work_allowed),
1943 cold_build_limiter: RwLock::new(crate::cold_build_limiter::global_limiter()),
1944 callgraph_store: Arc::new(RwLock::new(None)),
1945 callgraph_store_force_requested: AtomicU64::new(0),
1946 callgraph_store_force_fulfilled: AtomicU64::new(0),
1947 callgraph_store_rx: parking_lot::Mutex::new(None),
1948 callgraph_store_rx_generation: AtomicU64::new(0),
1949 callgraph_store_rx_epoch: AtomicU64::new(0),
1950 callgraph_store_build_denied: parking_lot::Mutex::new(None),
1951 callgraph_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(),
1952 callgraph_legacy_migration_summary_logged: Arc::new(AtomicBool::new(false)),
1953 pending_callgraph_store_paths: Arc::new(parking_lot::Mutex::new(BTreeSet::new())),
1954 search_index: RwLock::new(None),
1955 search_index_rx: RwLock::new(None),
1956 search_index_rx_generation: AtomicU64::new(0),
1957 search_index_rx_epoch: AtomicU64::new(0),
1958 search_index_rx_terminal_epoch: Arc::new(AtomicU64::new(0)),
1959 search_index_disconnect_reschedule: parking_lot::Mutex::new((0, 0)),
1960 search_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(),
1961 pending_search_index_paths: parking_lot::Mutex::new(BTreeSet::new()),
1962 symbol_cache,
1963 inspect_manager: Arc::new(InspectManager::with_heavy_root_work_gate(Arc::clone(
1964 &heavy_root_work_allowed,
1965 ))),
1966 tier2_refresh_scheduler: parking_lot::Mutex::new(Tier2RefreshScheduler::new()),
1967 pending_tier2_paths: parking_lot::Mutex::new(BTreeSet::new()),
1968 semantic_index: RwLock::new(None),
1969 semantic_index_rx: parking_lot::Mutex::new(None),
1970 semantic_index_rx_generation: AtomicU64::new(0),
1971 semantic_index_rx_epoch: AtomicU64::new(0),
1972 semantic_index_rx_terminal_epoch: Arc::new(AtomicU64::new(0)),
1973 semantic_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(),
1974 semantic_persist_lock: Arc::new(parking_lot::Mutex::new(())),
1975 semantic_index_status: RwLock::new(SemanticIndexStatus::Disabled),
1976 artifact_reload_lock: parking_lot::Mutex::new(()),
1977 semantic_cold_seed_active: Arc::new(AtomicBool::new(false)),
1978 semantic_cold_seed_generation: Arc::new(AtomicU64::new(0)),
1979 semantic_fingerprint_generation: Arc::new(AtomicU64::new(0)),
1980 semantic_callgraph_warm_deferred: AtomicBool::new(false),
1981 pending_semantic_index_paths: Arc::new(parking_lot::Mutex::new(BTreeSet::new())),
1982 pending_semantic_corpus_refresh: parking_lot::Mutex::new(false),
1983 semantic_refresh_tx: Arc::new(parking_lot::Mutex::new(None)),
1984 semantic_refresh_event_rx: parking_lot::Mutex::new(None),
1985 semantic_refresh_generation: AtomicU64::new(0),
1986 semantic_refresh_epoch: AtomicU64::new(0),
1987 semantic_refresh_build_epoch: AtomicU64::new(0),
1988 semantic_refresh_worker: parking_lot::Mutex::new(None),
1989 semantic_refresh_retry_attempts: parking_lot::Mutex::new(BTreeMap::new()),
1990 semantic_refresh_circuit: Arc::new(SemanticRefreshCircuit::default()),
1991 semantic_embedding_model: parking_lot::Mutex::new(None),
1992 watcher_runtime_lock: parking_lot::Mutex::new(()),
1993 watcher: parking_lot::Mutex::new(None),
1994 watcher_rx: parking_lot::Mutex::new(None),
1995 watcher_drain_slice: parking_lot::Mutex::new(None),
1996 watcher_thread: parking_lot::Mutex::new(None),
1997 lsp_manager: parking_lot::Mutex::new(lsp_manager),
1998 configure_generation: Arc::new(AtomicU64::new(0)),
1999 configure_content_generation: Arc::new(AtomicU64::new(0)),
2000 subc_lifecycle: SubcLifecycleAdmission::default(),
2001 configure_warm_state: parking_lot::Mutex::new(ConfigureWarmState::default()),
2002 configure_phase_timing: parking_lot::Mutex::new(ConfigurePhaseTiming::default()),
2003 configured_session_roots: parking_lot::Mutex::new(BTreeSet::new()),
2004 hashline_bindings: crate::hashline::integration::BindingRegistry::new(),
2005 configure_maintenance_jobs: parking_lot::Mutex::new(VecDeque::new()),
2006 artifact_cache_keys: parking_lot::Mutex::new(BTreeMap::new()),
2007 artifact_cache_key_derivations: AtomicU64::new(0),
2008 borrowed_index_cache: parking_lot::Mutex::new(BorrowedIndexCache::default()),
2009 worktree_bridge_cache: parking_lot::Mutex::new(BTreeMap::new()),
2010 #[cfg(test)]
2011 worktree_bridge_probe_spawns: AtomicU64::new(0),
2012 #[cfg(test)]
2013 force_worktree_bridge_reprobe: AtomicBool::new(false),
2014 last_seen_reuse_completions: AtomicU64::new(0),
2015 configure_warnings_tx,
2016 configure_warnings_rx,
2017 progress_sender: Arc::clone(&progress_sender),
2018 status_emitter,
2019 fleet_status_client: RwLock::new(None),
2020 status_bar_last_emitted: LegacyStatusBarEmission::default(),
2021 status_bar_cached: RwLock::new(StatusBarCache::default()),
2022 alert_state: parking_lot::Mutex::new(AlertDeltaState::default()),
2023 compression_aggregates,
2024 bash_background,
2025 #[cfg(unix)]
2026 escalation_grants: parking_lot::Mutex::new(
2027 crate::sandbox_spawn::EscalationGrantStore::default(),
2028 ),
2029 filter_registry: Arc::new(std::sync::RwLock::new(
2030 crate::compress::toml_filter::FilterRegistry::default(),
2031 )),
2032 filter_registry_rebuild_count: AtomicU64::new(0),
2033 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
2034 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
2035 gitignore: Arc::new(std::sync::RwLock::new(None)),
2036 gitignore_generation: Arc::new(AtomicU64::new(0)),
2037 status_bar_tier2: RwLock::new(StatusBarTier2::default()),
2038 tsconfig_membership: parking_lot::Mutex::new(
2039 crate::lsp::tsconfig_membership::TsconfigMembershipCache::new(),
2040 ),
2041 };
2042 crate::logging::sync_storage_root(context.storage_dir());
2043 context
2044 }
2045
2046 pub fn status_bar_count_values(&self) -> StatusBarCountValues {
2050 let tier2 = self
2051 .status_bar_tier2
2052 .read()
2053 .unwrap_or_else(std::sync::PoisonError::into_inner)
2054 .clone();
2055 let tsconfig_generation = self.tsconfig_membership.lock().generation();
2056 let lsp = self.lsp_manager.lock();
2057 let diagnostics_generation = lsp.diagnostics_generation();
2058
2059 {
2060 let cached = self
2061 .status_bar_cached
2062 .read()
2063 .unwrap_or_else(std::sync::PoisonError::into_inner);
2064 if cached.valid
2065 && cached.diagnostics_generation == diagnostics_generation
2066 && cached.tier2_generation == tier2.generation
2067 && cached.tsconfig_generation == tsconfig_generation
2068 {
2069 return cached
2070 .counts
2071 .clone()
2072 .expect("a valid status-count cache carries truthful values");
2073 }
2074 }
2075
2076 let previous_authoritative = self
2077 .status_bar_cached
2078 .read()
2079 .unwrap_or_else(std::sync::PoisonError::into_inner)
2080 .counts
2081 .as_ref()
2082 .map(|counts| (counts.errors, counts.warnings));
2083 let ((current_errors, current_warnings), provisional) =
2084 match self.canonical_cache_root_opt() {
2085 Some(root) => {
2086 let root = crate::inspect::job::normalize_path(&root);
2090 let mut membership = self.tsconfig_membership.lock();
2091 lsp.filtered_error_warning_counts_with_provisional(|file| {
2092 file.starts_with(&root) && !membership.should_skip_diagnostics(file)
2093 })
2094 }
2095 None => lsp.warm_error_warning_counts_with_provisional(),
2096 };
2097 let (errors, warnings) = if provisional {
2098 previous_authoritative.unwrap_or((None, None))
2101 } else if lsp.has_any_diagnostic_reports() {
2102 (Some(current_errors), Some(current_warnings))
2103 } else {
2104 (None, None)
2105 };
2106 let counts = StatusBarCountValues {
2107 errors,
2108 warnings,
2109 dead_code: tier2.dead_code,
2110 unused_exports: tier2.unused_exports,
2111 duplicates: tier2.duplicates,
2112 todos: tier2.todos,
2113 tier2_stale: tier2.stale,
2114 };
2115
2116 *self
2117 .status_bar_cached
2118 .write()
2119 .unwrap_or_else(std::sync::PoisonError::into_inner) = StatusBarCache {
2120 valid: true,
2121 diagnostics_generation,
2122 tier2_generation: tier2.generation,
2123 tsconfig_generation,
2124 counts: Some(counts.clone()),
2125 };
2126 counts
2127 }
2128
2129 pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
2132 self.status_bar_count_values().legacy_projection()
2133 }
2134
2135 pub(crate) fn try_health_summary(&self) -> RootHealthSummary {
2136 let heavy_root_work_allowed = match self.try_heavy_root_work_allowed() {
2140 Some(allowed) => allowed,
2141 None => return RootHealthSummary::busy(),
2142 };
2143 let config = match self.config.try_read() {
2144 Ok(guard) => Arc::clone(&*guard),
2145 Err(_) => return RootHealthSummary::busy(),
2146 };
2147 let search_index = match self.search_index.try_read() {
2148 Ok(guard) => guard,
2149 Err(_) => return RootHealthSummary::busy(),
2150 };
2151 let search_index_rx = match self.search_index_rx.try_read() {
2152 Ok(guard) => guard,
2153 Err(_) => return RootHealthSummary::busy(),
2154 };
2155 let semantic_status = match self.semantic_index_status.try_read() {
2156 Ok(guard) => guard,
2157 Err(_) => return RootHealthSummary::busy(),
2158 };
2159 let callgraph_store = match self.callgraph_store.try_read() {
2160 Ok(guard) => guard,
2161 Err(_) => return RootHealthSummary::busy(),
2162 };
2163 let callgraph_store_rx = match self.callgraph_store_rx.try_lock() {
2164 Some(guard) => guard,
2165 None => return RootHealthSummary::busy(),
2166 };
2167 let tier2 = match self.status_bar_tier2.try_read() {
2168 Ok(guard) => guard,
2169 Err(_) => return RootHealthSummary::busy(),
2170 };
2171 let bash = match self.bash_background.try_health_counts() {
2172 Some(counts) => counts,
2173 None => return RootHealthSummary::busy(),
2174 };
2175
2176 let borrows_shared_artifacts = self.shared_artifacts_read_only.load(Ordering::SeqCst);
2181 let search_index_status = if search_index
2182 .as_ref()
2183 .is_some_and(|index| index.ready || index.build_denied)
2184 || (borrows_shared_artifacts && config.search_index)
2185 {
2186 "ready"
2187 } else if config.search_index
2188 || search_index.as_ref().is_some()
2189 || search_index_rx.as_ref().is_some()
2190 {
2191 "building"
2192 } else {
2193 "disabled"
2194 };
2195 let semantic_index_status = match &*semantic_status {
2196 SemanticIndexStatus::Ready { .. } => "ready",
2197 SemanticIndexStatus::Building { .. } => "building",
2198 SemanticIndexStatus::Disabled => "disabled",
2199 SemanticIndexStatus::Failed(_) => "degraded",
2200 };
2201 let callgraph_writer = self.callgraph_writer.load(Ordering::SeqCst);
2202 let callgraph_store_status = if !heavy_root_work_allowed {
2203 "disabled"
2204 } else if callgraph_store.as_ref().is_some() {
2205 "ready"
2206 } else if !callgraph_writer && config.callgraph_store {
2207 "ready"
2210 } else if callgraph_store_rx.is_some() || config.callgraph_store {
2211 "building"
2212 } else {
2213 "disabled"
2214 };
2215 let dead_code_blocked_on_callgraph = tier2.dead_code_blocked_on_callgraph;
2219 let tier2_complete = (tier2.dead_code.is_some() || dead_code_blocked_on_callgraph)
2220 && tier2.unused_exports.is_some()
2221 && tier2.duplicates.is_some()
2222 && !tier2.stale;
2223 let tier2_has_aggregates = tier2.dead_code.is_some()
2224 || tier2.unused_exports.is_some()
2225 || tier2.duplicates.is_some();
2226 let tier2_refresh_gated = borrows_shared_artifacts
2227 || !heavy_root_work_allowed
2228 || !self.inspect_writer.load(Ordering::SeqCst)
2229 || !self.inspect_manager.automatic_tier2_refresh_enabled();
2230 let tier2_status = if tier2_complete {
2231 "ready"
2232 } else if !config.inspect.enabled || !tier2_has_aggregates || tier2_refresh_gated {
2233 "disabled"
2236 } else {
2237 "building"
2238 };
2239
2240 RootHealthSummary {
2241 state: RootHealthState::Ready,
2242 search_index_status: Some(search_index_status),
2243 semantic_index_status: Some(semantic_index_status),
2244 callgraph_store_status: Some(callgraph_store_status),
2245 tier2_status: Some(tier2_status),
2246 bash: Some(bash),
2247 }
2248 }
2249
2250 pub fn try_health_snapshot(&self, project_root: &Path) -> RootHealthSnapshot {
2251 self.try_health_summary().into_snapshot(project_root)
2252 }
2253
2254 pub fn should_emit_status_bar(&self, counts: &StatusBarCounts) -> bool {
2257 self.status_bar_last_emitted.should_emit(counts)
2258 }
2259
2260 pub fn accept_alert_observation_batch(
2264 &self,
2265 batch: &AcceptedObservationBatch,
2266 ) -> Result<Vec<AcceptedObservationResult>, ObservationError> {
2267 self.alert_state.lock().accept_batch(batch)
2268 }
2269
2270 pub fn clear_tsconfig_membership_cache(&self) {
2274 self.tsconfig_membership.lock().clear();
2275 }
2276
2277 #[cfg(test)]
2278 pub fn tsconfig_membership_clear_generation_for_test(&self) -> u64 {
2279 self.tsconfig_membership.lock().generation()
2280 }
2281
2282 pub fn mark_status_bar_tier2_stale(&self) -> bool {
2288 let mut tier2 = self
2289 .status_bar_tier2
2290 .write()
2291 .unwrap_or_else(std::sync::PoisonError::into_inner);
2292 if tier2.dead_code.is_some()
2294 || tier2.unused_exports.is_some()
2295 || tier2.duplicates.is_some()
2296 || tier2.todos.is_some()
2297 {
2298 let changed = !tier2.stale;
2299 tier2.stale = true;
2300 if changed {
2301 tier2.generation = tier2.generation.wrapping_add(1);
2302 }
2303 return changed;
2304 }
2305 false
2306 }
2307
2308 pub fn update_status_bar_tier2(
2314 &self,
2315 dead_code: Option<usize>,
2316 unused_exports: Option<usize>,
2317 duplicates: Option<usize>,
2318 todos: Option<usize>,
2319 stale: bool,
2320 ) {
2321 let mut tier2 = self
2322 .status_bar_tier2
2323 .write()
2324 .unwrap_or_else(std::sync::PoisonError::into_inner);
2325 let previous = (
2326 tier2.dead_code,
2327 tier2.unused_exports,
2328 tier2.duplicates,
2329 tier2.todos,
2330 tier2.stale,
2331 );
2332 if let Some(dead_code) = dead_code {
2333 tier2.dead_code = Some(dead_code);
2334 }
2335 if let Some(unused_exports) = unused_exports {
2336 tier2.unused_exports = Some(unused_exports);
2337 }
2338 if let Some(duplicates) = duplicates {
2339 tier2.duplicates = Some(duplicates);
2340 }
2341 if let Some(todos) = todos {
2342 tier2.todos = Some(todos);
2343 }
2344 tier2.stale = stale;
2345 let current = (
2346 tier2.dead_code,
2347 tier2.unused_exports,
2348 tier2.duplicates,
2349 tier2.todos,
2350 tier2.stale,
2351 );
2352 if current != previous {
2353 tier2.generation = tier2.generation.wrapping_add(1);
2354 }
2355 }
2356
2357 pub(crate) fn set_status_bar_tier2_dead_code_blocked_on_callgraph(&self, blocked: bool) {
2363 let mut tier2 = self
2364 .status_bar_tier2
2365 .write()
2366 .unwrap_or_else(std::sync::PoisonError::into_inner);
2367 tier2.dead_code_blocked_on_callgraph = blocked;
2368 }
2369
2370 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
2373 self.gitignore
2374 .read()
2375 .unwrap_or_else(|poisoned| poisoned.into_inner())
2376 .clone()
2377 }
2378
2379 pub fn shared_gitignore(&self) -> SharedGitignore {
2381 Arc::clone(&self.gitignore)
2382 }
2383
2384 pub fn gitignore_generation(&self) -> Arc<AtomicU64> {
2388 Arc::clone(&self.gitignore_generation)
2389 }
2390
2391 fn set_gitignore(&self, matcher: Option<Arc<ignore::gitignore::Gitignore>>) {
2392 *self
2393 .gitignore
2394 .write()
2395 .unwrap_or_else(|poisoned| poisoned.into_inner()) = matcher;
2396 self.gitignore_generation.fetch_add(1, Ordering::SeqCst);
2397 }
2398
2399 pub fn clear_gitignore(&self) {
2421 self.set_gitignore(None);
2422 }
2423
2424 pub fn rebuild_gitignore(&self) {
2425 use ignore::gitignore::GitignoreBuilder;
2426 use std::path::Path;
2427 let root_raw = match self.config().project_root.clone() {
2428 Some(r) => r,
2429 None => {
2430 self.set_gitignore(None);
2431 return;
2432 }
2433 };
2434 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
2442 let mut builder = GitignoreBuilder::new(&root);
2443 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
2448 if global_ignore.is_file() {
2449 if let Some(err) = builder.add(&global_ignore) {
2450 crate::slog_warn!(
2451 "global gitignore parse error in {}: {}",
2452 global_ignore.display(),
2453 err
2454 );
2455 }
2456 }
2457 }
2458 let root_ignore = Path::new(&root).join(".gitignore");
2460 if root_ignore.exists() {
2461 if let Some(err) = builder.add(&root_ignore) {
2462 crate::slog_warn!(
2463 "gitignore parse error in {}: {}",
2464 root_ignore.display(),
2465 err
2466 );
2467 }
2468 }
2469 let root_aftignore = Path::new(&root).join(".aftignore");
2474 if root_aftignore.exists() {
2475 if let Some(err) = builder.add(&root_aftignore) {
2476 crate::slog_warn!(
2477 "aftignore parse error in {}: {}",
2478 root_aftignore.display(),
2479 err
2480 );
2481 }
2482 }
2483 let info_exclude = self
2488 .git_common_dir
2489 .lock()
2490 .clone()
2491 .unwrap_or_else(|| Path::new(&root).join(".git"))
2492 .join("info")
2493 .join("exclude");
2494 if info_exclude.exists() {
2495 if let Some(err) = builder.add(&info_exclude) {
2496 crate::slog_warn!(
2497 "gitignore parse error in {}: {}",
2498 info_exclude.display(),
2499 err
2500 );
2501 }
2502 }
2503 let walker = ignore::WalkBuilder::new(&root)
2509 .standard_filters(true)
2510 .hidden(false)
2518 .filter_entry(|entry| {
2519 let name = entry.file_name().to_string_lossy();
2520 !matches!(
2521 name.as_ref(),
2522 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
2523 )
2524 })
2525 .build();
2526 for entry in walker.flatten() {
2527 let file_name = entry.file_name();
2528 let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
2529 let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
2530 if is_nested_gitignore || is_nested_aftignore {
2531 if let Some(err) = builder.add(entry.path()) {
2532 crate::slog_warn!(
2533 "nested ignore parse error in {}: {}",
2534 entry.path().display(),
2535 err
2536 );
2537 }
2538 }
2539 }
2540 match builder.build() {
2541 Ok(gi) => {
2542 let count = gi.num_ignores();
2543 if count > 0 {
2544 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
2545 self.set_gitignore(Some(Arc::new(gi)));
2546 } else {
2547 self.set_gitignore(None);
2548 }
2549 }
2550 Err(err) => {
2551 crate::slog_warn!("gitignore matcher build failed: {}", err);
2552 self.set_gitignore(None);
2553 }
2554 }
2555 }
2556
2557 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
2560 Arc::clone(&self.bash_compress_flag)
2561 }
2562
2563 pub fn sync_bash_compress_flag(&self) {
2567 let value = self.config().experimental_bash_compress;
2568 self.bash_compress_flag
2569 .store(value, std::sync::atomic::Ordering::Relaxed);
2570 }
2571
2572 pub fn set_bash_compress_enabled(&self, enabled: bool) {
2573 self.update_config(|config| {
2574 config.experimental_bash_compress = enabled;
2575 });
2576 self.bash_compress_flag
2577 .store(enabled, std::sync::atomic::Ordering::Relaxed);
2578 }
2579
2580 pub fn filter_registry(
2584 &self,
2585 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
2586 self.ensure_filter_registry_loaded();
2587 match self.filter_registry.read() {
2588 Ok(g) => g,
2589 Err(poisoned) => poisoned.into_inner(),
2590 }
2591 }
2592
2593 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
2597 self.ensure_filter_registry_loaded();
2598 Arc::clone(&self.filter_registry)
2599 }
2600
2601 pub fn reset_filter_registry(&self) {
2605 let new_registry = crate::compress::build_registry_for_context(self);
2606 self.filter_registry_rebuild_count
2607 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2608 match self.filter_registry.write() {
2609 Ok(mut slot) => *slot = new_registry,
2610 Err(poisoned) => *poisoned.into_inner() = new_registry,
2611 }
2612 self.filter_registry_loaded
2613 .store(true, std::sync::atomic::Ordering::Release);
2614 }
2615
2616 fn ensure_filter_registry_loaded(&self) {
2617 use std::sync::atomic::Ordering;
2618 if self.filter_registry_loaded.load(Ordering::Acquire) {
2619 return;
2620 }
2621 let new_registry = crate::compress::build_registry_for_context(self);
2624 self.filter_registry_rebuild_count
2625 .fetch_add(1, Ordering::SeqCst);
2626 if let Ok(mut slot) = self.filter_registry.write() {
2627 *slot = new_registry;
2628 self.filter_registry_loaded.store(true, Ordering::Release);
2629 }
2630 }
2631
2632 #[cfg(test)]
2633 pub fn filter_registry_rebuild_count_for_test(&self) -> u64 {
2634 self.filter_registry_rebuild_count.load(Ordering::SeqCst)
2635 }
2636
2637 pub fn app(&self) -> Arc<App> {
2638 Arc::clone(&self.app)
2639 }
2640
2641 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
2644 self.app.lsp_child_registry()
2645 }
2646
2647 pub fn stdout_writer(&self) -> SharedStdoutWriter {
2648 self.app.stdout_writer()
2649 }
2650
2651 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
2652 if let Ok(mut progress_sender) = self.progress_sender.lock() {
2653 *progress_sender = sender;
2654 }
2655 }
2656
2657 pub fn emit_progress(&self, frame: ProgressFrame) {
2658 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
2659 return;
2660 };
2661 if let Some(sender) = progress_sender.as_ref() {
2662 sender(PushFrame::Progress(frame));
2663 }
2664 }
2665
2666 pub fn status_emitter(&self) -> &StatusEmitter {
2667 &self.status_emitter
2668 }
2669
2670 pub(crate) fn install_fleet_status_client(
2671 &self,
2672 client: Option<crate::fleet_status::FleetStatusClient>,
2673 ) {
2674 *self
2675 .fleet_status_client
2676 .write()
2677 .unwrap_or_else(std::sync::PoisonError::into_inner) = client;
2678 }
2679
2680 pub(crate) fn fleet_status_client(&self) -> Option<crate::fleet_status::FleetStatusClient> {
2681 self.fleet_status_client
2682 .read()
2683 .unwrap_or_else(std::sync::PoisonError::into_inner)
2684 .clone()
2685 }
2686
2687 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
2695 self.progress_sender
2696 .lock()
2697 .ok()
2698 .and_then(|sender| sender.clone())
2699 }
2700
2701 pub fn advance_configure_generation(&self) -> u64 {
2702 self.subc_lifecycle
2703 .advance_generation(self.configure_generation.as_ref())
2704 }
2705
2706 pub(crate) fn mark_subc_bound(&self) {
2707 self.subc_lifecycle.mark_bound();
2708 }
2709
2710 pub(crate) fn mark_subc_unbound(&self) {
2711 self.subc_lifecycle
2712 .mark_unbound(self.configure_generation.as_ref());
2713 }
2714
2715 #[doc(hidden)]
2716 pub fn subc_unbound_quiesced(&self) -> bool {
2717 self.subc_lifecycle.is_unbound()
2718 }
2719
2720 pub(crate) fn subc_lifecycle_admission(&self) -> SubcLifecycleAdmission {
2721 self.subc_lifecycle.clone()
2722 }
2723
2724 pub(crate) fn run_if_subc_bound_generation<R>(
2725 &self,
2726 expected_generation: u64,
2727 action: impl FnOnce() -> R,
2728 ) -> Option<R> {
2729 self.subc_lifecycle.run_if_current(
2730 self.configure_generation.as_ref(),
2731 expected_generation,
2732 action,
2733 )
2734 }
2735
2736 pub fn note_configure_warm_key(&self, key: String) -> (u64, bool) {
2747 let mut state = self.configure_warm_state.lock();
2748 let equivalent = state.key.as_ref().is_some_and(|previous| *previous == key);
2749 let generation = if equivalent {
2750 self.configure_generation()
2751 } else {
2752 self.configure_content_generation
2753 .fetch_add(1, Ordering::SeqCst);
2754 self.advance_configure_generation()
2755 };
2756 state.generation = generation;
2757 state.key = Some(key);
2758 (generation, equivalent)
2759 }
2760
2761 pub(crate) fn configure_warm_key_matches(&self, key: &str) -> bool {
2762 self.configure_warm_state
2763 .lock()
2764 .key
2765 .as_deref()
2766 .is_some_and(|current| current == key)
2767 }
2768
2769 pub(crate) fn invalidate_configure_warm_state(&self) {
2770 self.configure_warm_state.lock().key = None;
2771 }
2772
2773 pub fn note_configure_session_binding(&self, root: PathBuf, session_id: String) -> bool {
2774 self.configured_session_roots
2775 .lock()
2776 .insert((root, session_id))
2777 }
2778
2779 pub(crate) fn has_configure_session_binding(&self, root: &Path, session_id: &str) -> bool {
2780 self.configured_session_roots
2781 .lock()
2782 .contains(&(root.to_path_buf(), session_id.to_string()))
2783 }
2784
2785 pub fn forget_configure_session_binding(&self, root: &Path, session_id: &str) {
2789 self.configured_session_roots
2790 .lock()
2791 .remove(&(root.to_path_buf(), session_id.to_string()));
2792 }
2793
2794 pub fn watcher_drain_has_work(&self) -> bool {
2800 let receiver_pending = self
2801 .watcher_rx
2802 .lock()
2803 .as_ref()
2804 .is_some_and(|rx| !rx.is_empty());
2805 receiver_pending
2806 || self
2807 .watcher_drain_slice
2808 .lock()
2809 .as_ref()
2810 .is_some_and(WatcherDrainSliceState::has_pending_work)
2811 }
2812
2813 pub fn lsp_drain_has_work(&self) -> bool {
2814 match self.lsp_manager.try_lock() {
2815 Some(lsp) => lsp.has_pending_events(),
2816 None => true,
2818 }
2819 }
2820
2821 pub fn completion_drains_have_work(&self) -> bool {
2822 let search_pending = self
2823 .search_index_rx
2824 .try_read()
2825 .map(|slot| {
2826 slot.as_ref().is_some_and(|receiver| {
2827 !receiver.is_empty()
2828 || self.search_index_rx_terminal_epoch.load(Ordering::SeqCst)
2829 == self.search_index_rx_epoch()
2830 })
2831 })
2832 .unwrap_or(true);
2833 if search_pending {
2834 return true;
2835 }
2836 if self
2837 .callgraph_store_rx
2838 .lock()
2839 .as_ref()
2840 .is_some_and(|rx| !rx.is_empty())
2841 {
2842 return true;
2843 }
2844 if self
2845 .semantic_index_rx
2846 .lock()
2847 .as_ref()
2848 .is_some_and(|receiver| {
2849 !receiver.is_empty()
2850 || self.semantic_index_rx_terminal_epoch.load(Ordering::SeqCst)
2851 == self.semantic_index_rx_epoch()
2852 })
2853 {
2854 return true;
2855 }
2856 if self
2857 .semantic_refresh_event_rx
2858 .lock()
2859 .as_ref()
2860 .is_some_and(|rx| !rx.is_empty())
2861 {
2862 return true;
2863 }
2864 if self.semantic_refresh_probe_ready() && self.semantic_refresh_event_rx.lock().is_some() {
2865 return true;
2866 }
2867 if self
2868 .semantic_refresh_worker
2869 .lock()
2870 .as_ref()
2871 .is_some_and(|worker_slot| match worker_slot.try_lock() {
2872 Ok(handle) => handle
2873 .as_ref()
2874 .is_some_and(std::thread::JoinHandle::is_finished),
2875 Err(std::sync::TryLockError::WouldBlock) => true,
2876 Err(std::sync::TryLockError::Poisoned(_)) => true,
2877 })
2878 {
2879 return true;
2880 }
2881 self.inspect_manager().has_pending_completions() || self.has_new_reuse_completions()
2882 }
2883
2884 pub fn configure_tail_has_work(&self) -> bool {
2885 !self.configure_maintenance_jobs.lock().is_empty() || !self.configure_warnings_rx.is_empty()
2886 }
2887
2888 pub(crate) fn configure_maintenance_has_capacity(&self) -> bool {
2889 self.configure_maintenance_jobs.lock().len() < crate::executor::MAINTENANCE_QUEUE_CAP
2890 }
2891
2892 pub(crate) fn enqueue_configure_maintenance(
2893 &self,
2894 job: ConfigureMaintenanceJob,
2895 ) -> Result<(), ConfigureMaintenanceJob> {
2896 let mut jobs = self.configure_maintenance_jobs.lock();
2897 if jobs.len() >= crate::executor::MAINTENANCE_QUEUE_CAP {
2898 return Err(job);
2899 }
2900 jobs.push_back(job);
2901 Ok(())
2902 }
2903
2904 pub(crate) fn drain_configure_maintenance(&self) -> Vec<ConfigureMaintenanceJob> {
2905 self.configure_maintenance_jobs.lock().drain(..).collect()
2906 }
2907
2908 #[cfg(test)]
2909 pub(crate) fn configure_maintenance_job_count_for_test(&self) -> usize {
2910 self.configure_maintenance_jobs.lock().len()
2911 }
2912
2913 pub fn cached_artifact_cache_key(&self, canonical_root: &Path) -> Option<String> {
2916 self.artifact_cache_keys.lock().get(canonical_root).cloned()
2917 }
2918
2919 pub(crate) fn cached_worktree_bridge(
2922 &self,
2923 canonical_root: &Path,
2924 ) -> Option<(bool, Option<PathBuf>)> {
2925 #[cfg(test)]
2926 if self.force_worktree_bridge_reprobe.load(Ordering::SeqCst) {
2927 return None;
2928 }
2929
2930 let signature = git_entry_signature(canonical_root);
2931 self.worktree_bridge_cache
2932 .lock()
2933 .get(canonical_root)
2934 .filter(|entry| entry.git_entry == signature)
2935 .map(|entry| (entry.is_worktree_bridge, entry.git_common_dir.clone()))
2936 }
2937
2938 pub(crate) fn cache_worktree_bridge(
2941 &self,
2942 canonical_root: &Path,
2943 is_worktree_bridge: bool,
2944 git_common_dir: PathBuf,
2945 ) {
2946 self.worktree_bridge_cache.lock().insert(
2947 canonical_root.to_path_buf(),
2948 WorktreeBridgeCacheEntry {
2949 git_entry: git_entry_signature(canonical_root),
2950 is_worktree_bridge,
2951 git_common_dir: Some(git_common_dir),
2952 },
2953 );
2954 }
2955
2956 #[cfg(test)]
2957 pub(crate) fn record_worktree_bridge_probe_spawn_for_test(&self) {
2958 self.worktree_bridge_probe_spawns
2959 .fetch_add(1, Ordering::SeqCst);
2960 }
2961
2962 #[cfg(test)]
2963 pub(crate) fn worktree_bridge_probe_spawns_for_test(&self) -> u64 {
2964 self.worktree_bridge_probe_spawns.load(Ordering::SeqCst)
2965 }
2966
2967 #[cfg(test)]
2968 pub(crate) fn force_worktree_bridge_reprobe_for_test(&self, enabled: bool) {
2969 self.force_worktree_bridge_reprobe
2970 .store(enabled, Ordering::SeqCst);
2971 }
2972
2973 pub fn memoized_artifact_cache_key(&self, canonical_root: &Path) -> String {
2974 let mut keys = self.artifact_cache_keys.lock();
2975 if let Some(key) = keys.get(canonical_root).cloned() {
2976 return key;
2977 }
2978 let key = crate::search_index::artifact_cache_key(canonical_root);
2979 self.artifact_cache_key_derivations
2980 .fetch_add(1, Ordering::SeqCst);
2981 keys.insert(canonical_root.to_path_buf(), key.clone());
2982 key
2983 }
2984
2985 pub fn memoized_artifact_cache_key_for_configure(
2986 &self,
2987 raw_root: &Path,
2988 canonical_root: &Path,
2989 storage_root: &Path,
2990 git_common_dir: Option<&Path>,
2991 ) -> Result<String, crate::search_index::ArtifactCacheKeyProbeError> {
2992 {
2993 let keys = self.artifact_cache_keys.lock();
2994 if let Some(key) = keys
2995 .get(canonical_root)
2996 .or_else(|| keys.get(raw_root))
2997 .cloned()
2998 {
2999 return Ok(key);
3000 }
3001 }
3002
3003 let key = crate::search_index::artifact_cache_key_with_memo(
3004 canonical_root,
3005 raw_root,
3006 storage_root,
3007 git_common_dir,
3008 )?;
3009 self.artifact_cache_key_derivations
3010 .fetch_add(1, Ordering::SeqCst);
3011 let mut keys = self.artifact_cache_keys.lock();
3012 keys.insert(canonical_root.to_path_buf(), key.clone());
3013 keys.insert(raw_root.to_path_buf(), key.clone());
3014 Ok(key)
3015 }
3016
3017 #[cfg(test)]
3018 pub fn artifact_cache_key_derivation_count_for_test(&self) -> u64 {
3019 self.artifact_cache_key_derivations.load(Ordering::SeqCst)
3020 }
3021
3022 pub(crate) fn resolve_external_git_root(
3023 &self,
3024 project_root: &Path,
3025 requested_path: &str,
3026 ) -> Result<PathBuf, crate::readonly_artifacts::GitRootResolutionError> {
3027 let raw_path = Path::new(requested_path);
3028 let canonical_requested = if raw_path.is_absolute() {
3029 std::fs::canonicalize(raw_path).ok()
3030 } else {
3031 None
3032 };
3033 if let Some(root) = canonical_requested
3034 .as_deref()
3035 .and_then(|root| self.borrowed_index_cache.lock().resolved_root(root))
3036 {
3037 return Ok(root);
3038 }
3039
3040 let root = crate::readonly_artifacts::resolve_git_root_from_user_path(
3041 project_root,
3042 requested_path,
3043 )?;
3044 if canonical_requested.as_deref() == Some(root.as_path()) {
3045 self.borrowed_index_cache
3046 .lock()
3047 .remember_resolved_root(root.clone());
3048 }
3049 Ok(root)
3050 }
3051
3052 pub(crate) fn open_borrowed_search_index(
3053 &self,
3054 external_root: &Path,
3055 storage_dir: Option<&Path>,
3056 ) -> crate::readonly_artifacts::ReadOnlyArtifact<Arc<SearchIndex>> {
3057 let canonical_root =
3058 std::fs::canonicalize(external_root).unwrap_or_else(|_| external_root.to_path_buf());
3059 let project_key = self.memoized_artifact_cache_key(&canonical_root);
3060 let Some(artifact) = crate::readonly_artifacts::search_index_artifact_generation_with_key(
3061 &project_key,
3062 storage_dir,
3063 ) else {
3064 return crate::readonly_artifacts::ReadOnlyArtifact::Absent;
3065 };
3066 let key = BorrowedIndexCacheKey {
3067 canonical_root: canonical_root.clone(),
3068 artifact,
3069 };
3070 let mut cache = self.borrowed_index_cache.lock();
3071 if let Some(index) = cache.search(&key) {
3072 return index;
3073 }
3074
3075 let opened = crate::readonly_artifacts::open_search_index_read_only_with_key(
3076 &canonical_root,
3077 storage_dir,
3078 &project_key,
3079 )
3080 .map(Arc::new);
3081 if !matches!(opened, crate::readonly_artifacts::ReadOnlyArtifact::Absent) {
3082 cache.insert(key, BorrowedIndexCacheValue::Search(opened.clone()));
3083 }
3084 opened
3085 }
3086
3087 pub(crate) fn open_borrowed_semantic_index(
3088 &self,
3089 external_root: &Path,
3090 storage_dir: Option<&Path>,
3091 ) -> crate::readonly_artifacts::ReadOnlyArtifact<Arc<SemanticIndex>> {
3092 let canonical_root =
3093 std::fs::canonicalize(external_root).unwrap_or_else(|_| external_root.to_path_buf());
3094 let project_key = self.memoized_artifact_cache_key(&canonical_root);
3095 let Some(artifact) = crate::readonly_artifacts::semantic_index_artifact_generation_with_key(
3096 &project_key,
3097 storage_dir,
3098 ) else {
3099 return crate::readonly_artifacts::ReadOnlyArtifact::Absent;
3100 };
3101 let key = BorrowedIndexCacheKey {
3102 canonical_root: canonical_root.clone(),
3103 artifact,
3104 };
3105 let mut cache = self.borrowed_index_cache.lock();
3106 if let Some(index) = cache.semantic(&key) {
3107 return index;
3108 }
3109
3110 let opened = crate::readonly_artifacts::open_semantic_index_read_only_with_key(
3111 &canonical_root,
3112 storage_dir,
3113 &project_key,
3114 )
3115 .map(Arc::new);
3116 if !matches!(opened, crate::readonly_artifacts::ReadOnlyArtifact::Absent) {
3117 cache.insert(key, BorrowedIndexCacheValue::Semantic(opened.clone()));
3118 }
3119 opened
3120 }
3121
3122 #[cfg(test)]
3123 pub(crate) fn borrowed_index_cache_len_for_test(&self) -> usize {
3124 self.borrowed_index_cache.lock().entries.len()
3125 }
3126
3127 pub fn configure_generation(&self) -> u64 {
3128 self.configure_generation.load(Ordering::SeqCst)
3129 }
3130
3131 pub fn configure_generation_flag(&self) -> Arc<AtomicU64> {
3132 Arc::clone(&self.configure_generation)
3133 }
3134
3135 pub(crate) fn configure_content_generation(&self) -> u64 {
3136 self.configure_content_generation.load(Ordering::SeqCst)
3137 }
3138
3139 pub(crate) fn configure_content_generation_flag(&self) -> Arc<AtomicU64> {
3140 Arc::clone(&self.configure_content_generation)
3141 }
3142
3143 pub(crate) fn begin_configure_ack_phase(&self, phase: &'static str) {
3144 let now = Instant::now();
3145 let mut timing = self.configure_phase_timing.lock();
3146 if phase == "canonicalize" {
3147 timing.completed.clear();
3148 } else if timing.phase != "idle" && timing.phase != "ack_ready" {
3149 let previous = timing.phase;
3150 let elapsed = now.saturating_duration_since(timing.started_at);
3151 timing.completed.push((previous, elapsed));
3152 }
3153 timing.phase = phase;
3154 timing.started_at = now;
3155 }
3156
3157 pub(crate) fn configure_ack_phase_snapshot(&self) -> String {
3158 let timing = self.configure_phase_timing.lock();
3159 let mut parts = timing
3160 .completed
3161 .iter()
3162 .map(|(phase, elapsed)| format!("{phase}={}ms", elapsed.as_millis()))
3163 .collect::<Vec<_>>();
3164 parts.push(format!(
3165 "{}={}ms",
3166 timing.phase,
3167 timing.started_at.elapsed().as_millis()
3168 ));
3169 parts.join(",")
3170 }
3171
3172 pub fn advance_semantic_fingerprint_generation(&self) -> u64 {
3173 self.semantic_fingerprint_generation
3174 .fetch_add(1, Ordering::SeqCst)
3175 .wrapping_add(1)
3176 }
3177
3178 pub fn semantic_fingerprint_generation(&self) -> u64 {
3179 self.semantic_fingerprint_generation.load(Ordering::SeqCst)
3180 }
3181
3182 pub fn semantic_fingerprint_generation_flag(&self) -> Arc<AtomicU64> {
3183 Arc::clone(&self.semantic_fingerprint_generation)
3184 }
3185
3186 pub fn configure_warnings_sender(
3187 &self,
3188 ) -> crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)> {
3189 self.configure_warnings_tx.clone()
3190 }
3191
3192 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
3193 let mut warnings = Vec::new();
3194 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
3195 warnings.push(warning);
3196 }
3197 warnings
3198 }
3199
3200 pub fn bash_background(&self) -> &BgTaskRegistry {
3201 &self.bash_background
3202 }
3203
3204 #[cfg(unix)]
3205 pub(crate) fn escalation_grants(
3206 &self,
3207 ) -> &parking_lot::Mutex<crate::sandbox_spawn::EscalationGrantStore> {
3208 &self.escalation_grants
3209 }
3210
3211 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
3212 self.bash_background.drain_completions()
3213 }
3214
3215 pub fn provider(&self) -> &dyn LanguageProvider {
3217 self.provider.as_ref()
3218 }
3219
3220 pub fn backup(&self) -> &parking_lot::Mutex<BackupStore> {
3222 &self.backup
3223 }
3224
3225 pub fn hashline_bindings(&self) -> &crate::hashline::integration::BindingRegistry {
3227 &self.hashline_bindings
3228 }
3229
3230 pub fn checkpoint(&self) -> &parking_lot::Mutex<CheckpointStore> {
3232 &self.checkpoint
3233 }
3234
3235 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
3236 self.app.set_db(conn);
3237 self.compression_aggregates.clear();
3238 }
3239
3240 pub fn clear_db(&self) {
3241 self.app.clear_db();
3242 self.compression_aggregates.clear();
3243 }
3244
3245 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
3246 self.app.db()
3247 }
3248
3249 pub(crate) fn compression_aggregate_cache(
3250 &self,
3251 ) -> &crate::db::compression_events::CompressionAggregateCache {
3252 self.compression_aggregates.as_ref()
3253 }
3254
3255 pub fn config(&self) -> Arc<Config> {
3257 let guard = match self.config.read() {
3258 Ok(guard) => guard,
3259 Err(poisoned) => poisoned.into_inner(),
3260 };
3261 Arc::clone(&*guard)
3262 }
3263
3264 pub fn set_config(&self, config: Config) {
3266 let next = Arc::new(config);
3267 let project_root_changed = {
3268 let mut guard = self
3269 .config
3270 .write()
3271 .unwrap_or_else(std::sync::PoisonError::into_inner);
3272 let changed = guard.project_root.as_ref().map(|root| root.as_os_str())
3275 != next.project_root.as_ref().map(|root| root.as_os_str());
3276 *guard = next;
3277 changed
3278 };
3279 if project_root_changed {
3280 self.path_restriction_root_memo.lock().take();
3281 }
3282 }
3283
3284 #[cfg(test)]
3285 pub(crate) fn path_restriction_root_memo_is_empty_for_test(&self) -> bool {
3286 self.path_restriction_root_memo.lock().is_none()
3287 }
3288
3289 #[cfg(test)]
3290 pub(crate) fn path_restriction_root_canonicalizations_for_test(&self) -> usize {
3291 self.path_restriction_root_canonicalizations
3292 .load(Ordering::SeqCst)
3293 }
3294
3295 pub fn update_config(&self, update: impl FnOnce(&mut Config)) {
3297 let mut next = self.config().as_ref().clone();
3298 update(&mut next);
3299 self.set_config(next);
3300 }
3301
3302 pub fn force_restrict_guard(&self, req_id: &str) -> ForceRestrictGuard<'_> {
3303 let mut requests = self.force_restrict_requests.lock();
3304 *requests.entry(req_id.to_string()).or_insert(0) += 1;
3305 ForceRestrictGuard {
3306 ctx: self,
3307 req_id: req_id.to_string(),
3308 }
3309 }
3310
3311 pub fn with_force_restrict<R>(&self, req_id: &str, f: impl FnOnce() -> R) -> R {
3312 let _guard = self.force_restrict_guard(req_id);
3313 f()
3314 }
3315
3316 pub fn request_force_restrict(&self, req_id: &str) -> bool {
3317 self.force_restrict_requests.lock().contains_key(req_id)
3318 }
3319
3320 fn release_force_restrict(&self, req_id: &str) {
3321 let mut requests = self.force_restrict_requests.lock();
3322 match requests.get_mut(req_id) {
3323 Some(count) if *count > 1 => *count -= 1,
3324 Some(_) => {
3325 requests.remove(req_id);
3326 }
3327 None => {}
3328 }
3329 }
3330
3331 pub fn set_harness(&self, harness: Harness) {
3332 self.bash_background.set_harness(harness.clone());
3333 *self.harness.lock() = Some(harness);
3334 }
3335
3336 pub fn harness_opt(&self) -> Option<Harness> {
3337 self.harness.lock().clone()
3338 }
3339
3340 pub fn harness(&self) -> Harness {
3341 self.harness_opt()
3342 .expect("harness set by configure before any tool call")
3343 }
3344
3345 pub fn storage_dir(&self) -> PathBuf {
3346 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
3347 }
3348
3349 pub fn harness_dir(&self) -> PathBuf {
3350 self.storage_dir().join(self.harness().storage_segment())
3351 }
3352
3353 pub fn inspect_dir(&self) -> PathBuf {
3354 if let Some(root) = self
3355 .canonical_cache_root_opt()
3356 .or_else(|| self.config().project_root.clone())
3357 {
3358 self.storage_dir()
3359 .join("inspect")
3360 .join(crate::path_identity::project_scope_key(&root))
3361 } else {
3362 self.storage_dir().join("inspect").join("unconfigured")
3363 }
3364 }
3365
3366 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
3367 self.harness_dir()
3368 .join("bash-tasks")
3369 .join(hash_session(session_id))
3370 }
3371
3372 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
3373 self.harness_dir()
3374 .join("backups")
3375 .join(hash_session(session_id))
3376 .join(path_hash)
3377 }
3378
3379 pub fn filters_dir(&self) -> PathBuf {
3380 self.harness_dir().join("filters")
3381 }
3382
3383 pub fn trust_file(&self) -> PathBuf {
3385 self.storage_dir().join("trusted-filter-projects.json")
3386 }
3387
3388 pub fn set_canonical_cache_root(&self, root: PathBuf) {
3389 debug_assert!(root.is_absolute());
3390 let root_changed = {
3391 let mut current = self.canonical_cache_root.lock();
3392 let changed = current.as_deref() != Some(root.as_path());
3393 *current = Some(root);
3394 changed
3395 };
3396 if root_changed {
3397 let mut tier2 = self
3398 .status_bar_tier2
3399 .write()
3400 .unwrap_or_else(std::sync::PoisonError::into_inner);
3401 let generation = tier2.generation.wrapping_add(1);
3402 *tier2 = StatusBarTier2 {
3403 generation,
3404 ..StatusBarTier2::default()
3405 };
3406 self.status_bar_last_emitted.clear();
3407 }
3408 }
3409
3410 pub fn canonical_cache_root(&self) -> PathBuf {
3411 self.canonical_cache_root
3412 .lock()
3413 .clone()
3414 .expect("canonical_cache_root accessed before handle_configure")
3415 }
3416
3417 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
3418 self.canonical_cache_root.lock().clone()
3419 }
3420
3421 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
3422 *self.is_worktree_bridge.lock() = is_worktree_bridge;
3423 *self.git_common_dir.lock() = git_common_dir;
3424 self.inspect_manager
3428 .set_automatic_tier2_refresh_allowed(!is_worktree_bridge);
3429 let artifact_read_only = self.shared_artifacts_read_only.load(Ordering::SeqCst);
3430 self.callgraph_writer
3431 .store(!is_worktree_bridge && !artifact_read_only, Ordering::SeqCst);
3432 }
3433
3434 pub fn set_artifact_owner(
3435 &self,
3436 status: Option<ArtifactOwnerStatus>,
3437 lease: Option<ArtifactOwnerLease>,
3438 ) {
3439 let read_only = status
3440 .as_ref()
3441 .is_some_and(|status| status.mode == ArtifactOwnerMode::ReadOnly);
3442 self.shared_artifacts_read_only
3443 .store(read_only, Ordering::SeqCst);
3444 self.callgraph_writer
3445 .store(!self.is_worktree_bridge() && !read_only, Ordering::SeqCst);
3446 self.inspect_writer.store(true, Ordering::SeqCst);
3447 *self.artifact_owner_status.lock() = status;
3448 *self.artifact_owner_lease.lock() = lease.map(crate::artifact_owner::register_heartbeat);
3449 }
3450
3451 pub fn set_cache_writer_capabilities(&self, callgraph_writer: bool, inspect_writer: bool) {
3452 self.callgraph_writer
3453 .store(callgraph_writer, Ordering::SeqCst);
3454 self.inspect_writer.store(inspect_writer, Ordering::SeqCst);
3455 }
3456
3457 pub fn callgraph_writer(&self) -> bool {
3458 self.callgraph_writer.load(Ordering::SeqCst)
3459 }
3460
3461 pub fn inspect_writer(&self) -> bool {
3462 self.inspect_writer.load(Ordering::SeqCst)
3463 }
3464
3465 pub fn shared_artifacts_read_only(&self) -> bool {
3466 !self.callgraph_writer()
3467 }
3468
3469 pub fn ram_overlay_active(&self) -> bool {
3475 self.shared_artifacts_read_only() && self.config().worktree.ram_overlay
3476 }
3477
3478 pub fn artifact_owner_status(&self) -> Option<ArtifactOwnerStatus> {
3479 self.artifact_owner_status.lock().clone()
3480 }
3481
3482 pub fn is_worktree_bridge(&self) -> bool {
3483 *self.is_worktree_bridge.lock()
3484 }
3485
3486 pub fn git_common_dir(&self) -> Option<PathBuf> {
3487 self.git_common_dir.lock().clone()
3488 }
3489
3490 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
3494 *self.degraded_reasons.lock() = reasons;
3495 }
3496
3497 pub fn set_heavy_root_work_allowed(&self, allowed: bool) {
3498 self.heavy_root_work_allowed
3499 .store(allowed, Ordering::SeqCst);
3500 }
3501
3502 pub fn heavy_root_work_allowed(&self) -> bool {
3503 self.heavy_root_work_allowed.load(Ordering::SeqCst) && !self.subc_lifecycle.is_unbound()
3504 }
3505
3506 fn try_heavy_root_work_allowed(&self) -> Option<bool> {
3507 if !self.heavy_root_work_allowed.load(Ordering::SeqCst) {
3508 return Some(false);
3509 }
3510 self.subc_lifecycle.try_is_bound()
3511 }
3512
3513 pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
3514 let reason = reason.into();
3515 let mut reasons = self.degraded_reasons.lock();
3516 if reasons.iter().any(|existing| existing == &reason) {
3517 return false;
3518 }
3519 reasons.push(reason);
3520 true
3521 }
3522
3523 pub fn degraded_reasons(&self) -> Vec<String> {
3527 self.degraded_reasons.lock().clone()
3528 }
3529
3530 pub fn is_degraded(&self) -> bool {
3532 !self.degraded_reasons.lock().is_empty()
3533 }
3534
3535 pub fn cache_role(&self) -> &'static str {
3536 if self.canonical_cache_root.lock().is_none() {
3537 "not_initialized"
3538 } else if self.is_worktree_bridge() {
3539 "worktree"
3540 } else if self.shared_artifacts_read_only.load(Ordering::SeqCst) {
3541 "read_only"
3542 } else {
3543 "main"
3544 }
3545 }
3546
3547 pub fn callgraph_store(&self) -> &RwLock<Option<Arc<ReadonlyCallGraphStore>>> {
3549 self.callgraph_store.as_ref()
3550 }
3551
3552 pub fn mark_callgraph_store_force_rebuild(&self) -> u64 {
3553 self.callgraph_store_force_requested
3554 .fetch_add(1, Ordering::SeqCst)
3555 .wrapping_add(1)
3556 }
3557
3558 pub(crate) fn pending_callgraph_store_force_token(&self) -> Option<u64> {
3559 let requested = self.callgraph_store_force_requested.load(Ordering::SeqCst);
3560 let fulfilled = self.callgraph_store_force_fulfilled.load(Ordering::SeqCst);
3561 (requested > fulfilled).then_some(requested)
3562 }
3563
3564 pub fn fulfill_callgraph_store_force_token(&self, token: u64) {
3565 self.callgraph_store_force_fulfilled
3566 .fetch_max(token, Ordering::SeqCst);
3567 }
3568
3569 #[doc(hidden)]
3570 pub fn record_callgraph_store_build_denied(&self, generation: u64, reason: String) {
3571 *self.callgraph_store_build_denied.lock() = Some((generation, reason));
3572 }
3573
3574 #[doc(hidden)]
3575 pub fn clear_callgraph_store_build_denied(&self) {
3576 *self.callgraph_store_build_denied.lock() = None;
3577 }
3578
3579 fn callgraph_store_build_denial(&self) -> Option<String> {
3580 let generation = self.configure_generation();
3581 let mut denied = self.callgraph_store_build_denied.lock();
3582 match denied.as_ref() {
3583 Some((denied_generation, reason)) if *denied_generation == generation => {
3584 Some(reason.clone())
3585 }
3586 Some(_) => {
3587 *denied = None;
3588 None
3589 }
3590 None => None,
3591 }
3592 }
3593
3594 pub fn callgraph_store_dir(&self) -> PathBuf {
3595 if let Some(root) = self.callgraph_project_root() {
3596 self.storage_dir()
3597 .join("callgraph")
3598 .join(self.memoized_artifact_cache_key(&root))
3599 } else {
3600 self.storage_dir().join("callgraph").join("unconfigured")
3601 }
3602 }
3603
3604 pub fn ensure_callgraph_store(
3605 &self,
3606 ) -> Result<Option<Arc<ReadonlyCallGraphStore>>, CallGraphStoreError> {
3607 self.ensure_callgraph_store_with_flag(true)
3608 }
3609
3610 fn ensure_callgraph_store_with_flag(
3611 &self,
3612 respect_config_flag: bool,
3613 ) -> Result<Option<Arc<ReadonlyCallGraphStore>>, CallGraphStoreError> {
3614 if respect_config_flag && !self.config().callgraph_store {
3615 return Ok(None);
3616 }
3617 if !self.heavy_root_work_allowed() {
3618 return Ok(None);
3619 }
3620 self.revalidate_callgraph_store_generation();
3621 let force_token = self.pending_callgraph_store_force_token();
3622 if force_token.is_none() {
3623 if let Some(store) = {
3624 let guard = self
3625 .callgraph_store
3626 .read()
3627 .unwrap_or_else(std::sync::PoisonError::into_inner);
3628 guard.as_ref().map(Arc::clone)
3629 } {
3630 self.schedule_legacy_callgraph_migration_if_needed(
3631 store.as_ref(),
3632 store.project_root().to_path_buf(),
3633 self.callgraph_store_dir(),
3634 );
3635 return Ok(Some(store));
3636 }
3637 }
3638
3639 let Some(project_root) = self.callgraph_project_root() else {
3640 return Ok(None);
3641 };
3642 let callgraph_dir = self.callgraph_store_dir();
3643
3644 if force_token.is_none() {
3648 if let Some(store) =
3649 CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone())?
3650 {
3651 let store = Arc::new(store);
3652 {
3653 let mut guard = self
3654 .callgraph_store
3655 .write()
3656 .unwrap_or_else(std::sync::PoisonError::into_inner);
3657 *guard = Some(Arc::clone(&store));
3658 }
3659 self.schedule_legacy_callgraph_migration_if_needed(
3660 store.as_ref(),
3661 project_root,
3662 callgraph_dir,
3663 );
3664 return Ok(Some(store));
3665 }
3666 }
3667
3668 if !self.callgraph_writer() {
3669 return Ok(None);
3670 }
3671 let build_generation = self.configure_generation();
3672 let persist_epoch_flag = self.callgraph_persist_epoch_flag();
3673 let Some(persist_epoch) = self
3674 .run_if_subc_bound_generation(build_generation, || self.next_callgraph_persist_epoch())
3675 else {
3676 return Ok(None);
3677 };
3678 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
3679 let (store, _stats) = crate::callgraph_store::with_publish_epoch(
3680 persist_epoch_flag.clone(),
3681 persist_epoch,
3682 || {
3683 if force_token.is_some() {
3684 CallGraphStore::force_cold_build_with_lease_chunked(
3685 callgraph_dir.clone(),
3686 project_root.clone(),
3687 &files,
3688 self.config().callgraph_chunk_size,
3689 )
3690 .map(|(store, _stats)| (store, ()))
3691 } else {
3692 CallGraphStore::ensure_built_with_lease_chunked(
3693 callgraph_dir.clone(),
3694 project_root.clone(),
3695 &files,
3696 self.config().callgraph_chunk_size,
3697 )
3698 .map(|(store, _stats)| (store, ()))
3699 }
3700 },
3701 )?;
3702 drop(store);
3703
3704 let Some(store) = CallGraphStore::open_readonly(callgraph_dir, project_root)? else {
3705 return Ok(None);
3706 };
3707 let store = Arc::new(store);
3708 self.run_if_subc_bound_generation(build_generation, || {
3709 if persist_epoch_flag.current() != persist_epoch {
3710 return None;
3711 }
3712 let mut guard = self
3713 .callgraph_store
3714 .write()
3715 .unwrap_or_else(std::sync::PoisonError::into_inner);
3716 *guard = Some(Arc::clone(&store));
3717 if let Some(force_token) = force_token {
3718 self.fulfill_callgraph_store_force_token(force_token);
3719 }
3720 Some(Arc::clone(&store))
3721 })
3722 .flatten()
3723 .map_or(Ok(None), |store| Ok(Some(store)))
3724 }
3725
3726 pub fn callgraph_project_root(&self) -> Option<PathBuf> {
3729 self.canonical_cache_root_opt().or_else(|| {
3730 self.config()
3731 .project_root
3732 .clone()
3733 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
3734 })
3735 }
3736
3737 pub fn revalidate_callgraph_store_generation(&self) {
3741 let (superseded, legacy_fallback) = {
3742 let guard = self
3743 .callgraph_store
3744 .read()
3745 .unwrap_or_else(std::sync::PoisonError::into_inner);
3746 guard
3747 .as_ref()
3748 .map(|store| (!store.is_current(), store.is_legacy_fallback()))
3749 .unwrap_or((false, false))
3750 };
3751 if !superseded {
3752 return;
3753 }
3754 if legacy_fallback && self.callgraph_store_rx.lock().is_some() {
3758 return;
3759 }
3760 let mut guard = self
3761 .callgraph_store
3762 .write()
3763 .unwrap_or_else(std::sync::PoisonError::into_inner);
3764 *guard = None;
3765 }
3766
3767 pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess {
3768 if !self.heavy_root_work_allowed() {
3769 return CallgraphStoreAccess::Unavailable;
3770 }
3771 let operation_generation = self.configure_generation();
3772
3773 self.revalidate_callgraph_store_generation();
3777 let force_token = self.pending_callgraph_store_force_token();
3778 if force_token.is_none() {
3779 if let Some(store) = {
3780 let guard = self
3781 .callgraph_store
3782 .read()
3783 .unwrap_or_else(std::sync::PoisonError::into_inner);
3784 guard.as_ref().map(Arc::clone)
3785 } {
3786 self.clear_callgraph_store_build_denied();
3787 self.schedule_legacy_callgraph_migration_if_needed(
3788 store.as_ref(),
3789 store.project_root().to_path_buf(),
3790 self.callgraph_store_dir(),
3791 );
3792 return CallgraphStoreAccess::Ready(store);
3793 }
3794 }
3795
3796 if let Some(reason) = self.callgraph_store_build_denial() {
3797 return CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason));
3798 }
3799
3800 if self.callgraph_store_rx.lock().is_some() {
3802 return CallgraphStoreAccess::Building;
3803 }
3804
3805 let Some(project_root) = self.callgraph_project_root() else {
3806 return CallgraphStoreAccess::Unavailable;
3807 };
3808 let callgraph_dir = self.callgraph_store_dir();
3809
3810 if force_token.is_none() {
3811 match CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone()) {
3812 Ok(Some(store)) => {
3813 let store = Arc::new(store);
3814 let installed = self.run_if_subc_bound_generation(operation_generation, || {
3815 let mut guard = self
3816 .callgraph_store
3817 .write()
3818 .unwrap_or_else(std::sync::PoisonError::into_inner);
3819 *guard = Some(Arc::clone(&store));
3820 Arc::clone(&store)
3821 });
3822 let Some(store) = installed else {
3823 return CallgraphStoreAccess::Unavailable;
3824 };
3825 self.clear_callgraph_store_build_denied();
3826 self.schedule_legacy_callgraph_migration_if_needed(
3827 store.as_ref(),
3828 project_root.clone(),
3829 callgraph_dir.clone(),
3830 );
3831 return CallgraphStoreAccess::Ready(store);
3832 }
3833 Ok(None) => {
3834 if !self.callgraph_writer() {
3835 return CallgraphStoreAccess::Unavailable;
3836 }
3837 }
3838 Err(error) => {
3839 if !self.callgraph_writer() {
3840 return CallgraphStoreAccess::Unavailable;
3841 }
3842 crate::slog_warn!(
3843 "callgraph read-only open failed before writer promotion: {}",
3844 error
3845 );
3846 }
3847 }
3848 } else if !self.callgraph_writer() {
3849 return CallgraphStoreAccess::Unavailable;
3850 }
3851
3852 if self.semantic_cold_seed_active() {
3853 self.defer_callgraph_store_warm_for_semantic_cold_seed();
3854 return CallgraphStoreAccess::Building;
3855 }
3856
3857 let work = if let Some(force_token) = force_token {
3865 CallgraphBackgroundWork::ForceRebuild(force_token)
3866 } else {
3867 CallgraphBackgroundWork::Ensure
3868 };
3869 if !self.spawn_callgraph_store_cold_build(project_root.clone(), callgraph_dir.clone(), work)
3870 {
3871 return CallgraphStoreAccess::Building;
3872 }
3873
3874 let wait = callgraph_build_wait_window();
3875 if !wait.is_zero() {
3876 let (received, receiver_generation, receiver_epoch) = {
3877 let rx_ref = self.callgraph_store_rx.lock();
3878 let Some(rx) = rx_ref.as_ref() else {
3879 return CallgraphStoreAccess::Building;
3880 };
3881 (
3882 rx.recv_timeout(wait),
3883 self.callgraph_store_rx_generation(),
3884 self.callgraph_store_rx_epoch(),
3885 )
3886 };
3887 match received {
3888 Ok(CallGraphStoreBuildEvent::Ready {
3889 store,
3890 fulfilled_force_token,
3891 publication_epoch,
3892 }) => {
3893 if self.callgraph_persist_epoch_flag().current() != publication_epoch {
3894 drop(store);
3898 let _ = self.with_current_callgraph_store_rx(
3899 receiver_generation,
3900 receiver_epoch,
3901 |receiver| {
3902 *receiver = None;
3903 },
3904 );
3905 return CallgraphStoreAccess::Building;
3906 }
3907 remove_callgraph_pointer_before_inline_reopen_for_test(&callgraph_dir, &store);
3910 drop(store);
3911 let reopened =
3912 CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone());
3913 let mut pending = Vec::new();
3914 let outcome = self.with_current_callgraph_store_rx(
3915 receiver_generation,
3916 receiver_epoch,
3917 |receiver| {
3918 *receiver = None;
3919 match reopened {
3920 Ok(Some(store)) => {
3921 let ready = Arc::new(store);
3922 self.clear_callgraph_store_build_denied();
3923 *self
3924 .callgraph_store
3925 .write()
3926 .unwrap_or_else(std::sync::PoisonError::into_inner) =
3927 Some(Arc::clone(&ready));
3928 pending = self.take_pending_callgraph_store_paths();
3933 if let Some(force_token) = fulfilled_force_token {
3934 self.fulfill_callgraph_store_force_token(force_token);
3935 }
3936 CallgraphStoreAccess::Ready(ready)
3937 }
3938 Ok(None) => CallgraphStoreAccess::Building,
3939 Err(error) => CallgraphStoreAccess::Error(error),
3940 }
3941 },
3942 );
3943 let Some(outcome) = outcome else {
3944 return if self.subc_unbound_quiesced()
3945 || self.configure_generation() != receiver_generation
3946 {
3947 CallgraphStoreAccess::Unavailable
3948 } else {
3949 CallgraphStoreAccess::Building
3950 };
3951 };
3952 if !pending.is_empty() {
3953 let _ = self.enqueue_callgraph_store_refresh(pending);
3954 }
3955 if matches!(&outcome, CallgraphStoreAccess::Ready(_)) {
3956 let _ = self.request_tier2_refresh_pull();
3957 }
3958 return outcome;
3959 }
3960 Ok(CallGraphStoreBuildEvent::Denied { reason }) => {
3961 let denied = self.with_current_callgraph_store_rx(
3962 receiver_generation,
3963 receiver_epoch,
3964 |receiver| {
3965 *receiver = None;
3966 self.record_callgraph_store_build_denied(
3967 receiver_generation,
3968 reason.clone(),
3969 );
3970 CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason))
3971 },
3972 );
3973 return denied.unwrap_or(CallgraphStoreAccess::Unavailable);
3974 }
3975 Ok(CallGraphStoreBuildEvent::Settled) => {
3976 let _ = self.with_current_callgraph_store_rx(
3977 receiver_generation,
3978 receiver_epoch,
3979 |receiver| *receiver = None,
3980 );
3981 return CallgraphStoreAccess::Building;
3982 }
3983 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
3984 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
3985 let _ = self.with_current_callgraph_store_rx(
3986 receiver_generation,
3987 receiver_epoch,
3988 |receiver| *receiver = None,
3989 );
3990 }
3991 }
3992 }
3993 CallgraphStoreAccess::Building
3994 }
3995
3996 fn schedule_legacy_callgraph_migration_if_needed(
3997 &self,
3998 store: &ReadonlyCallGraphStore,
3999 project_root: PathBuf,
4000 callgraph_dir: PathBuf,
4001 ) {
4002 if !store.is_legacy_fallback()
4003 || !self.callgraph_writer()
4004 || !self.heavy_root_work_allowed()
4005 {
4006 return;
4007 }
4008 if self.semantic_cold_seed_active() {
4009 self.defer_callgraph_store_warm_for_semantic_cold_seed();
4010 return;
4011 }
4012 let _ = self.spawn_callgraph_store_cold_build(
4013 project_root,
4014 callgraph_dir,
4015 CallgraphBackgroundWork::LegacyMigration,
4016 );
4017 }
4018
4019 fn configured_callgraph_keys(&self, current_root: &Path) -> BTreeSet<String> {
4020 let mut roots = self
4021 .configured_session_roots
4022 .lock()
4023 .iter()
4024 .map(|(root, _session)| root.clone())
4025 .collect::<BTreeSet<_>>();
4026 roots.insert(current_root.to_path_buf());
4027 roots
4028 .iter()
4029 .map(|root| crate::search_index::artifact_cache_key(root))
4030 .collect()
4031 }
4032
4033 fn spawn_callgraph_store_cold_build(
4038 &self,
4039 project_root: PathBuf,
4040 callgraph_dir: PathBuf,
4041 work: CallgraphBackgroundWork,
4042 ) -> bool {
4043 if !self.heavy_root_work_allowed() || !self.callgraph_writer() {
4044 return false;
4045 }
4046 let generation = self.configure_generation();
4047 self.run_if_subc_bound_generation(generation, || {
4048 self.spawn_callgraph_store_cold_build_admitted(project_root, callgraph_dir, work)
4049 })
4050 .unwrap_or(false)
4051 }
4052
4053 fn spawn_callgraph_store_cold_build_admitted(
4055 &self,
4056 project_root: PathBuf,
4057 callgraph_dir: PathBuf,
4058 work: CallgraphBackgroundWork,
4059 ) -> bool {
4060 let session_id = crate::log_ctx::current_session();
4061 let chunk_size = self.config().callgraph_chunk_size;
4062 let build_generation = self.configure_generation();
4063 let generation_flag = self.configure_generation_flag();
4064 let configured_keys = self.configured_callgraph_keys(&project_root);
4065 let summary_logged = Arc::clone(&self.callgraph_legacy_migration_summary_logged);
4066
4067 let mut rx_guard = self.callgraph_store_rx.lock();
4068 if rx_guard.is_some() {
4069 return false;
4070 }
4071
4072 let limiter = self.cold_build_limiter();
4073 let Some(permit) = limiter.try_acquire() else {
4074 crate::slog_info!(
4075 "callgraph store background work deferred by cold build limit ({})",
4076 limiter.limit()
4077 );
4078 return false;
4079 };
4080
4081 let force_token = match work {
4082 CallgraphBackgroundWork::ForceRebuild(token) => Some(token),
4083 CallgraphBackgroundWork::Ensure | CallgraphBackgroundWork::LegacyMigration => None,
4084 };
4085 let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStoreBuildEvent>();
4086 self.note_callgraph_store_rx_generation(build_generation);
4087 self.next_callgraph_store_rx_epoch();
4088 *rx_guard = Some(rx);
4089 let persist_epoch = self.next_callgraph_persist_epoch();
4090 let persist_epoch_flag = self.callgraph_persist_epoch_flag();
4091
4092 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.fetch_add(1, Ordering::SeqCst);
4093
4094 std::thread::spawn(move || {
4095 let _permit = permit;
4096 let mut settlement = CallGraphStoreBuildSettlement::new(tx, force_token, persist_epoch);
4097 crate::log_ctx::with_session(session_id, || {
4098 wait_on_callgraph_build_start_gate(&project_root);
4099 if persist_epoch_flag.current() != persist_epoch {
4100 crate::slog_info!(
4101 "callgraph store background work skipped for superseded epoch {}",
4102 persist_epoch
4103 );
4104 return;
4105 }
4106 let built = crate::callgraph_store::with_publish_epoch(
4107 persist_epoch_flag,
4108 persist_epoch,
4109 || match work {
4110 CallgraphBackgroundWork::LegacyMigration => {
4111 CallGraphStore::migrate_legacy_with_lease(
4112 callgraph_dir.clone(),
4113 project_root.clone(),
4114 )
4115 }
4116 CallgraphBackgroundWork::ForceRebuild(_) => {
4117 let files = crate::callgraph::walk_project_files(&project_root)
4118 .collect::<Vec<_>>();
4119 CallGraphStore::force_cold_build_with_lease_chunked(
4120 callgraph_dir.clone(),
4121 project_root.clone(),
4122 &files,
4123 chunk_size,
4124 )
4125 .map(|(store, _)| Some(store))
4126 }
4127 CallgraphBackgroundWork::Ensure => {
4128 let files = crate::callgraph::walk_project_files(&project_root)
4129 .collect::<Vec<_>>();
4130 CallGraphStore::ensure_built_with_lease_chunked(
4131 callgraph_dir.clone(),
4132 project_root.clone(),
4133 &files,
4134 chunk_size,
4135 )
4136 .map(|(store, _)| Some(store))
4137 }
4138 },
4139 );
4140 match built {
4141 Ok(Some(store)) => {
4142 if store.is_legacy_migration() {
4143 match crate::callgraph_store::all_legacy_partitions_migrated_for_keys(
4144 &callgraph_dir,
4145 &configured_keys,
4146 ) {
4147 Ok(true)
4148 if summary_logged
4149 .compare_exchange(
4150 false,
4151 true,
4152 Ordering::SeqCst,
4153 Ordering::SeqCst,
4154 )
4155 .is_ok() =>
4156 {
4157 crate::slog_info!(
4158 "all legacy callgraph partitions migrated for configured roots"
4159 );
4160 }
4161 Ok(_) => {}
4162 Err(error) => crate::slog_warn!(
4163 "failed to inspect legacy callgraph migration completion: {}",
4164 error
4165 ),
4166 }
4167 }
4168 if generation_flag.load(Ordering::SeqCst) == build_generation {
4169 settlement.ready(store);
4170 } else {
4171 crate::slog_info!(
4172 "callgraph store warm build result discarded for stale generation {}",
4173 build_generation
4174 );
4175 }
4176 }
4177 Ok(None) => {}
4178 Err(crate::callgraph_store::CallGraphStoreError::Superseded) => {
4179 crate::slog_info!(
4180 "callgraph store disk publication skipped for superseded epoch {}",
4181 persist_epoch
4182 );
4183 }
4184 Err(crate::callgraph_store::CallGraphStoreError::Unavailable(reason))
4185 if reason.ends_with("could not acquire writer capability") =>
4186 {
4187 crate::slog_warn!(
4188 "callgraph store background work denied writer capability: {}",
4189 reason
4190 );
4191 settlement.denied(reason);
4192 }
4193 Err(error) => {
4194 crate::slog_warn!("callgraph store background work failed: {}", error);
4195 }
4196 }
4197 });
4198 });
4199 true
4200 }
4201
4202 pub fn callgraph_store_rx(
4205 &self,
4206 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>> {
4207 &self.callgraph_store_rx
4208 }
4209
4210 #[doc(hidden)]
4214 pub fn with_current_callgraph_store_rx<R>(
4215 &self,
4216 generation: u64,
4217 epoch: u64,
4218 action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>) -> R,
4219 ) -> Option<R> {
4220 self.run_if_subc_bound_generation(generation, || {
4221 let mut receiver = self.callgraph_store_rx.lock();
4222 if receiver.is_none()
4223 || self.callgraph_store_rx_generation() != generation
4224 || self.callgraph_store_rx_epoch() != epoch
4225 {
4226 return None;
4227 }
4228 Some(action(&mut receiver))
4229 })
4230 .flatten()
4231 }
4232
4233 pub(crate) fn retire_callgraph_store_rx(&self) {
4234 let mut receiver = self.callgraph_store_rx.lock();
4235 *receiver = None;
4236 self.next_callgraph_store_rx_epoch();
4237 }
4238
4239 pub(crate) fn note_callgraph_store_rx_generation(&self, generation: u64) {
4240 self.callgraph_store_rx_generation
4241 .store(generation, Ordering::SeqCst);
4242 }
4243
4244 #[doc(hidden)]
4245 pub fn callgraph_store_rx_generation(&self) -> u64 {
4246 self.callgraph_store_rx_generation.load(Ordering::SeqCst)
4247 }
4248
4249 pub(crate) fn next_callgraph_store_rx_epoch(&self) -> u64 {
4250 self.callgraph_store_rx_epoch
4251 .fetch_add(1, Ordering::SeqCst)
4252 .wrapping_add(1)
4253 }
4254
4255 #[doc(hidden)]
4256 pub fn callgraph_store_rx_epoch(&self) -> u64 {
4257 self.callgraph_store_rx_epoch.load(Ordering::SeqCst)
4258 }
4259
4260 pub(crate) fn next_callgraph_persist_epoch(&self) -> u64 {
4261 self.callgraph_persist_epoch.next()
4262 }
4263
4264 #[doc(hidden)]
4265 pub fn callgraph_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
4266 self.callgraph_persist_epoch.clone()
4267 }
4268
4269 pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
4272 where
4273 I: IntoIterator<Item = PathBuf>,
4274 {
4275 self.pending_callgraph_store_paths.lock().extend(paths);
4276 }
4277
4278 pub fn enqueue_callgraph_store_refresh<I>(&self, paths: I) -> bool
4279 where
4280 I: IntoIterator<Item = PathBuf>,
4281 {
4282 let generation = self.configure_generation();
4283 self.enqueue_callgraph_store_refresh_for_generation(paths, generation)
4284 }
4285
4286 pub(crate) fn enqueue_callgraph_store_refresh_for_generation<I>(
4287 &self,
4288 paths: I,
4289 generation: u64,
4290 ) -> bool
4291 where
4292 I: IntoIterator<Item = PathBuf>,
4293 {
4294 let paths = paths.into_iter().collect::<Vec<_>>();
4295 if paths.is_empty() {
4296 return true;
4297 }
4298 self.run_if_subc_bound_generation(generation, || {
4299 if !self.callgraph_writer() {
4300 self.add_pending_callgraph_store_paths(paths);
4301 return false;
4302 }
4303 let Some(project_root) = self.callgraph_project_root() else {
4304 self.add_pending_callgraph_store_paths(paths);
4305 return false;
4306 };
4307
4308 let ticket = crate::callgraph_store::CallgraphRefreshTicket::new(
4313 self.subc_lifecycle_admission(),
4314 self.configure_generation_flag(),
4315 generation,
4316 self.callgraph_persist_epoch_flag(),
4317 self.callgraph_persist_epoch_flag().current(),
4318 );
4319 crate::callgraph_store::enqueue_callgraph_store_refresh_fenced_with_state(
4320 self.callgraph_store_dir(),
4321 project_root,
4322 paths,
4323 Arc::clone(&self.pending_callgraph_store_paths),
4324 crate::callgraph_store::CallgraphRefreshState::new(
4325 Arc::clone(&self.callgraph_store),
4326 Arc::clone(&self.heavy_root_work_allowed),
4327 ),
4328 ticket,
4329 )
4330 })
4331 .unwrap_or(false)
4332 }
4333
4334 pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
4342 let roots: Vec<PathBuf> = [
4343 self.canonical_cache_root_opt(),
4344 self.config().project_root.clone(),
4345 ]
4346 .into_iter()
4347 .flatten()
4348 .collect();
4349 std::mem::take(&mut *self.pending_callgraph_store_paths.lock())
4350 .into_iter()
4351 .filter(|path| {
4352 let in_root = pending_path_in_roots(path, &roots);
4353 if !in_root {
4354 crate::slog_debug!(
4355 "dropping pending callgraph path outside current root: {}",
4356 path.display()
4357 );
4358 }
4359 in_root
4360 })
4361 .collect()
4362 }
4363
4364 pub fn search_index(&self) -> &RwLock<Option<SearchIndex>> {
4366 &self.search_index
4367 }
4368
4369 pub fn search_index_rx(&self) -> &RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>> {
4371 &self.search_index_rx
4372 }
4373
4374 pub(crate) fn install_search_index_rx(
4375 &self,
4376 receiver: crossbeam_channel::Receiver<SearchIndex>,
4377 generation: u64,
4378 ) -> u64 {
4379 let mut slot = self
4380 .search_index_rx
4381 .write()
4382 .unwrap_or_else(std::sync::PoisonError::into_inner);
4383 self.note_search_index_rx_generation(generation);
4384 let epoch = self.next_search_index_rx_epoch();
4385 *slot = Some(receiver);
4386 epoch
4387 }
4388
4389 pub(crate) fn search_index_rx_terminal_guard(&self, epoch: u64) -> ReceiverTerminalGuard {
4390 ReceiverTerminalGuard::new(Arc::clone(&self.search_index_rx_terminal_epoch), epoch)
4391 }
4392
4393 pub(crate) fn with_current_search_index_rx<R>(
4396 &self,
4397 generation: u64,
4398 epoch: u64,
4399 action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<SearchIndex>>) -> R,
4400 ) -> Option<R> {
4401 self.run_if_subc_bound_generation(generation, || {
4402 let mut receiver = self
4403 .search_index_rx
4404 .write()
4405 .unwrap_or_else(std::sync::PoisonError::into_inner);
4406 if receiver.is_none()
4407 || self.search_index_rx_generation() != generation
4408 || self.search_index_rx_epoch() != epoch
4409 {
4410 return None;
4411 }
4412 Some(action(&mut receiver))
4413 })
4414 .flatten()
4415 }
4416
4417 pub(crate) fn retire_search_index_rx(&self) {
4418 let mut receiver = self
4419 .search_index_rx
4420 .write()
4421 .unwrap_or_else(std::sync::PoisonError::into_inner);
4422 *receiver = None;
4423 self.next_search_index_rx_epoch();
4424 }
4425
4426 pub(crate) fn note_search_index_rx_generation(&self, generation: u64) {
4427 self.search_index_rx_generation
4428 .store(generation, Ordering::SeqCst);
4429 }
4430
4431 pub(crate) fn search_index_rx_generation(&self) -> u64 {
4432 self.search_index_rx_generation.load(Ordering::SeqCst)
4433 }
4434
4435 pub(crate) fn next_search_index_rx_epoch(&self) -> u64 {
4436 self.search_index_rx_epoch
4437 .fetch_add(1, Ordering::SeqCst)
4438 .wrapping_add(1)
4439 }
4440
4441 pub(crate) fn search_index_rx_epoch(&self) -> u64 {
4442 self.search_index_rx_epoch.load(Ordering::SeqCst)
4443 }
4444
4445 pub(crate) fn allow_search_index_disconnect_reschedule(&self) -> bool {
4452 const MAX_REPLACEMENTS_PER_GENERATION: u32 = 1;
4453 let generation = self.configure_generation();
4454 let mut state = self.search_index_disconnect_reschedule.lock();
4455 if state.0 != generation {
4456 *state = (generation, 0);
4457 }
4458 if state.1 >= MAX_REPLACEMENTS_PER_GENERATION {
4459 return false;
4460 }
4461 state.1 += 1;
4462 true
4463 }
4464
4465 pub(crate) fn next_search_persist_epoch(&self) -> u64 {
4466 self.search_persist_epoch.next()
4467 }
4468
4469 pub(crate) fn search_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
4470 self.search_persist_epoch.clone()
4471 }
4472
4473 pub fn add_pending_search_index_paths<I>(&self, paths: I)
4474 where
4475 I: IntoIterator<Item = PathBuf>,
4476 {
4477 let paths = paths.into_iter().collect::<Vec<_>>();
4478 if !paths.is_empty() {
4479 self.invalidate_warm_verify_memo();
4480 self.pending_search_index_paths.lock().extend(paths);
4481 }
4482 }
4483
4484 pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
4485 std::mem::take(&mut *self.pending_search_index_paths.lock())
4486 .into_iter()
4487 .collect()
4488 }
4489
4490 pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
4491 where
4492 I: IntoIterator<Item = PathBuf>,
4493 {
4494 let paths = paths.into_iter().collect::<Vec<_>>();
4495 if !paths.is_empty() {
4496 self.invalidate_warm_verify_memo();
4497 self.pending_semantic_index_paths.lock().extend(paths);
4498 }
4499 }
4500
4501 pub(crate) fn invalidate_warm_verify_memo(&self) {
4502 if let Some(root) = self.canonical_cache_root_opt() {
4503 crate::cache_freshness::invalidate_verify_memo(&root);
4504 }
4505 }
4506
4507 pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
4508 std::mem::take(&mut *self.pending_semantic_index_paths.lock())
4509 .into_iter()
4510 .collect()
4511 }
4512
4513 pub fn mark_pending_semantic_corpus_refresh(&self) {
4514 *self.pending_semantic_corpus_refresh.lock() = true;
4515 }
4516
4517 pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
4518 std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock())
4519 }
4520
4521 pub fn clear_pending_index_updates(&self) {
4522 self.pending_search_index_paths.lock().clear();
4523 self.pending_callgraph_store_paths.lock().clear();
4524 self.pending_tier2_paths.lock().clear();
4525 self.pending_semantic_index_paths.lock().clear();
4526 *self.pending_semantic_corpus_refresh.lock() = false;
4527 }
4528
4529 pub(crate) fn take_pending_reconciliation_state(&self) -> PendingReconciliationState {
4537 PendingReconciliationState {
4538 search: std::mem::take(&mut *self.pending_search_index_paths.lock()),
4539 callgraph: std::mem::take(&mut *self.pending_callgraph_store_paths.lock()),
4540 tier2: std::mem::take(&mut *self.pending_tier2_paths.lock()),
4541 semantic: std::mem::take(&mut *self.pending_semantic_index_paths.lock()),
4542 corpus_refresh: std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock()),
4543 }
4544 }
4545
4546 pub(crate) fn restore_pending_reconciliation_state(&self, state: PendingReconciliationState) {
4547 self.pending_search_index_paths.lock().extend(state.search);
4548 self.pending_callgraph_store_paths
4549 .lock()
4550 .extend(state.callgraph);
4551 self.pending_tier2_paths.lock().extend(state.tier2);
4552 self.pending_semantic_index_paths
4553 .lock()
4554 .extend(state.semantic);
4555 if state.corpus_refresh {
4556 *self.pending_semantic_corpus_refresh.lock() = true;
4557 }
4558 }
4559
4560 pub(crate) fn cancel_unbound_artifact_work(&self) {
4574 let search_refresh_cancelled = self
4580 .search_index_rx
4581 .read()
4582 .unwrap_or_else(std::sync::PoisonError::into_inner)
4583 .is_some();
4584 self.retire_search_index_rx();
4585 if search_refresh_cancelled {
4586 let mut resident = self
4587 .search_index
4588 .write()
4589 .unwrap_or_else(std::sync::PoisonError::into_inner);
4590 if resident.as_ref().is_some_and(|index| !index.ready) {
4591 *resident = None;
4592 }
4593 }
4594 self.retire_callgraph_store_rx();
4595 let semantic_cancelled = self.semantic_index_rx.lock().is_some();
4596 self.retire_semantic_index_rx();
4597 let semantic_refresh_cancelled = self.semantic_refresh_event_rx.lock().is_some();
4598 self.clear_semantic_refresh_worker();
4599 self.reset_semantic_cold_seed_gate_for_configure();
4600 let _ = self.inspect_manager.discard_completions();
4601 let _ = self.take_new_reuse_completions();
4602 if semantic_cancelled || semantic_refresh_cancelled {
4603 let has_index = self
4604 .semantic_index
4605 .read()
4606 .unwrap_or_else(std::sync::PoisonError::into_inner)
4607 .is_some();
4608 {
4612 let mut status = self
4613 .semantic_index_status
4614 .write()
4615 .unwrap_or_else(std::sync::PoisonError::into_inner);
4616 let refreshing = status.take_refreshing_files();
4617 if !refreshing.is_empty() {
4618 self.pending_semantic_index_paths.lock().extend(refreshing);
4619 }
4620 if status.corpus_refresh_in_flight() {
4621 *self.pending_semantic_corpus_refresh.lock() = true;
4622 }
4623 *status = if has_index {
4624 SemanticIndexStatus::ready()
4625 } else {
4626 SemanticIndexStatus::Disabled
4627 };
4628 }
4629 }
4630 }
4631
4632 pub(crate) fn invalidate_artifacts_after_watcher_gap(&self) {
4636 self.next_search_persist_epoch();
4637 self.next_semantic_persist_epoch();
4638 self.next_callgraph_persist_epoch();
4639
4640 self.search_index
4641 .write()
4642 .unwrap_or_else(std::sync::PoisonError::into_inner)
4643 .take();
4644 self.semantic_index
4645 .write()
4646 .unwrap_or_else(std::sync::PoisonError::into_inner)
4647 .take();
4648 self.callgraph_store
4649 .write()
4650 .unwrap_or_else(std::sync::PoisonError::into_inner)
4651 .take();
4652 *self
4658 .semantic_index_status
4659 .write()
4660 .unwrap_or_else(std::sync::PoisonError::into_inner) = if self.config().semantic_search {
4661 SemanticIndexStatus::ready()
4662 } else {
4663 SemanticIndexStatus::Disabled
4664 };
4665 if self.callgraph_writer() {
4669 self.mark_callgraph_store_force_rebuild();
4670 }
4671
4672 if let Some(root) = self
4673 .canonical_cache_root_opt()
4674 .or_else(|| self.config().project_root.clone())
4675 {
4676 crate::cache_freshness::invalidate_verify_memo_strict(&root);
4677 }
4678 self.borrowed_index_cache.lock().clear();
4679 self.inspect_manager.evict_idle_caches();
4680 self.reset_symbol_cache();
4681 self.clear_tsconfig_membership_cache();
4682 }
4683
4684 fn drain_search_index_events_for_graceful_shutdown(&self) {
4685 crate::runtime_drain::drain_watcher_events(self);
4686 crate::runtime_drain::drain_search_index_events(self);
4687 }
4688
4689 fn search_index_build_in_progress(&self) -> bool {
4690 self.search_index_rx()
4691 .read()
4692 .unwrap_or_else(std::sync::PoisonError::into_inner)
4693 .is_some()
4694 }
4695
4696 fn wait_for_search_index_build_to_settle_on_graceful_shutdown(&self) {
4700 crate::runtime_drain::note_search_rebuild_shutdown_wait_for_test();
4701 let deadline = Instant::now() + GRACEFUL_SHUTDOWN_SEARCH_BUILD_WAIT;
4702 while self.search_index_build_in_progress() && Instant::now() < deadline {
4703 let remaining = deadline.saturating_duration_since(Instant::now());
4704 std::thread::sleep(remaining.min(GRACEFUL_SHUTDOWN_SEARCH_BUILD_POLL));
4705 self.drain_search_index_events_for_graceful_shutdown();
4706 }
4707 }
4708
4709 #[doc(hidden)]
4716 pub fn flush_search_index_on_graceful_shutdown(&self) -> bool {
4717 if self.shared_artifacts_read_only() {
4718 return false;
4719 }
4720
4721 self.drain_search_index_events_for_graceful_shutdown();
4722 if self.search_index_build_in_progress() {
4723 self.wait_for_search_index_build_to_settle_on_graceful_shutdown();
4724 self.drain_search_index_events_for_graceful_shutdown();
4725 }
4726
4727 if self.search_index_build_in_progress() {
4728 return false;
4729 }
4730
4731 let Some(canonical_root) = self.canonical_cache_root_opt() else {
4732 return false;
4733 };
4734 let config = self.config();
4735 let project_key = self.memoized_artifact_cache_key(&canonical_root);
4736 let cache_dir = crate::search_index::resolve_cache_dir_with_key(
4737 &project_key,
4738 config.storage_dir.as_deref(),
4739 );
4740
4741 {
4742 let search_index = self
4743 .search_index()
4744 .read()
4745 .unwrap_or_else(std::sync::PoisonError::into_inner);
4746 let Some(index) = search_index.as_ref() else {
4747 return false;
4748 };
4749 if !index.ready || !index.has_pending_disk_changes() {
4750 return false;
4751 }
4752 }
4753
4754 let _cache_lock = match crate::search_index::CacheLock::try_acquire_for_shutdown(
4755 &cache_dir,
4756 &canonical_root,
4757 ) {
4758 Ok(lock) => lock,
4759 Err(error) => {
4760 crate::slog_warn!(
4761 "search index: skipped shutdown flush because cache lock was unavailable: {}",
4762 error
4763 );
4764 return false;
4765 }
4766 };
4767
4768 let mut search_index = self
4769 .search_index()
4770 .write()
4771 .unwrap_or_else(std::sync::PoisonError::into_inner);
4772 let Some(index) = search_index.as_mut() else {
4773 return false;
4774 };
4775 if !index.ready || !index.has_pending_disk_changes() {
4776 return false;
4777 }
4778
4779 let git_head = index.stored_git_head().map(str::to_owned);
4780 index.write_to_disk(&cache_dir, git_head.as_deref())
4781 }
4782
4783 pub fn inspect_manager(&self) -> Arc<InspectManager> {
4784 Arc::clone(&self.inspect_manager)
4785 }
4786
4787 pub(crate) fn cold_build_limiter(&self) -> Arc<crate::cold_build_limiter::ColdBuildLimiter> {
4788 Arc::clone(
4789 &self
4790 .cold_build_limiter
4791 .read()
4792 .unwrap_or_else(std::sync::PoisonError::into_inner),
4793 )
4794 }
4795
4796 #[doc(hidden)]
4799 pub fn isolate_cold_build_limiter_for_test(&self, limit: usize) {
4800 let limiter = crate::cold_build_limiter::isolated_limiter(limit);
4801 self.inspect_manager
4802 .set_cold_build_limiter(Arc::clone(&limiter));
4803 *self
4804 .cold_build_limiter
4805 .write()
4806 .unwrap_or_else(std::sync::PoisonError::into_inner) = limiter;
4807 }
4808
4809 pub fn add_pending_tier2_paths<I>(&self, paths: I)
4810 where
4811 I: IntoIterator<Item = PathBuf>,
4812 {
4813 self.pending_tier2_paths.lock().extend(paths);
4814 }
4815
4816 pub fn pending_tier2_paths(&self) -> Vec<PathBuf> {
4817 self.pending_tier2_paths.lock().iter().cloned().collect()
4818 }
4819
4820 pub fn remove_pending_tier2_paths<I>(&self, paths: I)
4821 where
4822 I: IntoIterator<Item = PathBuf>,
4823 {
4824 let mut pending = self.pending_tier2_paths.lock();
4825 for path in paths {
4826 pending.remove(&path);
4827 }
4828 }
4829
4830 pub fn has_new_reuse_completions(&self) -> bool {
4838 self.inspect_manager.reuse_completion_count()
4839 != self.last_seen_reuse_completions.load(Ordering::SeqCst)
4840 }
4841
4842 pub fn take_new_reuse_completions(&self) -> bool {
4843 let current = self.inspect_manager.reuse_completion_count();
4844 let previous = self
4845 .last_seen_reuse_completions
4846 .swap(current, Ordering::SeqCst);
4847 current != previous
4848 }
4849
4850 pub fn reset_tier2_refresh_scheduler(&self) {
4851 self.reset_tier2_refresh_scheduler_at(Instant::now());
4852 }
4853
4854 #[doc(hidden)]
4855 pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
4856 self.tier2_refresh_scheduler
4857 .lock()
4858 .reset_after_configure(now);
4859 }
4860
4861 pub fn request_tier2_refresh_pull(&self) -> bool {
4862 let can_schedule = self.inspect_writer()
4863 && self.heavy_root_work_allowed()
4864 && self.inspect_manager.automatic_tier2_refresh_allowed();
4865 self.tier2_refresh_scheduler
4866 .lock()
4867 .request_pull(can_schedule)
4868 }
4869
4870 pub fn tick_tier2_refresh_scheduler(
4871 &self,
4872 changed_path_count: usize,
4873 ) -> Option<Tier2TriggerReason> {
4874 self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
4875 }
4876
4877 #[doc(hidden)]
4878 pub fn tick_tier2_refresh_scheduler_at(
4879 &self,
4880 now: Instant,
4881 changed_path_count: usize,
4882 ) -> Option<Tier2TriggerReason> {
4883 let manager = self.inspect_manager();
4884 let can_write = self.inspect_writer()
4885 && self.heavy_root_work_allowed()
4886 && manager.automatic_tier2_refresh_allowed();
4887 let in_flight = manager.tier2_any_in_flight();
4888 let semantic_cold_seed_active = self.semantic_cold_seed_active();
4889 let decision = self.tier2_refresh_scheduler.lock().tick_with_semantic_gate(
4890 now,
4891 changed_path_count,
4892 can_write,
4893 in_flight,
4894 semantic_cold_seed_active,
4895 );
4896
4897 if let Some(reason) = decision {
4898 self.start_tier2_refresh(reason, manager);
4899 }
4900
4901 decision
4902 }
4903
4904 pub fn note_tier2_refresh_started(&self) {
4905 self.note_tier2_refresh_started_at(Instant::now());
4906 }
4907
4908 #[doc(hidden)]
4909 pub fn note_tier2_refresh_started_at(&self, now: Instant) {
4910 self.tier2_refresh_scheduler
4911 .lock()
4912 .note_external_scan_started(now);
4913 }
4914
4915 pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
4916 self.tier2_refresh_scheduler
4917 .lock()
4918 .last_trigger_reason()
4919 .map(Tier2TriggerReason::as_str)
4920 }
4921
4922 #[doc(hidden)]
4923 pub fn tier2_pull_demand_pending(&self) -> bool {
4924 self.tier2_refresh_scheduler.lock().pull_demand_pending()
4925 }
4926
4927 fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
4928 let generation = self.configure_generation();
4929 if !self.inspect_writer()
4930 || !self.heavy_root_work_allowed()
4931 || !manager.automatic_tier2_refresh_allowed()
4932 || !self.config().inspect.enabled
4933 {
4934 return;
4935 }
4936 let _ = self.run_if_subc_bound_generation(generation, || {
4937 self.start_tier2_refresh_admitted(reason, manager);
4938 });
4939 }
4940
4941 fn start_tier2_refresh_admitted(
4942 &self,
4943 reason: Tier2TriggerReason,
4944 manager: Arc<InspectManager>,
4945 ) {
4946 let Some(snapshot) = self.tier2_refresh_snapshot() else {
4947 return;
4948 };
4949 let categories = Self::automatic_tier2_refresh_categories(&snapshot);
4950 let submission =
4951 manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
4952 if !submission.deferred_categories.is_empty() {
4953 self.tier2_refresh_scheduler.lock().note_dispatch_deferred();
4954 crate::slog_info!(
4955 "tier2 refresh deferred by cold build limit: categories={:?}",
4956 submission
4957 .deferred_categories
4958 .iter()
4959 .map(|category| category.as_str())
4960 .collect::<Vec<_>>()
4961 );
4962 }
4963 if submission.has_new_work() {
4964 crate::slog_info!(
4965 "tier2 refresh scheduled: reason={}, categories={:?}",
4966 reason.as_str(),
4967 submission
4968 .newly_queued_categories
4969 .iter()
4970 .map(|category| category.as_str())
4971 .collect::<Vec<_>>()
4972 );
4973 }
4974 for error in submission.errors {
4975 crate::slog_warn!(
4976 "tier2 refresh schedule failed for {}: {}",
4977 error.category,
4978 error.message
4979 );
4980 }
4981 }
4982
4983 fn automatic_tier2_refresh_categories(snapshot: &InspectSnapshot) -> Vec<InspectCategory> {
4984 let callgraph_store_enabled = snapshot.config.callgraph_store;
4985 InspectCategory::active()
4986 .iter()
4987 .copied()
4988 .filter(|category| category.is_tier2())
4989 .filter(|category| {
4990 if *category == InspectCategory::DeadCode && !callgraph_store_enabled {
4991 return false;
4995 }
4996 true
4997 })
4998 .collect()
4999 }
5000
5001 #[doc(hidden)]
5002 pub fn automatic_tier2_refresh_categories_for_test(&self) -> Vec<InspectCategory> {
5003 self.tier2_refresh_snapshot()
5004 .map(|snapshot| Self::automatic_tier2_refresh_categories(&snapshot))
5005 .unwrap_or_default()
5006 }
5007
5008 fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
5009 self.harness_opt()?;
5010 let config = self.config();
5011 let project_root = config
5012 .project_root
5013 .clone()
5014 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
5015 let project_root = crate::inspect::job::canonicalize_normalized(&project_root);
5019 Some(InspectSnapshot::new_with_capabilities(
5020 project_root,
5021 self.inspect_dir(),
5022 config,
5023 self.symbol_cache(),
5024 self.inspect_writer(),
5025 self.callgraph_writer(),
5026 ))
5027 }
5028
5029 pub fn symbol_cache(&self) -> SharedSymbolCache {
5031 Arc::clone(&self.symbol_cache)
5032 }
5033
5034 pub fn reset_symbol_cache(&self) -> u64 {
5036 self.symbol_cache
5037 .write()
5038 .map(|mut cache| cache.reset())
5039 .unwrap_or(0)
5040 }
5041
5042 pub fn semantic_index(&self) -> &RwLock<Option<SemanticIndex>> {
5044 &self.semantic_index
5045 }
5046
5047 pub fn semantic_index_rx(
5049 &self,
5050 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
5051 &self.semantic_index_rx
5052 }
5053
5054 pub(crate) fn install_semantic_index_rx(
5055 &self,
5056 receiver: crossbeam_channel::Receiver<SemanticIndexEvent>,
5057 generation: u64,
5058 ) -> u64 {
5059 let mut slot = self.semantic_index_rx.lock();
5060 self.note_semantic_index_rx_generation(generation);
5061 let epoch = self.next_semantic_index_rx_epoch();
5062 *slot = Some(receiver);
5063 epoch
5064 }
5065
5066 pub(crate) fn semantic_index_rx_terminal_guard(&self, epoch: u64) -> ReceiverTerminalGuard {
5067 ReceiverTerminalGuard::new(Arc::clone(&self.semantic_index_rx_terminal_epoch), epoch)
5068 }
5069
5070 pub(crate) fn with_current_semantic_index_rx<R>(
5073 &self,
5074 generation: u64,
5075 epoch: u64,
5076 action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<SemanticIndexEvent>>) -> R,
5077 ) -> Option<R> {
5078 self.run_if_subc_bound_generation(generation, || {
5079 let mut receiver = self.semantic_index_rx.lock();
5080 if receiver.is_none()
5081 || self.semantic_index_rx_generation() != generation
5082 || self.semantic_index_rx_epoch() != epoch
5083 {
5084 return None;
5085 }
5086 Some(action(&mut receiver))
5087 })
5088 .flatten()
5089 }
5090
5091 pub(crate) fn retire_semantic_index_rx(&self) {
5092 let mut receiver = self.semantic_index_rx.lock();
5093 *receiver = None;
5094 self.next_semantic_index_rx_epoch();
5095 }
5096
5097 pub(crate) fn retire_semantic_index_rx_if_epoch(&self, expected_epoch: u64) -> Option<bool> {
5101 let mut receiver = self.semantic_index_rx.lock();
5102 if self.semantic_index_rx_epoch() != expected_epoch {
5103 return None;
5104 }
5105 let retired = receiver.take().is_some();
5106 if retired {
5107 self.next_semantic_index_rx_epoch();
5108 }
5109 Some(retired)
5110 }
5111
5112 pub(crate) fn note_semantic_index_rx_generation(&self, generation: u64) {
5113 self.semantic_index_rx_generation
5114 .store(generation, Ordering::SeqCst);
5115 }
5116
5117 pub(crate) fn semantic_index_rx_generation(&self) -> u64 {
5118 self.semantic_index_rx_generation.load(Ordering::SeqCst)
5119 }
5120
5121 pub(crate) fn next_semantic_index_rx_epoch(&self) -> u64 {
5122 self.semantic_index_rx_epoch
5123 .fetch_add(1, Ordering::SeqCst)
5124 .wrapping_add(1)
5125 }
5126
5127 pub(crate) fn semantic_index_rx_epoch(&self) -> u64 {
5128 self.semantic_index_rx_epoch.load(Ordering::SeqCst)
5129 }
5130
5131 pub(crate) fn next_semantic_persist_epoch(&self) -> u64 {
5132 self.semantic_persist_epoch.next()
5133 }
5134
5135 pub(crate) fn semantic_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
5136 self.semantic_persist_epoch.clone()
5137 }
5138
5139 pub(crate) fn semantic_persist_lock(&self) -> Arc<parking_lot::Mutex<()>> {
5140 Arc::clone(&self.semantic_persist_lock)
5141 }
5142
5143 pub fn semantic_index_status(&self) -> &RwLock<SemanticIndexStatus> {
5144 &self.semantic_index_status
5145 }
5146
5147 pub(crate) fn artifact_reload_guard(&self) -> parking_lot::MutexGuard<'_, ()> {
5148 self.artifact_reload_lock.lock()
5149 }
5150
5151 pub fn reset_semantic_cold_seed_gate_for_configure(&self) -> u64 {
5154 self.semantic_cold_seed_active
5155 .store(false, Ordering::SeqCst);
5156 self.semantic_callgraph_warm_deferred
5157 .store(false, Ordering::SeqCst);
5158 self.semantic_cold_seed_generation
5159 .fetch_add(1, Ordering::SeqCst)
5160 .wrapping_add(1)
5161 }
5162
5163 pub fn semantic_cold_seed_active_flag(&self) -> Arc<AtomicBool> {
5164 Arc::clone(&self.semantic_cold_seed_active)
5165 }
5166
5167 pub fn semantic_cold_seed_generation_flag(&self) -> Arc<AtomicU64> {
5168 Arc::clone(&self.semantic_cold_seed_generation)
5169 }
5170
5171 pub fn semantic_cold_seed_generation(&self) -> u64 {
5172 self.semantic_cold_seed_generation.load(Ordering::SeqCst)
5173 }
5174
5175 pub fn semantic_cold_seed_active(&self) -> bool {
5176 self.semantic_cold_seed_active.load(Ordering::SeqCst)
5177 }
5178
5179 pub fn schedule_semantic_cold_seed_gate_for_configure(&self) {
5180 self.semantic_cold_seed_active.store(true, Ordering::SeqCst);
5181 }
5182
5183 pub fn defer_callgraph_store_warm_for_semantic_cold_seed(&self) {
5184 self.semantic_callgraph_warm_deferred
5185 .store(true, Ordering::SeqCst);
5186 }
5187
5188 fn semantic_callgraph_warm_deferred(&self) -> bool {
5189 self.semantic_callgraph_warm_deferred.load(Ordering::SeqCst)
5190 }
5191
5192 pub fn clear_semantic_cold_seed_gate_and_resume_deferred_work(&self) {
5196 self.resume_semantic_cold_seed_deferred_work(false);
5197 }
5198
5199 pub fn resume_deferred_work_after_semantic_cold_seed_gate_cleared(&self) {
5202 self.resume_semantic_cold_seed_deferred_work(true);
5203 }
5204
5205 pub(crate) fn take_semantic_cold_seed_resume(&self, force: bool) -> SemanticColdSeedResume {
5206 let was_active = self.semantic_cold_seed_active.swap(false, Ordering::SeqCst);
5207 let warm_callgraph = self
5208 .semantic_callgraph_warm_deferred
5209 .swap(false, Ordering::SeqCst);
5210 SemanticColdSeedResume {
5211 request_tier2: force || was_active || warm_callgraph,
5212 warm_callgraph,
5213 }
5214 }
5215
5216 pub(crate) fn apply_semantic_cold_seed_resume(&self, resume: SemanticColdSeedResume) {
5217 if resume.request_tier2 {
5218 let _ = self.request_tier2_refresh_pull();
5219 }
5220
5221 if !resume.warm_callgraph
5222 || !self.config().callgraph_store
5223 || !self.heavy_root_work_allowed()
5224 {
5225 return;
5226 }
5227
5228 match self.callgraph_store_for_ops() {
5229 CallgraphStoreAccess::Ready(_) => {
5230 crate::slog_debug!(
5231 "deferred callgraph store warm completed after semantic cold seed gate cleared"
5232 );
5233 }
5234 CallgraphStoreAccess::Building => {
5235 crate::slog_info!(
5236 "deferred callgraph store warm scheduled after semantic cold seed gate cleared"
5237 );
5238 }
5239 CallgraphStoreAccess::Unavailable => {
5240 crate::slog_info!(
5241 "deferred callgraph store warm unavailable after semantic cold seed gate cleared"
5242 );
5243 }
5244 CallgraphStoreAccess::Error(error) => {
5245 crate::slog_warn!(
5246 "deferred callgraph store warm failed after semantic cold seed gate cleared: {}",
5247 error
5248 );
5249 }
5250 }
5251 }
5252
5253 fn resume_semantic_cold_seed_deferred_work(&self, force: bool) {
5254 let resume = self.take_semantic_cold_seed_resume(force);
5255 self.apply_semantic_cold_seed_resume(resume);
5256 }
5257
5258 #[doc(hidden)]
5259 pub fn set_semantic_cold_seed_active_for_test(&self, active: bool) {
5260 self.semantic_cold_seed_active
5261 .store(active, Ordering::SeqCst);
5262 }
5263
5264 #[doc(hidden)]
5265 pub fn semantic_callgraph_warm_deferred_for_test(&self) -> bool {
5266 self.semantic_callgraph_warm_deferred()
5267 }
5268
5269 pub fn install_semantic_refresh_worker(
5270 &self,
5271 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
5272 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
5273 worker_slot: SemanticRefreshWorkerSlot,
5274 ) {
5275 self.install_semantic_refresh_worker_for_build_epoch(
5276 sender,
5277 event_rx,
5278 worker_slot,
5279 self.semantic_index_rx_epoch(),
5280 );
5281 }
5282
5283 pub(crate) fn install_semantic_refresh_worker_for_build_epoch(
5284 &self,
5285 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
5286 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
5287 worker_slot: SemanticRefreshWorkerSlot,
5288 build_epoch: u64,
5289 ) {
5290 self.clear_semantic_refresh_worker();
5291 {
5292 let mut receiver = self.semantic_refresh_event_rx.lock();
5293 let mut request = self.semantic_refresh_tx.lock();
5294 let mut worker = self.semantic_refresh_worker.lock();
5295 self.semantic_refresh_generation
5296 .store(self.configure_generation(), Ordering::SeqCst);
5297 self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
5298 self.semantic_refresh_build_epoch
5299 .store(build_epoch, Ordering::SeqCst);
5300 *receiver = Some(event_rx);
5301 *request = Some(sender);
5302 *worker = Some(worker_slot);
5303 }
5304 }
5305
5306 pub(crate) fn semantic_refresh_generation(&self) -> u64 {
5307 self.semantic_refresh_generation.load(Ordering::SeqCst)
5308 }
5309
5310 pub(crate) fn semantic_refresh_epoch(&self) -> u64 {
5311 self.semantic_refresh_epoch.load(Ordering::SeqCst)
5312 }
5313
5314 pub(crate) fn with_current_semantic_refresh_rx<R>(
5317 &self,
5318 generation: u64,
5319 epoch: u64,
5320 action: impl FnOnce() -> R,
5321 ) -> Option<R> {
5322 self.run_if_subc_bound_generation(generation, || {
5323 let receiver = self.semantic_refresh_event_rx.lock();
5324 if receiver.is_none()
5325 || self.semantic_refresh_generation() != generation
5326 || self.semantic_refresh_epoch() != epoch
5327 {
5328 return None;
5329 }
5330 Some(action())
5331 })
5332 .flatten()
5333 }
5334
5335 pub(crate) fn clear_semantic_refresh_worker_if_current(
5336 &self,
5337 generation: u64,
5338 epoch: u64,
5339 ) -> Option<u64> {
5340 let worker_slot = {
5341 let mut receiver = self.semantic_refresh_event_rx.lock();
5342 if receiver.is_none()
5343 || self.semantic_refresh_generation() != generation
5344 || self.semantic_refresh_epoch() != epoch
5345 {
5346 return None;
5347 }
5348 let disconnected_build_epoch = self.semantic_refresh_build_epoch.load(Ordering::SeqCst);
5349 self.semantic_refresh_build_epoch.store(0, Ordering::SeqCst);
5350 let mut request = self.semantic_refresh_tx.lock();
5351 let mut worker = self.semantic_refresh_worker.lock();
5352 *receiver = None;
5353 *request = None;
5354 self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
5355 self.invalidate_semantic_refresh_probe();
5356 (worker.take(), disconnected_build_epoch)
5357 };
5358 if let Some(worker_slot) = worker_slot.0 {
5359 if let Ok(mut handle) = worker_slot.lock() {
5360 drop(handle.take());
5361 }
5362 }
5363 Some(worker_slot.1)
5364 }
5365
5366 pub fn clear_semantic_refresh_worker(&self) {
5367 let worker_slot = {
5368 let mut receiver = self.semantic_refresh_event_rx.lock();
5369 let mut request = self.semantic_refresh_tx.lock();
5370 let mut worker = self.semantic_refresh_worker.lock();
5371 *receiver = None;
5372 *request = None;
5373 self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
5374 self.semantic_refresh_build_epoch.store(0, Ordering::SeqCst);
5375 self.invalidate_semantic_refresh_probe();
5376 worker.take()
5377 };
5378 if let Some(worker_slot) = worker_slot {
5379 if let Ok(mut handle) = worker_slot.lock() {
5380 drop(handle.take());
5381 }
5382 }
5383 }
5384
5385 pub fn semantic_refresh_sender(
5386 &self,
5387 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
5388 self.semantic_refresh_tx.lock().clone()
5389 }
5390
5391 pub(crate) fn semantic_refresh_retry_slots(
5392 &self,
5393 ) -> (
5394 Arc<parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>>,
5395 Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>,
5396 ) {
5397 (
5398 Arc::clone(&self.semantic_refresh_tx),
5399 Arc::clone(&self.pending_semantic_index_paths),
5400 )
5401 }
5402
5403 pub fn semantic_refresh_event_rx(
5404 &self,
5405 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
5406 &self.semantic_refresh_event_rx
5407 }
5408
5409 pub fn with_semantic_refresh_retry_attempts_mut<R>(
5410 &self,
5411 f: impl FnOnce(&mut BTreeMap<PathBuf, usize>) -> R,
5412 ) -> R {
5413 let mut attempts = self.semantic_refresh_retry_attempts.lock();
5414 f(&mut attempts)
5415 }
5416
5417 pub fn clear_semantic_refresh_retry_attempts(&self, paths: &[PathBuf]) {
5418 let mut attempts = self.semantic_refresh_retry_attempts.lock();
5419 for path in paths {
5420 attempts.remove(path);
5421 }
5422 }
5423
5424 pub fn clear_all_semantic_refresh_retry_attempts(&self) {
5425 self.semantic_refresh_retry_attempts.lock().clear();
5426 }
5427
5428 pub fn semantic_refresh_circuit_is_open(&self) -> bool {
5429 self.semantic_refresh_circuit.open.load(Ordering::SeqCst)
5430 }
5431
5432 pub fn record_semantic_refresh_transient_failure(&self, trip_threshold: usize) -> bool {
5433 let failures = self
5434 .semantic_refresh_circuit
5435 .consecutive_transient_failures
5436 .fetch_add(1, Ordering::SeqCst)
5437 .saturating_add(1);
5438 if failures >= trip_threshold
5439 && !self
5440 .semantic_refresh_circuit
5441 .open
5442 .swap(true, Ordering::SeqCst)
5443 {
5444 crate::slog_warn!(
5445 "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
5446 );
5447 }
5448 self.semantic_refresh_circuit_is_open()
5449 }
5450
5451 pub fn trip_semantic_refresh_circuit(&self, trip_threshold: usize) {
5452 self.semantic_refresh_circuit
5453 .consecutive_transient_failures
5454 .store(trip_threshold, Ordering::SeqCst);
5455 if !self
5456 .semantic_refresh_circuit
5457 .open
5458 .swap(true, Ordering::SeqCst)
5459 {
5460 crate::slog_warn!(
5461 "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
5462 );
5463 }
5464 }
5465
5466 pub fn reset_semantic_refresh_transient_failure_count(&self) {
5467 self.semantic_refresh_circuit
5468 .consecutive_transient_failures
5469 .store(0, Ordering::SeqCst);
5470 }
5471
5472 pub fn reset_semantic_refresh_circuit_after_success(&self) {
5473 self.reset_semantic_refresh_transient_failure_count();
5474 self.semantic_refresh_circuit
5475 .probe_ready
5476 .store(false, Ordering::SeqCst);
5477 if self
5478 .semantic_refresh_circuit
5479 .open
5480 .swap(false, Ordering::SeqCst)
5481 {
5482 crate::slog_info!("embedding backend recovered; resuming normal refresh retries");
5483 }
5484 }
5485
5486 pub fn semantic_refresh_transient_failure_count(&self) -> usize {
5487 self.semantic_refresh_circuit
5488 .consecutive_transient_failures
5489 .load(Ordering::SeqCst)
5490 }
5491
5492 pub fn semantic_refresh_probe_is_scheduled(&self) -> bool {
5493 self.semantic_refresh_circuit
5494 .probe_in_flight
5495 .load(Ordering::SeqCst)
5496 || self.semantic_refresh_probe_ready()
5497 }
5498
5499 pub fn semantic_refresh_probe_ready(&self) -> bool {
5500 self.semantic_refresh_circuit
5501 .probe_ready
5502 .load(Ordering::SeqCst)
5503 }
5504
5505 pub fn take_semantic_refresh_probe_ready(&self) -> bool {
5506 self.semantic_refresh_circuit
5507 .probe_ready
5508 .swap(false, Ordering::SeqCst)
5509 }
5510
5511 fn invalidate_semantic_refresh_probe(&self) {
5512 self.semantic_refresh_circuit
5513 .probe_token
5514 .fetch_add(1, Ordering::SeqCst);
5515 self.semantic_refresh_circuit
5516 .probe_ready
5517 .store(false, Ordering::SeqCst);
5518 self.semantic_refresh_circuit
5519 .probe_in_flight
5520 .store(false, Ordering::SeqCst);
5521 }
5522
5523 pub fn ensure_semantic_refresh_probe_scheduled(&self, delay: Duration) {
5524 let receiver = self.semantic_refresh_event_rx.lock();
5525 if receiver.is_none()
5526 || self
5527 .semantic_refresh_circuit
5528 .probe_ready
5529 .load(Ordering::SeqCst)
5530 || self
5531 .semantic_refresh_circuit
5532 .probe_in_flight
5533 .swap(true, Ordering::SeqCst)
5534 {
5535 return;
5536 }
5537 let probe_token = self
5538 .semantic_refresh_circuit
5539 .probe_token
5540 .fetch_add(1, Ordering::SeqCst)
5541 .wrapping_add(1);
5542 drop(receiver);
5543
5544 let circuit = Arc::clone(&self.semantic_refresh_circuit);
5545 let session_id = crate::log_ctx::current_session();
5546 std::thread::spawn(move || {
5547 crate::log_ctx::with_session(session_id, || {
5548 std::thread::sleep(delay);
5549 if circuit.probe_token.load(Ordering::SeqCst) == probe_token {
5550 circuit.probe_ready.store(true, Ordering::SeqCst);
5551 circuit.probe_in_flight.store(false, Ordering::SeqCst);
5552 }
5553 });
5554 });
5555 }
5556
5557 pub fn semantic_embedding_model(
5559 &self,
5560 ) -> &parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>> {
5561 &self.semantic_embedding_model
5562 }
5563
5564 pub fn watcher(&self) -> &parking_lot::Mutex<Option<RecommendedWatcher>> {
5566 &self.watcher
5567 }
5568
5569 pub fn watcher_rx(
5571 &self,
5572 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>> {
5573 &self.watcher_rx
5574 }
5575
5576 pub(crate) fn watcher_drain_slice(
5578 &self,
5579 ) -> &parking_lot::Mutex<Option<WatcherDrainSliceState>> {
5580 &self.watcher_drain_slice
5581 }
5582
5583 pub fn watcher_drain_pending_path_count(&self) -> usize {
5585 self.watcher_drain_slice.lock().as_ref().map_or(0, |state| {
5586 let active_paths = match &state.phase {
5587 WatcherDrainPhase::Collect => 0,
5588 WatcherDrainPhase::Apply { paths, .. } => paths.len(),
5589 };
5590 active_paths + state.pending_paths.len()
5591 })
5592 }
5593
5594 pub fn watcher_drain_path_slice_count(&self) -> usize {
5596 self.watcher_drain_slice
5597 .lock()
5598 .as_ref()
5599 .map_or(0, |state| state.path_slice_count)
5600 }
5601
5602 pub fn install_watcher_runtime(
5605 &self,
5606 rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
5607 runtime: WatcherThreadHandle,
5608 ) {
5609 let _runtime_guard = self.watcher_runtime_lock.lock();
5610 let replaced = self.watcher_thread.lock().replace(runtime);
5611 self.app.watcher_started();
5612 if let Some(runtime) = replaced {
5613 Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
5614 }
5615 *self.watcher_rx.lock() = Some(rx);
5616 *self.watcher_drain_slice.lock() = None;
5617 }
5618
5619 fn watcher_root_path(&self) -> PathBuf {
5620 self.canonical_cache_root_opt()
5621 .or_else(|| self.config().project_root.clone())
5622 .unwrap_or_else(|| PathBuf::from("<unconfigured>"))
5623 }
5624
5625 fn spawn_watcher_shutdown(app: Arc<App>, root: PathBuf, runtime: WatcherThreadHandle) {
5626 const JOIN_TIMEOUT: Duration = Duration::from_secs(2);
5627 runtime.request_shutdown();
5630 std::thread::spawn(
5631 move || match runtime.shutdown_and_join_timeout(JOIN_TIMEOUT) {
5632 WatcherJoinOutcome::Joined => {
5633 app.watcher_stopped();
5634 crate::slog_info!("watcher stopped: {}", root.display());
5635 }
5636 WatcherJoinOutcome::TimedOut(join) => {
5637 crate::slog_warn!(
5638 "watcher stop timed out after {} ms: {}",
5639 JOIN_TIMEOUT.as_millis(),
5640 root.display()
5641 );
5642 std::thread::spawn(move || {
5643 let _ = join.join();
5644 app.watcher_stopped();
5645 crate::slog_info!("watcher stopped: {}", root.display());
5646 });
5647 }
5648 },
5649 );
5650 }
5651
5652 fn take_watcher_runtime(&self) -> Option<WatcherThreadHandle> {
5653 let _runtime_guard = self.watcher_runtime_lock.lock();
5654 let runtime = self.watcher_thread.lock().take();
5655 *self.watcher_rx.lock() = None;
5656 *self.watcher_drain_slice.lock() = None;
5657 *self.watcher.lock() = None;
5658 runtime
5659 }
5660
5661 pub fn stop_watcher_runtime(&self) {
5665 if let Some(runtime) = self.take_watcher_runtime() {
5666 Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
5667 }
5668 }
5669
5670 pub fn stop_watcher_runtime_in_background(&self) {
5672 self.stop_watcher_runtime();
5673 }
5674
5675 pub(crate) fn take_finished_watcher_runtime(&self) -> bool {
5680 let runtime = {
5681 let _runtime_guard = self.watcher_runtime_lock.lock();
5682 let finished = self
5683 .watcher_thread
5684 .lock()
5685 .as_ref()
5686 .is_some_and(|runtime| runtime.is_finished());
5687 if !finished {
5688 return false;
5689 }
5690 let runtime = self.watcher_thread.lock().take();
5691 *self.watcher_rx.lock() = None;
5692 *self.watcher_drain_slice.lock() = None;
5693 *self.watcher.lock() = None;
5694 runtime
5695 };
5696 if let Some(runtime) = runtime {
5697 Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
5698 }
5699 true
5700 }
5701
5702 pub fn watcher_registry_count(&self) -> usize {
5705 self.app.watcher_count()
5706 }
5707
5708 pub(crate) fn watcher_runtime_active(&self) -> bool {
5709 let _runtime_guard = self.watcher_runtime_lock.lock();
5710 let thread_live = self
5715 .watcher_thread
5716 .lock()
5717 .as_ref()
5718 .is_some_and(|runtime| !runtime.is_finished());
5719 thread_live && self.watcher_rx.lock().is_some()
5720 }
5721
5722 pub fn artifact_eviction_blocked(&self) -> bool {
5726 let semantic_refresh_in_flight = match &*self
5727 .semantic_index_status
5728 .read()
5729 .unwrap_or_else(std::sync::PoisonError::into_inner)
5730 {
5731 SemanticIndexStatus::Building { .. } => true,
5732 SemanticIndexStatus::Ready { refreshing, .. } => !refreshing.is_empty(),
5733 SemanticIndexStatus::Disabled | SemanticIndexStatus::Failed(_) => false,
5734 };
5735 if crate::runtime_drain::any_build_in_flight(self)
5736 || semantic_refresh_in_flight
5737 || self.inspect_manager.tier2_any_in_flight()
5738 || !self.bash_background.running_tasks().is_empty()
5739 || !self.pending_callgraph_store_paths.lock().is_empty()
5740 || !self.pending_search_index_paths.lock().is_empty()
5741 || !self.pending_tier2_paths.lock().is_empty()
5742 || !self.pending_semantic_index_paths.lock().is_empty()
5743 || *self.pending_semantic_corpus_refresh.lock()
5744 {
5745 return true;
5746 }
5747
5748 let search_has_pending_disk_changes = self
5749 .search_index
5750 .read()
5751 .unwrap_or_else(std::sync::PoisonError::into_inner)
5752 .as_ref()
5753 .is_some_and(SearchIndex::has_pending_disk_changes);
5754 search_has_pending_disk_changes
5755 }
5756
5757 pub fn evict_idle_artifacts(&self) -> bool {
5762 if self.artifact_eviction_blocked() {
5763 return false;
5764 }
5765
5766 self.callgraph_store
5767 .write()
5768 .unwrap_or_else(std::sync::PoisonError::into_inner)
5769 .take();
5770 self.search_index
5771 .write()
5772 .unwrap_or_else(std::sync::PoisonError::into_inner)
5773 .take();
5774 self.semantic_index
5775 .write()
5776 .unwrap_or_else(std::sync::PoisonError::into_inner)
5777 .take();
5778 self.borrowed_index_cache.lock().clear();
5779 self.inspect_manager.evict_idle_caches();
5780 self.reset_symbol_cache();
5781 self.clear_tsconfig_membership_cache();
5782 true
5783 }
5784
5785 #[doc(hidden)]
5788 pub fn force_idle_teardown_for_test(self: &Arc<Self>) -> bool {
5789 if std::env::var("AFT_TEST_ALLOW_FORCE_IDLE_REAP").as_deref() != Ok("1") {
5790 return false;
5791 }
5792 if !self.evict_idle_artifacts() {
5793 return false;
5794 }
5795 self.stop_watcher_runtime_in_background();
5796 self.invalidate_artifacts_after_watcher_gap();
5797 true
5798 }
5799
5800 pub(crate) fn release_idle_reopenable_resources_in_background(self: &Arc<Self>) {
5804 let ctx = Arc::clone(self);
5805 std::thread::spawn(move || {
5806 if !ctx.subc_unbound_quiesced() {
5807 return;
5808 }
5809 {
5810 let mut lsp = ctx.lsp_manager.lock();
5811 if !ctx.subc_unbound_quiesced() {
5812 return;
5813 }
5814 lsp.shutdown_all();
5815 }
5816 let _ = ctx.subc_lifecycle.run_if_unbound(|| {
5817 ctx.bash_background.clear_db_pool();
5818 ctx.backup.lock().clear_db_pool();
5819 });
5820 });
5821 }
5822
5823 pub(crate) fn teardown_deleted_root(&self) {
5827 self.bash_background.detach();
5828 self.bash_background.clear_db_pool();
5829 self.backup.lock().clear_db_pool();
5830 self.lsp_manager.lock().shutdown_all();
5831 }
5832
5833 pub fn lsp(&self) -> parking_lot::MutexGuard<'_, LspManager> {
5835 self.lsp_manager.lock()
5836 }
5837
5838 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
5841 let config = self.config();
5842 if let Some(mut lsp) = self.lsp_manager.try_lock() {
5843 if let Err(e) = lsp.notify_file_changed_if_running(file_path, content, &config) {
5844 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
5845 }
5846 }
5847 }
5848
5849 pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
5855 if let Some(mut lsp) = self.lsp_manager.try_lock() {
5856 lsp.clear_diagnostics_for_file(file_path)
5857 } else {
5858 false
5859 }
5860 }
5861
5862 pub fn lsp_mark_diagnostics_stale_for_file(&self, file_path: &Path) -> StaleDiagnosticsMark {
5866 if let Some(mut lsp) = self.lsp_manager.try_lock() {
5867 lsp.mark_diagnostics_stale_for_file(file_path)
5868 } else {
5869 StaleDiagnosticsMark::default()
5870 }
5871 }
5872
5873 pub fn lsp_resync_changed_file_for_diagnostics(&self, file_path: &Path) -> bool {
5881 if !file_path.is_file() {
5882 return false;
5883 }
5884
5885 let content = match std::fs::read_to_string(file_path) {
5886 Ok(content) => content,
5887 Err(err) => {
5888 crate::slog_warn!(
5889 "skipping LSP resync for {} after external edit: {}",
5890 file_path.display(),
5891 err
5892 );
5893 return false;
5894 }
5895 };
5896
5897 let config = self.config();
5898 if let Some(mut lsp) = self.lsp_manager.try_lock() {
5899 if let Err(err) = lsp.notify_file_changed(file_path, &content, &config) {
5900 crate::slog_warn!(
5901 "LSP resync failed for {} after external edit: {}",
5902 file_path.display(),
5903 err
5904 );
5905 return false;
5906 }
5907 true
5908 } else {
5909 false
5910 }
5911 }
5912
5913 pub fn lsp_notify_and_collect_diagnostics(
5922 &self,
5923 file_path: &Path,
5924 content: &str,
5925 timeout: std::time::Duration,
5926 ) -> crate::lsp::manager::PostEditWaitOutcome {
5927 let config = self.config();
5928 let Some(mut lsp) = self.lsp_manager.try_lock() else {
5929 return crate::lsp::manager::PostEditWaitOutcome::default();
5930 };
5931
5932 lsp.drain_events();
5935
5936 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
5940
5941 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
5945 {
5946 Ok(v) => v,
5947 Err(e) => {
5948 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
5949 return crate::lsp::manager::PostEditWaitOutcome::default();
5950 }
5951 };
5952
5953 if expected_versions.is_empty() {
5956 return crate::lsp::manager::PostEditWaitOutcome::default();
5957 }
5958
5959 let mut wait = lsp.start_post_edit_diagnostics_wait(
5963 file_path,
5964 &expected_versions,
5965 &pre_snapshot,
5966 timeout,
5967 );
5968 let mut complete = lsp.poll_post_edit_diagnostics_wait(&mut wait, None);
5969 drop(lsp);
5970
5971 while !complete && !wait.deadline_reached() {
5972 let event = wait.next_event();
5975 let mut lsp = self.lsp_manager.lock();
5976 complete = lsp.poll_post_edit_diagnostics_wait(&mut wait, event);
5977 }
5978
5979 self.lsp_manager
5980 .lock()
5981 .finish_post_edit_diagnostics_wait(wait)
5982 }
5983
5984 fn custom_lsp_root_markers(&self) -> Vec<String> {
5987 self.config()
5988 .lsp_servers
5989 .iter()
5990 .flat_map(|s| s.root_markers.iter().cloned())
5991 .collect()
5992 }
5993
5994 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
5995 let custom_markers = self.custom_lsp_root_markers();
5996 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
5997 .iter()
5998 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
5999 .cloned()
6000 .map(|path| {
6001 let change_type = if path.exists() {
6002 FileChangeType::CHANGED
6003 } else {
6004 FileChangeType::DELETED
6005 };
6006 (path, change_type)
6007 })
6008 .collect();
6009
6010 self.notify_watched_config_events(&config_paths);
6011 }
6012
6013 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
6014 let paths = params
6015 .get("multi_file_write_paths")
6016 .and_then(|value| value.as_array())?
6017 .iter()
6018 .filter_map(|value| value.as_str())
6019 .map(PathBuf::from)
6020 .collect::<Vec<_>>();
6021
6022 (!paths.is_empty()).then_some(paths)
6023 }
6024
6025 fn watched_file_events_from_params(
6037 params: &serde_json::Value,
6038 extra_markers: &[String],
6039 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
6040 let events = params
6041 .get("multi_file_write_paths")
6042 .and_then(|value| value.as_array())?
6043 .iter()
6044 .filter_map(|entry| {
6045 let path = entry
6047 .get("path")
6048 .and_then(|value| value.as_str())
6049 .map(PathBuf::from)?;
6050
6051 if !is_config_file_path_with_custom(&path, extra_markers) {
6052 return None;
6053 }
6054
6055 let change_type = entry
6056 .get("type")
6057 .and_then(|value| value.as_str())
6058 .and_then(Self::parse_file_change_type)
6059 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
6060
6061 Some((path, change_type))
6062 })
6063 .collect::<Vec<_>>();
6064
6065 (!events.is_empty()).then_some(events)
6066 }
6067
6068 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
6069 match value {
6070 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
6071 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
6072 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
6073 _ => None,
6074 }
6075 }
6076
6077 fn change_type_from_current_state(path: &Path) -> FileChangeType {
6078 if path.exists() {
6079 FileChangeType::CHANGED
6080 } else {
6081 FileChangeType::DELETED
6082 }
6083 }
6084
6085 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
6086 if config_paths.is_empty() {
6087 return;
6088 }
6089
6090 let config = self.config();
6091 if let Some(mut lsp) = self.lsp_manager.try_lock() {
6092 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
6093 crate::slog_warn!("watched-file sync error: {}", e);
6094 }
6095 }
6096 }
6097
6098 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
6099 let custom_markers = self.custom_lsp_root_markers();
6100 if !is_config_file_path_with_custom(file_path, &custom_markers) {
6101 return;
6102 }
6103
6104 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
6105 }
6106
6107 pub fn lsp_post_multi_file_write(
6112 &self,
6113 file_path: &Path,
6114 content: &str,
6115 file_paths: &[PathBuf],
6116 params: &serde_json::Value,
6117 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
6118 self.notify_watched_config_files(file_paths);
6119 self.add_pending_tier2_paths(file_paths.iter().cloned());
6120 let _ = self.mark_status_bar_tier2_stale();
6121
6122 let wants_diagnostics = params
6123 .get("diagnostics")
6124 .and_then(|v| v.as_bool())
6125 .unwrap_or(false);
6126
6127 if !wants_diagnostics {
6128 self.lsp_notify_file_changed(file_path, content);
6129 return None;
6130 }
6131
6132 let wait_ms = params
6133 .get("wait_ms")
6134 .and_then(|v| v.as_u64())
6135 .unwrap_or(3000)
6136 .min(10_000);
6137
6138 Some(self.lsp_notify_and_collect_diagnostics(
6139 file_path,
6140 content,
6141 std::time::Duration::from_millis(wait_ms),
6142 ))
6143 }
6144
6145 pub fn lsp_post_write(
6162 &self,
6163 file_path: &Path,
6164 content: &str,
6165 params: &serde_json::Value,
6166 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
6167 let wants_diagnostics = params
6168 .get("diagnostics")
6169 .and_then(|v| v.as_bool())
6170 .unwrap_or(false);
6171
6172 let custom_markers = self.custom_lsp_root_markers();
6173 if let Some(file_paths) = Self::multi_file_write_paths(params) {
6174 self.add_pending_tier2_paths(file_paths);
6175 } else {
6176 self.add_pending_tier2_paths([file_path.to_path_buf()]);
6177 }
6178 let _ = self.mark_status_bar_tier2_stale();
6179
6180 if !wants_diagnostics {
6181 if let Some(file_paths) = Self::multi_file_write_paths(params) {
6182 self.notify_watched_config_files(&file_paths);
6183 } else if let Some(config_events) =
6184 Self::watched_file_events_from_params(params, &custom_markers)
6185 {
6186 self.notify_watched_config_events(&config_events);
6187 }
6188 self.lsp_notify_file_changed(file_path, content);
6189 return None;
6190 }
6191
6192 let wait_ms = params
6193 .get("wait_ms")
6194 .and_then(|v| v.as_u64())
6195 .unwrap_or(3000)
6196 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
6199 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
6200 }
6201
6202 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
6203 {
6204 self.notify_watched_config_events(&config_events);
6205 }
6206
6207 Some(self.lsp_notify_and_collect_diagnostics(
6208 file_path,
6209 content,
6210 std::time::Duration::from_millis(wait_ms),
6211 ))
6212 }
6213
6214 fn resolved_path_restriction_root(&self, root: &Path) -> PathBuf {
6215 let mut memo = self.path_restriction_root_memo.lock();
6216 if let Some(cached) = memo.as_ref() {
6217 if cached.configured_root.as_os_str() == root.as_os_str()
6218 && cached.resolved_root.exists()
6219 {
6220 return cached.resolved_root.clone();
6221 }
6222 }
6223
6224 #[cfg(test)]
6230 self.path_restriction_root_canonicalizations
6231 .fetch_add(1, Ordering::SeqCst);
6232 let resolved_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
6233 *memo = Some(PathRestrictionRootMemo {
6234 configured_root: root.to_path_buf(),
6235 resolved_root: resolved_root.clone(),
6236 });
6237 resolved_root
6238 }
6239
6240 fn path_restriction_context(
6241 &self,
6242 req_id: &str,
6243 path: &Path,
6244 ) -> Result<Option<PathRestrictionContext>, crate::protocol::Response> {
6245 let config = self.config();
6246 let force_restrict = self.request_force_restrict(req_id);
6247 if !config.restrict_to_project_root && !force_restrict {
6248 return Ok(None);
6249 }
6250 let root = match &config.project_root {
6251 Some(root) => root.clone(),
6252 None if force_restrict => {
6253 return Err(crate::protocol::Response::error(
6254 req_id,
6255 "path_outside_root",
6256 "project root is required when path restriction is forced",
6257 ));
6258 }
6259 None => return Ok(None),
6260 };
6261 drop(config);
6262
6263 let raw_root = root.clone();
6264 let resolved_root = self.resolved_path_restriction_root(&root);
6265 let path_for_resolution = if path.is_relative() {
6266 raw_root.join(path)
6267 } else {
6268 path.to_path_buf()
6269 };
6270 Ok(Some(PathRestrictionContext {
6271 raw_root,
6272 resolved_root,
6273 path_for_resolution,
6274 }))
6275 }
6276
6277 pub fn resolve_relative_path(&self, path: &Path) -> PathBuf {
6290 if path.is_absolute() {
6291 return path.to_path_buf();
6292 }
6293 if let Some(root) = &self.config().project_root {
6294 return root.join(path);
6295 }
6296 std::env::current_dir()
6297 .unwrap_or_else(|_| PathBuf::from("."))
6298 .join(path)
6299 }
6300
6301 pub fn validate_path(
6310 &self,
6311 req_id: &str,
6312 path: &Path,
6313 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6314 self.validate_path_with_artifact_session(req_id, path, None)
6315 }
6316
6317 pub fn validate_write_location(
6324 &self,
6325 req_id: &str,
6326 path: &Path,
6327 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6328 let Some(PathRestrictionContext {
6329 raw_root,
6330 resolved_root,
6331 path_for_resolution,
6332 }) = self.path_restriction_context(req_id, path)?
6333 else {
6334 return Ok(path.to_path_buf());
6335 };
6336 let normalized = normalize_path(&path_for_resolution);
6337 let Some(file_name) = normalized.file_name() else {
6338 return self.validate_path(req_id, path);
6339 };
6340 let parent = normalized.parent().unwrap_or_else(|| Path::new(""));
6341 let resolved_parent = match std::fs::canonicalize(parent) {
6342 Ok(resolved) => resolved,
6343 Err(_) => {
6344 reject_escaping_symlink(req_id, path, parent, &resolved_root, &raw_root)?;
6345 resolve_with_existing_ancestors(parent)
6346 }
6347 };
6348 let resolved = normalize_path(&resolved_parent.join(file_name));
6349
6350 if !resolved.starts_with(&resolved_root) {
6351 return Err(path_error_response(req_id, path, &resolved_root));
6352 }
6353
6354 Ok(resolved)
6355 }
6356
6357 pub fn validate_read_path(
6363 &self,
6364 req_id: &str,
6365 session_id: &str,
6366 path: &Path,
6367 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6368 self.validate_path_with_artifact_session(req_id, path, Some(session_id))
6369 }
6370
6371 fn validate_path_with_artifact_session(
6372 &self,
6373 req_id: &str,
6374 path: &Path,
6375 artifact_session_id: Option<&str>,
6376 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6377 let Some(PathRestrictionContext {
6378 raw_root,
6379 resolved_root,
6380 path_for_resolution,
6381 }) = self.path_restriction_context(req_id, path)?
6382 else {
6383 return Ok(path.to_path_buf());
6386 };
6387
6388 let resolved = match std::fs::canonicalize(&path_for_resolution) {
6393 Ok(resolved) => resolved,
6394 Err(_) => {
6395 let normalized = normalize_path(&path_for_resolution);
6396 reject_escaping_symlink(
6397 req_id,
6398 &path_for_resolution,
6399 &normalized,
6400 &resolved_root,
6401 &raw_root,
6402 )?;
6403 resolve_with_existing_ancestors(&normalized)
6404 }
6405 };
6406
6407 if !resolved.starts_with(&resolved_root) {
6408 let is_owned_bash_artifact = artifact_session_id.is_some_and(|session_id| {
6409 self.bash_background
6410 .is_session_owned_artifact_path(session_id, &resolved)
6411 });
6412 if !is_owned_bash_artifact {
6413 return Err(path_error_response(req_id, path, &resolved_root));
6414 }
6415 }
6416
6417 Ok(resolved)
6418 }
6419
6420 pub fn lsp_server_count(&self) -> usize {
6422 self.lsp_manager
6423 .try_lock()
6424 .map(|lsp| lsp.server_count())
6425 .unwrap_or(0)
6426 }
6427
6428 pub fn symbol_cache_stats(&self) -> serde_json::Value {
6430 let entries = self
6431 .symbol_cache
6432 .read()
6433 .map(|cache| cache.len())
6434 .unwrap_or(0);
6435 serde_json::json!({
6436 "local_entries": entries,
6437 "warm_entries": 0,
6438 })
6439 }
6440
6441 fn memory_estimates(&self) -> [crate::memory::MemoryEstimate; 9] {
6442 let semantic = match self.semantic_index.try_read() {
6443 Ok(index) => index
6444 .as_ref()
6445 .map(SemanticIndex::estimated_memory)
6446 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("entries", 0)),
6447 Err(TryLockError::Poisoned(error)) => error
6448 .into_inner()
6449 .as_ref()
6450 .map(SemanticIndex::estimated_memory)
6451 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("entries", 0)),
6452 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
6453 };
6454 let trigram = match self.search_index.try_read() {
6455 Ok(index) => index
6456 .as_ref()
6457 .map(SearchIndex::estimated_memory)
6458 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("files", 0)),
6459 Err(TryLockError::Poisoned(error)) => error
6460 .into_inner()
6461 .as_ref()
6462 .map(SearchIndex::estimated_memory)
6463 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("files", 0)),
6464 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
6465 };
6466 let symbols = match self.symbol_cache.try_read() {
6467 Ok(cache) => cache.estimated_memory(),
6468 Err(TryLockError::Poisoned(error)) => error.into_inner().estimated_memory(),
6469 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
6470 };
6471 let callgraph = match self.callgraph_store.try_read() {
6472 Ok(store) => store
6473 .as_ref()
6474 .map(|store| store.estimated_memory())
6475 .unwrap_or_else(|| {
6476 crate::memory::MemoryEstimate::estimated(0).count("open_generation_handles", 0)
6477 }),
6478 Err(TryLockError::Poisoned(error)) => error
6479 .into_inner()
6480 .as_ref()
6481 .map(|store| store.estimated_memory())
6482 .unwrap_or_else(|| {
6483 crate::memory::MemoryEstimate::estimated(0).count("open_generation_handles", 0)
6484 }),
6485 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
6486 };
6487 let callgraph_projection = self.inspect_manager.callgraph_projection_estimated_memory();
6488 let inspect = self.inspect_manager.estimated_memory();
6489 let bash = self.bash_background.estimated_memory();
6490 let lsp = self
6491 .lsp_manager
6492 .try_lock()
6493 .map(|lsp| lsp.estimated_memory())
6494 .unwrap_or_else(crate::memory::MemoryEstimate::busy);
6495 let parser_pool = crate::memory::MemoryEstimate::not_estimated()
6498 .count("pooled_parsers", 0)
6499 .gap("tree_sitter_parser_bytes");
6500 [
6501 semantic,
6502 trigram,
6503 symbols,
6504 callgraph,
6505 callgraph_projection,
6506 inspect,
6507 bash,
6508 lsp,
6509 parser_pool,
6510 ]
6511 }
6512
6513 pub fn memory_root_snapshot(&self) -> crate::memory::RootMemorySnapshot {
6517 let [semantic, trigram, symbols, callgraph, callgraph_projection, inspect, bash, lsp, parser_pool] =
6518 self.memory_estimates();
6519 crate::memory::RootMemorySnapshot::new(
6520 semantic,
6521 trigram,
6522 symbols,
6523 callgraph,
6524 callgraph_projection,
6525 inspect,
6526 bash,
6527 lsp,
6528 parser_pool,
6529 )
6530 }
6531
6532 pub(crate) fn memory_root_rollup(&self) -> crate::memory::RootMemoryRollup {
6535 let estimates = self.memory_estimates();
6536 crate::memory::RootMemoryRollup::from_estimates(&[
6537 &estimates[0],
6538 &estimates[1],
6539 &estimates[2],
6540 &estimates[3],
6541 &estimates[4],
6542 &estimates[5],
6543 &estimates[6],
6544 &estimates[7],
6545 &estimates[8],
6546 ])
6547 }
6548
6549 pub fn memory_snapshot(&self, current_root: Option<&Path>) -> crate::memory::MemorySnapshot {
6552 let mut roots = BTreeMap::new();
6553 let (roots_status, contexts) = match self.app.try_memory_contexts() {
6554 Some(contexts) => ("ready", contexts),
6555 None => ("busy", Vec::new()),
6556 };
6557 for (root, context) in contexts {
6558 roots.insert(root.display().to_string(), context.memory_root_snapshot());
6559 }
6560 let current_label = current_root
6564 .map(|root| {
6565 cortexkit_paths::ProjectRootId::from_path(root)
6566 .map(|id| id.as_path().display().to_string())
6567 .unwrap_or_else(|_| root.display().to_string())
6568 })
6569 .unwrap_or_else(|| "<unconfigured>".to_string());
6570 roots
6571 .entry(current_label)
6572 .or_insert_with(|| self.memory_root_snapshot());
6573 crate::memory::MemorySnapshot::new(roots_status, roots)
6574 }
6575}
6576
6577#[cfg(test)]
6578mod subc_lifecycle_admission_tests {
6579 use super::*;
6580
6581 #[test]
6582 fn route_teardown_does_not_supersede_disk_artifact_compatibility() {
6583 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6584 ctx.note_configure_warm_key("config-a".to_string());
6585 let content_generation = ctx.configure_content_generation();
6586 let lifecycle_generation = ctx.configure_generation();
6587 let search_epoch = ctx.next_search_persist_epoch();
6588 let semantic_epoch = ctx.next_semantic_persist_epoch();
6589 let search_persist_epoch = ctx.search_persist_epoch_flag();
6590 let semantic_persist_epoch = ctx.semantic_persist_epoch_flag();
6591
6592 ctx.mark_subc_unbound();
6593 assert!(ctx.configure_generation() > lifecycle_generation);
6594 assert_eq!(ctx.configure_content_generation(), content_generation);
6595 assert_eq!(search_persist_epoch.current(), search_epoch);
6596 assert_eq!(semantic_persist_epoch.current(), semantic_epoch);
6597
6598 ctx.mark_subc_bound();
6599 ctx.note_configure_warm_key("config-b".to_string());
6600 assert!(ctx.configure_content_generation() > content_generation);
6601 let replacement_search_epoch = ctx.next_search_persist_epoch();
6602 let replacement_semantic_epoch = ctx.next_semantic_persist_epoch();
6603 assert!(replacement_search_epoch > search_epoch);
6604 assert!(replacement_semantic_epoch > semantic_epoch);
6605 assert_eq!(search_persist_epoch.current(), replacement_search_epoch);
6606 assert_eq!(semantic_persist_epoch.current(), replacement_semantic_epoch);
6607 }
6608
6609 #[test]
6610 fn lifecycle_gate_serializes_unbind_with_worker_start_commit() {
6611 let admission = SubcLifecycleAdmission::default();
6612 let generation = Arc::new(AtomicU64::new(11));
6613 let expected = generation.load(Ordering::SeqCst);
6614 let starts = Arc::new(AtomicUsize::new(0));
6615 let (entered_tx, entered_rx) = std::sync::mpsc::channel();
6616 let (release_tx, release_rx) = std::sync::mpsc::channel();
6617
6618 let worker_admission = admission.clone();
6619 let worker_generation = Arc::clone(&generation);
6620 let worker_starts = Arc::clone(&starts);
6621 let worker = std::thread::spawn(move || {
6622 worker_admission.run_if_current(&worker_generation, expected, || {
6623 entered_tx.send(()).unwrap();
6624 release_rx.recv().unwrap();
6625 worker_starts.fetch_add(1, Ordering::SeqCst);
6626 })
6627 });
6628 entered_rx.recv().unwrap();
6629
6630 let unbind_admission = admission.clone();
6631 let unbind_generation = Arc::clone(&generation);
6632 let (unbound_tx, unbound_rx) = std::sync::mpsc::channel();
6633 let unbind = std::thread::spawn(move || {
6634 unbind_admission.mark_unbound(&unbind_generation);
6635 unbound_tx.send(()).unwrap();
6636 });
6637
6638 assert!(
6639 unbound_rx
6640 .recv_timeout(std::time::Duration::from_millis(50))
6641 .is_err(),
6642 "unbind must wait for an admitted worker-start commit"
6643 );
6644 release_tx.send(()).unwrap();
6645 assert!(worker.join().unwrap().is_some());
6646 unbound_rx
6647 .recv_timeout(std::time::Duration::from_secs(1))
6648 .unwrap();
6649 unbind.join().unwrap();
6650 assert_eq!(starts.load(Ordering::SeqCst), 1);
6651 assert!(
6652 admission
6653 .run_if_current(&generation, generation.load(Ordering::SeqCst), || {
6654 starts.fetch_add(1, Ordering::SeqCst);
6655 })
6656 .is_none(),
6657 "worker starts after unbind must be denied"
6658 );
6659 }
6660
6661 #[test]
6662 fn health_snapshot_returns_busy_before_locking_artifact_receivers() {
6663 let ctx = Arc::new(AppContext::new(
6664 default_language_provider_factory(),
6665 Config::default(),
6666 ));
6667 let lifecycle_guard = ctx.subc_lifecycle.unbound.lock();
6668 let (started_tx, started_rx) = std::sync::mpsc::channel();
6669 let (snapshot_tx, snapshot_rx) = std::sync::mpsc::channel();
6670 let worker_ctx = Arc::clone(&ctx);
6671 let worker = std::thread::spawn(move || {
6672 started_tx.send(()).unwrap();
6673 snapshot_tx
6674 .send(worker_ctx.try_health_snapshot(Path::new("health-root")))
6675 .unwrap();
6676 });
6677 started_rx
6678 .recv_timeout(Duration::from_secs(1))
6679 .expect("health snapshot worker should start");
6680
6681 let snapshot = snapshot_rx.recv_timeout(Duration::from_secs(2));
6682 let callgraph_receiver_available = ctx.callgraph_store_rx.try_lock().is_some();
6683 drop(lifecycle_guard);
6684 worker.join().unwrap();
6685
6686 assert!(
6687 matches!(
6688 snapshot,
6689 Ok(RootHealthSnapshot {
6690 state: RootHealthState::Busy,
6691 ..
6692 })
6693 ),
6694 "health snapshots must report busy instead of waiting for lifecycle admission"
6695 );
6696 assert!(
6697 callgraph_receiver_available,
6698 "health snapshots must not hold the callgraph receiver while lifecycle admission is busy"
6699 );
6700 }
6701
6702 #[test]
6703 fn borrow_only_root_with_partial_tier2_aggregates_reports_disabled() {
6704 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6705 ctx.set_artifact_owner(
6706 Some(crate::artifact_owner::ArtifactOwnerStatus {
6707 mode: crate::artifact_owner::ArtifactOwnerMode::ReadOnly,
6708 project_key: "borrowed".to_string(),
6709 manifest_path: "manifest.json".to_string(),
6710 owner_project_scope_key: "owner".to_string(),
6711 owner_checkout_path: "/owner".to_string(),
6712 note: None,
6713 }),
6714 None,
6715 );
6716 ctx.update_status_bar_tier2(Some(4), None, None, None, true);
6717
6718 let snapshot = ctx.try_health_snapshot(Path::new("borrow-only-root"));
6719
6720 assert_eq!(snapshot.tier2.expect("tier2 health").status, "disabled");
6721 }
6722
6723 #[test]
6724 fn worktree_guard_prevents_partial_tier2_from_reporting_building() {
6725 let root = tempfile::tempdir().unwrap();
6726 let ctx = AppContext::new(
6727 default_language_provider_factory(),
6728 Config {
6729 project_root: Some(root.path().to_path_buf()),
6730 ..Config::default()
6731 },
6732 );
6733 ctx.set_harness(crate::harness::Harness::Opencode);
6734 ctx.set_cache_writer_capabilities(true, true);
6735 ctx.update_status_bar_tier2(Some(4), None, None, None, true);
6736 assert_eq!(
6737 ctx.try_health_snapshot(Path::new("writer-root"))
6738 .tier2
6739 .expect("tier2 health")
6740 .status,
6741 "building"
6742 );
6743
6744 ctx.set_cache_role(true, None);
6745
6746 assert_eq!(
6747 ctx.try_health_snapshot(Path::new("worktree-root"))
6748 .tier2
6749 .expect("tier2 health")
6750 .status,
6751 "disabled"
6752 );
6753 let tier2_snapshot = ctx.tier2_refresh_snapshot().expect("tier2 snapshot");
6754 assert!(!tier2_snapshot.callgraph_writer);
6755 }
6756
6757 #[test]
6758 fn unbound_artifact_cancellation_clears_semantic_refresh_state() {
6759 let temp = tempfile::tempdir().unwrap();
6760 let ctx = AppContext::new(
6761 default_language_provider_factory(),
6762 Config {
6763 project_root: Some(temp.path().to_path_buf()),
6764 semantic_search: true,
6765 ..Config::default()
6766 },
6767 );
6768 *ctx.semantic_index()
6769 .write()
6770 .unwrap_or_else(std::sync::PoisonError::into_inner) =
6771 Some(SemanticIndex::new(temp.path().to_path_buf(), 3));
6772 let mut status = SemanticIndexStatus::ready();
6773 status.add_refreshing_file(temp.path().join("changed.rs"));
6774 *ctx.semantic_index_status()
6775 .write()
6776 .unwrap_or_else(std::sync::PoisonError::into_inner) = status;
6777 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
6778 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
6779 ctx.install_semantic_refresh_worker_for_build_epoch(
6780 request_tx,
6781 event_rx,
6782 Arc::new(Mutex::new(None)),
6783 ctx.semantic_index_rx_epoch(),
6784 );
6785
6786 ctx.cancel_unbound_artifact_work();
6787
6788 assert!(ctx.semantic_refresh_event_rx().lock().is_none());
6789 assert!(matches!(
6790 &*ctx
6791 .semantic_index_status()
6792 .read()
6793 .unwrap_or_else(std::sync::PoisonError::into_inner),
6794 SemanticIndexStatus::Ready { refreshing, .. } if refreshing.is_empty()
6795 ));
6796 }
6797
6798 #[test]
6799 fn terminal_empty_search_receiver_reports_completion_work() {
6800 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6801 let (sender, receiver) = crossbeam_channel::unbounded();
6802 let epoch = ctx.install_search_index_rx(receiver, ctx.configure_generation());
6803 let terminal_guard = ctx.search_index_rx_terminal_guard(epoch);
6804 drop(sender);
6805 drop(terminal_guard);
6806
6807 assert!(
6808 ctx.completion_drains_have_work(),
6809 "an empty disconnected one-shot receiver must wake the completion drain"
6810 );
6811 }
6812
6813 #[test]
6814 fn conditional_semantic_receiver_retire_preserves_replacement_epoch() {
6815 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6816 let (_old_sender, old_receiver) = crossbeam_channel::unbounded();
6817 let old_epoch = ctx.install_semantic_index_rx(old_receiver, ctx.configure_generation());
6818 let (_replacement_sender, replacement_receiver) = crossbeam_channel::unbounded();
6819 let replacement_epoch =
6820 ctx.install_semantic_index_rx(replacement_receiver, ctx.configure_generation());
6821
6822 assert!(replacement_epoch > old_epoch);
6823 assert_eq!(ctx.retire_semantic_index_rx_if_epoch(old_epoch), None);
6824 assert!(ctx.semantic_index_rx().lock().is_some());
6825 assert_eq!(ctx.semantic_index_rx_epoch(), replacement_epoch);
6826 }
6827
6828 #[test]
6829 fn stale_terminal_guard_cannot_hide_newer_finished_receiver() {
6830 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6831 let (old_sender, old_receiver) = crossbeam_channel::unbounded();
6832 let old_epoch = ctx.install_search_index_rx(old_receiver, ctx.configure_generation());
6833 let old_guard = ctx.search_index_rx_terminal_guard(old_epoch);
6834 let (current_sender, current_receiver) = crossbeam_channel::unbounded();
6835 let current_epoch =
6836 ctx.install_search_index_rx(current_receiver, ctx.configure_generation());
6837 let current_guard = ctx.search_index_rx_terminal_guard(current_epoch);
6838 drop(old_sender);
6839 drop(current_sender);
6840
6841 drop(current_guard);
6842 drop(old_guard);
6843
6844 assert!(current_epoch > old_epoch);
6845 assert_eq!(
6846 ctx.search_index_rx_terminal_epoch.load(Ordering::SeqCst),
6847 current_epoch,
6848 "a stale worker must not move the terminal watermark backward"
6849 );
6850 assert!(ctx.completion_drains_have_work());
6851 }
6852
6853 #[test]
6854 fn finished_semantic_refresh_worker_reports_completion_work() {
6855 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6856 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
6857 let (event_tx, event_rx) = crossbeam_channel::unbounded();
6858 let worker_slot = Arc::new(Mutex::new(Some(std::thread::spawn(|| {}))));
6859 ctx.install_semantic_refresh_worker_for_build_epoch(
6860 request_tx,
6861 event_rx,
6862 Arc::clone(&worker_slot),
6863 ctx.semantic_index_rx_epoch(),
6864 );
6865 drop(event_tx);
6866 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
6867 while !worker_slot
6868 .lock()
6869 .unwrap_or_else(std::sync::PoisonError::into_inner)
6870 .as_ref()
6871 .is_some_and(std::thread::JoinHandle::is_finished)
6872 {
6873 assert!(
6874 std::time::Instant::now() < deadline,
6875 "worker did not finish"
6876 );
6877 std::thread::yield_now();
6878 }
6879
6880 assert!(
6881 ctx.completion_drains_have_work(),
6882 "a finished refresh worker must wake the completion drain after its event queue empties"
6883 );
6884 }
6885
6886 #[test]
6887 fn unbound_lifecycle_rejects_all_deferred_worker_starts() {
6888 let admission = SubcLifecycleAdmission::default();
6889 let generation = Arc::new(AtomicU64::new(7));
6890 admission.mark_unbound(&generation);
6891 let expected = generation.load(Ordering::SeqCst);
6892 let starts = Arc::new(AtomicUsize::new(0));
6893
6894 let workers = (0..16)
6895 .map(|_| {
6896 let admission = admission.clone();
6897 let generation = Arc::clone(&generation);
6898 let starts = Arc::clone(&starts);
6899 std::thread::spawn(move || {
6900 admission.run_if_current(&generation, expected, || {
6901 starts.fetch_add(1, Ordering::SeqCst);
6902 })
6903 })
6904 })
6905 .collect::<Vec<_>>();
6906
6907 for worker in workers {
6908 assert!(worker.join().unwrap().is_none());
6909 }
6910 assert_eq!(starts.load(Ordering::SeqCst), 0);
6911 }
6912}
6913
6914#[cfg(test)]
6915mod force_restrict_tests {
6916 use super::*;
6917 use crate::language::StubProvider;
6918 use tempfile::TempDir;
6919
6920 fn test_context(project_root: Option<PathBuf>, restrict_to_project_root: bool) -> AppContext {
6921 AppContext::new(
6922 Box::new(StubProvider),
6923 Config {
6924 project_root,
6925 restrict_to_project_root,
6926 ..Config::default()
6927 },
6928 )
6929 }
6930
6931 #[test]
6932 fn standalone_validate_path_parity_without_force_restrict() {
6933 let root = TempDir::new().expect("root tempdir");
6934 let outside = TempDir::new().expect("outside tempdir");
6935 let outside_path = outside.path().join("outside.txt");
6936
6937 let unrestricted = test_context(Some(root.path().to_path_buf()), false);
6938 assert_eq!(
6939 unrestricted
6940 .validate_path("standalone-unrestricted", &outside_path)
6941 .expect("unrestricted standalone validates"),
6942 outside_path
6943 );
6944
6945 let restricted = test_context(Some(root.path().to_path_buf()), true);
6946 let err = restricted
6947 .validate_path("standalone-restricted", &outside_path)
6948 .expect_err("restricted standalone rejects outside root");
6949 assert_eq!(
6950 serde_json::to_value(err).unwrap()["code"],
6951 "path_outside_root"
6952 );
6953 }
6954
6955 #[test]
6956 fn path_restriction_root_memo_canonicalizes_once_for_1000_validations() {
6957 let root = TempDir::new().expect("root tempdir");
6958 let target = root.path().join("target.txt");
6959 std::fs::write(&target, "inside").expect("write target");
6960 let ctx = test_context(Some(root.path().to_path_buf()), true);
6961
6962 for request in 0..1_000 {
6963 let validated = ctx
6964 .validate_path(&format!("memo-{request}"), &target)
6965 .expect("in-root path validates");
6966 assert_eq!(validated, std::fs::canonicalize(&target).unwrap());
6967 }
6968
6969 assert_eq!(
6970 ctx.path_restriction_root_canonicalizations_for_test(),
6971 1,
6972 "the configured root should be canonicalized once instead of once per validation"
6973 );
6974 }
6975
6976 #[cfg(unix)]
6977 #[test]
6978 fn path_restriction_root_memo_recanonicalizes_after_cached_target_disappears() {
6979 let workspace = TempDir::new().expect("workspace tempdir");
6980 let first_target = workspace.path().join("first-target");
6981 let second_target = workspace.path().join("second-target");
6982 let configured_root = workspace.path().join("configured-root");
6983 std::fs::create_dir_all(&first_target).expect("create first target");
6984 std::fs::create_dir_all(&second_target).expect("create second target");
6985 std::os::unix::fs::symlink(&first_target, &configured_root)
6986 .expect("create configured-root symlink");
6987 std::fs::write(first_target.join("inside.txt"), "first").expect("write first target");
6988
6989 let ctx = test_context(Some(configured_root.clone()), true);
6990 assert_eq!(
6991 ctx.validate_path("first-target", Path::new("inside.txt"))
6992 .expect("first target validates"),
6993 std::fs::canonicalize(first_target.join("inside.txt")).unwrap()
6994 );
6995
6996 std::fs::remove_dir_all(&first_target).expect("remove first target");
6999 std::fs::remove_file(&configured_root).expect("remove old root symlink");
7000 std::os::unix::fs::symlink(&second_target, &configured_root)
7001 .expect("recreate configured-root symlink");
7002 std::fs::write(second_target.join("inside.txt"), "second").expect("write second target");
7003
7004 assert_eq!(
7005 ctx.validate_path("second-target", Path::new("inside.txt"))
7006 .expect("second target validates"),
7007 std::fs::canonicalize(second_target.join("inside.txt")).unwrap()
7008 );
7009 assert_eq!(ctx.path_restriction_root_canonicalizations_for_test(), 2);
7010 }
7011
7012 #[test]
7013 fn force_restrict_guard_refcounts_duplicate_request_ids() {
7014 let root = TempDir::new().expect("root tempdir");
7015 let outside = TempDir::new().expect("outside tempdir");
7016 let outside_path = outside.path().join("outside.txt");
7017 let ctx = test_context(Some(root.path().to_path_buf()), false);
7018
7019 assert!(ctx.validate_path("dup", &outside_path).is_ok());
7020 let guard1 = ctx.force_restrict_guard("dup");
7021 let guard2 = ctx.force_restrict_guard("dup");
7022 assert!(ctx.validate_path("dup", &outside_path).is_err());
7023 drop(guard1);
7024 assert!(
7025 ctx.validate_path("dup", &outside_path).is_err(),
7026 "duplicate guard must keep the request over-restricted"
7027 );
7028 drop(guard2);
7029 assert!(ctx.validate_path("dup", &outside_path).is_ok());
7030 }
7031
7032 #[test]
7033 fn with_force_restrict_cleans_up_after_normal_completion_and_panic() {
7034 let root = TempDir::new().expect("root tempdir");
7035 let outside = TempDir::new().expect("outside tempdir");
7036 let outside_path = outside.path().join("outside.txt");
7037 let ctx = test_context(Some(root.path().to_path_buf()), false);
7038
7039 ctx.with_force_restrict("normal", || {
7040 assert!(ctx.validate_path("normal", &outside_path).is_err());
7041 });
7042 assert!(!ctx.request_force_restrict("normal"));
7043 assert!(ctx.validate_path("normal", &outside_path).is_ok());
7044
7045 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7046 ctx.with_force_restrict("panic", || {
7047 assert!(ctx.validate_path("panic", &outside_path).is_err());
7048 panic!("intentional force-restrict cleanup panic");
7049 });
7050 }));
7051 assert!(panicked.is_err());
7052 assert!(!ctx.request_force_restrict("panic"));
7053 assert!(ctx.validate_path("panic", &outside_path).is_ok());
7054 }
7055
7056 #[cfg(unix)]
7057 #[test]
7058 fn validate_write_location_keeps_final_symlink_as_the_authorized_location() {
7059 let root = TempDir::new().expect("root tempdir");
7060 let outside = tempfile::NamedTempFile::new().expect("outside file");
7061 let link = root.path().join("file.txt");
7062 std::os::unix::fs::symlink(outside.path(), &link).expect("create final symlink");
7063 let ctx = test_context(Some(root.path().to_path_buf()), false);
7064 let _guard = ctx.force_restrict_guard("write-location-final-link");
7065
7066 let validated = ctx
7067 .validate_write_location("write-location-final-link", &link)
7068 .expect("the in-root link location is writable");
7069
7070 assert_eq!(
7071 validated,
7072 std::fs::canonicalize(root.path()).unwrap().join("file.txt")
7073 );
7074 }
7075
7076 #[cfg(unix)]
7077 #[test]
7078 fn validate_write_location_rejects_symlinked_parent_escape() {
7079 let root = TempDir::new().expect("root tempdir");
7080 let outside = TempDir::new().expect("outside tempdir");
7081 let linked_parent = root.path().join("linked-parent");
7082 std::os::unix::fs::symlink(outside.path(), &linked_parent).expect("create parent symlink");
7083 let candidate = linked_parent.join("file.txt");
7084 let ctx = test_context(Some(root.path().to_path_buf()), false);
7085 let _guard = ctx.force_restrict_guard("write-location-parent-link");
7086
7087 let error = ctx
7088 .validate_write_location("write-location-parent-link", &candidate)
7089 .expect_err("a symlinked parent must not escape the project root");
7090
7091 assert_eq!(
7092 serde_json::to_value(error).unwrap()["code"],
7093 "path_outside_root"
7094 );
7095 }
7096
7097 #[cfg(unix)]
7098 #[test]
7099 fn validate_write_location_rejects_outside_link_to_inside_file() {
7100 let root = TempDir::new().expect("root tempdir");
7101 let outside = TempDir::new().expect("outside tempdir");
7102 let inside = root.path().join("inside.txt");
7103 std::fs::write(&inside, "inside").unwrap();
7104 let outside_link = outside.path().join("outside-link.txt");
7105 std::os::unix::fs::symlink(&inside, &outside_link).expect("create outside symlink");
7106 let ctx = test_context(Some(root.path().to_path_buf()), false);
7107 let _guard = ctx.force_restrict_guard("write-location-outside-link");
7108
7109 let error = ctx
7110 .validate_write_location("write-location-outside-link", &outside_link)
7111 .expect_err("an out-of-root lexical location must remain blocked");
7112
7113 assert_eq!(
7114 serde_json::to_value(error).unwrap()["code"],
7115 "path_outside_root"
7116 );
7117 }
7118
7119 #[test]
7120 fn forced_restrict_without_project_root_fails_closed() {
7121 let ctx = test_context(None, false);
7122 let _guard = ctx.force_restrict_guard("missing-root");
7123 let err = ctx
7124 .validate_path("missing-root", Path::new("relative.txt"))
7125 .expect_err("forced restriction without a root must fail closed");
7126 assert_eq!(
7127 serde_json::to_value(err).unwrap()["code"],
7128 "path_outside_root"
7129 );
7130
7131 let write_err = ctx
7132 .validate_write_location("missing-root", Path::new("relative.txt"))
7133 .expect_err("write-location validation must also fail closed");
7134 assert_eq!(
7135 serde_json::to_value(write_err).unwrap()["code"],
7136 "path_outside_root"
7137 );
7138 }
7139}
7140
7141#[cfg(test)]
7142mod callgraph_store_for_ops_tests {
7143 use super::*;
7144 use crate::inspect::{InspectCategory, InspectSnapshot, JobOutcome, JobScope};
7145 use crate::parser::TreeSitterProvider;
7146 use crate::protocol::RawRequest;
7147 use serde_json::json;
7148 use std::ffi::OsString;
7149 use std::path::Path;
7150 use std::sync::{Barrier, Mutex as StdMutex, MutexGuard, OnceLock};
7151 use tempfile::TempDir;
7152
7153 struct CallgraphWaitWindowEnvGuard {
7154 _guard: MutexGuard<'static, ()>,
7155 previous: Option<OsString>,
7156 }
7157
7158 impl Drop for CallgraphWaitWindowEnvGuard {
7159 fn drop(&mut self) {
7160 unsafe {
7163 match &self.previous {
7164 Some(value) => std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", value),
7165 None => std::env::remove_var("AFT_CALLGRAPH_BUILD_WAIT_MS"),
7166 }
7167 }
7168 }
7169 }
7170
7171 fn callgraph_build_wait_ms(ms: u64) -> CallgraphWaitWindowEnvGuard {
7172 static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
7173 let guard = LOCK
7174 .get_or_init(|| StdMutex::new(()))
7175 .lock()
7176 .unwrap_or_else(|error| error.into_inner());
7177 let previous = std::env::var_os("AFT_CALLGRAPH_BUILD_WAIT_MS");
7178 unsafe {
7180 std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", ms.to_string());
7181 }
7182 CallgraphWaitWindowEnvGuard {
7183 _guard: guard,
7184 previous,
7185 }
7186 }
7187
7188 fn force_async_callgraph_builds() -> CallgraphWaitWindowEnvGuard {
7189 callgraph_build_wait_ms(0)
7190 }
7191
7192 fn cold_build_context() -> Arc<AppContext> {
7193 let project = TempDir::new().expect("project tempdir");
7194 let storage = TempDir::new().expect("storage tempdir");
7195 let source_dir = project.path().join("src");
7196 std::fs::create_dir_all(&source_dir).expect("source dir");
7197 std::fs::write(
7198 source_dir.join("lib.rs"),
7199 "pub fn caller() { callee(); }\npub fn callee() {}\n",
7200 )
7201 .expect("source file");
7202
7203 Arc::new(AppContext::new(
7204 Box::new(TreeSitterProvider::new()),
7205 Config {
7206 project_root: Some(project.keep()),
7207 storage_dir: Some(storage.keep()),
7208 callgraph_chunk_size: 1,
7209 ..Config::default()
7210 },
7211 ))
7212 }
7213
7214 fn with_fake_home_env<R>(home: &Path, f: impl FnOnce() -> R) -> R {
7215 let _guard = crate::test_env::process_env_lock();
7216 let prev_home = std::env::var_os("HOME");
7217 let prev_userprofile = std::env::var_os("USERPROFILE");
7218 unsafe {
7219 std::env::set_var("HOME", home);
7220 std::env::set_var("USERPROFILE", home);
7221 }
7222 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
7223 unsafe {
7224 match prev_home {
7225 Some(value) => std::env::set_var("HOME", value),
7226 None => std::env::remove_var("HOME"),
7227 }
7228 match prev_userprofile {
7229 Some(value) => std::env::set_var("USERPROFILE", value),
7230 None => std::env::remove_var("USERPROFILE"),
7231 }
7232 }
7233 match result {
7234 Ok(value) => value,
7235 Err(payload) => std::panic::resume_unwind(payload),
7236 }
7237 }
7238
7239 fn configure_request_with_params(params: serde_json::Value) -> RawRequest {
7240 RawRequest {
7241 id: "cfg".to_string(),
7242 command: "configure".to_string(),
7243 lsp_hints: None,
7244 session_id: None,
7245 params,
7246 }
7247 }
7248
7249 fn user_tier(doc: serde_json::Value) -> serde_json::Value {
7250 json!({
7251 "tier": "user",
7252 "source": "/u/aft.jsonc",
7253 "doc": doc.to_string(),
7254 })
7255 }
7256
7257 fn configure_context(project_root: &Path, storage_dir: &Path) -> AppContext {
7258 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7259 let response = crate::commands::configure::handle_configure(
7260 &configure_request_with_params(json!({
7261 "project_root": project_root,
7262 "harness": "opencode",
7263 "storage_dir": storage_dir,
7264 "config": [user_tier(json!({
7265 "callgraph_store": true,
7266 "search_index": true,
7267 "semantic_search": true,
7268 }))],
7269 })),
7270 &ctx,
7271 );
7272 assert!(response.success, "configure should succeed: {response:?}");
7273 ctx
7274 }
7275
7276 fn inspect_snapshot(ctx: &AppContext) -> InspectSnapshot {
7277 InspectSnapshot::new(
7278 ctx.canonical_cache_root(),
7279 ctx.inspect_dir(),
7280 ctx.config(),
7281 ctx.symbol_cache(),
7282 )
7283 }
7284
7285 fn empty_semantic_index_for_ctx(ctx: &AppContext) -> SemanticIndex {
7286 let project_root = ctx
7287 .config()
7288 .project_root
7289 .clone()
7290 .expect("test context has a project root");
7291 let files: Vec<PathBuf> = Vec::new();
7292 let mut embed = |_texts: Vec<String>| -> Result<Vec<Vec<f32>>, String> { Ok(Vec::new()) };
7293 SemanticIndex::build(&project_root, &files, &mut embed, 1)
7294 .expect("empty semantic index should build")
7295 }
7296
7297 #[test]
7298 fn home_root_gate_blocks_callgraph_store_entry_points() {
7299 let _wait_guard = force_async_callgraph_builds();
7300 let home = TempDir::new().expect("home tempdir");
7301 let storage = TempDir::new().expect("storage tempdir");
7302 let source_dir = home.path().join("src");
7303 std::fs::create_dir_all(&source_dir).expect("source dir");
7304 std::fs::write(
7305 source_dir.join("lib.rs"),
7306 "pub fn caller() { callee(); }\npub fn callee() {}\n",
7307 )
7308 .expect("source file");
7309
7310 with_fake_home_env(home.path(), || {
7311 let ctx = configure_context(home.path(), storage.path());
7312 assert!(
7313 !ctx.heavy_root_work_allowed(),
7314 "HOME root configure must close the heavy-root-work gate"
7315 );
7316 assert_eq!(
7317 ctx.try_health_snapshot(home.path())
7318 .callgraph_store
7319 .as_ref()
7320 .map(|component| component.status),
7321 Some("disabled"),
7322 "HOME root health must not advertise callgraph building"
7323 );
7324
7325 reset_callgraph_cold_build_spawn_count_for_test();
7326 assert!(matches!(
7327 ctx.callgraph_store_for_ops(),
7328 CallgraphStoreAccess::Unavailable
7329 ));
7330 assert!(
7331 ctx.ensure_callgraph_store()
7332 .expect("ensure_callgraph_store should not error")
7333 .is_none(),
7334 "shared gate must also block synchronous standalone callgraph builds"
7335 );
7336 assert_eq!(
7337 callgraph_cold_build_spawn_count_for_test(),
7338 0,
7339 "HOME root gate must not spawn a cold callgraph build"
7340 );
7341 });
7342 }
7343
7344 #[test]
7345 fn home_root_gate_blocks_inspect_manager_submit_paths() {
7346 let home = TempDir::new().expect("home tempdir");
7347 let storage = TempDir::new().expect("storage tempdir");
7348 let source_dir = home.path().join("src");
7349 std::fs::create_dir_all(&source_dir).expect("source dir");
7350 std::fs::write(source_dir.join("lib.rs"), "pub fn one() {}\n").expect("source file");
7351
7352 with_fake_home_env(home.path(), || {
7353 let ctx = configure_context(home.path(), storage.path());
7354 let snapshot = inspect_snapshot(&ctx);
7355 let scope = JobScope::for_project(snapshot.project_root.clone());
7356 let manager = ctx.inspect_manager();
7357
7358 assert!(matches!(
7359 manager.submit_category(snapshot.clone(), InspectCategory::Metrics, scope.clone()),
7360 JobOutcome::Failed { .. }
7361 ));
7362
7363 let submission = manager.submit_tier2_run_with_reuse_serial_background(
7364 snapshot,
7365 vec![InspectCategory::DeadCode],
7366 );
7367 assert!(submission.queued_categories.is_empty());
7368 assert!(submission.newly_queued_categories.is_empty());
7369 assert!(submission.deferred_categories.is_empty());
7370 assert_eq!(submission.errors.len(), 1);
7371 assert!(
7372 !manager.tier2_any_in_flight(),
7373 "HOME root gate must reject Tier-2 submission before any job is queued"
7374 );
7375 });
7376 }
7377
7378 #[test]
7379 fn non_home_root_still_allows_callgraph_cold_builds() {
7380 let _env_guard = force_async_callgraph_builds();
7381 reset_callgraph_cold_build_spawn_count_for_test();
7382 let ctx = cold_build_context();
7383
7384 assert!(ctx.heavy_root_work_allowed());
7385 assert!(matches!(
7386 ctx.callgraph_store_for_ops(),
7387 CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
7388 ));
7389 assert_eq!(
7390 callgraph_cold_build_spawn_count_for_test(),
7391 1,
7392 "non-home roots must still be able to cold-build the callgraph store"
7393 );
7394
7395 let rx = ctx
7396 .callgraph_store_rx
7397 .lock()
7398 .as_ref()
7399 .cloned()
7400 .expect("non-home cold build should install an in-flight receiver");
7401 rx.recv_timeout(Duration::from_secs(30))
7402 .expect("background cold build should complete");
7403 *ctx.callgraph_store_rx.lock() = None;
7404 }
7405
7406 #[test]
7407 fn semantic_ready_event_resumes_deferred_callgraph_and_tier2() {
7408 let _env_guard = force_async_callgraph_builds();
7409 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
7410 let ctx = cold_build_context();
7411 let (tx, rx) = crossbeam_channel::unbounded();
7412 *ctx.semantic_index_rx().lock() = Some(rx);
7413 ctx.schedule_semantic_cold_seed_gate_for_configure();
7414
7415 assert!(matches!(
7416 ctx.callgraph_store_for_ops(),
7417 CallgraphStoreAccess::Building
7418 ));
7419 assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
7420 tx.send(SemanticIndexEvent::Ready(empty_semantic_index_for_ctx(
7421 &ctx,
7422 )))
7423 .expect("send ready event");
7424
7425 crate::runtime_drain::drain_semantic_index_events(&ctx);
7426
7427 assert!(
7428 !ctx.semantic_cold_seed_active(),
7429 "semantic Ready must clear the scheduled cold gate"
7430 );
7431 assert!(
7432 ctx.tier2_pull_demand_pending(),
7433 "semantic Ready must resume deferred Tier-2 work"
7434 );
7435 assert_eq!(
7436 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
7437 1,
7438 "semantic Ready must resume the deferred callgraph warm"
7439 );
7440 let rx = ctx
7441 .callgraph_store_rx
7442 .lock()
7443 .as_ref()
7444 .cloned()
7445 .expect("ready resume should install an in-flight callgraph receiver");
7446 rx.recv_timeout(Duration::from_secs(30))
7447 .expect("background cold build should complete");
7448 *ctx.callgraph_store_rx.lock() = None;
7449 }
7450
7451 #[test]
7452 fn semantic_gate_cleared_event_resumes_deferred_callgraph_and_tier2() {
7453 let _env_guard = force_async_callgraph_builds();
7454 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
7455 let ctx = cold_build_context();
7456 ctx.schedule_semantic_cold_seed_gate_for_configure();
7457
7458 assert!(matches!(
7459 ctx.callgraph_store_for_ops(),
7460 CallgraphStoreAccess::Building
7461 ));
7462 assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
7463 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
7464
7465 assert!(
7466 !ctx.semantic_cold_seed_active(),
7467 "cached-load or retry-wait clear must reopen the semantic cold gate"
7468 );
7469 assert!(
7470 ctx.tier2_pull_demand_pending(),
7471 "cached-load or retry-wait clear must resume deferred Tier-2 work"
7472 );
7473 assert_eq!(
7474 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
7475 1,
7476 "cached-load or retry-wait clear must resume deferred callgraph warm"
7477 );
7478 let rx = ctx
7479 .callgraph_store_rx
7480 .lock()
7481 .as_ref()
7482 .cloned()
7483 .expect("gate-clear resume should install an in-flight callgraph receiver");
7484 rx.recv_timeout(Duration::from_secs(30))
7485 .expect("background cold build should complete");
7486 *ctx.callgraph_store_rx.lock() = None;
7487 }
7488
7489 #[test]
7490 fn semantic_cold_seed_gate_defers_callgraph_cold_spawn_until_resume() {
7491 let _env_guard = force_async_callgraph_builds();
7492 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
7493 let ctx = cold_build_context();
7494
7495 ctx.set_semantic_cold_seed_active_for_test(true);
7496 assert!(
7497 matches!(
7498 ctx.callgraph_store_for_ops(),
7499 CallgraphStoreAccess::Building
7500 ),
7501 "callgraph ops should degrade as building while the semantic cold gate is active"
7502 );
7503 assert_eq!(
7504 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
7505 0,
7506 "semantic cold gate must not spawn a competing callgraph cold build"
7507 );
7508 assert!(ctx.semantic_callgraph_warm_deferred_for_test());
7509
7510 ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
7511 assert_eq!(
7512 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
7513 1,
7514 "clearing the semantic cold gate should resume the deferred callgraph warm"
7515 );
7516
7517 let rx = ctx
7518 .callgraph_store_rx
7519 .lock()
7520 .as_ref()
7521 .cloned()
7522 .expect("deferred warm should install an in-flight receiver");
7523 rx.recv_timeout(Duration::from_secs(30))
7524 .expect("background cold build should complete");
7525 *ctx.callgraph_store_rx.lock() = None;
7526 }
7527
7528 #[test]
7529 fn semantic_cold_seed_gate_clear_requests_tier2_pull() {
7530 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7531 ctx.schedule_semantic_cold_seed_gate_for_configure();
7532
7533 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
7534
7535 assert!(
7536 !ctx.semantic_cold_seed_active(),
7537 "retry-wait or cached-load events must reopen the semantic cold gate"
7538 );
7539 assert!(
7540 ctx.tier2_pull_demand_pending(),
7541 "clearing the semantic cold gate should kick a Tier-2 pull refresh"
7542 );
7543 }
7544
7545 #[test]
7546 fn semantic_failed_event_clears_scheduled_gate_and_requests_tier2_pull() {
7547 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7548 let (tx, rx) = crossbeam_channel::unbounded();
7549 *ctx.semantic_index_rx().lock() = Some(rx);
7550 ctx.schedule_semantic_cold_seed_gate_for_configure();
7551 tx.send(SemanticIndexEvent::Failed(
7552 "embedding backend failed".to_string(),
7553 ))
7554 .expect("send failed event");
7555
7556 crate::runtime_drain::drain_semantic_index_events(&ctx);
7557
7558 assert!(
7559 !ctx.semantic_cold_seed_active(),
7560 "semantic Failed must clear the scheduled cold gate"
7561 );
7562 assert!(
7563 ctx.tier2_pull_demand_pending(),
7564 "semantic Failed must resume deferred Tier-2 work"
7565 );
7566 }
7567
7568 #[test]
7569 fn semantic_disconnect_clears_scheduled_gate_and_requests_tier2_pull() {
7570 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7571 let (tx, rx) = crossbeam_channel::unbounded::<SemanticIndexEvent>();
7572 *ctx.semantic_index_rx().lock() = Some(rx);
7573 ctx.schedule_semantic_cold_seed_gate_for_configure();
7574 drop(tx);
7575
7576 crate::runtime_drain::drain_semantic_index_events(&ctx);
7577
7578 assert!(
7579 !ctx.semantic_cold_seed_active(),
7580 "semantic worker disconnect must clear the scheduled cold gate"
7581 );
7582 assert!(
7583 ctx.tier2_pull_demand_pending(),
7584 "semantic worker disconnect must resume deferred Tier-2 work"
7585 );
7586 }
7587
7588 #[test]
7589 fn semantic_cold_seed_gate_is_per_context_for_tier2_scheduler() {
7590 let ctx_a = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7591 let ctx_b = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7592 let base = Instant::now();
7593 ctx_a.reset_tier2_refresh_scheduler_at(base);
7594 ctx_b.reset_tier2_refresh_scheduler_at(base);
7595 ctx_a.set_semantic_cold_seed_active_for_test(true);
7596
7597 assert_eq!(
7598 ctx_a.tick_tier2_refresh_scheduler_at(
7599 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
7600 0,
7601 ),
7602 None,
7603 "root A should defer Tier-2 while its semantic cold seed is active"
7604 );
7605 assert_eq!(
7606 ctx_b.tick_tier2_refresh_scheduler_at(
7607 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
7608 0,
7609 ),
7610 Some(Tier2TriggerReason::ConfigureWarm),
7611 "root B must not inherit root A's semantic cold gate"
7612 );
7613 }
7614
7615 #[test]
7616 fn inline_wait_settled_event_clears_superseded_receiver() {
7617 let _env_guard = callgraph_build_wait_ms(2_000);
7618 let project = TempDir::new().expect("project tempdir");
7619 let storage = TempDir::new().expect("storage tempdir");
7620 std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
7621 let project_root = std::fs::canonicalize(project.path()).expect("canonical project root");
7622 let ctx = Arc::new(AppContext::new(
7623 Box::new(TreeSitterProvider::new()),
7624 Config {
7625 project_root: Some(project.path().to_path_buf()),
7626 storage_dir: Some(storage.path().to_path_buf()),
7627 callgraph_chunk_size: 1,
7628 ..Config::default()
7629 },
7630 ));
7631 let (reached, release) = install_callgraph_build_start_gate(project_root);
7632 let request_ctx = Arc::clone(&ctx);
7633 let request = std::thread::spawn(move || request_ctx.callgraph_store_for_ops());
7634 reached
7635 .recv_timeout(Duration::from_secs(2))
7636 .expect("callgraph worker did not reach start barrier");
7637
7638 ctx.next_callgraph_persist_epoch();
7639 release.send(()).unwrap();
7640 assert!(matches!(
7641 request.join().expect("callgraph request thread"),
7642 CallgraphStoreAccess::Building
7643 ));
7644 assert!(
7645 ctx.callgraph_store_rx().lock().is_none(),
7646 "inline Settled handling must retire the matching receiver"
7647 );
7648 assert!(
7649 ctx.callgraph_store()
7650 .read()
7651 .unwrap_or_else(std::sync::PoisonError::into_inner)
7652 .is_none(),
7653 "Settled must not reopen and install an older persisted store"
7654 );
7655 }
7656
7657 #[test]
7658 fn inline_ready_without_published_pointer_settles_and_preserves_pending_paths() {
7659 let _env_guard = callgraph_build_wait_ms(2_000);
7660 let project = TempDir::new().expect("project tempdir");
7661 let storage = TempDir::new().expect("storage tempdir");
7662 std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
7663 let ctx = AppContext::new(
7664 Box::new(TreeSitterProvider::new()),
7665 Config {
7666 project_root: Some(project.path().to_path_buf()),
7667 storage_dir: Some(storage.path().to_path_buf()),
7668 callgraph_chunk_size: 1,
7669 ..Config::default()
7670 },
7671 );
7672 let project_key = crate::search_index::artifact_cache_key(project.path());
7673 crate::root_cache::configure_artifact_access(project.path(), &project_key, false);
7674 let pending = project.path().join("pending.rs");
7675 ctx.add_pending_callgraph_store_paths([pending.clone()]);
7676 REMOVE_CALLGRAPH_POINTER_BEFORE_INLINE_REOPEN.store(true, Ordering::SeqCst);
7677 let _remove_pointer_guard = RemoveCallgraphPointerBeforeInlineReopenGuard;
7678
7679 assert!(matches!(
7680 ctx.callgraph_store_for_ops(),
7681 CallgraphStoreAccess::Building
7682 ));
7683 assert!(
7684 ctx.callgraph_store_rx().lock().is_none(),
7685 "inline Ready must settle after the published pointer disappears"
7686 );
7687 assert_eq!(
7688 ctx.take_pending_callgraph_store_paths(),
7689 vec![pending],
7690 "inline reopen failure must preserve pending watcher paths"
7691 );
7692 }
7693
7694 #[test]
7695 fn take_pending_callgraph_store_paths_drops_paths_outside_current_root() {
7696 let project = TempDir::new().expect("project tempdir");
7697 let foreign = TempDir::new().expect("foreign tempdir");
7698 let ctx = AppContext::new(
7699 Box::new(TreeSitterProvider::new()),
7700 Config {
7701 project_root: Some(project.path().to_path_buf()),
7702 ..Config::default()
7703 },
7704 );
7705 let inside = project.path().join("kept.rs");
7706 let outside = foreign.path().join("previous-root-file.rs");
7710 let dotdot_escape = project
7713 .path()
7714 .join("..")
7715 .join(
7716 foreign
7717 .path()
7718 .file_name()
7719 .expect("foreign tempdir has a name"),
7720 )
7721 .join("escaped.rs");
7722 ctx.add_pending_callgraph_store_paths([inside.clone(), outside, dotdot_escape]);
7723
7724 assert_eq!(
7725 ctx.take_pending_callgraph_store_paths(),
7726 vec![inside],
7727 "pending replay must drop foreign and dot-dot-escaping paths"
7728 );
7729 }
7730
7731 #[test]
7732 fn watcher_gap_invalidation_keeps_semantic_reloadable_and_skips_readonly_force_token() {
7733 let project = TempDir::new().expect("project tempdir");
7734 let ctx = AppContext::new(
7735 Box::new(TreeSitterProvider::new()),
7736 Config {
7737 project_root: Some(project.path().to_path_buf()),
7738 semantic_search: true,
7739 ..Config::default()
7740 },
7741 );
7742 ctx.set_canonical_cache_root(project.path().to_path_buf());
7743 ctx.set_cache_writer_capabilities(false, true);
7746 *ctx.semantic_index_status()
7747 .write()
7748 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
7749
7750 ctx.invalidate_artifacts_after_watcher_gap();
7751
7752 assert!(
7753 matches!(
7754 &*ctx
7755 .semantic_index_status()
7756 .read()
7757 .unwrap_or_else(std::sync::PoisonError::into_inner),
7758 SemanticIndexStatus::Ready { .. }
7759 ),
7760 "semantic-enabled root must stay reloadable (Disabled has no self-healing path)"
7761 );
7762 assert_eq!(
7763 ctx.pending_callgraph_store_force_token(),
7764 None,
7765 "read-only root must not be stuck behind an unfulfillable force token"
7766 );
7767 }
7768
7769 #[test]
7770 fn watcher_gap_invalidation_marks_force_rebuild_for_writer_roots() {
7771 let project = TempDir::new().expect("project tempdir");
7772 let ctx = AppContext::new(
7773 Box::new(TreeSitterProvider::new()),
7774 Config {
7775 project_root: Some(project.path().to_path_buf()),
7776 ..Config::default()
7777 },
7778 );
7779 ctx.set_canonical_cache_root(project.path().to_path_buf());
7780 ctx.set_cache_writer_capabilities(true, true);
7781
7782 ctx.invalidate_artifacts_after_watcher_gap();
7783
7784 assert!(
7785 ctx.pending_callgraph_store_force_token().is_some(),
7786 "writer roots must still reconcile the store after the unobserved interval"
7787 );
7788 assert!(
7789 matches!(
7790 &*ctx
7791 .semantic_index_status()
7792 .read()
7793 .unwrap_or_else(std::sync::PoisonError::into_inner),
7794 SemanticIndexStatus::Disabled
7795 ),
7796 "semantic-disabled config maps to Disabled status"
7797 );
7798 }
7799
7800 #[cfg(unix)]
7801 #[test]
7802 fn take_pending_callgraph_store_paths_drops_symlink_dotdot_escape() {
7803 let project = TempDir::new().expect("project tempdir");
7804 let foreign = TempDir::new().expect("foreign tempdir");
7805 std::fs::create_dir_all(foreign.path().join("dir")).expect("foreign dir");
7806 std::fs::write(foreign.path().join("secret.rs"), "pub fn s() {}\n").expect("secret");
7807 let ctx = AppContext::new(
7808 Box::new(TreeSitterProvider::new()),
7809 Config {
7810 project_root: Some(project.path().to_path_buf()),
7811 ..Config::default()
7812 },
7813 );
7814 std::os::unix::fs::symlink(foreign.path().join("dir"), project.path().join("link"))
7819 .expect("plant symlink");
7820 let escape = project.path().join("link").join("..").join("secret.rs");
7821 let dead_component_escape = project
7826 .path()
7827 .join("link")
7828 .join("dead")
7829 .join("..")
7830 .join("..")
7831 .join("deep-secret.rs");
7832 std::fs::write(foreign.path().join("reentry-secret.rs"), "pub fn r() {}\n")
7837 .expect("reentry secret");
7838 let reentry_escape = project
7839 .path()
7840 .join("dead")
7841 .join("..")
7842 .join("link")
7843 .join("..")
7844 .join("reentry-secret.rs");
7845 std::os::unix::fs::symlink(
7850 foreign.path().join("nonexistent-target"),
7851 project.path().join("dangling"),
7852 )
7853 .expect("plant dangling symlink");
7854 let dangling_reentry = project
7855 .path()
7856 .join("dangling")
7857 .join("..")
7858 .join("via-dangling.rs");
7859 std::fs::write(project.path().join("plain.rs"), "pub fn p() {}\n").expect("plain file");
7862 let through_file = project
7863 .path()
7864 .join("plain.rs")
7865 .join("..")
7866 .join("via-file.rs");
7867 let kept = project.path().join("kept.rs");
7868 ctx.add_pending_callgraph_store_paths([
7869 escape,
7870 dead_component_escape,
7871 reentry_escape,
7872 dangling_reentry,
7873 through_file,
7874 kept.clone(),
7875 ]);
7876
7877 assert_eq!(
7878 ctx.take_pending_callgraph_store_paths(),
7879 vec![kept],
7880 "symlink-plus-dotdot escapes must be dropped with filesystem-first semantics"
7881 );
7882 }
7883
7884 #[cfg(windows)]
7885 #[test]
7886 fn take_pending_callgraph_store_paths_drops_drive_relative_paths() {
7887 let cwd = std::env::current_dir().expect("drive cwd");
7894 let cwd_file = PathBuf::from(format!(
7895 "{}under-drive-cwd.rs",
7896 cwd.components()
7897 .next()
7898 .map(|prefix| prefix.as_os_str().to_string_lossy().into_owned())
7899 .expect("drive prefix")
7900 ));
7901 assert!(cwd_file.is_relative(), "C:foo must classify as relative");
7902 assert!(
7903 !pending_path_in_roots(&cwd_file, &[cwd.clone()]),
7904 "drive-relative spelling must be rejected even when the drive CWD is inside the root"
7905 );
7906 assert!(
7907 !pending_path_in_roots(Path::new(r"\root-relative.rs"), &[cwd]),
7908 "root-relative spelling must be rejected"
7909 );
7910
7911 let project = TempDir::new().expect("project tempdir");
7912 let ctx = AppContext::new(
7913 Box::new(TreeSitterProvider::new()),
7914 Config {
7915 project_root: Some(project.path().to_path_buf()),
7916 ..Config::default()
7917 },
7918 );
7919 let kept = project.path().join("kept.rs");
7920 ctx.add_pending_callgraph_store_paths([
7921 PathBuf::from("C:drive-relative.rs"),
7922 PathBuf::from(r"\root-relative.rs"),
7923 kept.clone(),
7924 ]);
7925
7926 assert_eq!(
7927 ctx.take_pending_callgraph_store_paths(),
7928 vec![kept],
7929 "drive-relative and root-relative spellings must be rejected"
7930 );
7931 }
7932
7933 #[test]
7934 fn take_pending_callgraph_store_paths_keeps_relative_and_deleted_paths() {
7935 let project = TempDir::new().expect("project tempdir");
7936 let ctx = AppContext::new(
7937 Box::new(TreeSitterProvider::new()),
7938 Config {
7939 project_root: Some(project.path().to_path_buf()),
7940 ..Config::default()
7941 },
7942 );
7943 let relative = PathBuf::from("src/relative.rs");
7946 let deleted = project.path().join("never-created.rs");
7947 ctx.add_pending_callgraph_store_paths([relative.clone(), deleted.clone()]);
7948
7949 let mut taken = ctx.take_pending_callgraph_store_paths();
7950 taken.sort();
7951 let mut expected = vec![relative, deleted];
7952 expected.sort();
7953 assert_eq!(
7954 taken, expected,
7955 "root-relative and deleted in-root paths must survive the filter"
7956 );
7957 }
7958
7959 #[test]
7960 fn writer_denied_callgraph_build_is_terminal_not_building() {
7961 let _env_guard = callgraph_build_wait_ms(30_000);
7962 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
7963
7964 let denied_ctx = cold_build_context();
7965 let denied_reason = match denied_ctx.callgraph_store_for_ops() {
7966 CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason)) => reason,
7967 CallgraphStoreAccess::Building => {
7968 panic!("writer-denied build must not remain in the retryable Building state")
7969 }
7970 _ => panic!("unregistered root must terminate with an unavailable reason"),
7971 };
7972 assert!(
7973 denied_reason.contains("could not acquire writer capability"),
7974 "terminal status must explain the writer-capability denial: {denied_reason}"
7975 );
7976 assert!(matches!(
7977 denied_ctx.callgraph_store_for_ops(),
7978 CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason))
7979 if reason.contains("could not acquire writer capability")
7980 ));
7981 assert_eq!(
7982 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
7983 1,
7984 "polling a denied root must not spawn another doomed build"
7985 );
7986
7987 let writable_ctx = cold_build_context();
7990 let writable_root = writable_ctx
7991 .config()
7992 .project_root
7993 .clone()
7994 .expect("writable fixture root");
7995 let writable_key = crate::search_index::artifact_cache_key(&writable_root);
7996 crate::root_cache::configure_artifact_access(&writable_root, &writable_key, false);
7997 assert!(
7998 matches!(
7999 writable_ctx.callgraph_store_for_ops(),
8000 CallgraphStoreAccess::Ready(_)
8001 ),
8002 "removing the forced denial must change the terminal status"
8003 );
8004 }
8005
8006 #[test]
8007 fn concurrent_cold_callgraph_store_for_ops_spawns_one_build() {
8008 let _env_guard = force_async_callgraph_builds();
8009 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
8010
8011 let project = TempDir::new().expect("project tempdir");
8012 let storage = TempDir::new().expect("storage tempdir");
8013 let source_dir = project.path().join("src");
8014 std::fs::create_dir_all(&source_dir).expect("source dir");
8015 std::fs::write(
8016 source_dir.join("lib.rs"),
8017 "pub fn caller() { callee(); }\npub fn callee() {}\n",
8018 )
8019 .expect("source file");
8020
8021 let ctx = Arc::new(AppContext::new(
8022 Box::new(TreeSitterProvider::new()),
8023 Config {
8024 project_root: Some(project.path().to_path_buf()),
8025 storage_dir: Some(storage.path().to_path_buf()),
8026 callgraph_chunk_size: 1,
8027 ..Config::default()
8028 },
8029 ));
8030
8031 let barrier = Arc::new(Barrier::new(3));
8032 let handles = (0..2)
8033 .map(|_| {
8034 let ctx = Arc::clone(&ctx);
8035 let barrier = Arc::clone(&barrier);
8036 std::thread::spawn(move || {
8037 barrier.wait();
8038 matches!(
8039 ctx.callgraph_store_for_ops(),
8040 CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
8041 )
8042 })
8043 })
8044 .collect::<Vec<_>>();
8045
8046 barrier.wait();
8047 for handle in handles {
8048 assert!(
8049 handle.join().expect("callgraph caller thread"),
8050 "cold callgraph ops should report Building or observe the installed store"
8051 );
8052 }
8053
8054 assert_eq!(
8055 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8056 1,
8057 "concurrent cold callers must share one background build"
8058 );
8059
8060 let rx = ctx
8061 .callgraph_store_rx
8062 .lock()
8063 .as_ref()
8064 .cloned()
8065 .expect("in-flight receiver installed before spawn");
8066 rx.recv_timeout(Duration::from_secs(30))
8067 .expect("background cold build should complete");
8068 *ctx.callgraph_store_rx.lock() = None;
8069 }
8070
8071 #[test]
8072 fn watcher_gap_invalidation_gates_resident_artifacts_and_forces_strict_verify() {
8073 let root = TempDir::new().expect("project tempdir");
8074 let canonical_root = std::fs::canonicalize(root.path()).expect("canonical project root");
8075 let ctx = AppContext::new(
8076 Box::new(TreeSitterProvider::new()),
8077 Config {
8078 project_root: Some(canonical_root.clone()),
8079 ..Config::default()
8080 },
8081 );
8082 *ctx.search_index
8083 .write()
8084 .unwrap_or_else(std::sync::PoisonError::into_inner) =
8085 Some(SearchIndex::build(&canonical_root));
8086 *ctx.semantic_index
8087 .write()
8088 .unwrap_or_else(std::sync::PoisonError::into_inner) =
8089 Some(SemanticIndex::new(canonical_root.clone(), 3));
8090 *ctx.semantic_index_status
8091 .write()
8092 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
8093
8094 let artifact = canonical_root.join("verify-artifact.bin");
8095 std::fs::write(&artifact, b"same-size").expect("write verification artifact");
8096 let generation =
8097 crate::cache_freshness::artifact_generation(&artifact).expect("artifact generation");
8098 crate::cache_freshness::record_verify_completed(
8099 &canonical_root,
8100 crate::cache_freshness::VerifyArtifact::Search,
8101 Some(generation),
8102 );
8103 assert_eq!(
8104 crate::cache_freshness::warm_verify_plan(
8105 &canonical_root,
8106 crate::cache_freshness::VerifyArtifact::Search,
8107 Some(generation),
8108 ),
8109 crate::cache_freshness::WarmVerifyPlan::Skip
8110 );
8111
8112 ctx.invalidate_artifacts_after_watcher_gap();
8113
8114 assert!(ctx
8115 .search_index
8116 .read()
8117 .unwrap_or_else(std::sync::PoisonError::into_inner)
8118 .is_none());
8119 assert!(ctx
8120 .semantic_index
8121 .read()
8122 .unwrap_or_else(std::sync::PoisonError::into_inner)
8123 .is_none());
8124 assert!(ctx.pending_callgraph_store_force_token().is_some());
8125 assert_eq!(
8126 crate::cache_freshness::warm_verify_plan(
8127 &canonical_root,
8128 crate::cache_freshness::VerifyArtifact::Search,
8129 Some(generation),
8130 ),
8131 crate::cache_freshness::WarmVerifyPlan::Strict
8132 );
8133 }
8134
8135 #[test]
8136 fn cancelled_semantic_refresh_transfers_refreshing_files_to_pending() {
8137 let root = TempDir::new().expect("project tempdir");
8138 let ctx = AppContext::new(
8139 Box::new(TreeSitterProvider::new()),
8140 Config {
8141 project_root: Some(root.path().to_path_buf()),
8142 semantic_search: true,
8143 ..Config::default()
8144 },
8145 );
8146 *ctx.semantic_index
8147 .write()
8148 .unwrap_or_else(std::sync::PoisonError::into_inner) =
8149 Some(SemanticIndex::new(root.path().to_path_buf(), 3));
8150 let refreshing_path = root.path().join("src/lib.rs");
8151 {
8152 let mut status = ctx
8153 .semantic_index_status
8154 .write()
8155 .unwrap_or_else(std::sync::PoisonError::into_inner);
8156 *status = SemanticIndexStatus::ready();
8157 status.start_refreshing_file(refreshing_path.clone());
8158 }
8159 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
8160 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
8161 ctx.install_semantic_refresh_worker_for_build_epoch(
8162 request_tx,
8163 event_rx,
8164 Arc::new(Mutex::new(None)),
8165 ctx.semantic_index_rx_epoch(),
8166 );
8167
8168 ctx.cancel_unbound_artifact_work();
8169
8170 assert_eq!(
8173 ctx.pending_semantic_index_paths
8174 .lock()
8175 .iter()
8176 .cloned()
8177 .collect::<Vec<_>>(),
8178 vec![refreshing_path],
8179 "cancelled in-flight refresh files must transfer to the pending set"
8180 );
8181 assert!(matches!(
8182 &*ctx
8183 .semantic_index_status
8184 .read()
8185 .unwrap_or_else(std::sync::PoisonError::into_inner),
8186 SemanticIndexStatus::Ready { refreshing, .. } if refreshing.is_empty()
8187 ));
8188 }
8189
8190 #[test]
8191 fn unbind_before_corpus_started_preserves_corpus_intent() {
8192 let root = TempDir::new().expect("project tempdir");
8197 let ctx = AppContext::new(
8198 Box::new(TreeSitterProvider::new()),
8199 Config {
8200 project_root: Some(root.path().to_path_buf()),
8201 semantic_search: true,
8202 ..Config::default()
8203 },
8204 );
8205 *ctx.semantic_index
8206 .write()
8207 .unwrap_or_else(std::sync::PoisonError::into_inner) =
8208 Some(SemanticIndex::new(root.path().to_path_buf(), 3));
8209 *ctx.semantic_index_status
8210 .write()
8211 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
8212 stage: "refreshing_corpus".to_string(),
8213 files: None,
8214 entries_done: None,
8215 entries_total: None,
8216 };
8217 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
8218 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
8219 ctx.install_semantic_refresh_worker_for_build_epoch(
8220 request_tx,
8221 event_rx,
8222 Arc::new(Mutex::new(None)),
8223 ctx.semantic_index_rx_epoch(),
8224 );
8225
8226 ctx.cancel_unbound_artifact_work();
8227
8228 assert!(
8229 *ctx.pending_semantic_corpus_refresh.lock(),
8230 "corpus intent stamped before CorpusStarted must survive the cancellation"
8231 );
8232 }
8233
8234 #[test]
8235 fn cancelled_search_corpus_refresh_drops_nonready_resident_index() {
8236 let root = TempDir::new().expect("project tempdir");
8237 let ctx = AppContext::new(
8238 Box::new(TreeSitterProvider::new()),
8239 Config {
8240 project_root: Some(root.path().to_path_buf()),
8241 ..Config::default()
8242 },
8243 );
8244 let mut refreshing = SearchIndex::new();
8248 refreshing.ready = false;
8249 *ctx.search_index
8250 .write()
8251 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(refreshing);
8252 let (_tx, rx) = crossbeam_channel::unbounded();
8253 ctx.install_search_index_rx(rx, ctx.configure_generation());
8254
8255 ctx.cancel_unbound_artifact_work();
8256
8257 assert!(
8258 ctx.search_index
8259 .read()
8260 .unwrap_or_else(std::sync::PoisonError::into_inner)
8261 .is_none(),
8262 "a cancelled corpus refresh must drop the non-ready resident so rebind reloads it"
8263 );
8264 assert!(ctx
8265 .search_index_rx
8266 .read()
8267 .unwrap_or_else(std::sync::PoisonError::into_inner)
8268 .is_none());
8269 }
8270
8271 #[test]
8272 fn active_semantic_file_refresh_blocks_idle_eviction_until_completion() {
8273 let root = TempDir::new().expect("project tempdir");
8274 let ctx = AppContext::new(
8275 Box::new(TreeSitterProvider::new()),
8276 Config {
8277 project_root: Some(root.path().to_path_buf()),
8278 ..Config::default()
8279 },
8280 );
8281 *ctx.semantic_index
8282 .write()
8283 .unwrap_or_else(std::sync::PoisonError::into_inner) =
8284 Some(SemanticIndex::new(root.path().to_path_buf(), 3));
8285 let refreshing_path = root.path().join("src/lib.rs");
8286 {
8287 let mut status = ctx
8288 .semantic_index_status
8289 .write()
8290 .unwrap_or_else(std::sync::PoisonError::into_inner);
8291 *status = SemanticIndexStatus::ready();
8292 status.start_refreshing_file(refreshing_path.clone());
8293 }
8294
8295 assert!(ctx.artifact_eviction_blocked());
8296 assert!(!ctx.evict_idle_artifacts());
8297 assert!(ctx
8298 .semantic_index
8299 .read()
8300 .unwrap_or_else(std::sync::PoisonError::into_inner)
8301 .is_some());
8302
8303 ctx.semantic_index_status
8304 .write()
8305 .unwrap_or_else(std::sync::PoisonError::into_inner)
8306 .complete_refreshing_file(&refreshing_path);
8307 assert!(ctx.evict_idle_artifacts());
8308 assert!(ctx
8309 .semantic_index
8310 .read()
8311 .unwrap_or_else(std::sync::PoisonError::into_inner)
8312 .is_none());
8313 }
8314}
8315
8316#[cfg(test)]
8317mod status_emitter_tests {
8318 use super::*;
8319 use crate::parser::TreeSitterProvider;
8320
8321 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
8322 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
8323 let (tx, rx) = mpsc::channel();
8324 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
8325 let _ = tx.send(frame);
8326 }))));
8327 (ctx, rx)
8328 }
8329
8330 #[test]
8331 fn status_emitter_signal_triggers_push() {
8332 let (ctx, rx) = ctx_with_frame_rx();
8333 ctx.status_emitter().signal(ctx.build_status_snapshot());
8334 let frame = rx
8335 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
8336 .expect("status_changed push");
8337 assert!(matches!(frame, PushFrame::StatusChanged(_)));
8338 }
8339
8340 #[test]
8341 fn status_emitter_debounces_burst() {
8342 let (ctx, rx) = ctx_with_frame_rx();
8343 for _ in 0..10 {
8344 ctx.status_emitter().signal(ctx.build_status_snapshot());
8345 }
8346 let frame = rx
8347 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
8348 .expect("status_changed push");
8349 assert!(matches!(frame, PushFrame::StatusChanged(_)));
8350 assert!(rx.try_recv().is_err());
8351 }
8352
8353 #[test]
8354 fn status_emitter_separate_windows_separate_pushes() {
8355 let (ctx, rx) = ctx_with_frame_rx();
8356 ctx.status_emitter().signal(ctx.build_status_snapshot());
8357 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
8358 .expect("first push");
8359 ctx.status_emitter().signal(ctx.build_status_snapshot());
8360 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
8361 .expect("second push");
8362 }
8363
8364 #[test]
8365 fn status_emitter_no_signal_no_push() {
8366 let (_ctx, rx) = ctx_with_frame_rx();
8367 assert!(rx
8368 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
8369 .is_err());
8370 }
8371
8372 #[test]
8373 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
8374 let (ctx, rx) = ctx_with_frame_rx();
8375 drop(ctx);
8376 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
8377 }
8378
8379 #[test]
8380 fn progress_sender_slot_is_per_context_for_shared_app() {
8381 let app = App::default_shared();
8382 let ctx_a = AppContext::from_app(Arc::clone(&app), Config::default());
8383 let ctx_b = AppContext::from_app(app, Config::default());
8384 let (tx_a, rx_a) = mpsc::channel();
8385 let (tx_b, rx_b) = mpsc::channel();
8386
8387 ctx_a.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
8388 let _ = tx_a.send(frame);
8389 }))));
8390 ctx_b.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
8391 let _ = tx_b.send(frame);
8392 }))));
8393
8394 ctx_a.emit_progress(ProgressFrame {
8395 frame_type: "progress",
8396 request_id: "ctx-a".to_string(),
8397 kind: crate::protocol::ProgressKind::Stdout,
8398 chunk: "a".to_string(),
8399 });
8400 ctx_b.emit_progress(ProgressFrame {
8401 frame_type: "progress",
8402 request_id: "ctx-b".to_string(),
8403 kind: crate::protocol::ProgressKind::Stdout,
8404 chunk: "b".to_string(),
8405 });
8406
8407 match rx_a
8408 .recv_timeout(Duration::from_millis(50))
8409 .expect("ctx A progress frame")
8410 {
8411 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-a"),
8412 other => panic!("unexpected frame for ctx A: {other:?}"),
8413 }
8414 assert!(rx_a.try_recv().is_err());
8415
8416 match rx_b
8417 .recv_timeout(Duration::from_millis(50))
8418 .expect("ctx B progress frame")
8419 {
8420 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-b"),
8421 other => panic!("unexpected frame for ctx B: {other:?}"),
8422 }
8423 assert!(rx_b.try_recv().is_err());
8424 }
8425}
8426
8427#[cfg(test)]
8428mod health_warming_honesty_tests {
8429 use super::*;
8430 use crate::parser::TreeSitterProvider;
8431
8432 fn ctx_with_config(config: Config) -> AppContext {
8433 AppContext::new(Box::new(TreeSitterProvider::new()), config)
8434 }
8435
8436 fn health_search_status(ctx: &AppContext) -> &'static str {
8437 let root = std::path::Path::new("/tmp/health-warming-honesty-test");
8438 ctx.try_health_snapshot(root)
8439 .search_index
8440 .expect("search_index component present")
8441 .status
8442 }
8443
8444 fn health_tier2_status(ctx: &AppContext) -> &'static str {
8445 let root = std::path::Path::new("/tmp/health-warming-honesty-test");
8446 ctx.try_health_snapshot(root)
8447 .tier2
8448 .expect("tier2 component present")
8449 .status
8450 }
8451
8452 #[test]
8453 fn write_denied_search_index_reports_ready_not_building() {
8454 let config = Config {
8458 search_index: true,
8459 ..Config::default()
8460 };
8461 let ctx = ctx_with_config(config);
8462 let mut index = SearchIndex::new();
8463 index.build_denied = true;
8464 *ctx.search_index()
8465 .write()
8466 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
8467
8468 assert_eq!(
8469 health_search_status(&ctx),
8470 "ready",
8471 "a build-denied index is a terminal settled state and must not report building forever"
8472 );
8473 }
8474
8475 #[test]
8476 fn in_progress_search_index_still_reports_building() {
8477 let config = Config {
8481 search_index: true,
8482 ..Config::default()
8483 };
8484 let ctx = ctx_with_config(config);
8485 let index = SearchIndex::new(); *ctx.search_index()
8487 .write()
8488 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
8489
8490 assert_eq!(health_search_status(&ctx), "building");
8491 }
8492
8493 #[test]
8494 fn tier2_blocked_on_callgraph_reports_ready_not_building() {
8495 let ctx = ctx_with_config(Config::default()); ctx.update_status_bar_tier2(None, Some(3), Some(2), None, false);
8501 ctx.set_status_bar_tier2_dead_code_blocked_on_callgraph(true);
8502
8503 assert_eq!(
8504 health_tier2_status(&ctx),
8505 "ready",
8506 "tier2 complete except dead_code-blocked-on-callgraph must not stay building"
8507 );
8508 }
8509
8510 #[test]
8511 fn tier2_missing_dead_code_without_callgraph_block_reports_building() {
8512 let ctx = ctx_with_config(Config::default());
8515 ctx.update_status_bar_tier2(None, Some(3), Some(2), None, false);
8516 ctx.set_status_bar_tier2_dead_code_blocked_on_callgraph(false);
8517
8518 assert_eq!(health_tier2_status(&ctx), "building");
8519 }
8520}
8521
8522#[cfg(test)]
8523mod status_bar_tests {
8524 use super::*;
8525 use crate::parser::TreeSitterProvider;
8526
8527 fn ctx() -> AppContext {
8528 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
8529 }
8530
8531 #[test]
8532 fn truthful_values_omit_unproven_categories_while_legacy_projection_stays_hidden() {
8533 let ctx = ctx();
8534 let values = ctx.status_bar_count_values();
8535 assert_eq!(values.errors, None);
8536 assert_eq!(values.warnings, None);
8537 assert_eq!(values.dead_code, None);
8538 assert_eq!(values.unused_exports, None);
8539 assert_eq!(values.duplicates, None);
8540 assert_eq!(values.todos, None);
8541 assert!(ctx.status_bar_counts().is_none());
8542
8543 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
8544 let values = ctx.status_bar_count_values();
8545 assert_eq!(values.dead_code, Some(5));
8546 assert_eq!(values.unused_exports, Some(3));
8547 assert_eq!(values.duplicates, Some(7));
8548 assert_eq!(values.todos, Some(2));
8549 assert_eq!(values.errors, None, "no analyzer report is not a clean E0");
8550 assert_eq!(
8551 values.warnings, None,
8552 "no analyzer report is not a clean W0"
8553 );
8554 assert!(!values.tier2_stale);
8555
8556 let legacy = ctx
8557 .status_bar_counts()
8558 .expect("legacy projection is populated");
8559 assert_eq!((legacy.errors, legacy.warnings), (0, 0));
8560 }
8561
8562 #[test]
8563 fn changing_root_clears_project_scoped_status_counts() {
8564 let temp = tempfile::tempdir().expect("tempdir");
8565 let first_root = temp.path().join("first");
8566 let second_root = temp.path().join("second");
8567 std::fs::create_dir_all(&first_root).expect("create first root");
8568 std::fs::create_dir_all(&second_root).expect("create second root");
8569 let ctx = ctx();
8570 ctx.set_canonical_cache_root(first_root);
8571 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
8572 assert!(ctx.status_bar_counts().is_some());
8573
8574 ctx.set_canonical_cache_root(second_root);
8575
8576 let values = ctx.status_bar_count_values();
8577 assert_eq!(values.dead_code, None);
8578 assert_eq!(values.unused_exports, None);
8579 assert_eq!(values.duplicates, None);
8580 assert!(
8581 ctx.status_bar_counts().is_none(),
8582 "counts from the previous root must not appear in a newly bound root"
8583 );
8584 }
8585
8586 #[test]
8587 fn partial_tier2_keeps_proven_categories_and_cache_hit_preserves_omissions() {
8588 let ctx = ctx();
8589 ctx.update_status_bar_tier2(Some(5), None, None, None, true);
8590
8591 let first = ctx.status_bar_count_values();
8592 assert_eq!(first.dead_code, Some(5));
8593 assert_eq!(first.unused_exports, None);
8594 assert_eq!(first.duplicates, None);
8595 assert_eq!(first.todos, None);
8596 assert!(first.tier2_stale);
8597 assert!(ctx.status_bar_counts().is_none());
8598
8599 let cached = ctx.status_bar_count_values();
8600 assert_eq!(cached, first, "a cache hit must preserve every omission");
8601 let cache = ctx
8602 .status_bar_cached
8603 .read()
8604 .unwrap_or_else(std::sync::PoisonError::into_inner);
8605 assert!(cache.valid);
8606 assert_eq!(cache.counts.as_ref(), Some(&first));
8607 drop(cache);
8608
8609 ctx.update_status_bar_tier2(None, Some(3), None, None, true);
8610 let partial = ctx.status_bar_count_values();
8611 assert_eq!(partial.dead_code, Some(5));
8612 assert_eq!(partial.unused_exports, Some(3));
8613 assert_eq!(partial.duplicates, None);
8614
8615 ctx.update_status_bar_tier2(None, None, Some(7), None, false);
8616 let complete = ctx.status_bar_count_values();
8617 assert_eq!(complete.dead_code, Some(5));
8618 assert_eq!(complete.unused_exports, Some(3));
8619 assert_eq!(complete.duplicates, Some(7));
8620 }
8621
8622 #[test]
8623 fn update_with_none_todos_preserves_last_known_todos() {
8624 let ctx = ctx();
8625 ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
8626 ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
8628 let counts = ctx.status_bar_count_values();
8629 assert_eq!(counts.todos, Some(9));
8630 assert_eq!(counts.dead_code, Some(2));
8631 }
8632
8633 #[test]
8634 fn update_with_none_count_preserves_last_known_count() {
8635 let ctx = ctx();
8636 ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
8637 ctx.update_status_bar_tier2(Some(11), None, None, None, false);
8640 let counts = ctx.status_bar_count_values();
8641 assert_eq!(counts.dead_code, Some(11));
8642 assert_eq!(counts.unused_exports, Some(20));
8643 assert_eq!(counts.duplicates, Some(30));
8644 }
8645
8646 #[test]
8647 fn mark_stale_sets_flag_after_any_proven_category() {
8648 let ctx = ctx();
8649 ctx.mark_status_bar_tier2_stale();
8650 assert!(!ctx.status_bar_count_values().tier2_stale);
8651
8652 ctx.update_status_bar_tier2(Some(4), None, None, None, false);
8653 ctx.mark_status_bar_tier2_stale();
8654 assert!(ctx.status_bar_count_values().tier2_stale);
8655
8656 ctx.update_status_bar_tier2(Some(4), None, None, None, false);
8658 assert!(!ctx.status_bar_count_values().tier2_stale);
8659 }
8660
8661 #[test]
8666 fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
8667 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
8668 use crate::lsp::registry::ServerKind;
8669 use crate::lsp::roots::ServerKey;
8670
8671 let ctx = ctx();
8672 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); let file = std::path::PathBuf::from("/proj/gone.ts");
8675 {
8676 let mut lsp = ctx.lsp();
8677 lsp.diagnostics_store_mut_for_test().publish(
8678 ServerKey {
8679 kind: ServerKind::TypeScript,
8680 root: std::path::PathBuf::from("/proj"),
8681 },
8682 file.clone(),
8683 vec![StoredDiagnostic {
8684 file: file.clone(),
8685 line: 1,
8686 column: 1,
8687 end_line: 1,
8688 end_column: 2,
8689 severity: DiagnosticSeverity::Error,
8690 message: "boom".into(),
8691 code: None,
8692 source: None,
8693 }],
8694 );
8695 }
8696
8697 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
8699
8700 let removed = ctx.lsp_clear_diagnostics_for_file(&file);
8702 assert!(removed);
8703 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
8704 }
8705
8706 #[test]
8707 fn status_bar_preserves_authoritative_counts_until_provisional_report_is_promoted() {
8708 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
8709 use crate::lsp::registry::ServerKind;
8710 use crate::lsp::roots::ServerKey;
8711
8712 let ctx = ctx();
8713 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
8714 let root = std::path::PathBuf::from("/proj");
8715 let file = root.join("src/main.rs");
8716 let key = ServerKey {
8717 kind: ServerKind::Rust,
8718 root,
8719 };
8720 let diagnostic = |severity, message: &str| StoredDiagnostic {
8721 file: file.clone(),
8722 line: 1,
8723 column: 1,
8724 end_line: 1,
8725 end_column: 2,
8726 severity,
8727 message: message.into(),
8728 code: None,
8729 source: None,
8730 };
8731
8732 {
8733 let mut lsp = ctx.lsp();
8734 lsp.diagnostics_store_mut_for_test().publish(
8735 key.clone(),
8736 file.clone(),
8737 vec![diagnostic(DiagnosticSeverity::Error, "settled error")],
8738 );
8739 }
8740 let counts = ctx.status_bar_counts().expect("populated");
8741 assert_eq!((counts.errors, counts.warnings), (1, 0));
8742
8743 {
8744 let mut lsp = ctx.lsp();
8745 lsp.diagnostics_store_mut_for_test()
8746 .publish_full_with_provisional(
8747 key.clone(),
8748 file.clone(),
8749 vec![diagnostic(
8750 DiagnosticSeverity::Warning,
8751 "latest warming warning",
8752 )],
8753 None,
8754 None,
8755 true,
8756 );
8757 }
8758 let counts = ctx.status_bar_counts().expect("populated");
8759 assert_eq!(
8760 (counts.errors, counts.warnings),
8761 (1, 0),
8762 "pre-quiescence diagnostics must not replace authoritative counts"
8763 );
8764
8765 {
8766 let mut lsp = ctx.lsp();
8767 assert!(lsp
8768 .diagnostics_store_mut_for_test()
8769 .promote_provisional_for_server(&key));
8770 }
8771 let counts = ctx.status_bar_counts().expect("populated");
8772 assert_eq!(
8773 (counts.errors, counts.warnings),
8774 (0, 1),
8775 "the latest report becomes authoritative at quiescence"
8776 );
8777 }
8778
8779 #[test]
8780 fn status_bar_filtered_counts_ignore_environmental_flap() {
8781 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
8782 use crate::lsp::registry::ServerKind;
8783 use crate::lsp::roots::ServerKey;
8784
8785 let ctx = ctx();
8786 let root = if cfg!(windows) {
8787 std::path::PathBuf::from(r"C:\proj")
8788 } else {
8789 std::path::PathBuf::from("/proj")
8790 };
8791 ctx.set_canonical_cache_root(root.clone());
8792 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
8793
8794 let file = root.join("aft.jsonc");
8795 let key = ServerKey {
8796 kind: ServerKind::TypeScript,
8797 root: root.clone(),
8798 };
8799 let env = StoredDiagnostic {
8800 file: file.clone(),
8801 line: 1,
8802 column: 1,
8803 end_line: 1,
8804 end_column: 2,
8805 severity: DiagnosticSeverity::Error,
8806 message: "Failed to load schema from https://example.com/schema.json".into(),
8807 code: None,
8808 source: Some("json".into()),
8809 };
8810
8811 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
8812
8813 {
8814 let mut lsp = ctx.lsp();
8815 lsp.diagnostics_store_mut_for_test()
8816 .publish(key.clone(), file.clone(), vec![env]);
8817 }
8818 assert_eq!(
8819 ctx.status_bar_counts().expect("populated").errors,
8820 0,
8821 "environmental publish must not change status-bar E"
8822 );
8823
8824 {
8825 let mut lsp = ctx.lsp();
8826 lsp.diagnostics_store_mut_for_test()
8827 .publish(key, file, vec![]);
8828 }
8829 assert_eq!(
8830 ctx.status_bar_counts().expect("populated").errors,
8831 0,
8832 "environmental clear must not change status-bar E"
8833 );
8834 }
8835}
8836
8837#[cfg(test)]
8838mod harness_path_tests {
8839 use super::*;
8840 use crate::harness::Harness;
8841 use crate::parser::TreeSitterProvider;
8842
8843 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
8844 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
8845 ctx.update_config(|config| {
8846 config.storage_dir = Some(storage_dir);
8847 });
8848 ctx.set_harness(harness);
8849 ctx
8850 }
8851
8852 #[test]
8853 fn harness_dir_resolves_correctly() {
8854 let storage = PathBuf::from("/tmp/cortexkit/aft");
8855 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
8856
8857 assert_eq!(ctx.harness_dir(), storage.join("pi"));
8858 }
8859
8860 #[test]
8861 fn bash_tasks_dir_uses_hash_session() {
8862 let storage = PathBuf::from("/tmp/cortexkit/aft");
8863 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
8864
8865 assert_eq!(
8866 ctx.bash_tasks_dir("ses_abc"),
8867 storage
8868 .join("opencode")
8869 .join("bash-tasks")
8870 .join(hash_session("ses_abc"))
8871 );
8872 }
8873
8874 #[test]
8875 fn backups_dir_includes_path_hash() {
8876 let storage = PathBuf::from("/tmp/cortexkit/aft");
8877 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
8878
8879 assert_eq!(
8880 ctx.backups_dir("ses_abc", "pathhash"),
8881 storage
8882 .join("pi")
8883 .join("backups")
8884 .join(hash_session("ses_abc"))
8885 .join("pathhash")
8886 );
8887 }
8888
8889 #[test]
8890 fn filters_dir_under_harness() {
8891 let storage = PathBuf::from("/tmp/cortexkit/aft");
8892 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
8893
8894 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
8895 }
8896
8897 #[test]
8898 fn trust_file_is_host_global() {
8899 let storage = PathBuf::from("/tmp/cortexkit/aft");
8900 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
8901
8902 assert_eq!(
8903 ctx.trust_file(),
8904 storage.join("trusted-filter-projects.json")
8905 );
8906 }
8907
8908 #[test]
8909 fn same_session_different_harness_resolve_different_paths() {
8910 let storage = PathBuf::from("/tmp/cortexkit/aft");
8911 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
8912 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
8913
8914 assert_ne!(
8915 opencode.bash_tasks_dir("ses_same"),
8916 pi.bash_tasks_dir("ses_same")
8917 );
8918 }
8919
8920 #[test]
8921 fn callgraph_and_inspect_dirs_are_root_keyed() {
8922 let temp = tempfile::tempdir().expect("tempdir");
8923 let storage = temp.path().join("storage");
8924 let root = temp.path().join("checkout");
8925 std::fs::create_dir_all(&root).expect("create root");
8926 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
8927 ctx.set_canonical_cache_root(root.clone());
8928
8929 assert_eq!(
8930 ctx.callgraph_store_dir(),
8931 storage
8932 .join("callgraph")
8933 .join(crate::search_index::artifact_cache_key(&root))
8934 );
8935 assert_eq!(
8936 ctx.inspect_dir(),
8937 storage
8938 .join("inspect")
8939 .join(crate::path_identity::project_scope_key(&root))
8940 );
8941 assert!(!ctx
8942 .callgraph_store_dir()
8943 .starts_with(storage.join("opencode")));
8944 assert!(!ctx.inspect_dir().starts_with(storage.join("opencode")));
8945 }
8946
8947 #[test]
8948 fn per_domain_capability_allows_inspect_writer_when_callgraph_read_only() {
8949 let storage = PathBuf::from("/tmp/cortexkit/aft");
8950 let ctx = ctx_with_storage_and_harness(storage, Harness::Opencode);
8951 ctx.set_cache_writer_capabilities(false, true);
8952
8953 assert!(ctx.shared_artifacts_read_only());
8954 assert!(!ctx.callgraph_writer());
8955 assert!(ctx.inspect_writer());
8956 }
8957}
8958
8959#[cfg(test)]
8960mod shared_db_tests {
8961 use super::*;
8962 use tempfile::tempdir;
8963
8964 #[test]
8965 fn app_contexts_share_one_database_connection() {
8966 let storage = tempdir().expect("storage tempdir");
8967 let root_one = tempdir().expect("first root tempdir");
8968 let root_two = tempdir().expect("second root tempdir");
8969 let app = App::default_shared();
8970 let ctx_one = AppContext::from_app(
8971 Arc::clone(&app),
8972 Config {
8973 project_root: Some(root_one.path().to_path_buf()),
8974 ..Config::default()
8975 },
8976 );
8977 let ctx_two = AppContext::from_app(
8978 Arc::clone(&app),
8979 Config {
8980 project_root: Some(root_two.path().to_path_buf()),
8981 ..Config::default()
8982 },
8983 );
8984 let path = storage.path().join("aft.db");
8985
8986 let first = app.open_db(&path).expect("open shared database");
8987 let second = app.open_db(&path).expect("reuse shared database");
8988
8989 assert!(Arc::ptr_eq(&first, &second));
8990 assert!(Arc::ptr_eq(
8991 &ctx_one.db().expect("first context database"),
8992 &ctx_two.db().expect("second context database")
8993 ));
8994 }
8995}
8996
8997#[cfg(test)]
8998mod gitignore_tests {
8999 use super::*;
9000 use std::fs;
9001 use std::path::Path;
9002 use tempfile::TempDir;
9003
9004 fn make_ctx_with_root(root: &Path) -> AppContext {
9005 let provider = Box::new(crate::parser::TreeSitterProvider::new());
9006 let config = Config {
9007 project_root: Some(root.to_path_buf()),
9008 ..Config::default()
9009 };
9010 AppContext::new(provider, config)
9011 }
9012
9013 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
9020 let Some(matcher) = ctx.gitignore() else {
9021 return false;
9022 };
9023 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
9024 if !canonical.starts_with(matcher.path()) {
9025 return false;
9026 }
9027 let is_dir = canonical.is_dir();
9028 matcher
9029 .matched_path_or_any_parents(&canonical, is_dir)
9030 .is_ignore()
9031 }
9032
9033 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
9046 let _guard = crate::test_env::process_env_lock();
9047 let tmp = TempDir::new().unwrap();
9048 let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
9049 let prev_home = std::env::var_os("HOME");
9050 let prev_userprofile = std::env::var_os("USERPROFILE");
9051 unsafe {
9054 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
9055 std::env::set_var("HOME", tmp.path());
9056 std::env::set_var("USERPROFILE", tmp.path());
9057 }
9058 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
9059 unsafe {
9060 match prev_xdg {
9061 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
9062 None => std::env::remove_var("XDG_CONFIG_HOME"),
9063 }
9064 match prev_home {
9065 Some(v) => std::env::set_var("HOME", v),
9066 None => std::env::remove_var("HOME"),
9067 }
9068 match prev_userprofile {
9069 Some(v) => std::env::set_var("USERPROFILE", v),
9070 None => std::env::remove_var("USERPROFILE"),
9071 }
9072 }
9073 match result {
9074 Ok(r) => r,
9075 Err(p) => std::panic::resume_unwind(p),
9076 }
9077 }
9078
9079 #[test]
9080 fn rebuild_gitignore_returns_none_without_project_root() {
9081 let provider = Box::new(crate::parser::TreeSitterProvider::new());
9082 let ctx = AppContext::new(provider, Config::default());
9083 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
9084 assert!(ctx.gitignore().is_none());
9085 }
9086
9087 #[test]
9088 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
9089 let tmp = TempDir::new().unwrap();
9090 let ctx = make_ctx_with_root(tmp.path());
9091 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
9092 assert!(ctx.gitignore().is_none());
9093 }
9094
9095 #[test]
9096 fn matcher_filters_files_in_ignored_dist_dir() {
9097 let tmp = TempDir::new().unwrap();
9098 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
9099 fs::create_dir_all(tmp.path().join("dist")).unwrap();
9100 fs::create_dir_all(tmp.path().join("src")).unwrap();
9101 let dist_file = tmp.path().join("dist").join("bundle.js");
9102 let src_file = tmp.path().join("src").join("app.ts");
9103 fs::write(&dist_file, "x").unwrap();
9104 fs::write(&src_file, "y").unwrap();
9105
9106 let ctx = make_ctx_with_root(tmp.path());
9107 ctx.rebuild_gitignore();
9108
9109 assert!(ctx.gitignore().is_some());
9110 assert!(
9111 is_ignored(&ctx, &dist_file),
9112 "dist/bundle.js should be ignored"
9113 );
9114 assert!(
9115 !is_ignored(&ctx, &src_file),
9116 "src/app.ts should NOT be ignored"
9117 );
9118 }
9119
9120 #[test]
9121 fn matcher_handles_node_modules_and_target() {
9122 let tmp = TempDir::new().unwrap();
9123 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
9124 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
9125 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
9126 let nm_file = tmp.path().join("node_modules/foo/index.js");
9127 let target_file = tmp.path().join("target/debug/aft");
9128 fs::write(&nm_file, "x").unwrap();
9129 fs::write(&target_file, "x").unwrap();
9130
9131 let ctx = make_ctx_with_root(tmp.path());
9132 ctx.rebuild_gitignore();
9133
9134 assert!(is_ignored(&ctx, &nm_file));
9135 assert!(is_ignored(&ctx, &target_file));
9136 }
9137
9138 #[test]
9139 fn matcher_honors_negation_pattern() {
9140 let tmp = TempDir::new().unwrap();
9142 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
9143 let random_log = tmp.path().join("random.log");
9144 let important_log = tmp.path().join("important.log");
9145 fs::write(&random_log, "x").unwrap();
9146 fs::write(&important_log, "y").unwrap();
9147
9148 let ctx = make_ctx_with_root(tmp.path());
9149 ctx.rebuild_gitignore();
9150
9151 assert!(is_ignored(&ctx, &random_log));
9152 assert!(
9153 !is_ignored(&ctx, &important_log),
9154 "negation pattern should un-ignore important.log"
9155 );
9156 }
9157
9158 #[test]
9159 fn rebuild_picks_up_gitignore_changes() {
9160 let tmp = TempDir::new().unwrap();
9161 let ignore_path = tmp.path().join(".gitignore");
9162 fs::write(&ignore_path, "foo.txt\n").unwrap();
9163 let foo = tmp.path().join("foo.txt");
9164 let bar = tmp.path().join("bar.txt");
9165 fs::write(&foo, "").unwrap();
9166 fs::write(&bar, "").unwrap();
9167
9168 let ctx = make_ctx_with_root(tmp.path());
9169 ctx.rebuild_gitignore();
9170 assert!(is_ignored(&ctx, &foo));
9171 assert!(!is_ignored(&ctx, &bar));
9172
9173 fs::write(&ignore_path, "bar.txt\n").unwrap();
9175 ctx.rebuild_gitignore();
9176 assert!(!is_ignored(&ctx, &foo));
9177 assert!(is_ignored(&ctx, &bar));
9178 }
9179
9180 #[test]
9181 fn gitignore_loads_info_exclude_when_present() {
9182 let tmp = TempDir::new().unwrap();
9183 let info_dir = tmp.path().join(".git/info");
9184 fs::create_dir_all(&info_dir).unwrap();
9185 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
9186 let secrets = tmp.path().join("secrets.txt");
9187 let public = tmp.path().join("public.txt");
9188 fs::write(&secrets, "token").unwrap();
9189 fs::write(&public, "ok").unwrap();
9190
9191 let ctx = make_ctx_with_root(tmp.path());
9192 ctx.rebuild_gitignore();
9193
9194 assert!(is_ignored(&ctx, &secrets));
9195 assert!(!is_ignored(&ctx, &public));
9196 }
9197
9198 #[test]
9199 fn matcher_picks_up_nested_gitignore() {
9200 let tmp = TempDir::new().unwrap();
9201 fs::write(tmp.path().join(".gitignore"), "").unwrap();
9203 let sub = tmp.path().join("packages/foo");
9204 fs::create_dir_all(&sub).unwrap();
9205 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
9206 let generated_file = sub.join("generated").join("out.js");
9207 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
9208 fs::write(&generated_file, "x").unwrap();
9209
9210 let ctx = make_ctx_with_root(tmp.path());
9211 ctx.rebuild_gitignore();
9212
9213 assert!(
9214 is_ignored(&ctx, &generated_file),
9215 "nested gitignore in packages/foo/.gitignore should ignore generated/"
9216 );
9217 }
9218}
9219
9220#[cfg(test)]
9221mod verify_memo_watcher_tests {
9222 use super::*;
9223
9224 #[test]
9225 fn pending_watcher_path_invalidates_root_verify_memo() {
9226 let root_dir = tempfile::tempdir().unwrap();
9227 let root = std::fs::canonicalize(root_dir.path()).unwrap();
9228 let artifact = root.join("cache.bin");
9229 std::fs::write(&artifact, b"generation").unwrap();
9230 let generation = crate::cache_freshness::artifact_generation(&artifact).unwrap();
9231 crate::cache_freshness::record_verify_completed(
9232 &root,
9233 crate::cache_freshness::VerifyArtifact::Search,
9234 Some(generation),
9235 );
9236 assert_eq!(
9237 crate::cache_freshness::warm_verify_plan(
9238 &root,
9239 crate::cache_freshness::VerifyArtifact::Search,
9240 Some(generation),
9241 ),
9242 crate::cache_freshness::WarmVerifyPlan::Skip
9243 );
9244
9245 let ctx = AppContext::from_app(
9246 App::default_shared(),
9247 Config {
9248 project_root: Some(root.clone()),
9249 ..Config::default()
9250 },
9251 );
9252 ctx.set_canonical_cache_root(root.clone());
9253 ctx.add_pending_search_index_paths([root.join("changed.rs")]);
9254 assert_eq!(
9255 crate::cache_freshness::warm_verify_plan(
9256 &root,
9257 crate::cache_freshness::VerifyArtifact::Search,
9258 Some(generation),
9259 ),
9260 crate::cache_freshness::WarmVerifyPlan::StatFirst
9261 );
9262 }
9263}
9264
9265#[cfg(test)]
9266mod watcher_runtime_state_tests {
9267 use super::*;
9268 use crate::language::StubProvider;
9269
9270 fn test_context() -> AppContext {
9271 AppContext::new(Box::new(StubProvider), Config::default())
9272 }
9273
9274 #[test]
9275 fn finished_watcher_thread_reports_inactive_and_is_reclaimed_with_invalidation() {
9276 let root = tempfile::tempdir().expect("project tempdir");
9277 let canonical_root = std::fs::canonicalize(root.path()).expect("canonical root");
9278 let ctx = AppContext::new(
9279 Box::new(StubProvider),
9280 Config {
9281 project_root: Some(canonical_root.clone()),
9282 ..Config::default()
9283 },
9284 );
9285 ctx.set_canonical_cache_root(canonical_root.clone());
9286 struct DisableWatcherGuard;
9290 impl Drop for DisableWatcherGuard {
9291 fn drop(&mut self) {
9292 unsafe { std::env::remove_var("AFT_TEST_DISABLE_FILE_WATCHER") };
9293 }
9294 }
9295 let _env_lock = crate::test_env::process_env_lock();
9296 unsafe { std::env::set_var("AFT_TEST_DISABLE_FILE_WATCHER", "1") };
9297 let _disable_watcher = DisableWatcherGuard;
9298 *ctx.search_index
9301 .write()
9302 .unwrap_or_else(std::sync::PoisonError::into_inner) =
9303 Some(crate::search_index::SearchIndex::new());
9304 let artifact = canonical_root.join("artifact.bin");
9305 std::fs::write(&artifact, b"artifact").expect("artifact");
9306 let generation = crate::cache_freshness::artifact_generation(&artifact);
9307 crate::cache_freshness::record_verify_completed(
9308 &canonical_root,
9309 crate::cache_freshness::VerifyArtifact::Search,
9310 generation,
9311 );
9312
9313 let (dispatch_tx, dispatch_rx) = crate::watcher_filter::watcher_dispatch_channel();
9314 let _dispatch_tx = dispatch_tx;
9315 let join = std::thread::spawn(|| {});
9318 ctx.install_watcher_runtime(
9319 dispatch_rx,
9320 WatcherThreadHandle::new(Arc::new(AtomicBool::new(false)), join),
9321 );
9322 let deadline = std::time::Instant::now() + Duration::from_secs(2);
9323 while ctx.watcher_runtime_active() {
9324 assert!(
9325 std::time::Instant::now() < deadline,
9326 "a finished watcher thread must report the runtime inactive"
9327 );
9328 std::thread::yield_now();
9329 }
9330
9331 crate::commands::configure::ensure_project_watcher(&ctx);
9334
9335 assert!(
9336 ctx.search_index
9337 .read()
9338 .unwrap_or_else(std::sync::PoisonError::into_inner)
9339 .is_none(),
9340 "corpse reclaim must drop resident artifacts (events since the failure are lost)"
9341 );
9342 assert_eq!(
9343 crate::cache_freshness::warm_verify_plan(
9344 &canonical_root,
9345 crate::cache_freshness::VerifyArtifact::Search,
9346 generation,
9347 ),
9348 crate::cache_freshness::WarmVerifyPlan::Strict,
9349 "corpse reclaim must force strict re-verification"
9350 );
9351 assert!(
9352 !ctx.take_finished_watcher_runtime(),
9353 "reclaim is one-shot; the corpse is gone after ensure_project_watcher"
9354 );
9355 }
9356
9357 #[test]
9358 fn watcher_runtime_requires_both_thread_and_dispatch_receiver() {
9359 let ctx = test_context();
9360 let (dispatch_tx, dispatch_rx) = crate::watcher_filter::watcher_dispatch_channel();
9361 let shutdown = Arc::new(AtomicBool::new(false));
9362 let thread_shutdown = Arc::clone(&shutdown);
9363 let join = std::thread::spawn(move || {
9364 while !thread_shutdown.load(Ordering::SeqCst) {
9365 std::thread::sleep(Duration::from_millis(1));
9366 }
9367 drop(dispatch_tx);
9368 });
9369 ctx.install_watcher_runtime(
9370 dispatch_rx,
9371 WatcherThreadHandle::new(Arc::clone(&shutdown), join),
9372 );
9373 assert!(ctx.watcher_runtime_active());
9374
9375 *ctx.watcher_rx.lock() = None;
9376 assert!(
9377 !ctx.watcher_runtime_active(),
9378 "a thread without its dispatch receiver is not a usable watcher runtime"
9379 );
9380 ctx.stop_watcher_runtime();
9381 }
9382}
9383
9384#[cfg(test)]
9385mod semantic_probe_tests {
9386 use super::*;
9387
9388 #[test]
9389 fn cleared_semantic_worker_invalidates_orphaned_probe_timer() {
9390 let root = tempfile::tempdir().unwrap();
9391 let ctx = AppContext::new(
9392 default_language_provider_factory(),
9393 Config {
9394 project_root: Some(root.path().to_path_buf()),
9395 ..Config::default()
9396 },
9397 );
9398 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
9399 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
9400 let worker_slot = Arc::new(Mutex::new(None));
9401 ctx.install_semantic_refresh_worker_for_build_epoch(
9402 request_tx,
9403 event_rx,
9404 worker_slot,
9405 ctx.semantic_index_rx_epoch(),
9406 );
9407
9408 ctx.ensure_semantic_refresh_probe_scheduled(Duration::from_millis(20));
9409 assert!(ctx.semantic_refresh_probe_is_scheduled());
9410 ctx.clear_semantic_refresh_worker();
9411 std::thread::sleep(Duration::from_millis(50));
9412
9413 assert!(!ctx.semantic_refresh_probe_ready());
9414 assert!(!ctx.semantic_refresh_probe_is_scheduled());
9415 assert!(!ctx.completion_drains_have_work());
9416 }
9417}