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::artifact_owner::{
14 ArtifactOwnerLease, ArtifactOwnerLeaseRegistration, ArtifactOwnerMode, ArtifactOwnerStatus,
15};
16use crate::backup::hash_session;
17use crate::backup::BackupStore;
18use crate::bash_background::{BgCompletion, BgTaskHealthCounts, BgTaskRegistry};
19use crate::callgraph_store::{CallGraphStore, CallGraphStoreError, ReadonlyCallGraphStore};
20use crate::checkpoint::CheckpointStore;
21use crate::config::Config;
22use crate::harness::Harness;
23use crate::inspect::{
24 InspectCategory, InspectManager, InspectSnapshot, Tier2RefreshScheduler, Tier2TriggerReason,
25};
26use crate::language::LanguageProvider;
27use crate::lsp::manager::{LspManager, StaleDiagnosticsMark};
28use crate::lsp::registry::is_config_file_path_with_custom;
29use crate::parser::{SharedSymbolCache, SymbolCache, TreeSitterProvider};
30use crate::protocol::{
31 ConfigureWarningsFrame, ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload,
32};
33use crate::watcher_filter::WatcherJoinOutcome;
34use crate::watcher_filter::{SharedGitignore, WatcherDispatchEvent, WatcherThreadHandle};
35
36pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
37pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
38pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
39const STATUS_DEBOUNCE_MS: u64 = 1_000;
40
41fn canonicalize_lenient(path: &Path) -> Option<PathBuf> {
68 use std::path::Component;
69 if let Ok(canonical) = std::fs::canonicalize(path) {
70 return Some(canonical);
71 }
72 let mut resolved = PathBuf::new();
73 let mut missing: Vec<std::ffi::OsString> = Vec::new();
74 for component in path.components() {
75 match component {
76 Component::Prefix(_) | Component::RootDir => {
77 resolved.push(component.as_os_str());
78 if let Ok(canonical_anchor) = std::fs::canonicalize(&resolved) {
82 resolved = canonical_anchor;
83 }
84 }
85 Component::CurDir => {}
86 Component::ParentDir => {
87 if missing.pop().is_none() {
88 if !resolved.as_os_str().is_empty() && !resolved.is_dir() {
89 return None;
91 }
92 resolved.pop();
93 }
94 }
95 Component::Normal(name) => {
96 if missing.is_empty() {
97 let candidate = resolved.join(name);
98 match std::fs::canonicalize(&candidate) {
99 Ok(canonical) => resolved = canonical,
100 Err(_) => match std::fs::symlink_metadata(&candidate) {
101 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
104 missing.push(name.to_owned())
105 }
106 _ => return None,
109 },
110 }
111 } else {
112 missing.push(name.to_owned());
113 }
114 }
115 }
116 }
117 for name in missing {
118 resolved.push(name);
119 }
120 Some(resolved)
121}
122
123fn pending_path_in_roots(path: &Path, roots: &[PathBuf]) -> bool {
133 if path.is_relative() {
134 let has_prefix_or_root = path.components().next().is_some_and(|component| {
139 matches!(
140 component,
141 std::path::Component::Prefix(_) | std::path::Component::RootDir
142 )
143 });
144 if has_prefix_or_root {
145 return false;
146 }
147 return roots.iter().any(|root| {
150 let joined = root.join(path);
151 match (canonicalize_lenient(&joined), canonicalize_lenient(root)) {
152 (Some(path), Some(root)) => path.starts_with(&root),
153 _ => false,
154 }
155 });
156 }
157 let Some(canonical_path) = canonicalize_lenient(path) else {
158 return false;
159 };
160 roots.iter().any(|root| {
161 canonicalize_lenient(root)
162 .is_some_and(|canonical_root| canonical_path.starts_with(&canonical_root))
163 })
164}
165
166#[derive(Clone, Default)]
170pub(crate) struct SubcLifecycleAdmission {
171 unbound: Arc<parking_lot::Mutex<bool>>,
172}
173
174impl SubcLifecycleAdmission {
175 fn mark_bound(&self) {
176 *self.unbound.lock() = false;
177 }
178
179 fn mark_unbound(&self, configure_generation: &AtomicU64) {
180 let mut unbound = self.unbound.lock();
181 if !*unbound {
182 *unbound = true;
183 configure_generation.fetch_add(1, Ordering::SeqCst);
184 }
185 }
186
187 pub(crate) fn is_current(&self, generation: &AtomicU64, expected: u64) -> bool {
188 let unbound = self.unbound.lock();
189 !*unbound && generation.load(Ordering::SeqCst) == expected
190 }
191
192 fn advance_generation(&self, generation: &AtomicU64) -> u64 {
193 let _unbound = self.unbound.lock();
194 generation.fetch_add(1, Ordering::SeqCst).wrapping_add(1)
195 }
196
197 pub(crate) fn run_if_current<R>(
198 &self,
199 generation: &AtomicU64,
200 expected: u64,
201 action: impl FnOnce() -> R,
202 ) -> Option<R> {
203 let unbound = self.unbound.lock();
204 if *unbound || generation.load(Ordering::SeqCst) != expected {
205 return None;
206 }
207 Some(action())
208 }
209
210 pub(crate) fn is_bound(&self) -> bool {
211 !*self.unbound.lock()
212 }
213
214 fn try_is_bound(&self) -> Option<bool> {
215 self.unbound.try_lock().map(|unbound| !*unbound)
216 }
217
218 fn is_unbound(&self) -> bool {
219 !self.is_bound()
220 }
221
222 fn run_if_unbound<R>(&self, action: impl FnOnce() -> R) -> Option<R> {
223 let unbound = self.unbound.lock();
224 if !*unbound {
225 return None;
226 }
227 Some(action())
228 }
229}
230
231const GRACEFUL_SHUTDOWN_SEARCH_BUILD_WAIT: Duration = Duration::from_secs(5);
232const GRACEFUL_SHUTDOWN_SEARCH_BUILD_POLL: Duration = Duration::from_millis(10);
233
234#[derive(Debug, Clone, Default, PartialEq, Eq)]
242pub struct StatusBarCounts {
243 pub errors: usize,
244 pub warnings: usize,
245 pub dead_code: usize,
246 pub unused_exports: usize,
247 pub duplicates: usize,
248 pub todos: usize,
249 pub tier2_stale: bool,
250}
251
252#[derive(Debug, Clone, Default)]
261struct StatusBarTier2 {
262 dead_code: Option<usize>,
263 unused_exports: Option<usize>,
264 duplicates: Option<usize>,
265 todos: Option<usize>,
266 stale: bool,
267 generation: u64,
268}
269
270#[derive(Debug, Clone, Default)]
271struct StatusBarCache {
272 valid: bool,
273 diagnostics_generation: u64,
274 tier2_generation: u64,
275 tsconfig_generation: u64,
276 counts: Option<StatusBarCounts>,
277}
278
279#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
280#[serde(rename_all = "snake_case")]
281pub enum RootHealthState {
282 Ready,
283 Busy,
284}
285
286#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
287pub struct HealthComponentSnapshot {
288 pub status: &'static str,
289}
290
291#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
292pub struct Tier2HealthSnapshot {
293 pub status: &'static str,
294}
295
296#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
297pub struct RootHealthSnapshot {
298 pub project_root: String,
299 pub actor_count: usize,
300 pub state: RootHealthState,
301 #[serde(skip_serializing_if = "Option::is_none")]
302 pub search_index: Option<HealthComponentSnapshot>,
303 #[serde(skip_serializing_if = "Option::is_none")]
304 pub semantic_index: Option<HealthComponentSnapshot>,
305 #[serde(skip_serializing_if = "Option::is_none")]
306 pub callgraph_store: Option<HealthComponentSnapshot>,
307 #[serde(skip_serializing_if = "Option::is_none")]
308 pub tier2: Option<Tier2HealthSnapshot>,
309 #[serde(skip_serializing_if = "Option::is_none")]
310 pub bash: Option<BgTaskHealthCounts>,
311}
312
313impl RootHealthSnapshot {
314 fn busy(project_root: &Path) -> Self {
315 Self {
316 project_root: project_root.display().to_string(),
317 actor_count: 1,
318 state: RootHealthState::Busy,
319 search_index: None,
320 semantic_index: None,
321 callgraph_store: None,
322 tier2: None,
323 bash: None,
324 }
325 }
326
327 pub fn is_fully_ready(&self) -> bool {
328 let component_is_satisfied =
329 |status: &HealthComponentSnapshot| matches!(status.status, "ready" | "disabled");
330 let tier2_is_satisfied =
331 |tier2: &Tier2HealthSnapshot| matches!(tier2.status, "ready" | "disabled");
332
333 matches!(self.state, RootHealthState::Ready)
334 && self
335 .search_index
336 .as_ref()
337 .is_some_and(component_is_satisfied)
338 && self
339 .semantic_index
340 .as_ref()
341 .is_some_and(component_is_satisfied)
342 && self
343 .callgraph_store
344 .as_ref()
345 .is_some_and(component_is_satisfied)
346 && self.tier2.as_ref().is_some_and(tier2_is_satisfied)
347 }
348}
349
350pub struct StatusEmitter {
351 latest: Arc<Mutex<Option<StatusPayload>>>,
352 notify: mpsc::Sender<()>,
353}
354
355#[derive(Clone, Debug, Default)]
356struct ConfigureWarmState {
357 generation: u64,
358 key: Option<String>,
359}
360
361#[derive(Debug)]
362struct ConfigurePhaseTiming {
363 phase: &'static str,
364 started_at: Instant,
365 completed: Vec<(&'static str, Duration)>,
366}
367
368impl Default for ConfigurePhaseTiming {
369 fn default() -> Self {
370 Self {
371 phase: "idle",
372 started_at: Instant::now(),
373 completed: Vec::new(),
374 }
375 }
376}
377
378#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
379pub(crate) enum WatcherDrainApplyPhase {
380 #[default]
381 PendingTier2,
382 PendingIndexes,
383 SymbolCache,
384 Callgraph,
385 SearchIndex,
386 SemanticIndex,
387 LspDiagnostics,
388 Complete,
389}
390
391#[derive(Debug, Default)]
392pub(crate) enum WatcherDrainPhase {
393 #[default]
394 Collect,
395 Apply {
396 stage: WatcherDrainApplyPhase,
397 paths: VecDeque<PathBuf>,
398 remaining: usize,
399 oversized_inline_batch: bool,
400 },
401}
402
403#[derive(Debug)]
404pub(crate) struct WatcherDrainSliceState {
405 pub(crate) configure_generation: u64,
406 pub(crate) configure_content_generation: u64,
412 pub(crate) phase: WatcherDrainPhase,
413 pub(crate) pending_paths: VecDeque<PathBuf>,
414 pub(crate) ignore_changed: bool,
415 pub(crate) rescan_required: bool,
416 pub(crate) status_changed: bool,
417 pub(crate) scheduler_changed_path_count: usize,
418 pub(crate) semantic_refresh_paths: Vec<PathBuf>,
419 pub(crate) path_slice_count: usize,
420}
421
422pub(crate) struct PendingReconciliationState {
426 search: BTreeSet<PathBuf>,
427 callgraph: BTreeSet<PathBuf>,
428 tier2: BTreeSet<PathBuf>,
429 semantic: BTreeSet<PathBuf>,
430 corpus_refresh: bool,
431}
432
433impl WatcherDrainSliceState {
434 pub(crate) fn new(configure_generation: u64, configure_content_generation: u64) -> Self {
435 Self {
436 configure_generation,
437 configure_content_generation,
438 phase: WatcherDrainPhase::Collect,
439 pending_paths: VecDeque::new(),
440 ignore_changed: false,
441 rescan_required: false,
442 status_changed: false,
443 scheduler_changed_path_count: 0,
444 semantic_refresh_paths: Vec::new(),
445 path_slice_count: 0,
446 }
447 }
448
449 pub(crate) fn has_pending_work(&self) -> bool {
450 !matches!(self.phase, WatcherDrainPhase::Collect)
451 || !self.pending_paths.is_empty()
452 || self.ignore_changed
453 || self.rescan_required
454 }
455}
456
457#[doc(hidden)]
458pub enum CallGraphStoreBuildEvent {
459 Ready {
460 store: CallGraphStore,
461 fulfilled_force_token: Option<u64>,
462 publication_epoch: u64,
463 },
464 Settled,
465}
466
467struct CallGraphStoreBuildSettlement {
468 tx: crossbeam_channel::Sender<CallGraphStoreBuildEvent>,
469 sent: bool,
470 force_token: Option<u64>,
471 publication_epoch: u64,
472}
473
474impl CallGraphStoreBuildSettlement {
475 fn new(
476 tx: crossbeam_channel::Sender<CallGraphStoreBuildEvent>,
477 force_token: Option<u64>,
478 publication_epoch: u64,
479 ) -> Self {
480 Self {
481 tx,
482 sent: false,
483 force_token,
484 publication_epoch,
485 }
486 }
487
488 fn ready(&mut self, store: CallGraphStore) {
489 let _ = self.tx.send(CallGraphStoreBuildEvent::Ready {
490 store,
491 fulfilled_force_token: self.force_token,
492 publication_epoch: self.publication_epoch,
493 });
494 self.sent = true;
495 }
496}
497
498impl Drop for CallGraphStoreBuildSettlement {
499 fn drop(&mut self) {
500 if !self.sent {
501 let _ = self.tx.send(CallGraphStoreBuildEvent::Settled);
502 }
503 }
504}
505
506#[derive(Clone, Debug)]
507pub(crate) struct ConfigureMaintenanceJob {
508 pub(crate) generation: u64,
509 pub(crate) root_path: PathBuf,
510 pub(crate) canonical_cache_root: PathBuf,
511 pub(crate) harness: Harness,
512 pub(crate) storage_root: PathBuf,
513 pub(crate) harness_dir: PathBuf,
514 pub(crate) session_id: String,
515 pub(crate) home_match: bool,
516 pub(crate) format_tool_cache_clear_needed: bool,
517 pub(crate) run_bash_replay: bool,
518 pub(crate) refresh_project_runtime: bool,
519 pub(crate) sync_bash_compress_flag: bool,
520 pub(crate) reset_filter_registry: bool,
521 pub(crate) clear_failed_spawns: bool,
522 pub(crate) warm_callgraph_store: bool,
523 pub(crate) supersede_artifact_persistence: bool,
526 pub(crate) artifact_load_starts: Vec<crossbeam_channel::Sender<()>>,
529}
530
531impl StatusEmitter {
532 fn new(progress_sender: SharedProgressSender) -> Self {
533 let (notify, rx) = mpsc::channel();
534 let latest = Arc::new(Mutex::new(None));
535 let latest_for_thread = Arc::clone(&latest);
536 std::thread::spawn(move || {
537 status_debounce_loop(rx, latest_for_thread, progress_sender);
538 });
539 Self { latest, notify }
540 }
541
542 pub fn signal(&self, snapshot: StatusPayload) {
543 if let Ok(mut latest) = self.latest.lock() {
544 *latest = Some(snapshot);
545 }
546 let _ = self.notify.send(());
547 }
548}
549
550fn status_debounce_loop(
551 rx: mpsc::Receiver<()>,
552 latest: Arc<Mutex<Option<StatusPayload>>>,
553 progress_sender: SharedProgressSender,
554) {
555 while rx.recv().is_ok() {
556 let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
557 while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
558 match rx.recv_timeout(remaining) {
559 Ok(()) => continue,
560 Err(mpsc::RecvTimeoutError::Timeout) => break,
561 Err(mpsc::RecvTimeoutError::Disconnected) => return,
562 }
563 }
564
565 let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
566 let Some(snapshot) = snapshot else { continue };
567 let sender = progress_sender
568 .lock()
569 .ok()
570 .and_then(|sender| sender.clone());
571 if let Some(sender) = sender {
572 sender(PushFrame::StatusChanged(StatusChangedFrame::new(
573 None, snapshot,
574 )));
575 }
576 }
577}
578use crate::cache_freshness::FileFreshness;
579use crate::search_index::SearchIndex;
580use crate::semantic_index::{EmbeddingEntry, SemanticIndex};
581
582#[derive(Debug, Default, Clone)]
586#[doc(hidden)]
587pub struct SemanticRefreshAccounting {
588 #[doc(hidden)]
589 pub pending: usize,
590 #[doc(hidden)]
591 pub in_flight: usize,
592}
593
594#[derive(Debug, Default)]
595struct SemanticRefreshCircuit {
596 consecutive_transient_failures: AtomicUsize,
597 open: AtomicBool,
598 probe_in_flight: AtomicBool,
599 probe_ready: AtomicBool,
600 probe_token: AtomicU64,
601}
602
603#[derive(Clone, Copy, Debug, Default)]
604pub(crate) struct SemanticColdSeedResume {
605 request_tier2: bool,
606 warm_callgraph: bool,
607}
608
609fn ensure_refreshing_path(refreshing: &mut Vec<PathBuf>, path: PathBuf) {
610 if !refreshing.iter().any(|existing| existing == &path) {
611 refreshing.push(path);
612 refreshing.sort();
613 }
614}
615
616fn remove_refreshing_path(refreshing: &mut Vec<PathBuf>, path: &Path) {
617 refreshing.retain(|existing| existing != path);
618}
619
620#[derive(Debug, Clone)]
621pub enum SemanticIndexStatus {
622 Disabled,
623 Building {
624 stage: String,
626 files: Option<usize>,
627 entries_done: Option<usize>,
628 entries_total: Option<usize>,
629 },
630 Ready {
631 refreshing: Vec<PathBuf>,
634 #[doc(hidden)]
638 accounting: BTreeMap<PathBuf, SemanticRefreshAccounting>,
639 },
640 Failed(String),
641}
642
643impl SemanticIndexStatus {
644 pub fn ready() -> Self {
645 Self::Ready {
646 refreshing: Vec::new(),
647 accounting: BTreeMap::new(),
648 }
649 }
650
651 pub fn add_refreshing_file(&mut self, path: PathBuf) {
652 if let Self::Ready {
653 refreshing,
654 accounting,
655 } = self
656 {
657 let state = accounting.entry(path.clone()).or_default();
658 state.pending = state.pending.saturating_add(1);
659 ensure_refreshing_path(refreshing, path);
660 }
661 }
662
663 pub fn start_refreshing_file(&mut self, path: PathBuf) {
664 if let Self::Ready {
665 refreshing,
666 accounting,
667 } = self
668 {
669 let state = accounting.entry(path.clone()).or_default();
670 if state.pending == 0 {
671 state.pending = 1;
672 }
673 if state.in_flight == 0 {
674 state.in_flight = state.pending;
675 }
676 ensure_refreshing_path(refreshing, path);
677 }
678 }
679
680 pub fn cancel_refreshing_file(&mut self, path: &Path) {
681 self.finish_refreshing_file(path, false);
682 }
683
684 pub fn take_refreshing_files(&mut self) -> Vec<PathBuf> {
688 if let Self::Ready {
689 refreshing,
690 accounting,
691 } = self
692 {
693 accounting.clear();
694 std::mem::take(refreshing)
695 } else {
696 Vec::new()
697 }
698 }
699
700 pub fn corpus_refresh_in_flight(&self) -> bool {
702 matches!(self, Self::Building { stage, .. } if stage == "refreshing_corpus")
703 }
704
705 pub fn complete_refreshing_file(&mut self, path: &Path) {
706 self.finish_refreshing_file(path, true);
707 }
708
709 pub fn remove_refreshing_file(&mut self, path: &Path) {
710 self.complete_refreshing_file(path);
711 }
712
713 fn finish_refreshing_file(&mut self, path: &Path, complete_in_flight: bool) {
714 if let Self::Ready {
715 refreshing,
716 accounting,
717 } = self
718 {
719 let mut keep_refreshing = false;
720 if let Some(state) = accounting.get_mut(path) {
721 let finished = if complete_in_flight {
722 state.in_flight.max(1)
723 } else {
724 1
725 };
726 state.pending = state.pending.saturating_sub(finished);
727 if complete_in_flight {
728 state.in_flight = 0;
729 } else {
730 state.in_flight = state.in_flight.min(state.pending);
731 }
732 keep_refreshing = state.pending > 0;
733 if !keep_refreshing {
734 accounting.remove(path);
735 }
736 }
737
738 if !keep_refreshing {
739 remove_refreshing_path(refreshing, path);
740 }
741 }
742 }
743
744 pub fn refreshing_count(&self) -> usize {
745 match self {
746 Self::Ready { refreshing, .. } => refreshing.len(),
747 _ => 0,
748 }
749 }
750}
751
752pub enum SemanticIndexEvent {
753 Progress {
754 stage: String,
755 files: Option<usize>,
756 entries_done: Option<usize>,
757 entries_total: Option<usize>,
758 },
759 ColdSeedGateCleared,
764 Ready(SemanticIndex),
765 Failed(String),
766}
767
768#[derive(Debug, Clone)]
769pub enum SemanticRefreshRequest {
770 Files {
771 paths: Vec<PathBuf>,
772 },
773 Corpus,
777}
778
779#[derive(Debug)]
780pub enum SemanticRefreshEvent {
781 Started {
782 paths: Vec<PathBuf>,
783 },
784 CorpusStarted {
785 files: usize,
786 },
787 Completed {
788 added_entries: Vec<EmbeddingEntry>,
789 updated_metadata: Vec<(PathBuf, FileFreshness)>,
790 completed_paths: Vec<PathBuf>,
791 },
792 CorpusCompleted {
793 index: SemanticIndex,
794 changed: usize,
795 added: usize,
796 deleted: usize,
797 total_processed: usize,
798 },
799 Failed {
800 paths: Vec<PathBuf>,
801 error: String,
802 },
803 CorpusFailed {
804 error: String,
805 },
806}
807
808pub(crate) struct ReceiverTerminalGuard {
809 terminal_epoch: Arc<AtomicU64>,
810 epoch: u64,
811}
812
813impl ReceiverTerminalGuard {
814 fn new(terminal_epoch: Arc<AtomicU64>, epoch: u64) -> Self {
815 Self {
816 terminal_epoch,
817 epoch,
818 }
819 }
820}
821
822impl Drop for ReceiverTerminalGuard {
823 fn drop(&mut self) {
824 self.terminal_epoch.fetch_max(self.epoch, Ordering::SeqCst);
825 }
826}
827
828pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
829
830fn normalize_path(path: &Path) -> PathBuf {
834 let mut result = PathBuf::new();
835 for component in path.components() {
836 match component {
837 Component::ParentDir => {
838 if !result.pop() {
840 result.push(component);
841 }
842 }
843 Component::CurDir => {} _ => result.push(component),
845 }
846 }
847 result
848}
849
850fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
851 let mut existing = path.to_path_buf();
852 let mut tail_segments = Vec::new();
853
854 while !existing.exists() {
855 if let Some(name) = existing.file_name() {
856 tail_segments.push(name.to_owned());
857 } else {
858 break;
859 }
860
861 existing = match existing.parent() {
862 Some(parent) => parent.to_path_buf(),
863 None => break,
864 };
865 }
866
867 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
868 for segment in tail_segments.into_iter().rev() {
869 resolved.push(segment);
870 }
871
872 resolved
873}
874
875fn path_error_response(
876 req_id: &str,
877 path: &Path,
878 resolved_root: &Path,
879) -> crate::protocol::Response {
880 crate::protocol::Response::error(
881 req_id,
882 "path_outside_root",
883 format!(
884 "path '{}' is outside the project root '{}'",
885 path.display(),
886 resolved_root.display()
887 ),
888 )
889}
890
891fn reject_escaping_symlink(
901 req_id: &str,
902 original_path: &Path,
903 candidate: &Path,
904 resolved_root: &Path,
905 raw_root: &Path,
906) -> Result<(), crate::protocol::Response> {
907 let mut current = PathBuf::new();
908
909 for component in candidate.components() {
910 current.push(component);
911
912 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
913 continue;
914 };
915
916 if !metadata.file_type().is_symlink() {
917 continue;
918 }
919
920 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
929 if !inside_root {
930 continue;
931 }
932
933 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
934 }
935
936 Ok(())
937}
938
939fn iterative_follow_chain(
942 req_id: &str,
943 original_path: &Path,
944 start: &Path,
945 resolved_root: &Path,
946) -> Result<(), crate::protocol::Response> {
947 let mut link = start.to_path_buf();
948 let mut depth = 0usize;
949
950 loop {
951 if depth > 40 {
952 return Err(path_error_response(req_id, original_path, resolved_root));
953 }
954
955 let target = match std::fs::read_link(&link) {
956 Ok(t) => t,
957 Err(_) => {
958 return Err(path_error_response(req_id, original_path, resolved_root));
960 }
961 };
962
963 let resolved_target = if target.is_absolute() {
964 normalize_path(&target)
965 } else {
966 let parent = link.parent().unwrap_or_else(|| Path::new(""));
967 normalize_path(&parent.join(&target))
968 };
969
970 let canonical_target =
974 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
975
976 if !canonical_target.starts_with(resolved_root)
977 && !resolved_target.starts_with(resolved_root)
978 {
979 return Err(path_error_response(req_id, original_path, resolved_root));
980 }
981
982 match std::fs::symlink_metadata(&resolved_target) {
984 Ok(meta) if meta.file_type().is_symlink() => {
985 link = resolved_target;
986 depth += 1;
987 }
988 _ => break, }
990 }
991
992 Ok(())
993}
994
995pub type LanguageProviderFactory = fn() -> Box<dyn LanguageProvider>;
996
997pub fn default_language_provider_factory() -> Box<dyn LanguageProvider> {
998 Box::new(TreeSitterProvider::new())
999}
1000
1001fn database_path_key(path: &Path) -> PathBuf {
1002 if let Ok(canonical) = std::fs::canonicalize(path) {
1003 return canonical;
1004 }
1005 let Some(parent) = path.parent() else {
1006 return path.to_path_buf();
1007 };
1008 let canonical_parent = std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf());
1009 path.file_name()
1010 .map(|name| canonical_parent.join(name))
1011 .unwrap_or_else(|| canonical_parent.join(path))
1012}
1013
1014pub struct App {
1019 db: parking_lot::Mutex<Option<(PathBuf, Arc<Mutex<Connection>>)>>,
1023 active_watchers: AtomicUsize,
1024 active_actor_roots: AtomicUsize,
1025 open_routes: AtomicUsize,
1026 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
1027 stdout_writer: SharedStdoutWriter,
1028 provider_factory: LanguageProviderFactory,
1029 memory_contexts: parking_lot::Mutex<BTreeMap<PathBuf, Weak<AppContext>>>,
1032}
1033
1034impl App {
1035 pub fn new(provider_factory: LanguageProviderFactory) -> Self {
1036 Self {
1037 db: parking_lot::Mutex::new(None),
1038 active_watchers: AtomicUsize::new(0),
1039 active_actor_roots: AtomicUsize::new(0),
1040 open_routes: AtomicUsize::new(0),
1041 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry::new(),
1042 stdout_writer: Arc::new(Mutex::new(BufWriter::new(io::stdout()))),
1043 provider_factory,
1044 memory_contexts: parking_lot::Mutex::new(BTreeMap::new()),
1045 }
1046 }
1047
1048 pub fn shared(provider_factory: LanguageProviderFactory) -> Arc<Self> {
1050 Arc::new(Self::new(provider_factory))
1051 }
1052
1053 pub fn default_shared() -> Arc<Self> {
1054 Self::shared(default_language_provider_factory)
1055 }
1056
1057 pub fn create_provider(&self) -> Box<dyn LanguageProvider> {
1058 (self.provider_factory)()
1059 }
1060
1061 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
1062 self.lsp_child_registry.clone()
1063 }
1064
1065 pub fn stdout_writer(&self) -> SharedStdoutWriter {
1066 Arc::clone(&self.stdout_writer)
1067 }
1068
1069 pub(crate) fn register_memory_context(&self, root: PathBuf, ctx: &Arc<AppContext>) {
1070 let mut contexts = self.memory_contexts.lock();
1071 contexts.retain(|_, context| context.strong_count() > 0);
1072 contexts.insert(root, Arc::downgrade(ctx));
1073 }
1074
1075 pub(crate) fn unregister_memory_context(&self, root: &Path, ctx: &Arc<AppContext>) {
1076 let mut contexts = self.memory_contexts.lock();
1077 let removes_current = contexts
1078 .get(root)
1079 .and_then(Weak::upgrade)
1080 .is_some_and(|registered| Arc::ptr_eq(®istered, ctx));
1081 if removes_current {
1082 contexts.remove(root);
1083 }
1084 }
1085
1086 pub(crate) fn try_memory_contexts(&self) -> Option<Vec<(PathBuf, Arc<AppContext>)>> {
1089 let contexts = self.memory_contexts.try_lock()?;
1090 Some(
1091 contexts
1092 .iter()
1093 .filter_map(|(root, context)| {
1094 context.upgrade().map(|context| (root.clone(), context))
1095 })
1096 .collect(),
1097 )
1098 }
1099
1100 pub fn open_db(&self, path: &Path) -> Result<Arc<Mutex<Connection>>, crate::db::OpenError> {
1105 let key = database_path_key(path);
1106 let mut slot = self.db.lock();
1107 if let Some((existing_path, conn)) = slot.as_ref() {
1108 if existing_path == &key {
1109 return Ok(Arc::clone(conn));
1110 }
1111 }
1112
1113 let conn = Arc::new(Mutex::new(crate::db::open(path)?));
1114 *slot = Some((key, Arc::clone(&conn)));
1115 Ok(conn)
1116 }
1117
1118 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1119 *self.db.lock() = Some((PathBuf::new(), conn));
1120 }
1121
1122 pub fn clear_db(&self) {
1123 *self.db.lock() = None;
1124 }
1125
1126 pub fn clear_db_for_path(&self, path: &Path) {
1130 let key = database_path_key(path);
1131 let mut slot = self.db.lock();
1132 if slot.as_ref().is_some_and(|(existing_path, _)| {
1133 existing_path.as_os_str().is_empty() || existing_path == &key
1134 }) {
1135 *slot = None;
1136 }
1137 }
1138
1139 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1140 self.db.lock().as_ref().map(|(_, conn)| Arc::clone(conn))
1141 }
1142
1143 pub(crate) fn watcher_started(&self) {
1144 self.active_watchers.fetch_add(1, Ordering::SeqCst);
1145 }
1146
1147 pub(crate) fn watcher_stopped(&self) {
1148 self.active_watchers
1149 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
1150 Some(count.saturating_sub(1))
1151 })
1152 .ok();
1153 }
1154
1155 pub fn watcher_count(&self) -> usize {
1158 self.active_watchers.load(Ordering::SeqCst)
1159 }
1160
1161 pub(crate) fn actor_root_registered(&self) {
1162 self.active_actor_roots.fetch_add(1, Ordering::SeqCst);
1163 }
1164
1165 pub(crate) fn actor_root_unregistered(&self) {
1166 self.active_actor_roots
1167 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
1168 Some(count.saturating_sub(1))
1169 })
1170 .ok();
1171 }
1172
1173 pub fn actor_root_count(&self) -> usize {
1174 self.active_actor_roots.load(Ordering::SeqCst)
1175 }
1176
1177 pub(crate) fn set_open_route_count(&self, count: usize) {
1178 self.open_routes.store(count, Ordering::SeqCst);
1179 }
1180
1181 pub fn open_route_count(&self) -> usize {
1182 self.open_routes.load(Ordering::SeqCst)
1183 }
1184}
1185
1186impl Default for App {
1187 fn default() -> Self {
1188 Self::new(default_language_provider_factory)
1189 }
1190}
1191
1192const _: fn() = || {
1193 fn assert_send_sync<T: Send + Sync>() {}
1194 fn assert_send<T: Send>() {}
1195
1196 assert_send_sync::<App>();
1197 assert_send_sync::<AppContext>();
1198 assert_send::<crate::lsp::manager::LspManager>();
1199 assert_send::<crate::semantic_index::EmbeddingModel>();
1200};
1201
1202#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1203enum GitEntryKind {
1204 Missing,
1205 File,
1206 Directory,
1207 Other,
1208}
1209
1210#[derive(Clone, Debug, PartialEq, Eq)]
1211struct GitEntrySignature {
1212 kind: GitEntryKind,
1213 modified: Option<SystemTime>,
1214}
1215
1216#[derive(Clone, Debug)]
1217struct WorktreeBridgeCacheEntry {
1218 git_entry: GitEntrySignature,
1219 is_worktree_bridge: bool,
1220 git_common_dir: Option<PathBuf>,
1221}
1222
1223pub(crate) const BORROWED_INDEX_CACHE_CAPACITY: usize = 4;
1224
1225#[derive(Clone, Debug, PartialEq, Eq)]
1226struct BorrowedIndexCacheKey {
1227 canonical_root: PathBuf,
1228 artifact: crate::readonly_artifacts::BorrowedArtifactGeneration,
1229}
1230
1231#[derive(Clone, Debug)]
1232enum BorrowedIndexCacheValue {
1233 Search(crate::readonly_artifacts::ReadOnlyArtifact<Arc<SearchIndex>>),
1234 Semantic(crate::readonly_artifacts::ReadOnlyArtifact<Arc<SemanticIndex>>),
1235}
1236
1237#[derive(Debug, Default)]
1238struct BorrowedIndexCache {
1239 entries: VecDeque<(BorrowedIndexCacheKey, BorrowedIndexCacheValue)>,
1240 resolved_roots: VecDeque<(PathBuf, GitEntrySignature)>,
1241}
1242
1243impl BorrowedIndexCache {
1244 fn search(
1245 &mut self,
1246 key: &BorrowedIndexCacheKey,
1247 ) -> Option<crate::readonly_artifacts::ReadOnlyArtifact<Arc<SearchIndex>>> {
1248 let position = self.entries.iter().position(|(candidate, value)| {
1249 candidate == key && matches!(value, BorrowedIndexCacheValue::Search(_))
1250 })?;
1251 let entry = self.entries.remove(position)?;
1252 let BorrowedIndexCacheValue::Search(index) = &entry.1 else {
1253 return None;
1254 };
1255 let index = (*index).clone();
1256 self.entries.push_back(entry);
1257 Some(index)
1258 }
1259
1260 fn semantic(
1261 &mut self,
1262 key: &BorrowedIndexCacheKey,
1263 ) -> Option<crate::readonly_artifacts::ReadOnlyArtifact<Arc<SemanticIndex>>> {
1264 let position = self.entries.iter().position(|(candidate, value)| {
1265 candidate == key && matches!(value, BorrowedIndexCacheValue::Semantic(_))
1266 })?;
1267 let entry = self.entries.remove(position)?;
1268 let BorrowedIndexCacheValue::Semantic(index) = &entry.1 else {
1269 return None;
1270 };
1271 let index = (*index).clone();
1272 self.entries.push_back(entry);
1273 Some(index)
1274 }
1275
1276 fn insert(&mut self, key: BorrowedIndexCacheKey, value: BorrowedIndexCacheValue) {
1277 self.entries.retain(|(candidate, _)| {
1278 candidate.canonical_root != key.canonical_root
1279 || candidate.artifact.path != key.artifact.path
1280 });
1281 self.entries.push_back((key, value));
1282 while self.entries.len() > BORROWED_INDEX_CACHE_CAPACITY {
1283 self.entries.pop_front();
1284 }
1285 }
1286
1287 fn resolved_root(&mut self, requested_root: &Path) -> Option<PathBuf> {
1288 let position = self
1289 .resolved_roots
1290 .iter()
1291 .position(|(candidate, _)| candidate == requested_root)?;
1292 let entry = self.resolved_roots.remove(position)?;
1293 if entry.1 != git_entry_signature(requested_root) {
1294 return None;
1295 }
1296 let root = entry.0.clone();
1297 self.resolved_roots.push_back(entry);
1298 Some(root)
1299 }
1300
1301 fn remember_resolved_root(&mut self, root: PathBuf) {
1302 self.resolved_roots
1303 .retain(|(candidate, _)| candidate != &root);
1304 let signature = git_entry_signature(&root);
1305 self.resolved_roots.push_back((root, signature));
1306 while self.resolved_roots.len() > BORROWED_INDEX_CACHE_CAPACITY {
1307 self.resolved_roots.pop_front();
1308 }
1309 }
1310
1311 fn clear(&mut self) {
1312 self.entries.clear();
1313 self.resolved_roots.clear();
1314 }
1315}
1316
1317fn git_entry_signature(project_root: &Path) -> GitEntrySignature {
1318 match std::fs::symlink_metadata(project_root.join(".git")) {
1319 Ok(metadata) => GitEntrySignature {
1320 kind: if metadata.file_type().is_file() {
1321 GitEntryKind::File
1322 } else if metadata.file_type().is_dir() {
1323 GitEntryKind::Directory
1324 } else {
1325 GitEntryKind::Other
1326 },
1327 modified: metadata.modified().ok(),
1328 },
1329 Err(error) if error.kind() == io::ErrorKind::NotFound => GitEntrySignature {
1330 kind: GitEntryKind::Missing,
1331 modified: None,
1332 },
1333 Err(_) => GitEntrySignature {
1334 kind: GitEntryKind::Other,
1335 modified: None,
1336 },
1337 }
1338}
1339
1340pub struct AppContext {
1352 app: Arc<App>,
1353 provider: Box<dyn LanguageProvider>,
1354 backup: parking_lot::Mutex<BackupStore>,
1355 checkpoint: parking_lot::Mutex<CheckpointStore>,
1356 config: RwLock<Arc<Config>>,
1357 force_restrict_requests: parking_lot::Mutex<BTreeMap<String, usize>>,
1358 pub harness: parking_lot::Mutex<Option<Harness>>,
1359 canonical_cache_root: parking_lot::Mutex<Option<PathBuf>>,
1360 is_worktree_bridge: parking_lot::Mutex<bool>,
1361 git_common_dir: parking_lot::Mutex<Option<PathBuf>>,
1362 shared_artifacts_read_only: AtomicBool,
1363 callgraph_writer: AtomicBool,
1364 inspect_writer: AtomicBool,
1365 artifact_owner_status: parking_lot::Mutex<Option<ArtifactOwnerStatus>>,
1366 artifact_owner_lease: parking_lot::Mutex<Option<ArtifactOwnerLeaseRegistration>>,
1367 degraded_reasons: parking_lot::Mutex<Vec<String>>,
1374 heavy_root_work_allowed: Arc<AtomicBool>,
1379 callgraph_store: RwLock<Option<Arc<ReadonlyCallGraphStore>>>,
1380 callgraph_store_force_requested: AtomicU64,
1381 callgraph_store_force_fulfilled: AtomicU64,
1382 callgraph_store_rx:
1383 parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>>,
1384 callgraph_store_rx_generation: AtomicU64,
1385 callgraph_store_rx_epoch: AtomicU64,
1386 callgraph_persist_epoch: crate::root_cache::ArtifactPublishEpoch,
1387 callgraph_legacy_migration_summary_logged: Arc<AtomicBool>,
1388 pending_callgraph_store_paths: crate::callgraph_store::PendingCallGraphStorePaths,
1389 search_index: RwLock<Option<SearchIndex>>,
1390 search_index_rx: RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>>,
1391 search_index_rx_generation: AtomicU64,
1392 search_index_rx_epoch: AtomicU64,
1393 search_index_rx_terminal_epoch: Arc<AtomicU64>,
1394 search_persist_epoch: crate::root_cache::ArtifactPublishEpoch,
1395 pending_search_index_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
1396 symbol_cache: SharedSymbolCache,
1397 inspect_manager: Arc<InspectManager>,
1398 tier2_refresh_scheduler: parking_lot::Mutex<Tier2RefreshScheduler>,
1399 pending_tier2_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
1400 semantic_index: RwLock<Option<SemanticIndex>>,
1401 semantic_index_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
1402 semantic_index_rx_generation: AtomicU64,
1403 semantic_index_rx_epoch: AtomicU64,
1404 semantic_index_rx_terminal_epoch: Arc<AtomicU64>,
1405 semantic_persist_epoch: crate::root_cache::ArtifactPublishEpoch,
1406 semantic_persist_lock: Arc<parking_lot::Mutex<()>>,
1407 semantic_index_status: RwLock<SemanticIndexStatus>,
1408 artifact_reload_lock: parking_lot::Mutex<()>,
1411 semantic_cold_seed_active: Arc<AtomicBool>,
1415 semantic_cold_seed_generation: Arc<AtomicU64>,
1418 semantic_fingerprint_generation: Arc<AtomicU64>,
1419 semantic_callgraph_warm_deferred: AtomicBool,
1420 pending_semantic_index_paths: Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>,
1421 pending_semantic_corpus_refresh: parking_lot::Mutex<bool>,
1422 semantic_refresh_tx:
1423 Arc<parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>>,
1424 semantic_refresh_event_rx:
1425 parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
1426 semantic_refresh_generation: AtomicU64,
1427 semantic_refresh_epoch: AtomicU64,
1428 semantic_refresh_build_epoch: AtomicU64,
1429 semantic_refresh_worker: parking_lot::Mutex<Option<SemanticRefreshWorkerSlot>>,
1430 semantic_refresh_retry_attempts: parking_lot::Mutex<BTreeMap<PathBuf, usize>>,
1431 semantic_refresh_circuit: Arc<SemanticRefreshCircuit>,
1432 semantic_embedding_model: parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>>,
1433 watcher_runtime_lock: parking_lot::Mutex<()>,
1434 watcher: parking_lot::Mutex<Option<RecommendedWatcher>>,
1435 watcher_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>>,
1436 watcher_drain_slice: parking_lot::Mutex<Option<WatcherDrainSliceState>>,
1437 watcher_thread: parking_lot::Mutex<Option<WatcherThreadHandle>>,
1438 lsp_manager: parking_lot::Mutex<LspManager>,
1439 configure_generation: Arc<AtomicU64>,
1440 configure_content_generation: Arc<AtomicU64>,
1444 subc_lifecycle: SubcLifecycleAdmission,
1447 configure_warm_state: parking_lot::Mutex<ConfigureWarmState>,
1448 configure_phase_timing: parking_lot::Mutex<ConfigurePhaseTiming>,
1449 configured_session_roots: parking_lot::Mutex<BTreeSet<(PathBuf, String)>>,
1450 configure_maintenance_jobs: parking_lot::Mutex<VecDeque<ConfigureMaintenanceJob>>,
1451 artifact_cache_keys: parking_lot::Mutex<BTreeMap<PathBuf, String>>,
1452 artifact_cache_key_derivations: AtomicU64,
1453 borrowed_index_cache: parking_lot::Mutex<BorrowedIndexCache>,
1454 worktree_bridge_cache: parking_lot::Mutex<BTreeMap<PathBuf, WorktreeBridgeCacheEntry>>,
1457 #[cfg(test)]
1458 worktree_bridge_probe_spawns: AtomicU64,
1459 #[cfg(test)]
1460 force_worktree_bridge_reprobe: AtomicBool,
1461 last_seen_reuse_completions: AtomicU64,
1465 configure_warnings_tx: crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)>,
1466 configure_warnings_rx: crossbeam_channel::Receiver<(u64, ConfigureWarningsFrame)>,
1467 progress_sender: SharedProgressSender,
1470 status_emitter: StatusEmitter,
1471 status_bar_last_emitted: RwLock<Option<StatusBarCounts>>,
1475 status_bar_cached: RwLock<StatusBarCache>,
1476 bash_background: BgTaskRegistry,
1477 filter_registry: crate::compress::SharedFilterRegistry,
1484 filter_registry_rebuild_count: AtomicU64,
1485 filter_registry_loaded: std::sync::atomic::AtomicBool,
1488 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
1493 gitignore: SharedGitignore,
1500 gitignore_generation: Arc<AtomicU64>,
1501 status_bar_tier2: RwLock<StatusBarTier2>,
1505 tsconfig_membership:
1512 parking_lot::Mutex<crate::lsp::tsconfig_membership::TsconfigMembershipCache>,
1513}
1514
1515pub struct ForceRestrictGuard<'a> {
1521 ctx: &'a AppContext,
1522 req_id: String,
1523}
1524
1525impl Drop for ForceRestrictGuard<'_> {
1526 fn drop(&mut self) {
1527 self.ctx.release_force_restrict(&self.req_id);
1528 }
1529}
1530
1531impl Drop for AppContext {
1532 fn drop(&mut self) {
1533 self.artifact_owner_lease.get_mut().take();
1534 if let Some(runtime) = self.watcher_thread.get_mut().take() {
1535 let root = self
1536 .canonical_cache_root
1537 .get_mut()
1538 .clone()
1539 .or_else(|| {
1540 self.config
1541 .get_mut()
1542 .unwrap_or_else(std::sync::PoisonError::into_inner)
1543 .project_root
1544 .clone()
1545 })
1546 .unwrap_or_else(|| PathBuf::from("<unconfigured>"));
1547 Self::spawn_watcher_shutdown(Arc::clone(&self.app), root, runtime);
1548 }
1549 }
1550}
1551
1552pub enum CallgraphStoreAccess {
1560 Ready(Arc<ReadonlyCallGraphStore>),
1562 Building,
1564 Unavailable,
1566 Error(CallGraphStoreError),
1568}
1569
1570#[derive(Clone, Copy)]
1571enum CallgraphBackgroundWork {
1572 Ensure,
1573 ForceRebuild(u64),
1574 LegacyMigration,
1575}
1576
1577#[cfg(test)]
1578struct CallgraphBuildStartGate {
1579 root: PathBuf,
1580 reached: crossbeam_channel::Sender<()>,
1581 release: crossbeam_channel::Receiver<()>,
1582}
1583
1584#[cfg(test)]
1585static CALLGRAPH_BUILD_START_GATE: std::sync::OnceLock<
1586 parking_lot::Mutex<Option<CallgraphBuildStartGate>>,
1587> = std::sync::OnceLock::new();
1588
1589#[cfg(test)]
1590fn install_callgraph_build_start_gate(
1591 root: PathBuf,
1592) -> (
1593 crossbeam_channel::Receiver<()>,
1594 crossbeam_channel::Sender<()>,
1595) {
1596 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
1597 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1598 *CALLGRAPH_BUILD_START_GATE
1599 .get_or_init(|| parking_lot::Mutex::new(None))
1600 .lock() = Some(CallgraphBuildStartGate {
1601 root,
1602 reached: reached_tx,
1603 release: release_rx,
1604 });
1605 (reached_rx, release_tx)
1606}
1607
1608#[cfg(test)]
1609fn wait_on_callgraph_build_start_gate(root: &Path) {
1610 let mut slot = CALLGRAPH_BUILD_START_GATE
1611 .get_or_init(|| parking_lot::Mutex::new(None))
1612 .lock();
1613 if !slot.as_ref().is_some_and(|gate| gate.root == root) {
1614 return;
1615 }
1616 let gate = slot.take();
1617 drop(slot);
1618 if let Some(gate) = gate {
1619 let _ = gate.reached.send(());
1620 let _ = gate.release.recv_timeout(Duration::from_secs(5));
1621 }
1622}
1623
1624#[cfg(not(test))]
1625fn wait_on_callgraph_build_start_gate(_root: &Path) {}
1626
1627#[cfg(test)]
1628static REMOVE_CALLGRAPH_POINTER_BEFORE_INLINE_REOPEN: AtomicBool = AtomicBool::new(false);
1629
1630#[cfg(test)]
1631struct RemoveCallgraphPointerBeforeInlineReopenGuard;
1632
1633#[cfg(test)]
1634impl Drop for RemoveCallgraphPointerBeforeInlineReopenGuard {
1635 fn drop(&mut self) {
1636 REMOVE_CALLGRAPH_POINTER_BEFORE_INLINE_REOPEN.store(false, Ordering::SeqCst);
1637 }
1638}
1639
1640#[cfg(test)]
1641fn remove_callgraph_pointer_before_inline_reopen_for_test(
1642 callgraph_dir: &Path,
1643 store: &CallGraphStore,
1644) {
1645 if REMOVE_CALLGRAPH_POINTER_BEFORE_INLINE_REOPEN.swap(false, Ordering::SeqCst) {
1646 let pointer = callgraph_dir.join(format!("{}.current", store.project_key()));
1647 std::fs::remove_file(pointer).expect("remove callgraph pointer before inline reopen");
1648 }
1649}
1650
1651#[cfg(not(test))]
1652fn remove_callgraph_pointer_before_inline_reopen_for_test(
1653 _callgraph_dir: &Path,
1654 _store: &CallGraphStore,
1655) {
1656}
1657
1658fn callgraph_build_wait_window() -> Duration {
1663 std::env::var("AFT_CALLGRAPH_BUILD_WAIT_MS")
1664 .ok()
1665 .and_then(|raw| raw.parse::<u64>().ok())
1666 .map(Duration::from_millis)
1667 .unwrap_or(Duration::ZERO)
1668}
1669
1670static CALLGRAPH_COLD_BUILD_SPAWN_COUNT: AtomicUsize = AtomicUsize::new(0);
1671
1672#[doc(hidden)]
1673pub fn reset_callgraph_cold_build_spawn_count_for_test() {
1674 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
1675}
1676
1677#[doc(hidden)]
1678pub fn callgraph_cold_build_spawn_count_for_test() -> usize {
1679 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst)
1680}
1681
1682impl AppContext {
1683 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
1684 Self::with_app_and_provider(App::default_shared(), provider, config)
1685 }
1686
1687 pub fn from_app(app: Arc<App>, config: Config) -> Self {
1688 let provider = app.create_provider();
1689 Self::with_app_and_provider(app, provider, config)
1690 }
1691
1692 pub fn with_app_and_provider(
1693 app: Arc<App>,
1694 provider: Box<dyn LanguageProvider>,
1695 config: Config,
1696 ) -> Self {
1697 let bash_compress_enabled = config.experimental_bash_compress;
1698 let (configure_warnings_tx, configure_warnings_rx) = crossbeam_channel::unbounded();
1699 let progress_sender: SharedProgressSender = Arc::new(Mutex::new(None));
1700 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
1701 let heavy_root_work_allowed = Arc::new(AtomicBool::new(true));
1702 let symbol_cache = provider
1703 .as_any()
1704 .downcast_ref::<TreeSitterProvider>()
1705 .map(|provider| provider.symbol_cache())
1706 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
1707 let mut lsp_manager = LspManager::new();
1708 lsp_manager.set_child_registry(app.lsp_child_registry());
1709 lsp_manager.set_diagnostic_capacity(config.diagnostic_cache_size);
1712 let context = AppContext {
1713 app: Arc::clone(&app),
1714 provider,
1715 backup: parking_lot::Mutex::new(BackupStore::new()),
1716 checkpoint: parking_lot::Mutex::new(CheckpointStore::new()),
1717 config: RwLock::new(Arc::new(config)),
1718 force_restrict_requests: parking_lot::Mutex::new(BTreeMap::new()),
1719 harness: parking_lot::Mutex::new(None),
1720 canonical_cache_root: parking_lot::Mutex::new(None),
1721 is_worktree_bridge: parking_lot::Mutex::new(false),
1722 git_common_dir: parking_lot::Mutex::new(None),
1723 shared_artifacts_read_only: AtomicBool::new(false),
1724 callgraph_writer: AtomicBool::new(true),
1725 inspect_writer: AtomicBool::new(true),
1726 artifact_owner_status: parking_lot::Mutex::new(None),
1727 artifact_owner_lease: parking_lot::Mutex::new(None),
1728 degraded_reasons: parking_lot::Mutex::new(Vec::new()),
1729 heavy_root_work_allowed: Arc::clone(&heavy_root_work_allowed),
1730 callgraph_store: RwLock::new(None),
1731 callgraph_store_force_requested: AtomicU64::new(0),
1732 callgraph_store_force_fulfilled: AtomicU64::new(0),
1733 callgraph_store_rx: parking_lot::Mutex::new(None),
1734 callgraph_store_rx_generation: AtomicU64::new(0),
1735 callgraph_store_rx_epoch: AtomicU64::new(0),
1736 callgraph_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(),
1737 callgraph_legacy_migration_summary_logged: Arc::new(AtomicBool::new(false)),
1738 pending_callgraph_store_paths: Arc::new(parking_lot::Mutex::new(BTreeSet::new())),
1739 search_index: RwLock::new(None),
1740 search_index_rx: RwLock::new(None),
1741 search_index_rx_generation: AtomicU64::new(0),
1742 search_index_rx_epoch: AtomicU64::new(0),
1743 search_index_rx_terminal_epoch: Arc::new(AtomicU64::new(0)),
1744 search_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(),
1745 pending_search_index_paths: parking_lot::Mutex::new(BTreeSet::new()),
1746 symbol_cache,
1747 inspect_manager: Arc::new(InspectManager::with_heavy_root_work_gate(Arc::clone(
1748 &heavy_root_work_allowed,
1749 ))),
1750 tier2_refresh_scheduler: parking_lot::Mutex::new(Tier2RefreshScheduler::new()),
1751 pending_tier2_paths: parking_lot::Mutex::new(BTreeSet::new()),
1752 semantic_index: RwLock::new(None),
1753 semantic_index_rx: parking_lot::Mutex::new(None),
1754 semantic_index_rx_generation: AtomicU64::new(0),
1755 semantic_index_rx_epoch: AtomicU64::new(0),
1756 semantic_index_rx_terminal_epoch: Arc::new(AtomicU64::new(0)),
1757 semantic_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(),
1758 semantic_persist_lock: Arc::new(parking_lot::Mutex::new(())),
1759 semantic_index_status: RwLock::new(SemanticIndexStatus::Disabled),
1760 artifact_reload_lock: parking_lot::Mutex::new(()),
1761 semantic_cold_seed_active: Arc::new(AtomicBool::new(false)),
1762 semantic_cold_seed_generation: Arc::new(AtomicU64::new(0)),
1763 semantic_fingerprint_generation: Arc::new(AtomicU64::new(0)),
1764 semantic_callgraph_warm_deferred: AtomicBool::new(false),
1765 pending_semantic_index_paths: Arc::new(parking_lot::Mutex::new(BTreeSet::new())),
1766 pending_semantic_corpus_refresh: parking_lot::Mutex::new(false),
1767 semantic_refresh_tx: Arc::new(parking_lot::Mutex::new(None)),
1768 semantic_refresh_event_rx: parking_lot::Mutex::new(None),
1769 semantic_refresh_generation: AtomicU64::new(0),
1770 semantic_refresh_epoch: AtomicU64::new(0),
1771 semantic_refresh_build_epoch: AtomicU64::new(0),
1772 semantic_refresh_worker: parking_lot::Mutex::new(None),
1773 semantic_refresh_retry_attempts: parking_lot::Mutex::new(BTreeMap::new()),
1774 semantic_refresh_circuit: Arc::new(SemanticRefreshCircuit::default()),
1775 semantic_embedding_model: parking_lot::Mutex::new(None),
1776 watcher_runtime_lock: parking_lot::Mutex::new(()),
1777 watcher: parking_lot::Mutex::new(None),
1778 watcher_rx: parking_lot::Mutex::new(None),
1779 watcher_drain_slice: parking_lot::Mutex::new(None),
1780 watcher_thread: parking_lot::Mutex::new(None),
1781 lsp_manager: parking_lot::Mutex::new(lsp_manager),
1782 configure_generation: Arc::new(AtomicU64::new(0)),
1783 configure_content_generation: Arc::new(AtomicU64::new(0)),
1784 subc_lifecycle: SubcLifecycleAdmission::default(),
1785 configure_warm_state: parking_lot::Mutex::new(ConfigureWarmState::default()),
1786 configure_phase_timing: parking_lot::Mutex::new(ConfigurePhaseTiming::default()),
1787 configured_session_roots: parking_lot::Mutex::new(BTreeSet::new()),
1788 configure_maintenance_jobs: parking_lot::Mutex::new(VecDeque::new()),
1789 artifact_cache_keys: parking_lot::Mutex::new(BTreeMap::new()),
1790 artifact_cache_key_derivations: AtomicU64::new(0),
1791 borrowed_index_cache: parking_lot::Mutex::new(BorrowedIndexCache::default()),
1792 worktree_bridge_cache: parking_lot::Mutex::new(BTreeMap::new()),
1793 #[cfg(test)]
1794 worktree_bridge_probe_spawns: AtomicU64::new(0),
1795 #[cfg(test)]
1796 force_worktree_bridge_reprobe: AtomicBool::new(false),
1797 last_seen_reuse_completions: AtomicU64::new(0),
1798 configure_warnings_tx,
1799 configure_warnings_rx,
1800 progress_sender: Arc::clone(&progress_sender),
1801 status_emitter,
1802 status_bar_last_emitted: RwLock::new(None),
1803 status_bar_cached: RwLock::new(StatusBarCache::default()),
1804 bash_background: BgTaskRegistry::new(Arc::clone(&progress_sender)),
1805 filter_registry: Arc::new(std::sync::RwLock::new(
1806 crate::compress::toml_filter::FilterRegistry::default(),
1807 )),
1808 filter_registry_rebuild_count: AtomicU64::new(0),
1809 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
1810 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
1811 gitignore: Arc::new(std::sync::RwLock::new(None)),
1812 gitignore_generation: Arc::new(AtomicU64::new(0)),
1813 status_bar_tier2: RwLock::new(StatusBarTier2::default()),
1814 tsconfig_membership: parking_lot::Mutex::new(
1815 crate::lsp::tsconfig_membership::TsconfigMembershipCache::new(),
1816 ),
1817 };
1818 crate::logging::sync_storage_root(context.storage_dir());
1819 context
1820 }
1821
1822 pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
1826 let tier2 = self
1827 .status_bar_tier2
1828 .read()
1829 .unwrap_or_else(std::sync::PoisonError::into_inner)
1830 .clone();
1831 let tsconfig_generation = self.tsconfig_membership.lock().generation();
1832 let lsp = self.lsp_manager.lock();
1833 let diagnostics_generation = lsp.diagnostics_generation();
1834
1835 {
1836 let cached = self
1837 .status_bar_cached
1838 .read()
1839 .unwrap_or_else(std::sync::PoisonError::into_inner);
1840 if cached.valid
1841 && cached.diagnostics_generation == diagnostics_generation
1842 && cached.tier2_generation == tier2.generation
1843 && cached.tsconfig_generation == tsconfig_generation
1844 {
1845 return cached.counts.clone();
1846 }
1847 }
1848
1849 let counts = match (tier2.dead_code, tier2.unused_exports, tier2.duplicates) {
1850 (Some(dead_code), Some(unused_exports), Some(duplicates)) => {
1851 let (errors, warnings) = match self.canonical_cache_root_opt() {
1852 Some(root) => {
1853 let mut membership = self.tsconfig_membership.lock();
1854 lsp.filtered_error_warning_counts(|file| {
1855 file.starts_with(&root) && !membership.should_skip_diagnostics(file)
1856 })
1857 }
1858 None => lsp.warm_error_warning_counts(),
1859 };
1860 Some(StatusBarCounts {
1861 errors,
1862 warnings,
1863 dead_code,
1864 unused_exports,
1865 duplicates,
1866 todos: tier2.todos.unwrap_or(0),
1867 tier2_stale: tier2.stale,
1868 })
1869 }
1870 _ => None,
1871 };
1872
1873 *self
1874 .status_bar_cached
1875 .write()
1876 .unwrap_or_else(std::sync::PoisonError::into_inner) = StatusBarCache {
1877 valid: true,
1878 diagnostics_generation,
1879 tier2_generation: tier2.generation,
1880 tsconfig_generation,
1881 counts: counts.clone(),
1882 };
1883 counts
1884 }
1885
1886 pub fn try_health_snapshot(&self, project_root: &Path) -> RootHealthSnapshot {
1887 let heavy_root_work_allowed = match self.try_heavy_root_work_allowed() {
1891 Some(allowed) => allowed,
1892 None => return RootHealthSnapshot::busy(project_root),
1893 };
1894 let config = match self.config.try_read() {
1895 Ok(guard) => Arc::clone(&*guard),
1896 Err(_) => return RootHealthSnapshot::busy(project_root),
1897 };
1898 let search_index = match self.search_index.try_read() {
1899 Ok(guard) => guard,
1900 Err(_) => return RootHealthSnapshot::busy(project_root),
1901 };
1902 let search_index_rx = match self.search_index_rx.try_read() {
1903 Ok(guard) => guard,
1904 Err(_) => return RootHealthSnapshot::busy(project_root),
1905 };
1906 let semantic_status = match self.semantic_index_status.try_read() {
1907 Ok(guard) => guard,
1908 Err(_) => return RootHealthSnapshot::busy(project_root),
1909 };
1910 let callgraph_store = match self.callgraph_store.try_read() {
1911 Ok(guard) => guard,
1912 Err(_) => return RootHealthSnapshot::busy(project_root),
1913 };
1914 let callgraph_store_rx = match self.callgraph_store_rx.try_lock() {
1915 Some(guard) => guard,
1916 None => return RootHealthSnapshot::busy(project_root),
1917 };
1918 let tier2 = match self.status_bar_tier2.try_read() {
1919 Ok(guard) => guard,
1920 Err(_) => return RootHealthSnapshot::busy(project_root),
1921 };
1922 let bash = match self.bash_background.try_health_counts() {
1923 Some(counts) => counts,
1924 None => return RootHealthSnapshot::busy(project_root),
1925 };
1926
1927 let borrows_shared_artifacts = self.shared_artifacts_read_only.load(Ordering::SeqCst);
1933 let search_index_status = if search_index.as_ref().is_some_and(|index| index.ready)
1934 || (borrows_shared_artifacts && config.search_index)
1935 {
1936 "ready"
1937 } else if config.search_index
1938 || search_index.as_ref().is_some()
1939 || search_index_rx.as_ref().is_some()
1940 {
1941 "building"
1942 } else {
1943 "disabled"
1944 };
1945 let semantic_index_status = match &*semantic_status {
1946 SemanticIndexStatus::Ready { .. } => "ready",
1947 SemanticIndexStatus::Building { .. } => "building",
1948 SemanticIndexStatus::Disabled => "disabled",
1949 SemanticIndexStatus::Failed(_) => "degraded",
1950 };
1951 let callgraph_writer = self.callgraph_writer.load(Ordering::SeqCst);
1952 let callgraph_store_status = if !heavy_root_work_allowed {
1953 "disabled"
1954 } else if callgraph_store.as_ref().is_some() {
1955 "ready"
1956 } else if !callgraph_writer && config.callgraph_store {
1957 "ready"
1961 } else if callgraph_store_rx.is_some() || config.callgraph_store {
1962 "building"
1963 } else {
1964 "disabled"
1965 };
1966 let tier2_status = if !config.inspect.enabled
1967 || (tier2.dead_code.is_none()
1968 && tier2.unused_exports.is_none()
1969 && tier2.duplicates.is_none())
1970 {
1971 "disabled"
1972 } else if tier2.dead_code.is_some()
1973 && tier2.unused_exports.is_some()
1974 && tier2.duplicates.is_some()
1975 && !tier2.stale
1976 {
1977 "ready"
1978 } else {
1979 "building"
1980 };
1981
1982 RootHealthSnapshot {
1983 project_root: project_root.display().to_string(),
1984 actor_count: 1,
1985 state: RootHealthState::Ready,
1986 search_index: Some(HealthComponentSnapshot {
1987 status: search_index_status,
1988 }),
1989 semantic_index: Some(HealthComponentSnapshot {
1990 status: semantic_index_status,
1991 }),
1992 callgraph_store: Some(HealthComponentSnapshot {
1993 status: callgraph_store_status,
1994 }),
1995 tier2: Some(Tier2HealthSnapshot {
1996 status: tier2_status,
1997 }),
1998 bash: Some(bash),
1999 }
2000 }
2001
2002 pub fn should_emit_status_bar(&self, counts: &StatusBarCounts) -> bool {
2003 let mut last = self
2004 .status_bar_last_emitted
2005 .write()
2006 .unwrap_or_else(std::sync::PoisonError::into_inner);
2007 if last.as_ref() == Some(counts) {
2008 return false;
2009 }
2010 *last = Some(counts.clone());
2011 true
2012 }
2013
2014 pub fn clear_tsconfig_membership_cache(&self) {
2018 self.tsconfig_membership.lock().clear();
2019 }
2020
2021 #[cfg(test)]
2022 pub fn tsconfig_membership_clear_generation_for_test(&self) -> u64 {
2023 self.tsconfig_membership.lock().generation()
2024 }
2025
2026 pub fn mark_status_bar_tier2_stale(&self) -> bool {
2032 let mut tier2 = self
2033 .status_bar_tier2
2034 .write()
2035 .unwrap_or_else(std::sync::PoisonError::into_inner);
2036 if tier2.dead_code.is_some() && tier2.unused_exports.is_some() && tier2.duplicates.is_some()
2038 {
2039 let changed = !tier2.stale;
2040 tier2.stale = true;
2041 if changed {
2042 tier2.generation = tier2.generation.wrapping_add(1);
2043 }
2044 return changed;
2045 }
2046 false
2047 }
2048
2049 pub fn update_status_bar_tier2(
2055 &self,
2056 dead_code: Option<usize>,
2057 unused_exports: Option<usize>,
2058 duplicates: Option<usize>,
2059 todos: Option<usize>,
2060 stale: bool,
2061 ) {
2062 let mut tier2 = self
2063 .status_bar_tier2
2064 .write()
2065 .unwrap_or_else(std::sync::PoisonError::into_inner);
2066 let previous = (
2067 tier2.dead_code,
2068 tier2.unused_exports,
2069 tier2.duplicates,
2070 tier2.todos,
2071 tier2.stale,
2072 );
2073 if let Some(dead_code) = dead_code {
2074 tier2.dead_code = Some(dead_code);
2075 }
2076 if let Some(unused_exports) = unused_exports {
2077 tier2.unused_exports = Some(unused_exports);
2078 }
2079 if let Some(duplicates) = duplicates {
2080 tier2.duplicates = Some(duplicates);
2081 }
2082 if let Some(todos) = todos {
2083 tier2.todos = Some(todos);
2084 }
2085 tier2.stale = stale;
2086 let current = (
2087 tier2.dead_code,
2088 tier2.unused_exports,
2089 tier2.duplicates,
2090 tier2.todos,
2091 tier2.stale,
2092 );
2093 if current != previous {
2094 tier2.generation = tier2.generation.wrapping_add(1);
2095 }
2096 }
2097
2098 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
2101 self.gitignore
2102 .read()
2103 .unwrap_or_else(|poisoned| poisoned.into_inner())
2104 .clone()
2105 }
2106
2107 pub fn shared_gitignore(&self) -> SharedGitignore {
2109 Arc::clone(&self.gitignore)
2110 }
2111
2112 pub fn gitignore_generation(&self) -> Arc<AtomicU64> {
2116 Arc::clone(&self.gitignore_generation)
2117 }
2118
2119 fn set_gitignore(&self, matcher: Option<Arc<ignore::gitignore::Gitignore>>) {
2120 *self
2121 .gitignore
2122 .write()
2123 .unwrap_or_else(|poisoned| poisoned.into_inner()) = matcher;
2124 self.gitignore_generation.fetch_add(1, Ordering::SeqCst);
2125 }
2126
2127 pub fn clear_gitignore(&self) {
2149 self.set_gitignore(None);
2150 }
2151
2152 pub fn rebuild_gitignore(&self) {
2153 use ignore::gitignore::GitignoreBuilder;
2154 use std::path::Path;
2155 let root_raw = match self.config().project_root.clone() {
2156 Some(r) => r,
2157 None => {
2158 self.set_gitignore(None);
2159 return;
2160 }
2161 };
2162 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
2170 let mut builder = GitignoreBuilder::new(&root);
2171 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
2176 if global_ignore.is_file() {
2177 if let Some(err) = builder.add(&global_ignore) {
2178 crate::slog_warn!(
2179 "global gitignore parse error in {}: {}",
2180 global_ignore.display(),
2181 err
2182 );
2183 }
2184 }
2185 }
2186 let root_ignore = Path::new(&root).join(".gitignore");
2188 if root_ignore.exists() {
2189 if let Some(err) = builder.add(&root_ignore) {
2190 crate::slog_warn!(
2191 "gitignore parse error in {}: {}",
2192 root_ignore.display(),
2193 err
2194 );
2195 }
2196 }
2197 let root_aftignore = Path::new(&root).join(".aftignore");
2202 if root_aftignore.exists() {
2203 if let Some(err) = builder.add(&root_aftignore) {
2204 crate::slog_warn!(
2205 "aftignore parse error in {}: {}",
2206 root_aftignore.display(),
2207 err
2208 );
2209 }
2210 }
2211 let info_exclude = self
2216 .git_common_dir
2217 .lock()
2218 .clone()
2219 .unwrap_or_else(|| Path::new(&root).join(".git"))
2220 .join("info")
2221 .join("exclude");
2222 if info_exclude.exists() {
2223 if let Some(err) = builder.add(&info_exclude) {
2224 crate::slog_warn!(
2225 "gitignore parse error in {}: {}",
2226 info_exclude.display(),
2227 err
2228 );
2229 }
2230 }
2231 let walker = ignore::WalkBuilder::new(&root)
2237 .standard_filters(true)
2238 .hidden(false)
2246 .filter_entry(|entry| {
2247 let name = entry.file_name().to_string_lossy();
2248 !matches!(
2249 name.as_ref(),
2250 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
2251 )
2252 })
2253 .build();
2254 for entry in walker.flatten() {
2255 let file_name = entry.file_name();
2256 let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
2257 let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
2258 if is_nested_gitignore || is_nested_aftignore {
2259 if let Some(err) = builder.add(entry.path()) {
2260 crate::slog_warn!(
2261 "nested ignore parse error in {}: {}",
2262 entry.path().display(),
2263 err
2264 );
2265 }
2266 }
2267 }
2268 match builder.build() {
2269 Ok(gi) => {
2270 let count = gi.num_ignores();
2271 if count > 0 {
2272 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
2273 self.set_gitignore(Some(Arc::new(gi)));
2274 } else {
2275 self.set_gitignore(None);
2276 }
2277 }
2278 Err(err) => {
2279 crate::slog_warn!("gitignore matcher build failed: {}", err);
2280 self.set_gitignore(None);
2281 }
2282 }
2283 }
2284
2285 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
2288 Arc::clone(&self.bash_compress_flag)
2289 }
2290
2291 pub fn sync_bash_compress_flag(&self) {
2295 let value = self.config().experimental_bash_compress;
2296 self.bash_compress_flag
2297 .store(value, std::sync::atomic::Ordering::Relaxed);
2298 }
2299
2300 pub fn set_bash_compress_enabled(&self, enabled: bool) {
2301 self.update_config(|config| {
2302 config.experimental_bash_compress = enabled;
2303 });
2304 self.bash_compress_flag
2305 .store(enabled, std::sync::atomic::Ordering::Relaxed);
2306 }
2307
2308 pub fn filter_registry(
2312 &self,
2313 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
2314 self.ensure_filter_registry_loaded();
2315 match self.filter_registry.read() {
2316 Ok(g) => g,
2317 Err(poisoned) => poisoned.into_inner(),
2318 }
2319 }
2320
2321 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
2325 self.ensure_filter_registry_loaded();
2326 Arc::clone(&self.filter_registry)
2327 }
2328
2329 pub fn reset_filter_registry(&self) {
2333 let new_registry = crate::compress::build_registry_for_context(self);
2334 self.filter_registry_rebuild_count
2335 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2336 match self.filter_registry.write() {
2337 Ok(mut slot) => *slot = new_registry,
2338 Err(poisoned) => *poisoned.into_inner() = new_registry,
2339 }
2340 self.filter_registry_loaded
2341 .store(true, std::sync::atomic::Ordering::Release);
2342 }
2343
2344 fn ensure_filter_registry_loaded(&self) {
2345 use std::sync::atomic::Ordering;
2346 if self.filter_registry_loaded.load(Ordering::Acquire) {
2347 return;
2348 }
2349 let new_registry = crate::compress::build_registry_for_context(self);
2352 self.filter_registry_rebuild_count
2353 .fetch_add(1, Ordering::SeqCst);
2354 if let Ok(mut slot) = self.filter_registry.write() {
2355 *slot = new_registry;
2356 self.filter_registry_loaded.store(true, Ordering::Release);
2357 }
2358 }
2359
2360 #[cfg(test)]
2361 pub fn filter_registry_rebuild_count_for_test(&self) -> u64 {
2362 self.filter_registry_rebuild_count.load(Ordering::SeqCst)
2363 }
2364
2365 pub fn app(&self) -> Arc<App> {
2366 Arc::clone(&self.app)
2367 }
2368
2369 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
2372 self.app.lsp_child_registry()
2373 }
2374
2375 pub fn stdout_writer(&self) -> SharedStdoutWriter {
2376 self.app.stdout_writer()
2377 }
2378
2379 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
2380 if let Ok(mut progress_sender) = self.progress_sender.lock() {
2381 *progress_sender = sender;
2382 }
2383 }
2384
2385 pub fn emit_progress(&self, frame: ProgressFrame) {
2386 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
2387 return;
2388 };
2389 if let Some(sender) = progress_sender.as_ref() {
2390 sender(PushFrame::Progress(frame));
2391 }
2392 }
2393
2394 pub fn status_emitter(&self) -> &StatusEmitter {
2395 &self.status_emitter
2396 }
2397
2398 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
2406 self.progress_sender
2407 .lock()
2408 .ok()
2409 .and_then(|sender| sender.clone())
2410 }
2411
2412 pub fn advance_configure_generation(&self) -> u64 {
2413 self.subc_lifecycle
2414 .advance_generation(self.configure_generation.as_ref())
2415 }
2416
2417 pub(crate) fn mark_subc_bound(&self) {
2418 self.subc_lifecycle.mark_bound();
2419 }
2420
2421 pub(crate) fn mark_subc_unbound(&self) {
2422 self.subc_lifecycle
2423 .mark_unbound(self.configure_generation.as_ref());
2424 }
2425
2426 pub(crate) fn subc_unbound_quiesced(&self) -> bool {
2427 self.subc_lifecycle.is_unbound()
2428 }
2429
2430 pub(crate) fn subc_lifecycle_admission(&self) -> SubcLifecycleAdmission {
2431 self.subc_lifecycle.clone()
2432 }
2433
2434 pub(crate) fn run_if_subc_bound_generation<R>(
2435 &self,
2436 expected_generation: u64,
2437 action: impl FnOnce() -> R,
2438 ) -> Option<R> {
2439 self.subc_lifecycle.run_if_current(
2440 self.configure_generation.as_ref(),
2441 expected_generation,
2442 action,
2443 )
2444 }
2445
2446 pub fn note_configure_warm_key(&self, key: String) -> (u64, bool) {
2457 let mut state = self.configure_warm_state.lock();
2458 let equivalent = state.key.as_ref().is_some_and(|previous| *previous == key);
2459 let generation = if equivalent {
2460 self.configure_generation()
2461 } else {
2462 self.configure_content_generation
2463 .fetch_add(1, Ordering::SeqCst);
2464 self.advance_configure_generation()
2465 };
2466 state.generation = generation;
2467 state.key = Some(key);
2468 (generation, equivalent)
2469 }
2470
2471 pub(crate) fn configure_warm_key_matches(&self, key: &str) -> bool {
2472 self.configure_warm_state
2473 .lock()
2474 .key
2475 .as_deref()
2476 .is_some_and(|current| current == key)
2477 }
2478
2479 pub(crate) fn invalidate_configure_warm_state(&self) {
2480 self.configure_warm_state.lock().key = None;
2481 }
2482
2483 pub fn note_configure_session_binding(&self, root: PathBuf, session_id: String) -> bool {
2484 self.configured_session_roots
2485 .lock()
2486 .insert((root, session_id))
2487 }
2488
2489 pub fn forget_configure_session_binding(&self, root: &Path, session_id: &str) {
2493 self.configured_session_roots
2494 .lock()
2495 .remove(&(root.to_path_buf(), session_id.to_string()));
2496 }
2497
2498 pub fn watcher_drain_has_work(&self) -> bool {
2504 let receiver_pending = self
2505 .watcher_rx
2506 .lock()
2507 .as_ref()
2508 .is_some_and(|rx| !rx.is_empty());
2509 receiver_pending
2510 || self
2511 .watcher_drain_slice
2512 .lock()
2513 .as_ref()
2514 .is_some_and(WatcherDrainSliceState::has_pending_work)
2515 }
2516
2517 pub fn lsp_drain_has_work(&self) -> bool {
2518 match self.lsp_manager.try_lock() {
2519 Some(lsp) => lsp.has_pending_events(),
2520 None => true,
2522 }
2523 }
2524
2525 pub fn completion_drains_have_work(&self) -> bool {
2526 let search_pending = self
2527 .search_index_rx
2528 .try_read()
2529 .map(|slot| {
2530 slot.as_ref().is_some_and(|receiver| {
2531 !receiver.is_empty()
2532 || self.search_index_rx_terminal_epoch.load(Ordering::SeqCst)
2533 == self.search_index_rx_epoch()
2534 })
2535 })
2536 .unwrap_or(true);
2537 if search_pending {
2538 return true;
2539 }
2540 if self
2541 .callgraph_store_rx
2542 .lock()
2543 .as_ref()
2544 .is_some_and(|rx| !rx.is_empty())
2545 {
2546 return true;
2547 }
2548 if self
2549 .semantic_index_rx
2550 .lock()
2551 .as_ref()
2552 .is_some_and(|receiver| {
2553 !receiver.is_empty()
2554 || self.semantic_index_rx_terminal_epoch.load(Ordering::SeqCst)
2555 == self.semantic_index_rx_epoch()
2556 })
2557 {
2558 return true;
2559 }
2560 if self
2561 .semantic_refresh_event_rx
2562 .lock()
2563 .as_ref()
2564 .is_some_and(|rx| !rx.is_empty())
2565 {
2566 return true;
2567 }
2568 if self.semantic_refresh_probe_ready() && self.semantic_refresh_event_rx.lock().is_some() {
2569 return true;
2570 }
2571 if self
2572 .semantic_refresh_worker
2573 .lock()
2574 .as_ref()
2575 .is_some_and(|worker_slot| match worker_slot.try_lock() {
2576 Ok(handle) => handle
2577 .as_ref()
2578 .is_some_and(std::thread::JoinHandle::is_finished),
2579 Err(std::sync::TryLockError::WouldBlock) => true,
2580 Err(std::sync::TryLockError::Poisoned(_)) => true,
2581 })
2582 {
2583 return true;
2584 }
2585 self.inspect_manager().has_pending_completions() || self.has_new_reuse_completions()
2586 }
2587
2588 pub fn configure_tail_has_work(&self) -> bool {
2589 !self.configure_maintenance_jobs.lock().is_empty() || !self.configure_warnings_rx.is_empty()
2590 }
2591
2592 pub(crate) fn enqueue_configure_maintenance(&self, job: ConfigureMaintenanceJob) {
2593 self.configure_maintenance_jobs.lock().push_back(job);
2594 }
2595
2596 pub(crate) fn drain_configure_maintenance(&self) -> Vec<ConfigureMaintenanceJob> {
2597 self.configure_maintenance_jobs.lock().drain(..).collect()
2598 }
2599
2600 #[cfg(test)]
2601 pub(crate) fn configure_maintenance_job_count_for_test(&self) -> usize {
2602 self.configure_maintenance_jobs.lock().len()
2603 }
2604
2605 pub fn cached_artifact_cache_key(&self, canonical_root: &Path) -> Option<String> {
2608 self.artifact_cache_keys.lock().get(canonical_root).cloned()
2609 }
2610
2611 pub(crate) fn cached_worktree_bridge(
2614 &self,
2615 canonical_root: &Path,
2616 ) -> Option<(bool, Option<PathBuf>)> {
2617 #[cfg(test)]
2618 if self.force_worktree_bridge_reprobe.load(Ordering::SeqCst) {
2619 return None;
2620 }
2621
2622 let signature = git_entry_signature(canonical_root);
2623 self.worktree_bridge_cache
2624 .lock()
2625 .get(canonical_root)
2626 .filter(|entry| entry.git_entry == signature)
2627 .map(|entry| (entry.is_worktree_bridge, entry.git_common_dir.clone()))
2628 }
2629
2630 pub(crate) fn cache_worktree_bridge(
2633 &self,
2634 canonical_root: &Path,
2635 is_worktree_bridge: bool,
2636 git_common_dir: PathBuf,
2637 ) {
2638 self.worktree_bridge_cache.lock().insert(
2639 canonical_root.to_path_buf(),
2640 WorktreeBridgeCacheEntry {
2641 git_entry: git_entry_signature(canonical_root),
2642 is_worktree_bridge,
2643 git_common_dir: Some(git_common_dir),
2644 },
2645 );
2646 }
2647
2648 #[cfg(test)]
2649 pub(crate) fn record_worktree_bridge_probe_spawn_for_test(&self) {
2650 self.worktree_bridge_probe_spawns
2651 .fetch_add(1, Ordering::SeqCst);
2652 }
2653
2654 #[cfg(test)]
2655 pub(crate) fn worktree_bridge_probe_spawns_for_test(&self) -> u64 {
2656 self.worktree_bridge_probe_spawns.load(Ordering::SeqCst)
2657 }
2658
2659 #[cfg(test)]
2660 pub(crate) fn force_worktree_bridge_reprobe_for_test(&self, enabled: bool) {
2661 self.force_worktree_bridge_reprobe
2662 .store(enabled, Ordering::SeqCst);
2663 }
2664
2665 pub fn memoized_artifact_cache_key(&self, canonical_root: &Path) -> String {
2666 let mut keys = self.artifact_cache_keys.lock();
2667 if let Some(key) = keys.get(canonical_root).cloned() {
2668 return key;
2669 }
2670 let key = crate::search_index::artifact_cache_key(canonical_root);
2671 self.artifact_cache_key_derivations
2672 .fetch_add(1, Ordering::SeqCst);
2673 keys.insert(canonical_root.to_path_buf(), key.clone());
2674 key
2675 }
2676
2677 pub fn memoized_artifact_cache_key_for_configure(
2678 &self,
2679 raw_root: &Path,
2680 canonical_root: &Path,
2681 storage_root: &Path,
2682 git_common_dir: Option<&Path>,
2683 ) -> Result<String, crate::search_index::ArtifactCacheKeyProbeError> {
2684 {
2685 let keys = self.artifact_cache_keys.lock();
2686 if let Some(key) = keys
2687 .get(canonical_root)
2688 .or_else(|| keys.get(raw_root))
2689 .cloned()
2690 {
2691 return Ok(key);
2692 }
2693 }
2694
2695 let key = crate::search_index::artifact_cache_key_with_memo(
2696 canonical_root,
2697 raw_root,
2698 storage_root,
2699 git_common_dir,
2700 )?;
2701 self.artifact_cache_key_derivations
2702 .fetch_add(1, Ordering::SeqCst);
2703 let mut keys = self.artifact_cache_keys.lock();
2704 keys.insert(canonical_root.to_path_buf(), key.clone());
2705 keys.insert(raw_root.to_path_buf(), key.clone());
2706 Ok(key)
2707 }
2708
2709 #[cfg(test)]
2710 pub fn artifact_cache_key_derivation_count_for_test(&self) -> u64 {
2711 self.artifact_cache_key_derivations.load(Ordering::SeqCst)
2712 }
2713
2714 pub(crate) fn resolve_external_git_root(
2715 &self,
2716 project_root: &Path,
2717 requested_path: &str,
2718 ) -> Result<PathBuf, crate::readonly_artifacts::GitRootResolutionError> {
2719 let raw_path = Path::new(requested_path);
2720 let canonical_requested = if raw_path.is_absolute() {
2721 std::fs::canonicalize(raw_path).ok()
2722 } else {
2723 None
2724 };
2725 if let Some(root) = canonical_requested
2726 .as_deref()
2727 .and_then(|root| self.borrowed_index_cache.lock().resolved_root(root))
2728 {
2729 return Ok(root);
2730 }
2731
2732 let root = crate::readonly_artifacts::resolve_git_root_from_user_path(
2733 project_root,
2734 requested_path,
2735 )?;
2736 if canonical_requested.as_deref() == Some(root.as_path()) {
2737 self.borrowed_index_cache
2738 .lock()
2739 .remember_resolved_root(root.clone());
2740 }
2741 Ok(root)
2742 }
2743
2744 pub(crate) fn open_borrowed_search_index(
2745 &self,
2746 external_root: &Path,
2747 storage_dir: Option<&Path>,
2748 ) -> crate::readonly_artifacts::ReadOnlyArtifact<Arc<SearchIndex>> {
2749 let canonical_root =
2750 std::fs::canonicalize(external_root).unwrap_or_else(|_| external_root.to_path_buf());
2751 let project_key = self.memoized_artifact_cache_key(&canonical_root);
2752 let Some(artifact) = crate::readonly_artifacts::search_index_artifact_generation_with_key(
2753 &project_key,
2754 storage_dir,
2755 ) else {
2756 return crate::readonly_artifacts::ReadOnlyArtifact::Absent;
2757 };
2758 let key = BorrowedIndexCacheKey {
2759 canonical_root: canonical_root.clone(),
2760 artifact,
2761 };
2762 let mut cache = self.borrowed_index_cache.lock();
2763 if let Some(index) = cache.search(&key) {
2764 return index;
2765 }
2766
2767 let opened = crate::readonly_artifacts::open_search_index_read_only_with_key(
2768 &canonical_root,
2769 storage_dir,
2770 &project_key,
2771 )
2772 .map(Arc::new);
2773 if !matches!(opened, crate::readonly_artifacts::ReadOnlyArtifact::Absent) {
2774 cache.insert(key, BorrowedIndexCacheValue::Search(opened.clone()));
2775 }
2776 opened
2777 }
2778
2779 pub(crate) fn open_borrowed_semantic_index(
2780 &self,
2781 external_root: &Path,
2782 storage_dir: Option<&Path>,
2783 ) -> crate::readonly_artifacts::ReadOnlyArtifact<Arc<SemanticIndex>> {
2784 let canonical_root =
2785 std::fs::canonicalize(external_root).unwrap_or_else(|_| external_root.to_path_buf());
2786 let project_key = self.memoized_artifact_cache_key(&canonical_root);
2787 let Some(artifact) = crate::readonly_artifacts::semantic_index_artifact_generation_with_key(
2788 &project_key,
2789 storage_dir,
2790 ) else {
2791 return crate::readonly_artifacts::ReadOnlyArtifact::Absent;
2792 };
2793 let key = BorrowedIndexCacheKey {
2794 canonical_root: canonical_root.clone(),
2795 artifact,
2796 };
2797 let mut cache = self.borrowed_index_cache.lock();
2798 if let Some(index) = cache.semantic(&key) {
2799 return index;
2800 }
2801
2802 let opened = crate::readonly_artifacts::open_semantic_index_read_only_with_key(
2803 &canonical_root,
2804 storage_dir,
2805 &project_key,
2806 )
2807 .map(Arc::new);
2808 if !matches!(opened, crate::readonly_artifacts::ReadOnlyArtifact::Absent) {
2809 cache.insert(key, BorrowedIndexCacheValue::Semantic(opened.clone()));
2810 }
2811 opened
2812 }
2813
2814 #[cfg(test)]
2815 pub(crate) fn borrowed_index_cache_len_for_test(&self) -> usize {
2816 self.borrowed_index_cache.lock().entries.len()
2817 }
2818
2819 pub fn configure_generation(&self) -> u64 {
2820 self.configure_generation.load(Ordering::SeqCst)
2821 }
2822
2823 pub fn configure_generation_flag(&self) -> Arc<AtomicU64> {
2824 Arc::clone(&self.configure_generation)
2825 }
2826
2827 pub(crate) fn configure_content_generation(&self) -> u64 {
2828 self.configure_content_generation.load(Ordering::SeqCst)
2829 }
2830
2831 pub(crate) fn configure_content_generation_flag(&self) -> Arc<AtomicU64> {
2832 Arc::clone(&self.configure_content_generation)
2833 }
2834
2835 pub(crate) fn begin_configure_ack_phase(&self, phase: &'static str) {
2836 let now = Instant::now();
2837 let mut timing = self.configure_phase_timing.lock();
2838 if phase == "canonicalize" {
2839 timing.completed.clear();
2840 } else if timing.phase != "idle" && timing.phase != "ack_ready" {
2841 let previous = timing.phase;
2842 let elapsed = now.saturating_duration_since(timing.started_at);
2843 timing.completed.push((previous, elapsed));
2844 }
2845 timing.phase = phase;
2846 timing.started_at = now;
2847 }
2848
2849 pub(crate) fn configure_ack_phase_snapshot(&self) -> String {
2850 let timing = self.configure_phase_timing.lock();
2851 let mut parts = timing
2852 .completed
2853 .iter()
2854 .map(|(phase, elapsed)| format!("{phase}={}ms", elapsed.as_millis()))
2855 .collect::<Vec<_>>();
2856 parts.push(format!(
2857 "{}={}ms",
2858 timing.phase,
2859 timing.started_at.elapsed().as_millis()
2860 ));
2861 parts.join(",")
2862 }
2863
2864 pub fn advance_semantic_fingerprint_generation(&self) -> u64 {
2865 self.semantic_fingerprint_generation
2866 .fetch_add(1, Ordering::SeqCst)
2867 .wrapping_add(1)
2868 }
2869
2870 pub fn semantic_fingerprint_generation(&self) -> u64 {
2871 self.semantic_fingerprint_generation.load(Ordering::SeqCst)
2872 }
2873
2874 pub fn semantic_fingerprint_generation_flag(&self) -> Arc<AtomicU64> {
2875 Arc::clone(&self.semantic_fingerprint_generation)
2876 }
2877
2878 pub fn configure_warnings_sender(
2879 &self,
2880 ) -> crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)> {
2881 self.configure_warnings_tx.clone()
2882 }
2883
2884 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
2885 let mut warnings = Vec::new();
2886 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
2887 warnings.push(warning);
2888 }
2889 warnings
2890 }
2891
2892 pub fn bash_background(&self) -> &BgTaskRegistry {
2893 &self.bash_background
2894 }
2895
2896 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
2897 self.bash_background.drain_completions()
2898 }
2899
2900 pub fn provider(&self) -> &dyn LanguageProvider {
2902 self.provider.as_ref()
2903 }
2904
2905 pub fn backup(&self) -> &parking_lot::Mutex<BackupStore> {
2907 &self.backup
2908 }
2909
2910 pub fn checkpoint(&self) -> &parking_lot::Mutex<CheckpointStore> {
2912 &self.checkpoint
2913 }
2914
2915 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
2916 self.app.set_db(conn);
2917 }
2918
2919 pub fn clear_db(&self) {
2920 self.app.clear_db();
2921 }
2922
2923 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
2924 self.app.db()
2925 }
2926
2927 pub fn config(&self) -> Arc<Config> {
2929 let guard = match self.config.read() {
2930 Ok(guard) => guard,
2931 Err(poisoned) => poisoned.into_inner(),
2932 };
2933 Arc::clone(&*guard)
2934 }
2935
2936 pub fn set_config(&self, config: Config) {
2938 let next = Arc::new(config);
2939 match self.config.write() {
2940 Ok(mut guard) => *guard = next,
2941 Err(poisoned) => *poisoned.into_inner() = next,
2942 }
2943 }
2944
2945 pub fn update_config(&self, update: impl FnOnce(&mut Config)) {
2947 let mut next = self.config().as_ref().clone();
2948 update(&mut next);
2949 self.set_config(next);
2950 }
2951
2952 pub fn force_restrict_guard(&self, req_id: &str) -> ForceRestrictGuard<'_> {
2953 let mut requests = self.force_restrict_requests.lock();
2954 *requests.entry(req_id.to_string()).or_insert(0) += 1;
2955 ForceRestrictGuard {
2956 ctx: self,
2957 req_id: req_id.to_string(),
2958 }
2959 }
2960
2961 pub fn with_force_restrict<R>(&self, req_id: &str, f: impl FnOnce() -> R) -> R {
2962 let _guard = self.force_restrict_guard(req_id);
2963 f()
2964 }
2965
2966 pub fn request_force_restrict(&self, req_id: &str) -> bool {
2967 self.force_restrict_requests.lock().contains_key(req_id)
2968 }
2969
2970 fn release_force_restrict(&self, req_id: &str) {
2971 let mut requests = self.force_restrict_requests.lock();
2972 match requests.get_mut(req_id) {
2973 Some(count) if *count > 1 => *count -= 1,
2974 Some(_) => {
2975 requests.remove(req_id);
2976 }
2977 None => {}
2978 }
2979 }
2980
2981 pub fn set_harness(&self, harness: Harness) {
2982 self.bash_background.set_harness(harness.clone());
2983 *self.harness.lock() = Some(harness);
2984 }
2985
2986 pub fn harness_opt(&self) -> Option<Harness> {
2987 self.harness.lock().clone()
2988 }
2989
2990 pub fn harness(&self) -> Harness {
2991 self.harness_opt()
2992 .expect("harness set by configure before any tool call")
2993 }
2994
2995 pub fn storage_dir(&self) -> PathBuf {
2996 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
2997 }
2998
2999 pub fn harness_dir(&self) -> PathBuf {
3000 self.storage_dir().join(self.harness().storage_segment())
3001 }
3002
3003 pub fn inspect_dir(&self) -> PathBuf {
3004 if let Some(root) = self
3005 .canonical_cache_root_opt()
3006 .or_else(|| self.config().project_root.clone())
3007 {
3008 self.storage_dir()
3009 .join("inspect")
3010 .join(crate::path_identity::project_scope_key(&root))
3011 } else {
3012 self.storage_dir().join("inspect").join("unconfigured")
3013 }
3014 }
3015
3016 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
3017 self.harness_dir()
3018 .join("bash-tasks")
3019 .join(hash_session(session_id))
3020 }
3021
3022 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
3023 self.harness_dir()
3024 .join("backups")
3025 .join(hash_session(session_id))
3026 .join(path_hash)
3027 }
3028
3029 pub fn filters_dir(&self) -> PathBuf {
3030 self.harness_dir().join("filters")
3031 }
3032
3033 pub fn trust_file(&self) -> PathBuf {
3035 self.storage_dir().join("trusted-filter-projects.json")
3036 }
3037
3038 pub fn set_canonical_cache_root(&self, root: PathBuf) {
3039 debug_assert!(root.is_absolute());
3040 let root_changed = {
3041 let mut current = self.canonical_cache_root.lock();
3042 let changed = current.as_deref() != Some(root.as_path());
3043 *current = Some(root);
3044 changed
3045 };
3046 if root_changed {
3047 let mut tier2 = self
3048 .status_bar_tier2
3049 .write()
3050 .unwrap_or_else(std::sync::PoisonError::into_inner);
3051 let generation = tier2.generation.wrapping_add(1);
3052 *tier2 = StatusBarTier2 {
3053 generation,
3054 ..StatusBarTier2::default()
3055 };
3056 *self
3057 .status_bar_last_emitted
3058 .write()
3059 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
3060 }
3061 }
3062
3063 pub fn canonical_cache_root(&self) -> PathBuf {
3064 self.canonical_cache_root
3065 .lock()
3066 .clone()
3067 .expect("canonical_cache_root accessed before handle_configure")
3068 }
3069
3070 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
3071 self.canonical_cache_root.lock().clone()
3072 }
3073
3074 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
3075 *self.is_worktree_bridge.lock() = is_worktree_bridge;
3076 *self.git_common_dir.lock() = git_common_dir;
3077 self.inspect_manager
3081 .set_automatic_tier2_refresh_allowed(!is_worktree_bridge);
3082 let artifact_read_only = self.shared_artifacts_read_only.load(Ordering::SeqCst);
3083 self.callgraph_writer
3084 .store(!is_worktree_bridge && !artifact_read_only, Ordering::SeqCst);
3085 }
3086
3087 pub fn set_artifact_owner(
3088 &self,
3089 status: Option<ArtifactOwnerStatus>,
3090 lease: Option<ArtifactOwnerLease>,
3091 ) {
3092 let read_only = status
3093 .as_ref()
3094 .is_some_and(|status| status.mode == ArtifactOwnerMode::ReadOnly);
3095 self.shared_artifacts_read_only
3096 .store(read_only, Ordering::SeqCst);
3097 self.callgraph_writer
3098 .store(!self.is_worktree_bridge() && !read_only, Ordering::SeqCst);
3099 self.inspect_writer.store(true, Ordering::SeqCst);
3100 *self.artifact_owner_status.lock() = status;
3101 *self.artifact_owner_lease.lock() = lease.map(crate::artifact_owner::register_heartbeat);
3102 }
3103
3104 pub fn set_cache_writer_capabilities(&self, callgraph_writer: bool, inspect_writer: bool) {
3105 self.callgraph_writer
3106 .store(callgraph_writer, Ordering::SeqCst);
3107 self.inspect_writer.store(inspect_writer, Ordering::SeqCst);
3108 }
3109
3110 pub fn callgraph_writer(&self) -> bool {
3111 self.callgraph_writer.load(Ordering::SeqCst)
3112 }
3113
3114 pub fn inspect_writer(&self) -> bool {
3115 self.inspect_writer.load(Ordering::SeqCst)
3116 }
3117
3118 pub fn shared_artifacts_read_only(&self) -> bool {
3119 !self.callgraph_writer()
3120 }
3121
3122 pub fn artifact_owner_status(&self) -> Option<ArtifactOwnerStatus> {
3123 self.artifact_owner_status.lock().clone()
3124 }
3125
3126 pub fn is_worktree_bridge(&self) -> bool {
3127 *self.is_worktree_bridge.lock()
3128 }
3129
3130 pub fn git_common_dir(&self) -> Option<PathBuf> {
3131 self.git_common_dir.lock().clone()
3132 }
3133
3134 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
3138 *self.degraded_reasons.lock() = reasons;
3139 }
3140
3141 pub fn set_heavy_root_work_allowed(&self, allowed: bool) {
3142 self.heavy_root_work_allowed
3143 .store(allowed, Ordering::SeqCst);
3144 }
3145
3146 pub fn heavy_root_work_allowed(&self) -> bool {
3147 self.heavy_root_work_allowed.load(Ordering::SeqCst) && !self.subc_lifecycle.is_unbound()
3148 }
3149
3150 fn try_heavy_root_work_allowed(&self) -> Option<bool> {
3151 if !self.heavy_root_work_allowed.load(Ordering::SeqCst) {
3152 return Some(false);
3153 }
3154 self.subc_lifecycle.try_is_bound()
3155 }
3156
3157 pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
3158 let reason = reason.into();
3159 let mut reasons = self.degraded_reasons.lock();
3160 if reasons.iter().any(|existing| existing == &reason) {
3161 return false;
3162 }
3163 reasons.push(reason);
3164 true
3165 }
3166
3167 pub fn degraded_reasons(&self) -> Vec<String> {
3171 self.degraded_reasons.lock().clone()
3172 }
3173
3174 pub fn is_degraded(&self) -> bool {
3176 !self.degraded_reasons.lock().is_empty()
3177 }
3178
3179 pub fn cache_role(&self) -> &'static str {
3180 if self.canonical_cache_root.lock().is_none() {
3181 "not_initialized"
3182 } else if self.is_worktree_bridge() {
3183 "worktree"
3184 } else if self.shared_artifacts_read_only.load(Ordering::SeqCst) {
3185 "read_only"
3186 } else {
3187 "main"
3188 }
3189 }
3190
3191 pub fn callgraph_store(&self) -> &RwLock<Option<Arc<ReadonlyCallGraphStore>>> {
3193 &self.callgraph_store
3194 }
3195
3196 pub fn mark_callgraph_store_force_rebuild(&self) -> u64 {
3197 self.callgraph_store_force_requested
3198 .fetch_add(1, Ordering::SeqCst)
3199 .wrapping_add(1)
3200 }
3201
3202 pub(crate) fn pending_callgraph_store_force_token(&self) -> Option<u64> {
3203 let requested = self.callgraph_store_force_requested.load(Ordering::SeqCst);
3204 let fulfilled = self.callgraph_store_force_fulfilled.load(Ordering::SeqCst);
3205 (requested > fulfilled).then_some(requested)
3206 }
3207
3208 pub fn fulfill_callgraph_store_force_token(&self, token: u64) {
3209 self.callgraph_store_force_fulfilled
3210 .fetch_max(token, Ordering::SeqCst);
3211 }
3212
3213 pub fn callgraph_store_dir(&self) -> PathBuf {
3214 if let Some(root) = self.callgraph_project_root() {
3215 self.storage_dir()
3216 .join("callgraph")
3217 .join(self.memoized_artifact_cache_key(&root))
3218 } else {
3219 self.storage_dir().join("callgraph").join("unconfigured")
3220 }
3221 }
3222
3223 pub fn ensure_callgraph_store(
3224 &self,
3225 ) -> Result<Option<Arc<ReadonlyCallGraphStore>>, CallGraphStoreError> {
3226 self.ensure_callgraph_store_with_flag(true)
3227 }
3228
3229 fn ensure_callgraph_store_with_flag(
3230 &self,
3231 respect_config_flag: bool,
3232 ) -> Result<Option<Arc<ReadonlyCallGraphStore>>, CallGraphStoreError> {
3233 if respect_config_flag && !self.config().callgraph_store {
3234 return Ok(None);
3235 }
3236 if !self.heavy_root_work_allowed() {
3237 return Ok(None);
3238 }
3239 self.revalidate_callgraph_store_generation();
3240 let force_token = self.pending_callgraph_store_force_token();
3241 if force_token.is_none() {
3242 if let Some(store) = {
3243 let guard = self
3244 .callgraph_store
3245 .read()
3246 .unwrap_or_else(std::sync::PoisonError::into_inner);
3247 guard.as_ref().map(Arc::clone)
3248 } {
3249 self.schedule_legacy_callgraph_migration_if_needed(
3250 store.as_ref(),
3251 store.project_root().to_path_buf(),
3252 self.callgraph_store_dir(),
3253 );
3254 return Ok(Some(store));
3255 }
3256 }
3257
3258 let Some(project_root) = self.callgraph_project_root() else {
3259 return Ok(None);
3260 };
3261 let callgraph_dir = self.callgraph_store_dir();
3262
3263 if force_token.is_none() {
3267 if let Some(store) =
3268 CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone())?
3269 {
3270 let store = Arc::new(store);
3271 {
3272 let mut guard = self
3273 .callgraph_store
3274 .write()
3275 .unwrap_or_else(std::sync::PoisonError::into_inner);
3276 *guard = Some(Arc::clone(&store));
3277 }
3278 self.schedule_legacy_callgraph_migration_if_needed(
3279 store.as_ref(),
3280 project_root,
3281 callgraph_dir,
3282 );
3283 return Ok(Some(store));
3284 }
3285 }
3286
3287 if !self.callgraph_writer() {
3288 return Ok(None);
3289 }
3290 let build_generation = self.configure_generation();
3291 let persist_epoch_flag = self.callgraph_persist_epoch_flag();
3292 let Some(persist_epoch) = self
3293 .run_if_subc_bound_generation(build_generation, || self.next_callgraph_persist_epoch())
3294 else {
3295 return Ok(None);
3296 };
3297 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
3298 let (store, _stats) = crate::callgraph_store::with_publish_epoch(
3299 persist_epoch_flag.clone(),
3300 persist_epoch,
3301 || {
3302 if force_token.is_some() {
3303 CallGraphStore::force_cold_build_with_lease_chunked(
3304 callgraph_dir.clone(),
3305 project_root.clone(),
3306 &files,
3307 self.config().callgraph_chunk_size,
3308 )
3309 .map(|(store, _stats)| (store, ()))
3310 } else {
3311 CallGraphStore::ensure_built_with_lease_chunked(
3312 callgraph_dir.clone(),
3313 project_root.clone(),
3314 &files,
3315 self.config().callgraph_chunk_size,
3316 )
3317 .map(|(store, _stats)| (store, ()))
3318 }
3319 },
3320 )?;
3321 drop(store);
3322
3323 let Some(store) = CallGraphStore::open_readonly(callgraph_dir, project_root)? else {
3324 return Ok(None);
3325 };
3326 let store = Arc::new(store);
3327 self.run_if_subc_bound_generation(build_generation, || {
3328 if persist_epoch_flag.current() != persist_epoch {
3329 return None;
3330 }
3331 let mut guard = self
3332 .callgraph_store
3333 .write()
3334 .unwrap_or_else(std::sync::PoisonError::into_inner);
3335 *guard = Some(Arc::clone(&store));
3336 if let Some(force_token) = force_token {
3337 self.fulfill_callgraph_store_force_token(force_token);
3338 }
3339 Some(Arc::clone(&store))
3340 })
3341 .flatten()
3342 .map_or(Ok(None), |store| Ok(Some(store)))
3343 }
3344
3345 pub fn callgraph_project_root(&self) -> Option<PathBuf> {
3348 self.canonical_cache_root_opt().or_else(|| {
3349 self.config()
3350 .project_root
3351 .clone()
3352 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
3353 })
3354 }
3355
3356 pub fn revalidate_callgraph_store_generation(&self) {
3360 let (superseded, legacy_fallback) = {
3361 let guard = self
3362 .callgraph_store
3363 .read()
3364 .unwrap_or_else(std::sync::PoisonError::into_inner);
3365 guard
3366 .as_ref()
3367 .map(|store| (!store.is_current(), store.is_legacy_fallback()))
3368 .unwrap_or((false, false))
3369 };
3370 if !superseded {
3371 return;
3372 }
3373 if legacy_fallback && self.callgraph_store_rx.lock().is_some() {
3377 return;
3378 }
3379 let mut guard = self
3380 .callgraph_store
3381 .write()
3382 .unwrap_or_else(std::sync::PoisonError::into_inner);
3383 *guard = None;
3384 }
3385
3386 pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess {
3387 if !self.heavy_root_work_allowed() {
3388 return CallgraphStoreAccess::Unavailable;
3389 }
3390 let operation_generation = self.configure_generation();
3391
3392 self.revalidate_callgraph_store_generation();
3396 let force_token = self.pending_callgraph_store_force_token();
3397 if force_token.is_none() {
3398 if let Some(store) = {
3399 let guard = self
3400 .callgraph_store
3401 .read()
3402 .unwrap_or_else(std::sync::PoisonError::into_inner);
3403 guard.as_ref().map(Arc::clone)
3404 } {
3405 self.schedule_legacy_callgraph_migration_if_needed(
3406 store.as_ref(),
3407 store.project_root().to_path_buf(),
3408 self.callgraph_store_dir(),
3409 );
3410 return CallgraphStoreAccess::Ready(store);
3411 }
3412 }
3413
3414 if self.callgraph_store_rx.lock().is_some() {
3416 return CallgraphStoreAccess::Building;
3417 }
3418
3419 let Some(project_root) = self.callgraph_project_root() else {
3420 return CallgraphStoreAccess::Unavailable;
3421 };
3422 let callgraph_dir = self.callgraph_store_dir();
3423
3424 if force_token.is_none() {
3425 match CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone()) {
3426 Ok(Some(store)) => {
3427 let store = Arc::new(store);
3428 let installed = self.run_if_subc_bound_generation(operation_generation, || {
3429 let mut guard = self
3430 .callgraph_store
3431 .write()
3432 .unwrap_or_else(std::sync::PoisonError::into_inner);
3433 *guard = Some(Arc::clone(&store));
3434 Arc::clone(&store)
3435 });
3436 let Some(store) = installed else {
3437 return CallgraphStoreAccess::Unavailable;
3438 };
3439 self.schedule_legacy_callgraph_migration_if_needed(
3440 store.as_ref(),
3441 project_root.clone(),
3442 callgraph_dir.clone(),
3443 );
3444 return CallgraphStoreAccess::Ready(store);
3445 }
3446 Ok(None) => {
3447 if !self.callgraph_writer() {
3448 return CallgraphStoreAccess::Unavailable;
3449 }
3450 }
3451 Err(error) => {
3452 if !self.callgraph_writer() {
3453 return CallgraphStoreAccess::Unavailable;
3454 }
3455 crate::slog_warn!(
3456 "callgraph read-only open failed before writer promotion: {}",
3457 error
3458 );
3459 }
3460 }
3461 } else if !self.callgraph_writer() {
3462 return CallgraphStoreAccess::Unavailable;
3463 }
3464
3465 if self.semantic_cold_seed_active() {
3466 self.defer_callgraph_store_warm_for_semantic_cold_seed();
3467 return CallgraphStoreAccess::Building;
3468 }
3469
3470 let work = if let Some(force_token) = force_token {
3478 CallgraphBackgroundWork::ForceRebuild(force_token)
3479 } else {
3480 CallgraphBackgroundWork::Ensure
3481 };
3482 if !self.spawn_callgraph_store_cold_build(project_root.clone(), callgraph_dir.clone(), work)
3483 {
3484 return CallgraphStoreAccess::Building;
3485 }
3486
3487 let wait = callgraph_build_wait_window();
3488 if !wait.is_zero() {
3489 let (received, receiver_generation, receiver_epoch) = {
3490 let rx_ref = self.callgraph_store_rx.lock();
3491 let Some(rx) = rx_ref.as_ref() else {
3492 return CallgraphStoreAccess::Building;
3493 };
3494 (
3495 rx.recv_timeout(wait),
3496 self.callgraph_store_rx_generation(),
3497 self.callgraph_store_rx_epoch(),
3498 )
3499 };
3500 match received {
3501 Ok(CallGraphStoreBuildEvent::Ready {
3502 store,
3503 fulfilled_force_token,
3504 publication_epoch,
3505 }) => {
3506 if self.callgraph_persist_epoch_flag().current() != publication_epoch {
3507 drop(store);
3511 let _ = self.with_current_callgraph_store_rx(
3512 receiver_generation,
3513 receiver_epoch,
3514 |receiver| {
3515 *receiver = None;
3516 },
3517 );
3518 return CallgraphStoreAccess::Building;
3519 }
3520 remove_callgraph_pointer_before_inline_reopen_for_test(&callgraph_dir, &store);
3523 drop(store);
3524 let reopened =
3525 CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone());
3526 let mut pending = Vec::new();
3527 let outcome = self.with_current_callgraph_store_rx(
3528 receiver_generation,
3529 receiver_epoch,
3530 |receiver| {
3531 *receiver = None;
3532 match reopened {
3533 Ok(Some(store)) => {
3534 let ready = Arc::new(store);
3535 pending = self.take_pending_callgraph_store_paths();
3536 *self
3537 .callgraph_store
3538 .write()
3539 .unwrap_or_else(std::sync::PoisonError::into_inner) =
3540 Some(Arc::clone(&ready));
3541 if let Some(force_token) = fulfilled_force_token {
3542 self.fulfill_callgraph_store_force_token(force_token);
3543 }
3544 CallgraphStoreAccess::Ready(ready)
3545 }
3546 Ok(None) => CallgraphStoreAccess::Building,
3547 Err(error) => CallgraphStoreAccess::Error(error),
3548 }
3549 },
3550 );
3551 let Some(outcome) = outcome else {
3552 return if self.subc_unbound_quiesced()
3553 || self.configure_generation() != receiver_generation
3554 {
3555 CallgraphStoreAccess::Unavailable
3556 } else {
3557 CallgraphStoreAccess::Building
3558 };
3559 };
3560 if !pending.is_empty() {
3561 let _ = self.enqueue_callgraph_store_refresh(pending);
3562 }
3563 if matches!(&outcome, CallgraphStoreAccess::Ready(_)) {
3564 let _ = self.request_tier2_refresh_pull();
3565 }
3566 return outcome;
3567 }
3568 Ok(CallGraphStoreBuildEvent::Settled) => {
3569 let _ = self.with_current_callgraph_store_rx(
3570 receiver_generation,
3571 receiver_epoch,
3572 |receiver| *receiver = None,
3573 );
3574 return CallgraphStoreAccess::Building;
3575 }
3576 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
3577 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
3578 let _ = self.with_current_callgraph_store_rx(
3579 receiver_generation,
3580 receiver_epoch,
3581 |receiver| *receiver = None,
3582 );
3583 }
3584 }
3585 }
3586 CallgraphStoreAccess::Building
3587 }
3588
3589 fn schedule_legacy_callgraph_migration_if_needed(
3590 &self,
3591 store: &ReadonlyCallGraphStore,
3592 project_root: PathBuf,
3593 callgraph_dir: PathBuf,
3594 ) {
3595 if !store.is_legacy_fallback()
3596 || !self.callgraph_writer()
3597 || !self.heavy_root_work_allowed()
3598 {
3599 return;
3600 }
3601 if self.semantic_cold_seed_active() {
3602 self.defer_callgraph_store_warm_for_semantic_cold_seed();
3603 return;
3604 }
3605 let _ = self.spawn_callgraph_store_cold_build(
3606 project_root,
3607 callgraph_dir,
3608 CallgraphBackgroundWork::LegacyMigration,
3609 );
3610 }
3611
3612 fn configured_callgraph_keys(&self, current_root: &Path) -> BTreeSet<String> {
3613 let mut roots = self
3614 .configured_session_roots
3615 .lock()
3616 .iter()
3617 .map(|(root, _session)| root.clone())
3618 .collect::<BTreeSet<_>>();
3619 roots.insert(current_root.to_path_buf());
3620 roots
3621 .iter()
3622 .map(|root| crate::search_index::artifact_cache_key(root))
3623 .collect()
3624 }
3625
3626 fn spawn_callgraph_store_cold_build(
3631 &self,
3632 project_root: PathBuf,
3633 callgraph_dir: PathBuf,
3634 work: CallgraphBackgroundWork,
3635 ) -> bool {
3636 if !self.heavy_root_work_allowed() || !self.callgraph_writer() {
3637 return false;
3638 }
3639 let generation = self.configure_generation();
3640 self.run_if_subc_bound_generation(generation, || {
3641 self.spawn_callgraph_store_cold_build_admitted(project_root, callgraph_dir, work)
3642 })
3643 .unwrap_or(false)
3644 }
3645
3646 fn spawn_callgraph_store_cold_build_admitted(
3648 &self,
3649 project_root: PathBuf,
3650 callgraph_dir: PathBuf,
3651 work: CallgraphBackgroundWork,
3652 ) -> bool {
3653 let session_id = crate::log_ctx::current_session();
3654 let chunk_size = self.config().callgraph_chunk_size;
3655 let build_generation = self.configure_generation();
3656 let generation_flag = self.configure_generation_flag();
3657 let configured_keys = self.configured_callgraph_keys(&project_root);
3658 let summary_logged = Arc::clone(&self.callgraph_legacy_migration_summary_logged);
3659
3660 let mut rx_guard = self.callgraph_store_rx.lock();
3661 if rx_guard.is_some() {
3662 return false;
3663 }
3664
3665 let Some(permit) = crate::cold_build_limiter::try_acquire() else {
3666 crate::slog_info!(
3667 "callgraph store background work deferred by cold build limit ({})",
3668 crate::cold_build_limiter::limit()
3669 );
3670 return false;
3671 };
3672
3673 let force_token = match work {
3674 CallgraphBackgroundWork::ForceRebuild(token) => Some(token),
3675 CallgraphBackgroundWork::Ensure | CallgraphBackgroundWork::LegacyMigration => None,
3676 };
3677 let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStoreBuildEvent>();
3678 self.note_callgraph_store_rx_generation(build_generation);
3679 self.next_callgraph_store_rx_epoch();
3680 *rx_guard = Some(rx);
3681 let persist_epoch = self.next_callgraph_persist_epoch();
3682 let persist_epoch_flag = self.callgraph_persist_epoch_flag();
3683
3684 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.fetch_add(1, Ordering::SeqCst);
3685
3686 std::thread::spawn(move || {
3687 let _permit = permit;
3688 let mut settlement = CallGraphStoreBuildSettlement::new(tx, force_token, persist_epoch);
3689 crate::log_ctx::with_session(session_id, || {
3690 wait_on_callgraph_build_start_gate(&project_root);
3691 if persist_epoch_flag.current() != persist_epoch {
3692 crate::slog_info!(
3693 "callgraph store background work skipped for superseded epoch {}",
3694 persist_epoch
3695 );
3696 return;
3697 }
3698 let built = crate::callgraph_store::with_publish_epoch(
3699 persist_epoch_flag,
3700 persist_epoch,
3701 || match work {
3702 CallgraphBackgroundWork::LegacyMigration => {
3703 CallGraphStore::migrate_legacy_with_lease(
3704 callgraph_dir.clone(),
3705 project_root.clone(),
3706 )
3707 }
3708 CallgraphBackgroundWork::ForceRebuild(_) => {
3709 let files = crate::callgraph::walk_project_files(&project_root)
3710 .collect::<Vec<_>>();
3711 CallGraphStore::force_cold_build_with_lease_chunked(
3712 callgraph_dir.clone(),
3713 project_root.clone(),
3714 &files,
3715 chunk_size,
3716 )
3717 .map(|(store, _)| Some(store))
3718 }
3719 CallgraphBackgroundWork::Ensure => {
3720 let files = crate::callgraph::walk_project_files(&project_root)
3721 .collect::<Vec<_>>();
3722 CallGraphStore::ensure_built_with_lease_chunked(
3723 callgraph_dir.clone(),
3724 project_root.clone(),
3725 &files,
3726 chunk_size,
3727 )
3728 .map(|(store, _)| Some(store))
3729 }
3730 },
3731 );
3732 match built {
3733 Ok(Some(store)) => {
3734 if store.is_legacy_migration() {
3735 match crate::callgraph_store::all_legacy_partitions_migrated_for_keys(
3736 &callgraph_dir,
3737 &configured_keys,
3738 ) {
3739 Ok(true)
3740 if summary_logged
3741 .compare_exchange(
3742 false,
3743 true,
3744 Ordering::SeqCst,
3745 Ordering::SeqCst,
3746 )
3747 .is_ok() =>
3748 {
3749 crate::slog_info!(
3750 "all legacy callgraph partitions migrated for configured roots"
3751 );
3752 }
3753 Ok(_) => {}
3754 Err(error) => crate::slog_warn!(
3755 "failed to inspect legacy callgraph migration completion: {}",
3756 error
3757 ),
3758 }
3759 }
3760 if generation_flag.load(Ordering::SeqCst) == build_generation {
3761 settlement.ready(store);
3762 } else {
3763 crate::slog_info!(
3764 "callgraph store warm build result discarded for stale generation {}",
3765 build_generation
3766 );
3767 }
3768 }
3769 Ok(None) => {}
3770 Err(crate::callgraph_store::CallGraphStoreError::Superseded) => {
3771 crate::slog_info!(
3772 "callgraph store disk publication skipped for superseded epoch {}",
3773 persist_epoch
3774 );
3775 }
3776 Err(error) => {
3777 crate::slog_warn!("callgraph store background work failed: {}", error);
3778 }
3779 }
3780 });
3781 });
3782 true
3783 }
3784
3785 pub fn callgraph_store_rx(
3788 &self,
3789 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>> {
3790 &self.callgraph_store_rx
3791 }
3792
3793 #[doc(hidden)]
3797 pub fn with_current_callgraph_store_rx<R>(
3798 &self,
3799 generation: u64,
3800 epoch: u64,
3801 action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>) -> R,
3802 ) -> Option<R> {
3803 self.run_if_subc_bound_generation(generation, || {
3804 let mut receiver = self.callgraph_store_rx.lock();
3805 if receiver.is_none()
3806 || self.callgraph_store_rx_generation() != generation
3807 || self.callgraph_store_rx_epoch() != epoch
3808 {
3809 return None;
3810 }
3811 Some(action(&mut receiver))
3812 })
3813 .flatten()
3814 }
3815
3816 pub(crate) fn retire_callgraph_store_rx(&self) {
3817 let mut receiver = self.callgraph_store_rx.lock();
3818 *receiver = None;
3819 self.next_callgraph_store_rx_epoch();
3820 }
3821
3822 pub(crate) fn note_callgraph_store_rx_generation(&self, generation: u64) {
3823 self.callgraph_store_rx_generation
3824 .store(generation, Ordering::SeqCst);
3825 }
3826
3827 #[doc(hidden)]
3828 pub fn callgraph_store_rx_generation(&self) -> u64 {
3829 self.callgraph_store_rx_generation.load(Ordering::SeqCst)
3830 }
3831
3832 pub(crate) fn next_callgraph_store_rx_epoch(&self) -> u64 {
3833 self.callgraph_store_rx_epoch
3834 .fetch_add(1, Ordering::SeqCst)
3835 .wrapping_add(1)
3836 }
3837
3838 #[doc(hidden)]
3839 pub fn callgraph_store_rx_epoch(&self) -> u64 {
3840 self.callgraph_store_rx_epoch.load(Ordering::SeqCst)
3841 }
3842
3843 pub(crate) fn next_callgraph_persist_epoch(&self) -> u64 {
3844 self.callgraph_persist_epoch.next()
3845 }
3846
3847 #[doc(hidden)]
3848 pub fn callgraph_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
3849 self.callgraph_persist_epoch.clone()
3850 }
3851
3852 pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
3855 where
3856 I: IntoIterator<Item = PathBuf>,
3857 {
3858 self.pending_callgraph_store_paths.lock().extend(paths);
3859 }
3860
3861 pub fn enqueue_callgraph_store_refresh<I>(&self, paths: I) -> bool
3862 where
3863 I: IntoIterator<Item = PathBuf>,
3864 {
3865 let generation = self.configure_generation();
3866 self.enqueue_callgraph_store_refresh_for_generation(paths, generation)
3867 }
3868
3869 pub(crate) fn enqueue_callgraph_store_refresh_for_generation<I>(
3870 &self,
3871 paths: I,
3872 generation: u64,
3873 ) -> bool
3874 where
3875 I: IntoIterator<Item = PathBuf>,
3876 {
3877 let paths = paths.into_iter().collect::<Vec<_>>();
3878 if paths.is_empty() {
3879 return true;
3880 }
3881 self.run_if_subc_bound_generation(generation, || {
3882 if !self.callgraph_writer() {
3883 self.add_pending_callgraph_store_paths(paths);
3884 return false;
3885 }
3886 let Some(project_root) = self.callgraph_project_root() else {
3887 self.add_pending_callgraph_store_paths(paths);
3888 return false;
3889 };
3890
3891 let ticket = crate::callgraph_store::CallgraphRefreshTicket::new(
3896 self.subc_lifecycle_admission(),
3897 self.configure_generation_flag(),
3898 generation,
3899 self.callgraph_persist_epoch_flag(),
3900 self.callgraph_persist_epoch_flag().current(),
3901 );
3902 crate::callgraph_store::enqueue_callgraph_store_refresh_fenced(
3903 self.callgraph_store_dir(),
3904 project_root,
3905 paths,
3906 Arc::clone(&self.pending_callgraph_store_paths),
3907 ticket,
3908 )
3909 })
3910 .unwrap_or(false)
3911 }
3912
3913 pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
3921 let roots: Vec<PathBuf> = [
3922 self.canonical_cache_root_opt(),
3923 self.config().project_root.clone(),
3924 ]
3925 .into_iter()
3926 .flatten()
3927 .collect();
3928 std::mem::take(&mut *self.pending_callgraph_store_paths.lock())
3929 .into_iter()
3930 .filter(|path| {
3931 let in_root = pending_path_in_roots(path, &roots);
3932 if !in_root {
3933 crate::slog_debug!(
3934 "dropping pending callgraph path outside current root: {}",
3935 path.display()
3936 );
3937 }
3938 in_root
3939 })
3940 .collect()
3941 }
3942
3943 pub fn search_index(&self) -> &RwLock<Option<SearchIndex>> {
3945 &self.search_index
3946 }
3947
3948 pub fn search_index_rx(&self) -> &RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>> {
3950 &self.search_index_rx
3951 }
3952
3953 pub(crate) fn install_search_index_rx(
3954 &self,
3955 receiver: crossbeam_channel::Receiver<SearchIndex>,
3956 generation: u64,
3957 ) -> u64 {
3958 let mut slot = self
3959 .search_index_rx
3960 .write()
3961 .unwrap_or_else(std::sync::PoisonError::into_inner);
3962 self.note_search_index_rx_generation(generation);
3963 let epoch = self.next_search_index_rx_epoch();
3964 *slot = Some(receiver);
3965 epoch
3966 }
3967
3968 pub(crate) fn search_index_rx_terminal_guard(&self, epoch: u64) -> ReceiverTerminalGuard {
3969 ReceiverTerminalGuard::new(Arc::clone(&self.search_index_rx_terminal_epoch), epoch)
3970 }
3971
3972 pub(crate) fn with_current_search_index_rx<R>(
3975 &self,
3976 generation: u64,
3977 epoch: u64,
3978 action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<SearchIndex>>) -> R,
3979 ) -> Option<R> {
3980 self.run_if_subc_bound_generation(generation, || {
3981 let mut receiver = self
3982 .search_index_rx
3983 .write()
3984 .unwrap_or_else(std::sync::PoisonError::into_inner);
3985 if receiver.is_none()
3986 || self.search_index_rx_generation() != generation
3987 || self.search_index_rx_epoch() != epoch
3988 {
3989 return None;
3990 }
3991 Some(action(&mut receiver))
3992 })
3993 .flatten()
3994 }
3995
3996 pub(crate) fn retire_search_index_rx(&self) {
3997 let mut receiver = self
3998 .search_index_rx
3999 .write()
4000 .unwrap_or_else(std::sync::PoisonError::into_inner);
4001 *receiver = None;
4002 self.next_search_index_rx_epoch();
4003 }
4004
4005 pub(crate) fn note_search_index_rx_generation(&self, generation: u64) {
4006 self.search_index_rx_generation
4007 .store(generation, Ordering::SeqCst);
4008 }
4009
4010 pub(crate) fn search_index_rx_generation(&self) -> u64 {
4011 self.search_index_rx_generation.load(Ordering::SeqCst)
4012 }
4013
4014 pub(crate) fn next_search_index_rx_epoch(&self) -> u64 {
4015 self.search_index_rx_epoch
4016 .fetch_add(1, Ordering::SeqCst)
4017 .wrapping_add(1)
4018 }
4019
4020 pub(crate) fn search_index_rx_epoch(&self) -> u64 {
4021 self.search_index_rx_epoch.load(Ordering::SeqCst)
4022 }
4023
4024 pub(crate) fn next_search_persist_epoch(&self) -> u64 {
4025 self.search_persist_epoch.next()
4026 }
4027
4028 pub(crate) fn search_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
4029 self.search_persist_epoch.clone()
4030 }
4031
4032 pub fn add_pending_search_index_paths<I>(&self, paths: I)
4033 where
4034 I: IntoIterator<Item = PathBuf>,
4035 {
4036 let paths = paths.into_iter().collect::<Vec<_>>();
4037 if !paths.is_empty() {
4038 self.invalidate_warm_verify_memo();
4039 self.pending_search_index_paths.lock().extend(paths);
4040 }
4041 }
4042
4043 pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
4044 std::mem::take(&mut *self.pending_search_index_paths.lock())
4045 .into_iter()
4046 .collect()
4047 }
4048
4049 pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
4050 where
4051 I: IntoIterator<Item = PathBuf>,
4052 {
4053 let paths = paths.into_iter().collect::<Vec<_>>();
4054 if !paths.is_empty() {
4055 self.invalidate_warm_verify_memo();
4056 self.pending_semantic_index_paths.lock().extend(paths);
4057 }
4058 }
4059
4060 pub(crate) fn invalidate_warm_verify_memo(&self) {
4061 if let Some(root) = self.canonical_cache_root_opt() {
4062 crate::cache_freshness::invalidate_verify_memo(&root);
4063 }
4064 }
4065
4066 pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
4067 std::mem::take(&mut *self.pending_semantic_index_paths.lock())
4068 .into_iter()
4069 .collect()
4070 }
4071
4072 pub fn mark_pending_semantic_corpus_refresh(&self) {
4073 *self.pending_semantic_corpus_refresh.lock() = true;
4074 }
4075
4076 pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
4077 std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock())
4078 }
4079
4080 pub fn clear_pending_index_updates(&self) {
4081 self.pending_search_index_paths.lock().clear();
4082 self.pending_callgraph_store_paths.lock().clear();
4083 self.pending_tier2_paths.lock().clear();
4084 self.pending_semantic_index_paths.lock().clear();
4085 *self.pending_semantic_corpus_refresh.lock() = false;
4086 }
4087
4088 pub(crate) fn take_pending_reconciliation_state(&self) -> PendingReconciliationState {
4096 PendingReconciliationState {
4097 search: std::mem::take(&mut *self.pending_search_index_paths.lock()),
4098 callgraph: std::mem::take(&mut *self.pending_callgraph_store_paths.lock()),
4099 tier2: std::mem::take(&mut *self.pending_tier2_paths.lock()),
4100 semantic: std::mem::take(&mut *self.pending_semantic_index_paths.lock()),
4101 corpus_refresh: std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock()),
4102 }
4103 }
4104
4105 pub(crate) fn restore_pending_reconciliation_state(&self, state: PendingReconciliationState) {
4106 self.pending_search_index_paths.lock().extend(state.search);
4107 self.pending_callgraph_store_paths
4108 .lock()
4109 .extend(state.callgraph);
4110 self.pending_tier2_paths.lock().extend(state.tier2);
4111 self.pending_semantic_index_paths
4112 .lock()
4113 .extend(state.semantic);
4114 if state.corpus_refresh {
4115 *self.pending_semantic_corpus_refresh.lock() = true;
4116 }
4117 }
4118
4119 pub(crate) fn cancel_unbound_artifact_work(&self) {
4133 let search_refresh_cancelled = self
4139 .search_index_rx
4140 .read()
4141 .unwrap_or_else(std::sync::PoisonError::into_inner)
4142 .is_some();
4143 self.retire_search_index_rx();
4144 if search_refresh_cancelled {
4145 let mut resident = self
4146 .search_index
4147 .write()
4148 .unwrap_or_else(std::sync::PoisonError::into_inner);
4149 if resident.as_ref().is_some_and(|index| !index.ready) {
4150 *resident = None;
4151 }
4152 }
4153 self.retire_callgraph_store_rx();
4154 let semantic_cancelled = self.semantic_index_rx.lock().is_some();
4155 self.retire_semantic_index_rx();
4156 let semantic_refresh_cancelled = self.semantic_refresh_event_rx.lock().is_some();
4157 self.clear_semantic_refresh_worker();
4158 self.reset_semantic_cold_seed_gate_for_configure();
4159 let _ = self.inspect_manager.discard_completions();
4160 let _ = self.take_new_reuse_completions();
4161 if semantic_cancelled || semantic_refresh_cancelled {
4162 let has_index = self
4163 .semantic_index
4164 .read()
4165 .unwrap_or_else(std::sync::PoisonError::into_inner)
4166 .is_some();
4167 {
4171 let mut status = self
4172 .semantic_index_status
4173 .write()
4174 .unwrap_or_else(std::sync::PoisonError::into_inner);
4175 let refreshing = status.take_refreshing_files();
4176 if !refreshing.is_empty() {
4177 self.pending_semantic_index_paths.lock().extend(refreshing);
4178 }
4179 if status.corpus_refresh_in_flight() {
4180 *self.pending_semantic_corpus_refresh.lock() = true;
4181 }
4182 *status = if has_index {
4183 SemanticIndexStatus::ready()
4184 } else {
4185 SemanticIndexStatus::Disabled
4186 };
4187 }
4188 }
4189 }
4190
4191 pub(crate) fn invalidate_artifacts_after_watcher_gap(&self) {
4195 self.next_search_persist_epoch();
4196 self.next_semantic_persist_epoch();
4197 self.next_callgraph_persist_epoch();
4198
4199 self.search_index
4200 .write()
4201 .unwrap_or_else(std::sync::PoisonError::into_inner)
4202 .take();
4203 self.semantic_index
4204 .write()
4205 .unwrap_or_else(std::sync::PoisonError::into_inner)
4206 .take();
4207 self.callgraph_store
4208 .write()
4209 .unwrap_or_else(std::sync::PoisonError::into_inner)
4210 .take();
4211 *self
4217 .semantic_index_status
4218 .write()
4219 .unwrap_or_else(std::sync::PoisonError::into_inner) = if self.config().semantic_search {
4220 SemanticIndexStatus::ready()
4221 } else {
4222 SemanticIndexStatus::Disabled
4223 };
4224 if self.callgraph_writer() {
4228 self.mark_callgraph_store_force_rebuild();
4229 }
4230
4231 if let Some(root) = self
4232 .canonical_cache_root_opt()
4233 .or_else(|| self.config().project_root.clone())
4234 {
4235 crate::cache_freshness::invalidate_verify_memo_strict(&root);
4236 }
4237 self.borrowed_index_cache.lock().clear();
4238 self.inspect_manager.evict_idle_caches();
4239 self.reset_symbol_cache();
4240 self.clear_tsconfig_membership_cache();
4241 }
4242
4243 fn drain_search_index_events_for_graceful_shutdown(&self) {
4244 crate::runtime_drain::drain_watcher_events(self);
4245 crate::runtime_drain::drain_search_index_events(self);
4246 }
4247
4248 fn search_index_build_in_progress(&self) -> bool {
4249 self.search_index_rx()
4250 .read()
4251 .unwrap_or_else(std::sync::PoisonError::into_inner)
4252 .is_some()
4253 }
4254
4255 fn wait_for_search_index_build_to_settle_on_graceful_shutdown(&self) {
4259 crate::runtime_drain::note_search_rebuild_shutdown_wait_for_test();
4260 let deadline = Instant::now() + GRACEFUL_SHUTDOWN_SEARCH_BUILD_WAIT;
4261 while self.search_index_build_in_progress() && Instant::now() < deadline {
4262 let remaining = deadline.saturating_duration_since(Instant::now());
4263 std::thread::sleep(remaining.min(GRACEFUL_SHUTDOWN_SEARCH_BUILD_POLL));
4264 self.drain_search_index_events_for_graceful_shutdown();
4265 }
4266 }
4267
4268 #[doc(hidden)]
4272 pub fn flush_search_index_on_graceful_shutdown(&self) -> bool {
4273 if self.shared_artifacts_read_only() {
4274 return false;
4275 }
4276
4277 self.drain_search_index_events_for_graceful_shutdown();
4278 if self.search_index_build_in_progress() {
4279 self.wait_for_search_index_build_to_settle_on_graceful_shutdown();
4280 self.drain_search_index_events_for_graceful_shutdown();
4281 }
4282
4283 if self.search_index_build_in_progress() {
4284 return false;
4285 }
4286
4287 let Some(canonical_root) = self.canonical_cache_root_opt() else {
4288 return false;
4289 };
4290 let config = self.config();
4291 let project_key = self.memoized_artifact_cache_key(&canonical_root);
4292 let cache_dir = crate::search_index::resolve_cache_dir_with_key(
4293 &project_key,
4294 config.storage_dir.as_deref(),
4295 );
4296
4297 {
4298 let search_index = self
4299 .search_index()
4300 .read()
4301 .unwrap_or_else(std::sync::PoisonError::into_inner);
4302 let Some(index) = search_index.as_ref() else {
4303 return false;
4304 };
4305 if !index.ready || !index.has_pending_disk_changes() {
4306 return false;
4307 }
4308 }
4309
4310 let _cache_lock = match crate::search_index::CacheLock::try_acquire_for_shutdown(
4311 &cache_dir,
4312 &canonical_root,
4313 ) {
4314 Ok(lock) => lock,
4315 Err(error) => {
4316 crate::slog_warn!(
4317 "search index: skipped shutdown flush because cache lock was unavailable: {}",
4318 error
4319 );
4320 return false;
4321 }
4322 };
4323
4324 let mut search_index = self
4325 .search_index()
4326 .write()
4327 .unwrap_or_else(std::sync::PoisonError::into_inner);
4328 let Some(index) = search_index.as_mut() else {
4329 return false;
4330 };
4331 if !index.ready || !index.has_pending_disk_changes() {
4332 return false;
4333 }
4334
4335 let git_head = index.stored_git_head().map(str::to_owned);
4336 index.write_to_disk(&cache_dir, git_head.as_deref())
4337 }
4338
4339 pub fn inspect_manager(&self) -> Arc<InspectManager> {
4340 Arc::clone(&self.inspect_manager)
4341 }
4342
4343 pub fn add_pending_tier2_paths<I>(&self, paths: I)
4344 where
4345 I: IntoIterator<Item = PathBuf>,
4346 {
4347 self.pending_tier2_paths.lock().extend(paths);
4348 }
4349
4350 pub fn pending_tier2_paths(&self) -> Vec<PathBuf> {
4351 self.pending_tier2_paths.lock().iter().cloned().collect()
4352 }
4353
4354 pub fn remove_pending_tier2_paths<I>(&self, paths: I)
4355 where
4356 I: IntoIterator<Item = PathBuf>,
4357 {
4358 let mut pending = self.pending_tier2_paths.lock();
4359 for path in paths {
4360 pending.remove(&path);
4361 }
4362 }
4363
4364 pub fn has_new_reuse_completions(&self) -> bool {
4372 self.inspect_manager.reuse_completion_count()
4373 != self.last_seen_reuse_completions.load(Ordering::SeqCst)
4374 }
4375
4376 pub fn take_new_reuse_completions(&self) -> bool {
4377 let current = self.inspect_manager.reuse_completion_count();
4378 let previous = self
4379 .last_seen_reuse_completions
4380 .swap(current, Ordering::SeqCst);
4381 current != previous
4382 }
4383
4384 pub fn reset_tier2_refresh_scheduler(&self) {
4385 self.reset_tier2_refresh_scheduler_at(Instant::now());
4386 }
4387
4388 #[doc(hidden)]
4389 pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
4390 self.tier2_refresh_scheduler
4391 .lock()
4392 .reset_after_configure(now);
4393 }
4394
4395 pub fn request_tier2_refresh_pull(&self) -> bool {
4396 let can_schedule = self.inspect_writer()
4397 && self.heavy_root_work_allowed()
4398 && self.inspect_manager.automatic_tier2_refresh_allowed();
4399 self.tier2_refresh_scheduler
4400 .lock()
4401 .request_pull(can_schedule)
4402 }
4403
4404 pub fn tick_tier2_refresh_scheduler(
4405 &self,
4406 changed_path_count: usize,
4407 ) -> Option<Tier2TriggerReason> {
4408 self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
4409 }
4410
4411 #[doc(hidden)]
4412 pub fn tick_tier2_refresh_scheduler_at(
4413 &self,
4414 now: Instant,
4415 changed_path_count: usize,
4416 ) -> Option<Tier2TriggerReason> {
4417 let manager = self.inspect_manager();
4418 let can_write = self.inspect_writer()
4419 && self.heavy_root_work_allowed()
4420 && manager.automatic_tier2_refresh_allowed();
4421 let in_flight = manager.tier2_any_in_flight();
4422 let semantic_cold_seed_active = self.semantic_cold_seed_active();
4423 let decision = self.tier2_refresh_scheduler.lock().tick_with_semantic_gate(
4424 now,
4425 changed_path_count,
4426 can_write,
4427 in_flight,
4428 semantic_cold_seed_active,
4429 );
4430
4431 if let Some(reason) = decision {
4432 self.start_tier2_refresh(reason, manager);
4433 }
4434
4435 decision
4436 }
4437
4438 pub fn note_tier2_refresh_started(&self) {
4439 self.note_tier2_refresh_started_at(Instant::now());
4440 }
4441
4442 #[doc(hidden)]
4443 pub fn note_tier2_refresh_started_at(&self, now: Instant) {
4444 self.tier2_refresh_scheduler
4445 .lock()
4446 .note_external_scan_started(now);
4447 }
4448
4449 pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
4450 self.tier2_refresh_scheduler
4451 .lock()
4452 .last_trigger_reason()
4453 .map(Tier2TriggerReason::as_str)
4454 }
4455
4456 #[doc(hidden)]
4457 pub fn tier2_pull_demand_pending(&self) -> bool {
4458 self.tier2_refresh_scheduler.lock().pull_demand_pending()
4459 }
4460
4461 fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
4462 let generation = self.configure_generation();
4463 if !self.inspect_writer()
4464 || !self.heavy_root_work_allowed()
4465 || !manager.automatic_tier2_refresh_allowed()
4466 || !self.config().inspect.enabled
4467 {
4468 return;
4469 }
4470 let _ = self.run_if_subc_bound_generation(generation, || {
4471 self.start_tier2_refresh_admitted(reason, manager);
4472 });
4473 }
4474
4475 fn start_tier2_refresh_admitted(
4476 &self,
4477 reason: Tier2TriggerReason,
4478 manager: Arc<InspectManager>,
4479 ) {
4480 let Some(snapshot) = self.tier2_refresh_snapshot() else {
4481 return;
4482 };
4483 let categories = InspectCategory::active()
4484 .iter()
4485 .copied()
4486 .filter(|category| category.is_tier2())
4487 .collect::<Vec<_>>();
4488 let submission =
4489 manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
4490 if !submission.deferred_categories.is_empty() {
4491 self.tier2_refresh_scheduler.lock().note_dispatch_deferred();
4492 crate::slog_info!(
4493 "tier2 refresh deferred by cold build limit: categories={:?}",
4494 submission
4495 .deferred_categories
4496 .iter()
4497 .map(|category| category.as_str())
4498 .collect::<Vec<_>>()
4499 );
4500 }
4501 if submission.has_new_work() {
4502 crate::slog_info!(
4503 "tier2 refresh scheduled: reason={}, categories={:?}",
4504 reason.as_str(),
4505 submission
4506 .newly_queued_categories
4507 .iter()
4508 .map(|category| category.as_str())
4509 .collect::<Vec<_>>()
4510 );
4511 }
4512 for error in submission.errors {
4513 crate::slog_warn!(
4514 "tier2 refresh schedule failed for {}: {}",
4515 error.category,
4516 error.message
4517 );
4518 }
4519 }
4520
4521 fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
4522 self.harness_opt()?;
4523 let config = self.config();
4524 let project_root = config
4525 .project_root
4526 .clone()
4527 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
4528 let project_root = std::fs::canonicalize(&project_root).unwrap_or(project_root);
4529 Some(InspectSnapshot::new(
4530 project_root,
4531 self.inspect_dir(),
4532 config,
4533 self.symbol_cache(),
4534 ))
4535 }
4536
4537 pub fn symbol_cache(&self) -> SharedSymbolCache {
4539 Arc::clone(&self.symbol_cache)
4540 }
4541
4542 pub fn reset_symbol_cache(&self) -> u64 {
4544 self.symbol_cache
4545 .write()
4546 .map(|mut cache| cache.reset())
4547 .unwrap_or(0)
4548 }
4549
4550 pub fn semantic_index(&self) -> &RwLock<Option<SemanticIndex>> {
4552 &self.semantic_index
4553 }
4554
4555 pub fn semantic_index_rx(
4557 &self,
4558 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
4559 &self.semantic_index_rx
4560 }
4561
4562 pub(crate) fn install_semantic_index_rx(
4563 &self,
4564 receiver: crossbeam_channel::Receiver<SemanticIndexEvent>,
4565 generation: u64,
4566 ) -> u64 {
4567 let mut slot = self.semantic_index_rx.lock();
4568 self.note_semantic_index_rx_generation(generation);
4569 let epoch = self.next_semantic_index_rx_epoch();
4570 *slot = Some(receiver);
4571 epoch
4572 }
4573
4574 pub(crate) fn semantic_index_rx_terminal_guard(&self, epoch: u64) -> ReceiverTerminalGuard {
4575 ReceiverTerminalGuard::new(Arc::clone(&self.semantic_index_rx_terminal_epoch), epoch)
4576 }
4577
4578 pub(crate) fn with_current_semantic_index_rx<R>(
4581 &self,
4582 generation: u64,
4583 epoch: u64,
4584 action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<SemanticIndexEvent>>) -> R,
4585 ) -> Option<R> {
4586 self.run_if_subc_bound_generation(generation, || {
4587 let mut receiver = self.semantic_index_rx.lock();
4588 if receiver.is_none()
4589 || self.semantic_index_rx_generation() != generation
4590 || self.semantic_index_rx_epoch() != epoch
4591 {
4592 return None;
4593 }
4594 Some(action(&mut receiver))
4595 })
4596 .flatten()
4597 }
4598
4599 pub(crate) fn retire_semantic_index_rx(&self) {
4600 let mut receiver = self.semantic_index_rx.lock();
4601 *receiver = None;
4602 self.next_semantic_index_rx_epoch();
4603 }
4604
4605 pub(crate) fn retire_semantic_index_rx_if_epoch(&self, expected_epoch: u64) -> Option<bool> {
4609 let mut receiver = self.semantic_index_rx.lock();
4610 if self.semantic_index_rx_epoch() != expected_epoch {
4611 return None;
4612 }
4613 let retired = receiver.take().is_some();
4614 if retired {
4615 self.next_semantic_index_rx_epoch();
4616 }
4617 Some(retired)
4618 }
4619
4620 pub(crate) fn note_semantic_index_rx_generation(&self, generation: u64) {
4621 self.semantic_index_rx_generation
4622 .store(generation, Ordering::SeqCst);
4623 }
4624
4625 pub(crate) fn semantic_index_rx_generation(&self) -> u64 {
4626 self.semantic_index_rx_generation.load(Ordering::SeqCst)
4627 }
4628
4629 pub(crate) fn next_semantic_index_rx_epoch(&self) -> u64 {
4630 self.semantic_index_rx_epoch
4631 .fetch_add(1, Ordering::SeqCst)
4632 .wrapping_add(1)
4633 }
4634
4635 pub(crate) fn semantic_index_rx_epoch(&self) -> u64 {
4636 self.semantic_index_rx_epoch.load(Ordering::SeqCst)
4637 }
4638
4639 pub(crate) fn next_semantic_persist_epoch(&self) -> u64 {
4640 self.semantic_persist_epoch.next()
4641 }
4642
4643 pub(crate) fn semantic_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
4644 self.semantic_persist_epoch.clone()
4645 }
4646
4647 pub(crate) fn semantic_persist_lock(&self) -> Arc<parking_lot::Mutex<()>> {
4648 Arc::clone(&self.semantic_persist_lock)
4649 }
4650
4651 pub fn semantic_index_status(&self) -> &RwLock<SemanticIndexStatus> {
4652 &self.semantic_index_status
4653 }
4654
4655 pub(crate) fn artifact_reload_guard(&self) -> parking_lot::MutexGuard<'_, ()> {
4656 self.artifact_reload_lock.lock()
4657 }
4658
4659 pub fn reset_semantic_cold_seed_gate_for_configure(&self) -> u64 {
4662 self.semantic_cold_seed_active
4663 .store(false, Ordering::SeqCst);
4664 self.semantic_callgraph_warm_deferred
4665 .store(false, Ordering::SeqCst);
4666 self.semantic_cold_seed_generation
4667 .fetch_add(1, Ordering::SeqCst)
4668 .wrapping_add(1)
4669 }
4670
4671 pub fn semantic_cold_seed_active_flag(&self) -> Arc<AtomicBool> {
4672 Arc::clone(&self.semantic_cold_seed_active)
4673 }
4674
4675 pub fn semantic_cold_seed_generation_flag(&self) -> Arc<AtomicU64> {
4676 Arc::clone(&self.semantic_cold_seed_generation)
4677 }
4678
4679 pub fn semantic_cold_seed_generation(&self) -> u64 {
4680 self.semantic_cold_seed_generation.load(Ordering::SeqCst)
4681 }
4682
4683 pub fn semantic_cold_seed_active(&self) -> bool {
4684 self.semantic_cold_seed_active.load(Ordering::SeqCst)
4685 }
4686
4687 pub fn schedule_semantic_cold_seed_gate_for_configure(&self) {
4688 self.semantic_cold_seed_active.store(true, Ordering::SeqCst);
4689 }
4690
4691 pub fn defer_callgraph_store_warm_for_semantic_cold_seed(&self) {
4692 self.semantic_callgraph_warm_deferred
4693 .store(true, Ordering::SeqCst);
4694 }
4695
4696 fn semantic_callgraph_warm_deferred(&self) -> bool {
4697 self.semantic_callgraph_warm_deferred.load(Ordering::SeqCst)
4698 }
4699
4700 pub fn clear_semantic_cold_seed_gate_and_resume_deferred_work(&self) {
4704 self.resume_semantic_cold_seed_deferred_work(false);
4705 }
4706
4707 pub fn resume_deferred_work_after_semantic_cold_seed_gate_cleared(&self) {
4710 self.resume_semantic_cold_seed_deferred_work(true);
4711 }
4712
4713 pub(crate) fn take_semantic_cold_seed_resume(&self, force: bool) -> SemanticColdSeedResume {
4714 let was_active = self.semantic_cold_seed_active.swap(false, Ordering::SeqCst);
4715 let warm_callgraph = self
4716 .semantic_callgraph_warm_deferred
4717 .swap(false, Ordering::SeqCst);
4718 SemanticColdSeedResume {
4719 request_tier2: force || was_active || warm_callgraph,
4720 warm_callgraph,
4721 }
4722 }
4723
4724 pub(crate) fn apply_semantic_cold_seed_resume(&self, resume: SemanticColdSeedResume) {
4725 if resume.request_tier2 {
4726 let _ = self.request_tier2_refresh_pull();
4727 }
4728
4729 if !resume.warm_callgraph
4730 || !self.config().callgraph_store
4731 || !self.heavy_root_work_allowed()
4732 {
4733 return;
4734 }
4735
4736 match self.callgraph_store_for_ops() {
4737 CallgraphStoreAccess::Ready(_) => {
4738 crate::slog_debug!(
4739 "deferred callgraph store warm completed after semantic cold seed gate cleared"
4740 );
4741 }
4742 CallgraphStoreAccess::Building => {
4743 crate::slog_info!(
4744 "deferred callgraph store warm scheduled after semantic cold seed gate cleared"
4745 );
4746 }
4747 CallgraphStoreAccess::Unavailable => {
4748 crate::slog_info!(
4749 "deferred callgraph store warm unavailable after semantic cold seed gate cleared"
4750 );
4751 }
4752 CallgraphStoreAccess::Error(error) => {
4753 crate::slog_warn!(
4754 "deferred callgraph store warm failed after semantic cold seed gate cleared: {}",
4755 error
4756 );
4757 }
4758 }
4759 }
4760
4761 fn resume_semantic_cold_seed_deferred_work(&self, force: bool) {
4762 let resume = self.take_semantic_cold_seed_resume(force);
4763 self.apply_semantic_cold_seed_resume(resume);
4764 }
4765
4766 #[doc(hidden)]
4767 pub fn set_semantic_cold_seed_active_for_test(&self, active: bool) {
4768 self.semantic_cold_seed_active
4769 .store(active, Ordering::SeqCst);
4770 }
4771
4772 #[doc(hidden)]
4773 pub fn semantic_callgraph_warm_deferred_for_test(&self) -> bool {
4774 self.semantic_callgraph_warm_deferred()
4775 }
4776
4777 pub fn install_semantic_refresh_worker(
4778 &self,
4779 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
4780 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
4781 worker_slot: SemanticRefreshWorkerSlot,
4782 ) {
4783 self.install_semantic_refresh_worker_for_build_epoch(
4784 sender,
4785 event_rx,
4786 worker_slot,
4787 self.semantic_index_rx_epoch(),
4788 );
4789 }
4790
4791 pub(crate) fn install_semantic_refresh_worker_for_build_epoch(
4792 &self,
4793 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
4794 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
4795 worker_slot: SemanticRefreshWorkerSlot,
4796 build_epoch: u64,
4797 ) {
4798 self.clear_semantic_refresh_worker();
4799 {
4800 let mut receiver = self.semantic_refresh_event_rx.lock();
4801 let mut request = self.semantic_refresh_tx.lock();
4802 let mut worker = self.semantic_refresh_worker.lock();
4803 self.semantic_refresh_generation
4804 .store(self.configure_generation(), Ordering::SeqCst);
4805 self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
4806 self.semantic_refresh_build_epoch
4807 .store(build_epoch, Ordering::SeqCst);
4808 *receiver = Some(event_rx);
4809 *request = Some(sender);
4810 *worker = Some(worker_slot);
4811 }
4812 }
4813
4814 pub(crate) fn semantic_refresh_generation(&self) -> u64 {
4815 self.semantic_refresh_generation.load(Ordering::SeqCst)
4816 }
4817
4818 pub(crate) fn semantic_refresh_epoch(&self) -> u64 {
4819 self.semantic_refresh_epoch.load(Ordering::SeqCst)
4820 }
4821
4822 pub(crate) fn with_current_semantic_refresh_rx<R>(
4825 &self,
4826 generation: u64,
4827 epoch: u64,
4828 action: impl FnOnce() -> R,
4829 ) -> Option<R> {
4830 self.run_if_subc_bound_generation(generation, || {
4831 let receiver = self.semantic_refresh_event_rx.lock();
4832 if receiver.is_none()
4833 || self.semantic_refresh_generation() != generation
4834 || self.semantic_refresh_epoch() != epoch
4835 {
4836 return None;
4837 }
4838 Some(action())
4839 })
4840 .flatten()
4841 }
4842
4843 pub(crate) fn clear_semantic_refresh_worker_if_current(
4844 &self,
4845 generation: u64,
4846 epoch: u64,
4847 ) -> Option<u64> {
4848 let worker_slot = {
4849 let mut receiver = self.semantic_refresh_event_rx.lock();
4850 if receiver.is_none()
4851 || self.semantic_refresh_generation() != generation
4852 || self.semantic_refresh_epoch() != epoch
4853 {
4854 return None;
4855 }
4856 let disconnected_build_epoch = self.semantic_refresh_build_epoch.load(Ordering::SeqCst);
4857 self.semantic_refresh_build_epoch.store(0, Ordering::SeqCst);
4858 let mut request = self.semantic_refresh_tx.lock();
4859 let mut worker = self.semantic_refresh_worker.lock();
4860 *receiver = None;
4861 *request = None;
4862 self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
4863 self.invalidate_semantic_refresh_probe();
4864 (worker.take(), disconnected_build_epoch)
4865 };
4866 if let Some(worker_slot) = worker_slot.0 {
4867 if let Ok(mut handle) = worker_slot.lock() {
4868 drop(handle.take());
4869 }
4870 }
4871 Some(worker_slot.1)
4872 }
4873
4874 pub fn clear_semantic_refresh_worker(&self) {
4875 let worker_slot = {
4876 let mut receiver = self.semantic_refresh_event_rx.lock();
4877 let mut request = self.semantic_refresh_tx.lock();
4878 let mut worker = self.semantic_refresh_worker.lock();
4879 *receiver = None;
4880 *request = None;
4881 self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
4882 self.semantic_refresh_build_epoch.store(0, Ordering::SeqCst);
4883 self.invalidate_semantic_refresh_probe();
4884 worker.take()
4885 };
4886 if let Some(worker_slot) = worker_slot {
4887 if let Ok(mut handle) = worker_slot.lock() {
4888 drop(handle.take());
4889 }
4890 }
4891 }
4892
4893 pub fn semantic_refresh_sender(
4894 &self,
4895 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
4896 self.semantic_refresh_tx.lock().clone()
4897 }
4898
4899 pub(crate) fn semantic_refresh_retry_slots(
4900 &self,
4901 ) -> (
4902 Arc<parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>>,
4903 Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>,
4904 ) {
4905 (
4906 Arc::clone(&self.semantic_refresh_tx),
4907 Arc::clone(&self.pending_semantic_index_paths),
4908 )
4909 }
4910
4911 pub fn semantic_refresh_event_rx(
4912 &self,
4913 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
4914 &self.semantic_refresh_event_rx
4915 }
4916
4917 pub fn with_semantic_refresh_retry_attempts_mut<R>(
4918 &self,
4919 f: impl FnOnce(&mut BTreeMap<PathBuf, usize>) -> R,
4920 ) -> R {
4921 let mut attempts = self.semantic_refresh_retry_attempts.lock();
4922 f(&mut attempts)
4923 }
4924
4925 pub fn clear_semantic_refresh_retry_attempts(&self, paths: &[PathBuf]) {
4926 let mut attempts = self.semantic_refresh_retry_attempts.lock();
4927 for path in paths {
4928 attempts.remove(path);
4929 }
4930 }
4931
4932 pub fn clear_all_semantic_refresh_retry_attempts(&self) {
4933 self.semantic_refresh_retry_attempts.lock().clear();
4934 }
4935
4936 pub fn semantic_refresh_circuit_is_open(&self) -> bool {
4937 self.semantic_refresh_circuit.open.load(Ordering::SeqCst)
4938 }
4939
4940 pub fn record_semantic_refresh_transient_failure(&self, trip_threshold: usize) -> bool {
4941 let failures = self
4942 .semantic_refresh_circuit
4943 .consecutive_transient_failures
4944 .fetch_add(1, Ordering::SeqCst)
4945 .saturating_add(1);
4946 if failures >= trip_threshold
4947 && !self
4948 .semantic_refresh_circuit
4949 .open
4950 .swap(true, Ordering::SeqCst)
4951 {
4952 crate::slog_warn!(
4953 "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
4954 );
4955 }
4956 self.semantic_refresh_circuit_is_open()
4957 }
4958
4959 pub fn trip_semantic_refresh_circuit(&self, trip_threshold: usize) {
4960 self.semantic_refresh_circuit
4961 .consecutive_transient_failures
4962 .store(trip_threshold, Ordering::SeqCst);
4963 if !self
4964 .semantic_refresh_circuit
4965 .open
4966 .swap(true, Ordering::SeqCst)
4967 {
4968 crate::slog_warn!(
4969 "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
4970 );
4971 }
4972 }
4973
4974 pub fn reset_semantic_refresh_transient_failure_count(&self) {
4975 self.semantic_refresh_circuit
4976 .consecutive_transient_failures
4977 .store(0, Ordering::SeqCst);
4978 }
4979
4980 pub fn reset_semantic_refresh_circuit_after_success(&self) {
4981 self.reset_semantic_refresh_transient_failure_count();
4982 self.semantic_refresh_circuit
4983 .probe_ready
4984 .store(false, Ordering::SeqCst);
4985 if self
4986 .semantic_refresh_circuit
4987 .open
4988 .swap(false, Ordering::SeqCst)
4989 {
4990 crate::slog_info!("embedding backend recovered; resuming normal refresh retries");
4991 }
4992 }
4993
4994 pub fn semantic_refresh_transient_failure_count(&self) -> usize {
4995 self.semantic_refresh_circuit
4996 .consecutive_transient_failures
4997 .load(Ordering::SeqCst)
4998 }
4999
5000 pub fn semantic_refresh_probe_is_scheduled(&self) -> bool {
5001 self.semantic_refresh_circuit
5002 .probe_in_flight
5003 .load(Ordering::SeqCst)
5004 || self.semantic_refresh_probe_ready()
5005 }
5006
5007 pub fn semantic_refresh_probe_ready(&self) -> bool {
5008 self.semantic_refresh_circuit
5009 .probe_ready
5010 .load(Ordering::SeqCst)
5011 }
5012
5013 pub fn take_semantic_refresh_probe_ready(&self) -> bool {
5014 self.semantic_refresh_circuit
5015 .probe_ready
5016 .swap(false, Ordering::SeqCst)
5017 }
5018
5019 fn invalidate_semantic_refresh_probe(&self) {
5020 self.semantic_refresh_circuit
5021 .probe_token
5022 .fetch_add(1, Ordering::SeqCst);
5023 self.semantic_refresh_circuit
5024 .probe_ready
5025 .store(false, Ordering::SeqCst);
5026 self.semantic_refresh_circuit
5027 .probe_in_flight
5028 .store(false, Ordering::SeqCst);
5029 }
5030
5031 pub fn ensure_semantic_refresh_probe_scheduled(&self, delay: Duration) {
5032 let receiver = self.semantic_refresh_event_rx.lock();
5033 if receiver.is_none()
5034 || self
5035 .semantic_refresh_circuit
5036 .probe_ready
5037 .load(Ordering::SeqCst)
5038 || self
5039 .semantic_refresh_circuit
5040 .probe_in_flight
5041 .swap(true, Ordering::SeqCst)
5042 {
5043 return;
5044 }
5045 let probe_token = self
5046 .semantic_refresh_circuit
5047 .probe_token
5048 .fetch_add(1, Ordering::SeqCst)
5049 .wrapping_add(1);
5050 drop(receiver);
5051
5052 let circuit = Arc::clone(&self.semantic_refresh_circuit);
5053 let session_id = crate::log_ctx::current_session();
5054 std::thread::spawn(move || {
5055 crate::log_ctx::with_session(session_id, || {
5056 std::thread::sleep(delay);
5057 if circuit.probe_token.load(Ordering::SeqCst) == probe_token {
5058 circuit.probe_ready.store(true, Ordering::SeqCst);
5059 circuit.probe_in_flight.store(false, Ordering::SeqCst);
5060 }
5061 });
5062 });
5063 }
5064
5065 pub fn semantic_embedding_model(
5067 &self,
5068 ) -> &parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>> {
5069 &self.semantic_embedding_model
5070 }
5071
5072 pub fn watcher(&self) -> &parking_lot::Mutex<Option<RecommendedWatcher>> {
5074 &self.watcher
5075 }
5076
5077 pub fn watcher_rx(
5079 &self,
5080 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>> {
5081 &self.watcher_rx
5082 }
5083
5084 pub(crate) fn watcher_drain_slice(
5086 &self,
5087 ) -> &parking_lot::Mutex<Option<WatcherDrainSliceState>> {
5088 &self.watcher_drain_slice
5089 }
5090
5091 pub fn watcher_drain_pending_path_count(&self) -> usize {
5093 self.watcher_drain_slice.lock().as_ref().map_or(0, |state| {
5094 let active_paths = match &state.phase {
5095 WatcherDrainPhase::Collect => 0,
5096 WatcherDrainPhase::Apply { paths, .. } => paths.len(),
5097 };
5098 active_paths + state.pending_paths.len()
5099 })
5100 }
5101
5102 pub fn watcher_drain_path_slice_count(&self) -> usize {
5104 self.watcher_drain_slice
5105 .lock()
5106 .as_ref()
5107 .map_or(0, |state| state.path_slice_count)
5108 }
5109
5110 pub fn install_watcher_runtime(
5113 &self,
5114 rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
5115 runtime: WatcherThreadHandle,
5116 ) {
5117 let _runtime_guard = self.watcher_runtime_lock.lock();
5118 let replaced = self.watcher_thread.lock().replace(runtime);
5119 self.app.watcher_started();
5120 if let Some(runtime) = replaced {
5121 Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
5122 }
5123 *self.watcher_rx.lock() = Some(rx);
5124 *self.watcher_drain_slice.lock() = None;
5125 }
5126
5127 fn watcher_root_path(&self) -> PathBuf {
5128 self.canonical_cache_root_opt()
5129 .or_else(|| self.config().project_root.clone())
5130 .unwrap_or_else(|| PathBuf::from("<unconfigured>"))
5131 }
5132
5133 fn spawn_watcher_shutdown(app: Arc<App>, root: PathBuf, runtime: WatcherThreadHandle) {
5134 const JOIN_TIMEOUT: Duration = Duration::from_secs(2);
5135 runtime.request_shutdown();
5138 std::thread::spawn(
5139 move || match runtime.shutdown_and_join_timeout(JOIN_TIMEOUT) {
5140 WatcherJoinOutcome::Joined => {
5141 app.watcher_stopped();
5142 crate::slog_info!("watcher stopped: {}", root.display());
5143 }
5144 WatcherJoinOutcome::TimedOut(join) => {
5145 crate::slog_warn!(
5146 "watcher stop timed out after {} ms: {}",
5147 JOIN_TIMEOUT.as_millis(),
5148 root.display()
5149 );
5150 std::thread::spawn(move || {
5151 let _ = join.join();
5152 app.watcher_stopped();
5153 crate::slog_info!("watcher stopped: {}", root.display());
5154 });
5155 }
5156 },
5157 );
5158 }
5159
5160 fn take_watcher_runtime(&self) -> Option<WatcherThreadHandle> {
5161 let _runtime_guard = self.watcher_runtime_lock.lock();
5162 let runtime = self.watcher_thread.lock().take();
5163 *self.watcher_rx.lock() = None;
5164 *self.watcher_drain_slice.lock() = None;
5165 *self.watcher.lock() = None;
5166 runtime
5167 }
5168
5169 pub fn stop_watcher_runtime(&self) {
5173 if let Some(runtime) = self.take_watcher_runtime() {
5174 Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
5175 }
5176 }
5177
5178 pub fn stop_watcher_runtime_in_background(&self) {
5180 self.stop_watcher_runtime();
5181 }
5182
5183 pub(crate) fn take_finished_watcher_runtime(&self) -> bool {
5188 let runtime = {
5189 let _runtime_guard = self.watcher_runtime_lock.lock();
5190 let finished = self
5191 .watcher_thread
5192 .lock()
5193 .as_ref()
5194 .is_some_and(|runtime| runtime.is_finished());
5195 if !finished {
5196 return false;
5197 }
5198 let runtime = self.watcher_thread.lock().take();
5199 *self.watcher_rx.lock() = None;
5200 *self.watcher_drain_slice.lock() = None;
5201 *self.watcher.lock() = None;
5202 runtime
5203 };
5204 if let Some(runtime) = runtime {
5205 Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
5206 }
5207 true
5208 }
5209
5210 pub fn watcher_registry_count(&self) -> usize {
5213 self.app.watcher_count()
5214 }
5215
5216 pub(crate) fn watcher_runtime_active(&self) -> bool {
5217 let _runtime_guard = self.watcher_runtime_lock.lock();
5218 let thread_live = self
5223 .watcher_thread
5224 .lock()
5225 .as_ref()
5226 .is_some_and(|runtime| !runtime.is_finished());
5227 thread_live && self.watcher_rx.lock().is_some()
5228 }
5229
5230 pub fn artifact_eviction_blocked(&self) -> bool {
5234 let semantic_refresh_in_flight = match &*self
5235 .semantic_index_status
5236 .read()
5237 .unwrap_or_else(std::sync::PoisonError::into_inner)
5238 {
5239 SemanticIndexStatus::Building { .. } => true,
5240 SemanticIndexStatus::Ready { refreshing, .. } => !refreshing.is_empty(),
5241 SemanticIndexStatus::Disabled | SemanticIndexStatus::Failed(_) => false,
5242 };
5243 if crate::runtime_drain::any_build_in_flight(self)
5244 || semantic_refresh_in_flight
5245 || self.inspect_manager.tier2_any_in_flight()
5246 || !self.bash_background.running_tasks().is_empty()
5247 || !self.pending_callgraph_store_paths.lock().is_empty()
5248 || !self.pending_search_index_paths.lock().is_empty()
5249 || !self.pending_tier2_paths.lock().is_empty()
5250 || !self.pending_semantic_index_paths.lock().is_empty()
5251 || *self.pending_semantic_corpus_refresh.lock()
5252 {
5253 return true;
5254 }
5255
5256 let search_has_pending_disk_changes = self
5257 .search_index
5258 .read()
5259 .unwrap_or_else(std::sync::PoisonError::into_inner)
5260 .as_ref()
5261 .is_some_and(SearchIndex::has_pending_disk_changes);
5262 search_has_pending_disk_changes
5263 }
5264
5265 pub fn evict_idle_artifacts(&self) -> bool {
5270 if self.artifact_eviction_blocked() {
5271 return false;
5272 }
5273
5274 self.callgraph_store
5275 .write()
5276 .unwrap_or_else(std::sync::PoisonError::into_inner)
5277 .take();
5278 self.search_index
5279 .write()
5280 .unwrap_or_else(std::sync::PoisonError::into_inner)
5281 .take();
5282 self.semantic_index
5283 .write()
5284 .unwrap_or_else(std::sync::PoisonError::into_inner)
5285 .take();
5286 self.borrowed_index_cache.lock().clear();
5287 self.inspect_manager.evict_idle_caches();
5288 self.reset_symbol_cache();
5289 self.clear_tsconfig_membership_cache();
5290 true
5291 }
5292
5293 #[doc(hidden)]
5296 pub fn force_idle_teardown_for_test(self: &Arc<Self>) -> bool {
5297 if std::env::var("AFT_TEST_ALLOW_FORCE_IDLE_REAP").as_deref() != Ok("1") {
5298 return false;
5299 }
5300 if !self.evict_idle_artifacts() {
5301 return false;
5302 }
5303 self.stop_watcher_runtime_in_background();
5304 self.invalidate_artifacts_after_watcher_gap();
5305 true
5306 }
5307
5308 pub(crate) fn release_idle_reopenable_resources_in_background(self: &Arc<Self>) {
5312 let ctx = Arc::clone(self);
5313 std::thread::spawn(move || {
5314 if !ctx.subc_unbound_quiesced() {
5315 return;
5316 }
5317 {
5318 let mut lsp = ctx.lsp_manager.lock();
5319 if !ctx.subc_unbound_quiesced() {
5320 return;
5321 }
5322 lsp.shutdown_all();
5323 }
5324 let _ = ctx.subc_lifecycle.run_if_unbound(|| {
5325 ctx.bash_background.clear_db_pool();
5326 ctx.backup.lock().clear_db_pool();
5327 });
5328 });
5329 }
5330
5331 pub(crate) fn teardown_deleted_root(&self) {
5335 self.bash_background.detach();
5336 self.bash_background.clear_db_pool();
5337 self.backup.lock().clear_db_pool();
5338 self.lsp_manager.lock().shutdown_all();
5339 }
5340
5341 pub fn lsp(&self) -> parking_lot::MutexGuard<'_, LspManager> {
5343 self.lsp_manager.lock()
5344 }
5345
5346 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
5349 let config = self.config();
5350 if let Some(mut lsp) = self.lsp_manager.try_lock() {
5351 if let Err(e) = lsp.notify_file_changed(file_path, content, &config) {
5352 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
5353 }
5354 }
5355 }
5356
5357 pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
5363 if let Some(mut lsp) = self.lsp_manager.try_lock() {
5364 lsp.clear_diagnostics_for_file(file_path)
5365 } else {
5366 false
5367 }
5368 }
5369
5370 pub fn lsp_mark_diagnostics_stale_for_file(&self, file_path: &Path) -> StaleDiagnosticsMark {
5374 if let Some(mut lsp) = self.lsp_manager.try_lock() {
5375 lsp.mark_diagnostics_stale_for_file(file_path)
5376 } else {
5377 StaleDiagnosticsMark::default()
5378 }
5379 }
5380
5381 pub fn lsp_resync_changed_file_for_diagnostics(&self, file_path: &Path) -> bool {
5389 if !file_path.is_file() {
5390 return false;
5391 }
5392
5393 let content = match std::fs::read_to_string(file_path) {
5394 Ok(content) => content,
5395 Err(err) => {
5396 crate::slog_warn!(
5397 "skipping LSP resync for {} after external edit: {}",
5398 file_path.display(),
5399 err
5400 );
5401 return false;
5402 }
5403 };
5404
5405 let config = self.config();
5406 if let Some(mut lsp) = self.lsp_manager.try_lock() {
5407 if let Err(err) = lsp.notify_file_changed(file_path, &content, &config) {
5408 crate::slog_warn!(
5409 "LSP resync failed for {} after external edit: {}",
5410 file_path.display(),
5411 err
5412 );
5413 return false;
5414 }
5415 true
5416 } else {
5417 false
5418 }
5419 }
5420
5421 pub fn lsp_notify_and_collect_diagnostics(
5432 &self,
5433 file_path: &Path,
5434 content: &str,
5435 timeout: std::time::Duration,
5436 ) -> crate::lsp::manager::PostEditWaitOutcome {
5437 let config = self.config();
5438 let Some(mut lsp) = self.lsp_manager.try_lock() else {
5439 return crate::lsp::manager::PostEditWaitOutcome::default();
5440 };
5441
5442 lsp.drain_events();
5445
5446 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
5450
5451 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
5453 {
5454 Ok(v) => v,
5455 Err(e) => {
5456 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
5457 return crate::lsp::manager::PostEditWaitOutcome::default();
5458 }
5459 };
5460
5461 if expected_versions.is_empty() {
5464 return crate::lsp::manager::PostEditWaitOutcome::default();
5465 }
5466
5467 lsp.wait_for_post_edit_diagnostics(
5468 file_path,
5469 &config,
5470 &expected_versions,
5471 &pre_snapshot,
5472 timeout,
5473 )
5474 }
5475
5476 fn custom_lsp_root_markers(&self) -> Vec<String> {
5479 self.config()
5480 .lsp_servers
5481 .iter()
5482 .flat_map(|s| s.root_markers.iter().cloned())
5483 .collect()
5484 }
5485
5486 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
5487 let custom_markers = self.custom_lsp_root_markers();
5488 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
5489 .iter()
5490 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
5491 .cloned()
5492 .map(|path| {
5493 let change_type = if path.exists() {
5494 FileChangeType::CHANGED
5495 } else {
5496 FileChangeType::DELETED
5497 };
5498 (path, change_type)
5499 })
5500 .collect();
5501
5502 self.notify_watched_config_events(&config_paths);
5503 }
5504
5505 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
5506 let paths = params
5507 .get("multi_file_write_paths")
5508 .and_then(|value| value.as_array())?
5509 .iter()
5510 .filter_map(|value| value.as_str())
5511 .map(PathBuf::from)
5512 .collect::<Vec<_>>();
5513
5514 (!paths.is_empty()).then_some(paths)
5515 }
5516
5517 fn watched_file_events_from_params(
5529 params: &serde_json::Value,
5530 extra_markers: &[String],
5531 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
5532 let events = params
5533 .get("multi_file_write_paths")
5534 .and_then(|value| value.as_array())?
5535 .iter()
5536 .filter_map(|entry| {
5537 let path = entry
5539 .get("path")
5540 .and_then(|value| value.as_str())
5541 .map(PathBuf::from)?;
5542
5543 if !is_config_file_path_with_custom(&path, extra_markers) {
5544 return None;
5545 }
5546
5547 let change_type = entry
5548 .get("type")
5549 .and_then(|value| value.as_str())
5550 .and_then(Self::parse_file_change_type)
5551 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
5552
5553 Some((path, change_type))
5554 })
5555 .collect::<Vec<_>>();
5556
5557 (!events.is_empty()).then_some(events)
5558 }
5559
5560 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
5561 match value {
5562 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
5563 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
5564 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
5565 _ => None,
5566 }
5567 }
5568
5569 fn change_type_from_current_state(path: &Path) -> FileChangeType {
5570 if path.exists() {
5571 FileChangeType::CHANGED
5572 } else {
5573 FileChangeType::DELETED
5574 }
5575 }
5576
5577 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
5578 if config_paths.is_empty() {
5579 return;
5580 }
5581
5582 let config = self.config();
5583 if let Some(mut lsp) = self.lsp_manager.try_lock() {
5584 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
5585 crate::slog_warn!("watched-file sync error: {}", e);
5586 }
5587 }
5588 }
5589
5590 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
5591 let custom_markers = self.custom_lsp_root_markers();
5592 if !is_config_file_path_with_custom(file_path, &custom_markers) {
5593 return;
5594 }
5595
5596 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
5597 }
5598
5599 pub fn lsp_post_multi_file_write(
5604 &self,
5605 file_path: &Path,
5606 content: &str,
5607 file_paths: &[PathBuf],
5608 params: &serde_json::Value,
5609 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
5610 self.notify_watched_config_files(file_paths);
5611 self.add_pending_tier2_paths(file_paths.iter().cloned());
5612 let _ = self.mark_status_bar_tier2_stale();
5613
5614 let wants_diagnostics = params
5615 .get("diagnostics")
5616 .and_then(|v| v.as_bool())
5617 .unwrap_or(false);
5618
5619 if !wants_diagnostics {
5620 self.lsp_notify_file_changed(file_path, content);
5621 return None;
5622 }
5623
5624 let wait_ms = params
5625 .get("wait_ms")
5626 .and_then(|v| v.as_u64())
5627 .unwrap_or(3000)
5628 .min(10_000);
5629
5630 Some(self.lsp_notify_and_collect_diagnostics(
5631 file_path,
5632 content,
5633 std::time::Duration::from_millis(wait_ms),
5634 ))
5635 }
5636
5637 pub fn lsp_post_write(
5654 &self,
5655 file_path: &Path,
5656 content: &str,
5657 params: &serde_json::Value,
5658 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
5659 let wants_diagnostics = params
5660 .get("diagnostics")
5661 .and_then(|v| v.as_bool())
5662 .unwrap_or(false);
5663
5664 let custom_markers = self.custom_lsp_root_markers();
5665 if let Some(file_paths) = Self::multi_file_write_paths(params) {
5666 self.add_pending_tier2_paths(file_paths);
5667 } else {
5668 self.add_pending_tier2_paths([file_path.to_path_buf()]);
5669 }
5670 let _ = self.mark_status_bar_tier2_stale();
5671
5672 if !wants_diagnostics {
5673 if let Some(file_paths) = Self::multi_file_write_paths(params) {
5674 self.notify_watched_config_files(&file_paths);
5675 } else if let Some(config_events) =
5676 Self::watched_file_events_from_params(params, &custom_markers)
5677 {
5678 self.notify_watched_config_events(&config_events);
5679 }
5680 self.lsp_notify_file_changed(file_path, content);
5681 return None;
5682 }
5683
5684 let wait_ms = params
5685 .get("wait_ms")
5686 .and_then(|v| v.as_u64())
5687 .unwrap_or(3000)
5688 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
5691 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
5692 }
5693
5694 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
5695 {
5696 self.notify_watched_config_events(&config_events);
5697 }
5698
5699 Some(self.lsp_notify_and_collect_diagnostics(
5700 file_path,
5701 content,
5702 std::time::Duration::from_millis(wait_ms),
5703 ))
5704 }
5705
5706 pub fn validate_path(
5715 &self,
5716 req_id: &str,
5717 path: &Path,
5718 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
5719 self.validate_path_with_artifact_session(req_id, path, None)
5720 }
5721
5722 pub fn validate_read_path(
5726 &self,
5727 req_id: &str,
5728 session_id: &str,
5729 path: &Path,
5730 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
5731 self.validate_path_with_artifact_session(req_id, path, Some(session_id))
5732 }
5733
5734 fn validate_path_with_artifact_session(
5735 &self,
5736 req_id: &str,
5737 path: &Path,
5738 artifact_session_id: Option<&str>,
5739 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
5740 let config = self.config();
5741 let force_restrict = self.request_force_restrict(req_id);
5742 let enforce = config.restrict_to_project_root || force_restrict;
5743 if !enforce {
5746 return Ok(path.to_path_buf());
5747 }
5748 let root = match &config.project_root {
5749 Some(r) => r.clone(),
5750 None if force_restrict => {
5751 return Err(crate::protocol::Response::error(
5752 req_id,
5753 "path_outside_root",
5754 "project root is required when path restriction is forced",
5755 ));
5756 }
5757 None => return Ok(path.to_path_buf()), };
5759 drop(config);
5760
5761 let raw_root = root.clone();
5766 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
5767
5768 let path_for_resolution = if path.is_relative() {
5773 raw_root.join(path)
5774 } else {
5775 path.to_path_buf()
5776 };
5777 let resolved = match std::fs::canonicalize(&path_for_resolution) {
5778 Ok(resolved) => resolved,
5779 Err(_) => {
5780 let normalized = normalize_path(&path_for_resolution);
5781 reject_escaping_symlink(
5782 req_id,
5783 &path_for_resolution,
5784 &normalized,
5785 &resolved_root,
5786 &raw_root,
5787 )?;
5788 resolve_with_existing_ancestors(&normalized)
5789 }
5790 };
5791
5792 if !resolved.starts_with(&resolved_root) {
5793 let is_owned_bash_artifact = artifact_session_id.is_some_and(|session_id| {
5794 self.bash_background
5795 .is_session_owned_artifact_path(session_id, &resolved)
5796 });
5797 if !is_owned_bash_artifact {
5798 return Err(path_error_response(req_id, path, &resolved_root));
5799 }
5800 }
5801
5802 Ok(resolved)
5803 }
5804
5805 pub fn lsp_server_count(&self) -> usize {
5807 self.lsp_manager
5808 .try_lock()
5809 .map(|lsp| lsp.server_count())
5810 .unwrap_or(0)
5811 }
5812
5813 pub fn symbol_cache_stats(&self) -> serde_json::Value {
5815 let entries = self
5816 .symbol_cache
5817 .read()
5818 .map(|cache| cache.len())
5819 .unwrap_or(0);
5820 serde_json::json!({
5821 "local_entries": entries,
5822 "warm_entries": 0,
5823 })
5824 }
5825
5826 pub fn memory_root_snapshot(&self) -> crate::memory::RootMemorySnapshot {
5830 let semantic = match self.semantic_index.try_read() {
5831 Ok(index) => index
5832 .as_ref()
5833 .map(SemanticIndex::estimated_memory)
5834 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("entries", 0)),
5835 Err(TryLockError::Poisoned(error)) => error
5836 .into_inner()
5837 .as_ref()
5838 .map(SemanticIndex::estimated_memory)
5839 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("entries", 0)),
5840 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
5841 };
5842 let trigram = match self.search_index.try_read() {
5843 Ok(index) => index
5844 .as_ref()
5845 .map(SearchIndex::estimated_memory)
5846 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("files", 0)),
5847 Err(TryLockError::Poisoned(error)) => error
5848 .into_inner()
5849 .as_ref()
5850 .map(SearchIndex::estimated_memory)
5851 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("files", 0)),
5852 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
5853 };
5854 let symbols = match self.symbol_cache.try_read() {
5855 Ok(cache) => cache.estimated_memory(),
5856 Err(TryLockError::Poisoned(error)) => error.into_inner().estimated_memory(),
5857 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
5858 };
5859 let callgraph = match self.callgraph_store.try_read() {
5860 Ok(store) => store
5861 .as_ref()
5862 .map(|store| store.estimated_memory())
5863 .unwrap_or_else(|| {
5864 crate::memory::MemoryEstimate::estimated(0).count("open_generation_handles", 0)
5865 }),
5866 Err(TryLockError::Poisoned(error)) => error
5867 .into_inner()
5868 .as_ref()
5869 .map(|store| store.estimated_memory())
5870 .unwrap_or_else(|| {
5871 crate::memory::MemoryEstimate::estimated(0).count("open_generation_handles", 0)
5872 }),
5873 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
5874 };
5875 let inspect = self.inspect_manager.estimated_memory();
5876 let bash = self.bash_background.estimated_memory();
5877 let lsp = self
5878 .lsp_manager
5879 .try_lock()
5880 .map(|lsp| lsp.estimated_memory())
5881 .unwrap_or_else(crate::memory::MemoryEstimate::busy);
5882 let parser_pool = crate::memory::MemoryEstimate::not_estimated()
5886 .count("pooled_parsers", 0)
5887 .gap("tree_sitter_parser_bytes");
5888 crate::memory::RootMemorySnapshot::new(
5889 semantic,
5890 trigram,
5891 symbols,
5892 callgraph,
5893 inspect,
5894 bash,
5895 lsp,
5896 parser_pool,
5897 )
5898 }
5899
5900 pub fn memory_snapshot(&self, current_root: Option<&Path>) -> crate::memory::MemorySnapshot {
5903 let mut roots = BTreeMap::new();
5904 let (roots_status, contexts) = match self.app.try_memory_contexts() {
5905 Some(contexts) => ("ready", contexts),
5906 None => ("busy", Vec::new()),
5907 };
5908 for (root, context) in contexts {
5909 roots.insert(root.display().to_string(), context.memory_root_snapshot());
5910 }
5911 let current_label = current_root
5915 .map(|root| {
5916 cortexkit_paths::ProjectRootId::from_path(root)
5917 .map(|id| id.as_path().display().to_string())
5918 .unwrap_or_else(|_| root.display().to_string())
5919 })
5920 .unwrap_or_else(|| "<unconfigured>".to_string());
5921 roots
5922 .entry(current_label)
5923 .or_insert_with(|| self.memory_root_snapshot());
5924 crate::memory::MemorySnapshot::new(roots_status, roots)
5925 }
5926}
5927
5928#[cfg(test)]
5929mod subc_lifecycle_admission_tests {
5930 use super::*;
5931
5932 #[test]
5933 fn route_teardown_does_not_supersede_disk_artifact_compatibility() {
5934 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
5935 ctx.note_configure_warm_key("config-a".to_string());
5936 let content_generation = ctx.configure_content_generation();
5937 let lifecycle_generation = ctx.configure_generation();
5938 let search_epoch = ctx.next_search_persist_epoch();
5939 let semantic_epoch = ctx.next_semantic_persist_epoch();
5940 let search_persist_epoch = ctx.search_persist_epoch_flag();
5941 let semantic_persist_epoch = ctx.semantic_persist_epoch_flag();
5942
5943 ctx.mark_subc_unbound();
5944 assert!(ctx.configure_generation() > lifecycle_generation);
5945 assert_eq!(ctx.configure_content_generation(), content_generation);
5946 assert_eq!(search_persist_epoch.current(), search_epoch);
5947 assert_eq!(semantic_persist_epoch.current(), semantic_epoch);
5948
5949 ctx.mark_subc_bound();
5950 ctx.note_configure_warm_key("config-b".to_string());
5951 assert!(ctx.configure_content_generation() > content_generation);
5952 let replacement_search_epoch = ctx.next_search_persist_epoch();
5953 let replacement_semantic_epoch = ctx.next_semantic_persist_epoch();
5954 assert!(replacement_search_epoch > search_epoch);
5955 assert!(replacement_semantic_epoch > semantic_epoch);
5956 assert_eq!(search_persist_epoch.current(), replacement_search_epoch);
5957 assert_eq!(semantic_persist_epoch.current(), replacement_semantic_epoch);
5958 }
5959
5960 #[test]
5961 fn lifecycle_gate_serializes_unbind_with_worker_start_commit() {
5962 let admission = SubcLifecycleAdmission::default();
5963 let generation = Arc::new(AtomicU64::new(11));
5964 let expected = generation.load(Ordering::SeqCst);
5965 let starts = Arc::new(AtomicUsize::new(0));
5966 let (entered_tx, entered_rx) = std::sync::mpsc::channel();
5967 let (release_tx, release_rx) = std::sync::mpsc::channel();
5968
5969 let worker_admission = admission.clone();
5970 let worker_generation = Arc::clone(&generation);
5971 let worker_starts = Arc::clone(&starts);
5972 let worker = std::thread::spawn(move || {
5973 worker_admission.run_if_current(&worker_generation, expected, || {
5974 entered_tx.send(()).unwrap();
5975 release_rx.recv().unwrap();
5976 worker_starts.fetch_add(1, Ordering::SeqCst);
5977 })
5978 });
5979 entered_rx.recv().unwrap();
5980
5981 let unbind_admission = admission.clone();
5982 let unbind_generation = Arc::clone(&generation);
5983 let (unbound_tx, unbound_rx) = std::sync::mpsc::channel();
5984 let unbind = std::thread::spawn(move || {
5985 unbind_admission.mark_unbound(&unbind_generation);
5986 unbound_tx.send(()).unwrap();
5987 });
5988
5989 assert!(
5990 unbound_rx
5991 .recv_timeout(std::time::Duration::from_millis(50))
5992 .is_err(),
5993 "unbind must wait for an admitted worker-start commit"
5994 );
5995 release_tx.send(()).unwrap();
5996 assert!(worker.join().unwrap().is_some());
5997 unbound_rx
5998 .recv_timeout(std::time::Duration::from_secs(1))
5999 .unwrap();
6000 unbind.join().unwrap();
6001 assert_eq!(starts.load(Ordering::SeqCst), 1);
6002 assert!(
6003 admission
6004 .run_if_current(&generation, generation.load(Ordering::SeqCst), || {
6005 starts.fetch_add(1, Ordering::SeqCst);
6006 })
6007 .is_none(),
6008 "worker starts after unbind must be denied"
6009 );
6010 }
6011
6012 #[test]
6013 fn health_snapshot_returns_busy_before_locking_artifact_receivers() {
6014 let ctx = Arc::new(AppContext::new(
6015 default_language_provider_factory(),
6016 Config::default(),
6017 ));
6018 let lifecycle_guard = ctx.subc_lifecycle.unbound.lock();
6019 let (started_tx, started_rx) = std::sync::mpsc::channel();
6020 let (snapshot_tx, snapshot_rx) = std::sync::mpsc::channel();
6021 let worker_ctx = Arc::clone(&ctx);
6022 let worker = std::thread::spawn(move || {
6023 started_tx.send(()).unwrap();
6024 snapshot_tx
6025 .send(worker_ctx.try_health_snapshot(Path::new("health-root")))
6026 .unwrap();
6027 });
6028 started_rx
6029 .recv_timeout(Duration::from_secs(1))
6030 .expect("health snapshot worker should start");
6031
6032 let snapshot = snapshot_rx.recv_timeout(Duration::from_secs(2));
6033 let callgraph_receiver_available = ctx.callgraph_store_rx.try_lock().is_some();
6034 drop(lifecycle_guard);
6035 worker.join().unwrap();
6036
6037 assert!(
6038 matches!(
6039 snapshot,
6040 Ok(RootHealthSnapshot {
6041 state: RootHealthState::Busy,
6042 ..
6043 })
6044 ),
6045 "health snapshots must report busy instead of waiting for lifecycle admission"
6046 );
6047 assert!(
6048 callgraph_receiver_available,
6049 "health snapshots must not hold the callgraph receiver while lifecycle admission is busy"
6050 );
6051 }
6052
6053 #[test]
6054 fn unbound_artifact_cancellation_clears_semantic_refresh_state() {
6055 let temp = tempfile::tempdir().unwrap();
6056 let ctx = AppContext::new(
6057 default_language_provider_factory(),
6058 Config {
6059 project_root: Some(temp.path().to_path_buf()),
6060 semantic_search: true,
6061 ..Config::default()
6062 },
6063 );
6064 *ctx.semantic_index()
6065 .write()
6066 .unwrap_or_else(std::sync::PoisonError::into_inner) =
6067 Some(SemanticIndex::new(temp.path().to_path_buf(), 3));
6068 let mut status = SemanticIndexStatus::ready();
6069 status.add_refreshing_file(temp.path().join("changed.rs"));
6070 *ctx.semantic_index_status()
6071 .write()
6072 .unwrap_or_else(std::sync::PoisonError::into_inner) = status;
6073 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
6074 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
6075 ctx.install_semantic_refresh_worker_for_build_epoch(
6076 request_tx,
6077 event_rx,
6078 Arc::new(Mutex::new(None)),
6079 ctx.semantic_index_rx_epoch(),
6080 );
6081
6082 ctx.cancel_unbound_artifact_work();
6083
6084 assert!(ctx.semantic_refresh_event_rx().lock().is_none());
6085 assert!(matches!(
6086 &*ctx
6087 .semantic_index_status()
6088 .read()
6089 .unwrap_or_else(std::sync::PoisonError::into_inner),
6090 SemanticIndexStatus::Ready { refreshing, .. } if refreshing.is_empty()
6091 ));
6092 }
6093
6094 #[test]
6095 fn terminal_empty_search_receiver_reports_completion_work() {
6096 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6097 let (sender, receiver) = crossbeam_channel::unbounded();
6098 let epoch = ctx.install_search_index_rx(receiver, ctx.configure_generation());
6099 let terminal_guard = ctx.search_index_rx_terminal_guard(epoch);
6100 drop(sender);
6101 drop(terminal_guard);
6102
6103 assert!(
6104 ctx.completion_drains_have_work(),
6105 "an empty disconnected one-shot receiver must wake the completion drain"
6106 );
6107 }
6108
6109 #[test]
6110 fn conditional_semantic_receiver_retire_preserves_replacement_epoch() {
6111 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6112 let (_old_sender, old_receiver) = crossbeam_channel::unbounded();
6113 let old_epoch = ctx.install_semantic_index_rx(old_receiver, ctx.configure_generation());
6114 let (_replacement_sender, replacement_receiver) = crossbeam_channel::unbounded();
6115 let replacement_epoch =
6116 ctx.install_semantic_index_rx(replacement_receiver, ctx.configure_generation());
6117
6118 assert!(replacement_epoch > old_epoch);
6119 assert_eq!(ctx.retire_semantic_index_rx_if_epoch(old_epoch), None);
6120 assert!(ctx.semantic_index_rx().lock().is_some());
6121 assert_eq!(ctx.semantic_index_rx_epoch(), replacement_epoch);
6122 }
6123
6124 #[test]
6125 fn stale_terminal_guard_cannot_hide_newer_finished_receiver() {
6126 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6127 let (old_sender, old_receiver) = crossbeam_channel::unbounded();
6128 let old_epoch = ctx.install_search_index_rx(old_receiver, ctx.configure_generation());
6129 let old_guard = ctx.search_index_rx_terminal_guard(old_epoch);
6130 let (current_sender, current_receiver) = crossbeam_channel::unbounded();
6131 let current_epoch =
6132 ctx.install_search_index_rx(current_receiver, ctx.configure_generation());
6133 let current_guard = ctx.search_index_rx_terminal_guard(current_epoch);
6134 drop(old_sender);
6135 drop(current_sender);
6136
6137 drop(current_guard);
6138 drop(old_guard);
6139
6140 assert!(current_epoch > old_epoch);
6141 assert_eq!(
6142 ctx.search_index_rx_terminal_epoch.load(Ordering::SeqCst),
6143 current_epoch,
6144 "a stale worker must not move the terminal watermark backward"
6145 );
6146 assert!(ctx.completion_drains_have_work());
6147 }
6148
6149 #[test]
6150 fn finished_semantic_refresh_worker_reports_completion_work() {
6151 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6152 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
6153 let (event_tx, event_rx) = crossbeam_channel::unbounded();
6154 let worker_slot = Arc::new(Mutex::new(Some(std::thread::spawn(|| {}))));
6155 ctx.install_semantic_refresh_worker_for_build_epoch(
6156 request_tx,
6157 event_rx,
6158 Arc::clone(&worker_slot),
6159 ctx.semantic_index_rx_epoch(),
6160 );
6161 drop(event_tx);
6162 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
6163 while !worker_slot
6164 .lock()
6165 .unwrap_or_else(std::sync::PoisonError::into_inner)
6166 .as_ref()
6167 .is_some_and(std::thread::JoinHandle::is_finished)
6168 {
6169 assert!(
6170 std::time::Instant::now() < deadline,
6171 "worker did not finish"
6172 );
6173 std::thread::yield_now();
6174 }
6175
6176 assert!(
6177 ctx.completion_drains_have_work(),
6178 "a finished refresh worker must wake the completion drain after its event queue empties"
6179 );
6180 }
6181
6182 #[test]
6183 fn unbound_lifecycle_rejects_all_deferred_worker_starts() {
6184 let admission = SubcLifecycleAdmission::default();
6185 let generation = Arc::new(AtomicU64::new(7));
6186 admission.mark_unbound(&generation);
6187 let expected = generation.load(Ordering::SeqCst);
6188 let starts = Arc::new(AtomicUsize::new(0));
6189
6190 let workers = (0..16)
6191 .map(|_| {
6192 let admission = admission.clone();
6193 let generation = Arc::clone(&generation);
6194 let starts = Arc::clone(&starts);
6195 std::thread::spawn(move || {
6196 admission.run_if_current(&generation, expected, || {
6197 starts.fetch_add(1, Ordering::SeqCst);
6198 })
6199 })
6200 })
6201 .collect::<Vec<_>>();
6202
6203 for worker in workers {
6204 assert!(worker.join().unwrap().is_none());
6205 }
6206 assert_eq!(starts.load(Ordering::SeqCst), 0);
6207 }
6208}
6209
6210#[cfg(test)]
6211mod force_restrict_tests {
6212 use super::*;
6213 use crate::language::StubProvider;
6214 use tempfile::TempDir;
6215
6216 fn test_context(project_root: Option<PathBuf>, restrict_to_project_root: bool) -> AppContext {
6217 AppContext::new(
6218 Box::new(StubProvider),
6219 Config {
6220 project_root,
6221 restrict_to_project_root,
6222 ..Config::default()
6223 },
6224 )
6225 }
6226
6227 #[test]
6228 fn standalone_validate_path_parity_without_force_restrict() {
6229 let root = TempDir::new().expect("root tempdir");
6230 let outside = TempDir::new().expect("outside tempdir");
6231 let outside_path = outside.path().join("outside.txt");
6232
6233 let unrestricted = test_context(Some(root.path().to_path_buf()), false);
6234 assert_eq!(
6235 unrestricted
6236 .validate_path("standalone-unrestricted", &outside_path)
6237 .expect("unrestricted standalone validates"),
6238 outside_path
6239 );
6240
6241 let restricted = test_context(Some(root.path().to_path_buf()), true);
6242 let err = restricted
6243 .validate_path("standalone-restricted", &outside_path)
6244 .expect_err("restricted standalone rejects outside root");
6245 assert_eq!(
6246 serde_json::to_value(err).unwrap()["code"],
6247 "path_outside_root"
6248 );
6249 }
6250
6251 #[test]
6252 fn force_restrict_guard_refcounts_duplicate_request_ids() {
6253 let root = TempDir::new().expect("root tempdir");
6254 let outside = TempDir::new().expect("outside tempdir");
6255 let outside_path = outside.path().join("outside.txt");
6256 let ctx = test_context(Some(root.path().to_path_buf()), false);
6257
6258 assert!(ctx.validate_path("dup", &outside_path).is_ok());
6259 let guard1 = ctx.force_restrict_guard("dup");
6260 let guard2 = ctx.force_restrict_guard("dup");
6261 assert!(ctx.validate_path("dup", &outside_path).is_err());
6262 drop(guard1);
6263 assert!(
6264 ctx.validate_path("dup", &outside_path).is_err(),
6265 "duplicate guard must keep the request over-restricted"
6266 );
6267 drop(guard2);
6268 assert!(ctx.validate_path("dup", &outside_path).is_ok());
6269 }
6270
6271 #[test]
6272 fn with_force_restrict_cleans_up_after_normal_completion_and_panic() {
6273 let root = TempDir::new().expect("root tempdir");
6274 let outside = TempDir::new().expect("outside tempdir");
6275 let outside_path = outside.path().join("outside.txt");
6276 let ctx = test_context(Some(root.path().to_path_buf()), false);
6277
6278 ctx.with_force_restrict("normal", || {
6279 assert!(ctx.validate_path("normal", &outside_path).is_err());
6280 });
6281 assert!(!ctx.request_force_restrict("normal"));
6282 assert!(ctx.validate_path("normal", &outside_path).is_ok());
6283
6284 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
6285 ctx.with_force_restrict("panic", || {
6286 assert!(ctx.validate_path("panic", &outside_path).is_err());
6287 panic!("intentional force-restrict cleanup panic");
6288 });
6289 }));
6290 assert!(panicked.is_err());
6291 assert!(!ctx.request_force_restrict("panic"));
6292 assert!(ctx.validate_path("panic", &outside_path).is_ok());
6293 }
6294
6295 #[test]
6296 fn forced_restrict_without_project_root_fails_closed() {
6297 let ctx = test_context(None, false);
6298 let _guard = ctx.force_restrict_guard("missing-root");
6299 let err = ctx
6300 .validate_path("missing-root", Path::new("relative.txt"))
6301 .expect_err("forced restriction without a root must fail closed");
6302 assert_eq!(
6303 serde_json::to_value(err).unwrap()["code"],
6304 "path_outside_root"
6305 );
6306 }
6307}
6308
6309#[cfg(test)]
6310mod callgraph_store_for_ops_tests {
6311 use super::*;
6312 use crate::inspect::{InspectCategory, InspectSnapshot, JobOutcome, JobScope};
6313 use crate::parser::TreeSitterProvider;
6314 use crate::protocol::RawRequest;
6315 use serde_json::json;
6316 use std::ffi::OsString;
6317 use std::path::Path;
6318 use std::sync::{Barrier, Mutex as StdMutex, MutexGuard, OnceLock};
6319 use tempfile::TempDir;
6320
6321 struct CallgraphWaitWindowEnvGuard {
6322 _guard: MutexGuard<'static, ()>,
6323 previous: Option<OsString>,
6324 }
6325
6326 impl Drop for CallgraphWaitWindowEnvGuard {
6327 fn drop(&mut self) {
6328 unsafe {
6331 match &self.previous {
6332 Some(value) => std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", value),
6333 None => std::env::remove_var("AFT_CALLGRAPH_BUILD_WAIT_MS"),
6334 }
6335 }
6336 }
6337 }
6338
6339 fn callgraph_build_wait_ms(ms: u64) -> CallgraphWaitWindowEnvGuard {
6340 static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
6341 let guard = LOCK
6342 .get_or_init(|| StdMutex::new(()))
6343 .lock()
6344 .unwrap_or_else(|error| error.into_inner());
6345 let previous = std::env::var_os("AFT_CALLGRAPH_BUILD_WAIT_MS");
6346 unsafe {
6348 std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", ms.to_string());
6349 }
6350 CallgraphWaitWindowEnvGuard {
6351 _guard: guard,
6352 previous,
6353 }
6354 }
6355
6356 fn force_async_callgraph_builds() -> CallgraphWaitWindowEnvGuard {
6357 callgraph_build_wait_ms(0)
6358 }
6359
6360 fn cold_build_context() -> Arc<AppContext> {
6361 let project = TempDir::new().expect("project tempdir");
6362 let storage = TempDir::new().expect("storage tempdir");
6363 let source_dir = project.path().join("src");
6364 std::fs::create_dir_all(&source_dir).expect("source dir");
6365 std::fs::write(
6366 source_dir.join("lib.rs"),
6367 "pub fn caller() { callee(); }\npub fn callee() {}\n",
6368 )
6369 .expect("source file");
6370
6371 Arc::new(AppContext::new(
6372 Box::new(TreeSitterProvider::new()),
6373 Config {
6374 project_root: Some(project.keep()),
6375 storage_dir: Some(storage.keep()),
6376 callgraph_chunk_size: 1,
6377 ..Config::default()
6378 },
6379 ))
6380 }
6381
6382 fn with_fake_home_env<R>(home: &Path, f: impl FnOnce() -> R) -> R {
6383 let _guard = crate::test_env::process_env_lock();
6384 let prev_home = std::env::var_os("HOME");
6385 let prev_userprofile = std::env::var_os("USERPROFILE");
6386 unsafe {
6387 std::env::set_var("HOME", home);
6388 std::env::set_var("USERPROFILE", home);
6389 }
6390 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
6391 unsafe {
6392 match prev_home {
6393 Some(value) => std::env::set_var("HOME", value),
6394 None => std::env::remove_var("HOME"),
6395 }
6396 match prev_userprofile {
6397 Some(value) => std::env::set_var("USERPROFILE", value),
6398 None => std::env::remove_var("USERPROFILE"),
6399 }
6400 }
6401 match result {
6402 Ok(value) => value,
6403 Err(payload) => std::panic::resume_unwind(payload),
6404 }
6405 }
6406
6407 fn configure_request_with_params(params: serde_json::Value) -> RawRequest {
6408 RawRequest {
6409 id: "cfg".to_string(),
6410 command: "configure".to_string(),
6411 lsp_hints: None,
6412 session_id: None,
6413 params,
6414 }
6415 }
6416
6417 fn user_tier(doc: serde_json::Value) -> serde_json::Value {
6418 json!({
6419 "tier": "user",
6420 "source": "/u/aft.jsonc",
6421 "doc": doc.to_string(),
6422 })
6423 }
6424
6425 fn configure_context(project_root: &Path, storage_dir: &Path) -> AppContext {
6426 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
6427 let response = crate::commands::configure::handle_configure(
6428 &configure_request_with_params(json!({
6429 "project_root": project_root,
6430 "harness": "opencode",
6431 "storage_dir": storage_dir,
6432 "config": [user_tier(json!({
6433 "callgraph_store": true,
6434 "search_index": true,
6435 "semantic_search": true,
6436 }))],
6437 })),
6438 &ctx,
6439 );
6440 assert!(response.success, "configure should succeed: {response:?}");
6441 ctx
6442 }
6443
6444 fn inspect_snapshot(ctx: &AppContext) -> InspectSnapshot {
6445 InspectSnapshot::new(
6446 ctx.canonical_cache_root(),
6447 ctx.inspect_dir(),
6448 ctx.config(),
6449 ctx.symbol_cache(),
6450 )
6451 }
6452
6453 fn empty_semantic_index_for_ctx(ctx: &AppContext) -> SemanticIndex {
6454 let project_root = ctx
6455 .config()
6456 .project_root
6457 .clone()
6458 .expect("test context has a project root");
6459 let files: Vec<PathBuf> = Vec::new();
6460 let mut embed = |_texts: Vec<String>| -> Result<Vec<Vec<f32>>, String> { Ok(Vec::new()) };
6461 SemanticIndex::build(&project_root, &files, &mut embed, 1)
6462 .expect("empty semantic index should build")
6463 }
6464
6465 #[test]
6466 fn home_root_gate_blocks_callgraph_store_entry_points() {
6467 let _wait_guard = force_async_callgraph_builds();
6468 let home = TempDir::new().expect("home tempdir");
6469 let storage = TempDir::new().expect("storage tempdir");
6470 let source_dir = home.path().join("src");
6471 std::fs::create_dir_all(&source_dir).expect("source dir");
6472 std::fs::write(
6473 source_dir.join("lib.rs"),
6474 "pub fn caller() { callee(); }\npub fn callee() {}\n",
6475 )
6476 .expect("source file");
6477
6478 with_fake_home_env(home.path(), || {
6479 let ctx = configure_context(home.path(), storage.path());
6480 assert!(
6481 !ctx.heavy_root_work_allowed(),
6482 "HOME root configure must close the heavy-root-work gate"
6483 );
6484 assert_eq!(
6485 ctx.try_health_snapshot(home.path())
6486 .callgraph_store
6487 .as_ref()
6488 .map(|component| component.status),
6489 Some("disabled"),
6490 "HOME root health must not advertise callgraph building"
6491 );
6492
6493 reset_callgraph_cold_build_spawn_count_for_test();
6494 assert!(matches!(
6495 ctx.callgraph_store_for_ops(),
6496 CallgraphStoreAccess::Unavailable
6497 ));
6498 assert!(
6499 ctx.ensure_callgraph_store()
6500 .expect("ensure_callgraph_store should not error")
6501 .is_none(),
6502 "shared gate must also block synchronous standalone callgraph builds"
6503 );
6504 assert_eq!(
6505 callgraph_cold_build_spawn_count_for_test(),
6506 0,
6507 "HOME root gate must not spawn a cold callgraph build"
6508 );
6509 });
6510 }
6511
6512 #[test]
6513 fn home_root_gate_blocks_inspect_manager_submit_paths() {
6514 let home = TempDir::new().expect("home tempdir");
6515 let storage = TempDir::new().expect("storage tempdir");
6516 let source_dir = home.path().join("src");
6517 std::fs::create_dir_all(&source_dir).expect("source dir");
6518 std::fs::write(source_dir.join("lib.rs"), "pub fn one() {}\n").expect("source file");
6519
6520 with_fake_home_env(home.path(), || {
6521 let ctx = configure_context(home.path(), storage.path());
6522 let snapshot = inspect_snapshot(&ctx);
6523 let scope = JobScope::for_project(snapshot.project_root.clone());
6524 let manager = ctx.inspect_manager();
6525
6526 assert!(matches!(
6527 manager.submit_category(snapshot.clone(), InspectCategory::Metrics, scope.clone()),
6528 JobOutcome::Failed { .. }
6529 ));
6530
6531 let submission = manager.submit_tier2_run_with_reuse_serial_background(
6532 snapshot,
6533 vec![InspectCategory::DeadCode],
6534 );
6535 assert!(submission.queued_categories.is_empty());
6536 assert!(submission.newly_queued_categories.is_empty());
6537 assert!(submission.deferred_categories.is_empty());
6538 assert_eq!(submission.errors.len(), 1);
6539 assert!(
6540 !manager.tier2_any_in_flight(),
6541 "HOME root gate must reject Tier-2 submission before any job is queued"
6542 );
6543 });
6544 }
6545
6546 #[test]
6547 fn non_home_root_still_allows_callgraph_cold_builds() {
6548 let _env_guard = force_async_callgraph_builds();
6549 reset_callgraph_cold_build_spawn_count_for_test();
6550 let ctx = cold_build_context();
6551
6552 assert!(ctx.heavy_root_work_allowed());
6553 assert!(matches!(
6554 ctx.callgraph_store_for_ops(),
6555 CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
6556 ));
6557 assert_eq!(
6558 callgraph_cold_build_spawn_count_for_test(),
6559 1,
6560 "non-home roots must still be able to cold-build the callgraph store"
6561 );
6562
6563 let rx = ctx
6564 .callgraph_store_rx
6565 .lock()
6566 .as_ref()
6567 .cloned()
6568 .expect("non-home cold build should install an in-flight receiver");
6569 rx.recv_timeout(Duration::from_secs(30))
6570 .expect("background cold build should complete");
6571 *ctx.callgraph_store_rx.lock() = None;
6572 }
6573
6574 #[test]
6575 fn semantic_ready_event_resumes_deferred_callgraph_and_tier2() {
6576 let _env_guard = force_async_callgraph_builds();
6577 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
6578 let ctx = cold_build_context();
6579 let (tx, rx) = crossbeam_channel::unbounded();
6580 *ctx.semantic_index_rx().lock() = Some(rx);
6581 ctx.schedule_semantic_cold_seed_gate_for_configure();
6582
6583 assert!(matches!(
6584 ctx.callgraph_store_for_ops(),
6585 CallgraphStoreAccess::Building
6586 ));
6587 assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
6588 tx.send(SemanticIndexEvent::Ready(empty_semantic_index_for_ctx(
6589 &ctx,
6590 )))
6591 .expect("send ready event");
6592
6593 crate::runtime_drain::drain_semantic_index_events(&ctx);
6594
6595 assert!(
6596 !ctx.semantic_cold_seed_active(),
6597 "semantic Ready must clear the scheduled cold gate"
6598 );
6599 assert!(
6600 ctx.tier2_pull_demand_pending(),
6601 "semantic Ready must resume deferred Tier-2 work"
6602 );
6603 assert_eq!(
6604 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
6605 1,
6606 "semantic Ready must resume the deferred callgraph warm"
6607 );
6608 let rx = ctx
6609 .callgraph_store_rx
6610 .lock()
6611 .as_ref()
6612 .cloned()
6613 .expect("ready resume should install an in-flight callgraph receiver");
6614 rx.recv_timeout(Duration::from_secs(30))
6615 .expect("background cold build should complete");
6616 *ctx.callgraph_store_rx.lock() = None;
6617 }
6618
6619 #[test]
6620 fn semantic_gate_cleared_event_resumes_deferred_callgraph_and_tier2() {
6621 let _env_guard = force_async_callgraph_builds();
6622 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
6623 let ctx = cold_build_context();
6624 ctx.schedule_semantic_cold_seed_gate_for_configure();
6625
6626 assert!(matches!(
6627 ctx.callgraph_store_for_ops(),
6628 CallgraphStoreAccess::Building
6629 ));
6630 assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
6631 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
6632
6633 assert!(
6634 !ctx.semantic_cold_seed_active(),
6635 "cached-load or retry-wait clear must reopen the semantic cold gate"
6636 );
6637 assert!(
6638 ctx.tier2_pull_demand_pending(),
6639 "cached-load or retry-wait clear must resume deferred Tier-2 work"
6640 );
6641 assert_eq!(
6642 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
6643 1,
6644 "cached-load or retry-wait clear must resume deferred callgraph warm"
6645 );
6646 let rx = ctx
6647 .callgraph_store_rx
6648 .lock()
6649 .as_ref()
6650 .cloned()
6651 .expect("gate-clear resume should install an in-flight callgraph receiver");
6652 rx.recv_timeout(Duration::from_secs(30))
6653 .expect("background cold build should complete");
6654 *ctx.callgraph_store_rx.lock() = None;
6655 }
6656
6657 #[test]
6658 fn semantic_cold_seed_gate_defers_callgraph_cold_spawn_until_resume() {
6659 let _env_guard = force_async_callgraph_builds();
6660 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
6661 let ctx = cold_build_context();
6662
6663 ctx.set_semantic_cold_seed_active_for_test(true);
6664 assert!(
6665 matches!(
6666 ctx.callgraph_store_for_ops(),
6667 CallgraphStoreAccess::Building
6668 ),
6669 "callgraph ops should degrade as building while the semantic cold gate is active"
6670 );
6671 assert_eq!(
6672 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
6673 0,
6674 "semantic cold gate must not spawn a competing callgraph cold build"
6675 );
6676 assert!(ctx.semantic_callgraph_warm_deferred_for_test());
6677
6678 ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
6679 assert_eq!(
6680 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
6681 1,
6682 "clearing the semantic cold gate should resume the deferred callgraph warm"
6683 );
6684
6685 let rx = ctx
6686 .callgraph_store_rx
6687 .lock()
6688 .as_ref()
6689 .cloned()
6690 .expect("deferred warm should install an in-flight receiver");
6691 rx.recv_timeout(Duration::from_secs(30))
6692 .expect("background cold build should complete");
6693 *ctx.callgraph_store_rx.lock() = None;
6694 }
6695
6696 #[test]
6697 fn semantic_cold_seed_gate_clear_requests_tier2_pull() {
6698 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
6699 ctx.schedule_semantic_cold_seed_gate_for_configure();
6700
6701 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
6702
6703 assert!(
6704 !ctx.semantic_cold_seed_active(),
6705 "retry-wait or cached-load events must reopen the semantic cold gate"
6706 );
6707 assert!(
6708 ctx.tier2_pull_demand_pending(),
6709 "clearing the semantic cold gate should kick a Tier-2 pull refresh"
6710 );
6711 }
6712
6713 #[test]
6714 fn semantic_failed_event_clears_scheduled_gate_and_requests_tier2_pull() {
6715 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
6716 let (tx, rx) = crossbeam_channel::unbounded();
6717 *ctx.semantic_index_rx().lock() = Some(rx);
6718 ctx.schedule_semantic_cold_seed_gate_for_configure();
6719 tx.send(SemanticIndexEvent::Failed(
6720 "embedding backend failed".to_string(),
6721 ))
6722 .expect("send failed event");
6723
6724 crate::runtime_drain::drain_semantic_index_events(&ctx);
6725
6726 assert!(
6727 !ctx.semantic_cold_seed_active(),
6728 "semantic Failed must clear the scheduled cold gate"
6729 );
6730 assert!(
6731 ctx.tier2_pull_demand_pending(),
6732 "semantic Failed must resume deferred Tier-2 work"
6733 );
6734 }
6735
6736 #[test]
6737 fn semantic_disconnect_clears_scheduled_gate_and_requests_tier2_pull() {
6738 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
6739 let (tx, rx) = crossbeam_channel::unbounded::<SemanticIndexEvent>();
6740 *ctx.semantic_index_rx().lock() = Some(rx);
6741 ctx.schedule_semantic_cold_seed_gate_for_configure();
6742 drop(tx);
6743
6744 crate::runtime_drain::drain_semantic_index_events(&ctx);
6745
6746 assert!(
6747 !ctx.semantic_cold_seed_active(),
6748 "semantic worker disconnect must clear the scheduled cold gate"
6749 );
6750 assert!(
6751 ctx.tier2_pull_demand_pending(),
6752 "semantic worker disconnect must resume deferred Tier-2 work"
6753 );
6754 }
6755
6756 #[test]
6757 fn semantic_cold_seed_gate_is_per_context_for_tier2_scheduler() {
6758 let ctx_a = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
6759 let ctx_b = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
6760 let base = Instant::now();
6761 ctx_a.reset_tier2_refresh_scheduler_at(base);
6762 ctx_b.reset_tier2_refresh_scheduler_at(base);
6763 ctx_a.set_semantic_cold_seed_active_for_test(true);
6764
6765 assert_eq!(
6766 ctx_a.tick_tier2_refresh_scheduler_at(
6767 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
6768 0,
6769 ),
6770 None,
6771 "root A should defer Tier-2 while its semantic cold seed is active"
6772 );
6773 assert_eq!(
6774 ctx_b.tick_tier2_refresh_scheduler_at(
6775 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
6776 0,
6777 ),
6778 Some(Tier2TriggerReason::ConfigureWarm),
6779 "root B must not inherit root A's semantic cold gate"
6780 );
6781 }
6782
6783 #[test]
6784 fn inline_wait_settled_event_clears_superseded_receiver() {
6785 let _env_guard = callgraph_build_wait_ms(2_000);
6786 let project = TempDir::new().expect("project tempdir");
6787 let storage = TempDir::new().expect("storage tempdir");
6788 std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
6789 let project_root = std::fs::canonicalize(project.path()).expect("canonical project root");
6790 let ctx = Arc::new(AppContext::new(
6791 Box::new(TreeSitterProvider::new()),
6792 Config {
6793 project_root: Some(project.path().to_path_buf()),
6794 storage_dir: Some(storage.path().to_path_buf()),
6795 callgraph_chunk_size: 1,
6796 ..Config::default()
6797 },
6798 ));
6799 let (reached, release) = install_callgraph_build_start_gate(project_root);
6800 let request_ctx = Arc::clone(&ctx);
6801 let request = std::thread::spawn(move || request_ctx.callgraph_store_for_ops());
6802 reached
6803 .recv_timeout(Duration::from_secs(2))
6804 .expect("callgraph worker did not reach start barrier");
6805
6806 ctx.next_callgraph_persist_epoch();
6807 release.send(()).unwrap();
6808 assert!(matches!(
6809 request.join().expect("callgraph request thread"),
6810 CallgraphStoreAccess::Building
6811 ));
6812 assert!(
6813 ctx.callgraph_store_rx().lock().is_none(),
6814 "inline Settled handling must retire the matching receiver"
6815 );
6816 assert!(
6817 ctx.callgraph_store()
6818 .read()
6819 .unwrap_or_else(std::sync::PoisonError::into_inner)
6820 .is_none(),
6821 "Settled must not reopen and install an older persisted store"
6822 );
6823 }
6824
6825 #[test]
6826 fn inline_ready_without_published_pointer_settles_and_preserves_pending_paths() {
6827 let _env_guard = callgraph_build_wait_ms(2_000);
6828 let project = TempDir::new().expect("project tempdir");
6829 let storage = TempDir::new().expect("storage tempdir");
6830 std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
6831 let ctx = AppContext::new(
6832 Box::new(TreeSitterProvider::new()),
6833 Config {
6834 project_root: Some(project.path().to_path_buf()),
6835 storage_dir: Some(storage.path().to_path_buf()),
6836 callgraph_chunk_size: 1,
6837 ..Config::default()
6838 },
6839 );
6840 let pending = project.path().join("pending.rs");
6841 ctx.add_pending_callgraph_store_paths([pending.clone()]);
6842 REMOVE_CALLGRAPH_POINTER_BEFORE_INLINE_REOPEN.store(true, Ordering::SeqCst);
6843 let _remove_pointer_guard = RemoveCallgraphPointerBeforeInlineReopenGuard;
6844
6845 assert!(matches!(
6846 ctx.callgraph_store_for_ops(),
6847 CallgraphStoreAccess::Building
6848 ));
6849 assert!(
6850 ctx.callgraph_store_rx().lock().is_none(),
6851 "inline Ready must settle after the published pointer disappears"
6852 );
6853 assert_eq!(
6854 ctx.take_pending_callgraph_store_paths(),
6855 vec![pending],
6856 "inline reopen failure must preserve pending watcher paths"
6857 );
6858 }
6859
6860 #[test]
6861 fn take_pending_callgraph_store_paths_drops_paths_outside_current_root() {
6862 let project = TempDir::new().expect("project tempdir");
6863 let foreign = TempDir::new().expect("foreign tempdir");
6864 let ctx = AppContext::new(
6865 Box::new(TreeSitterProvider::new()),
6866 Config {
6867 project_root: Some(project.path().to_path_buf()),
6868 ..Config::default()
6869 },
6870 );
6871 let inside = project.path().join("kept.rs");
6872 let outside = foreign.path().join("previous-root-file.rs");
6876 let dotdot_escape = project
6879 .path()
6880 .join("..")
6881 .join(
6882 foreign
6883 .path()
6884 .file_name()
6885 .expect("foreign tempdir has a name"),
6886 )
6887 .join("escaped.rs");
6888 ctx.add_pending_callgraph_store_paths([inside.clone(), outside, dotdot_escape]);
6889
6890 assert_eq!(
6891 ctx.take_pending_callgraph_store_paths(),
6892 vec![inside],
6893 "pending replay must drop foreign and dot-dot-escaping paths"
6894 );
6895 }
6896
6897 #[test]
6898 fn watcher_gap_invalidation_keeps_semantic_reloadable_and_skips_readonly_force_token() {
6899 let project = TempDir::new().expect("project tempdir");
6900 let ctx = AppContext::new(
6901 Box::new(TreeSitterProvider::new()),
6902 Config {
6903 project_root: Some(project.path().to_path_buf()),
6904 semantic_search: true,
6905 ..Config::default()
6906 },
6907 );
6908 ctx.set_canonical_cache_root(project.path().to_path_buf());
6909 ctx.set_cache_writer_capabilities(false, true);
6912 *ctx.semantic_index_status()
6913 .write()
6914 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
6915
6916 ctx.invalidate_artifacts_after_watcher_gap();
6917
6918 assert!(
6919 matches!(
6920 &*ctx
6921 .semantic_index_status()
6922 .read()
6923 .unwrap_or_else(std::sync::PoisonError::into_inner),
6924 SemanticIndexStatus::Ready { .. }
6925 ),
6926 "semantic-enabled root must stay reloadable (Disabled has no self-healing path)"
6927 );
6928 assert_eq!(
6929 ctx.pending_callgraph_store_force_token(),
6930 None,
6931 "read-only root must not be stuck behind an unfulfillable force token"
6932 );
6933 }
6934
6935 #[test]
6936 fn watcher_gap_invalidation_marks_force_rebuild_for_writer_roots() {
6937 let project = TempDir::new().expect("project tempdir");
6938 let ctx = AppContext::new(
6939 Box::new(TreeSitterProvider::new()),
6940 Config {
6941 project_root: Some(project.path().to_path_buf()),
6942 ..Config::default()
6943 },
6944 );
6945 ctx.set_canonical_cache_root(project.path().to_path_buf());
6946 ctx.set_cache_writer_capabilities(true, true);
6947
6948 ctx.invalidate_artifacts_after_watcher_gap();
6949
6950 assert!(
6951 ctx.pending_callgraph_store_force_token().is_some(),
6952 "writer roots must still reconcile the store after the unobserved interval"
6953 );
6954 assert!(
6955 matches!(
6956 &*ctx
6957 .semantic_index_status()
6958 .read()
6959 .unwrap_or_else(std::sync::PoisonError::into_inner),
6960 SemanticIndexStatus::Disabled
6961 ),
6962 "semantic-disabled config maps to Disabled status"
6963 );
6964 }
6965
6966 #[cfg(unix)]
6967 #[test]
6968 fn take_pending_callgraph_store_paths_drops_symlink_dotdot_escape() {
6969 let project = TempDir::new().expect("project tempdir");
6970 let foreign = TempDir::new().expect("foreign tempdir");
6971 std::fs::create_dir_all(foreign.path().join("dir")).expect("foreign dir");
6972 std::fs::write(foreign.path().join("secret.rs"), "pub fn s() {}\n").expect("secret");
6973 let ctx = AppContext::new(
6974 Box::new(TreeSitterProvider::new()),
6975 Config {
6976 project_root: Some(project.path().to_path_buf()),
6977 ..Config::default()
6978 },
6979 );
6980 std::os::unix::fs::symlink(foreign.path().join("dir"), project.path().join("link"))
6985 .expect("plant symlink");
6986 let escape = project.path().join("link").join("..").join("secret.rs");
6987 let dead_component_escape = project
6992 .path()
6993 .join("link")
6994 .join("dead")
6995 .join("..")
6996 .join("..")
6997 .join("deep-secret.rs");
6998 std::fs::write(foreign.path().join("reentry-secret.rs"), "pub fn r() {}\n")
7003 .expect("reentry secret");
7004 let reentry_escape = project
7005 .path()
7006 .join("dead")
7007 .join("..")
7008 .join("link")
7009 .join("..")
7010 .join("reentry-secret.rs");
7011 std::os::unix::fs::symlink(
7016 foreign.path().join("nonexistent-target"),
7017 project.path().join("dangling"),
7018 )
7019 .expect("plant dangling symlink");
7020 let dangling_reentry = project
7021 .path()
7022 .join("dangling")
7023 .join("..")
7024 .join("via-dangling.rs");
7025 std::fs::write(project.path().join("plain.rs"), "pub fn p() {}\n").expect("plain file");
7028 let through_file = project
7029 .path()
7030 .join("plain.rs")
7031 .join("..")
7032 .join("via-file.rs");
7033 let kept = project.path().join("kept.rs");
7034 ctx.add_pending_callgraph_store_paths([
7035 escape,
7036 dead_component_escape,
7037 reentry_escape,
7038 dangling_reentry,
7039 through_file,
7040 kept.clone(),
7041 ]);
7042
7043 assert_eq!(
7044 ctx.take_pending_callgraph_store_paths(),
7045 vec![kept],
7046 "symlink-plus-dotdot escapes must be dropped with filesystem-first semantics"
7047 );
7048 }
7049
7050 #[cfg(windows)]
7051 #[test]
7052 fn take_pending_callgraph_store_paths_drops_drive_relative_paths() {
7053 let cwd = std::env::current_dir().expect("drive cwd");
7060 let cwd_file = PathBuf::from(format!(
7061 "{}under-drive-cwd.rs",
7062 cwd.components()
7063 .next()
7064 .map(|prefix| prefix.as_os_str().to_string_lossy().into_owned())
7065 .expect("drive prefix")
7066 ));
7067 assert!(cwd_file.is_relative(), "C:foo must classify as relative");
7068 assert!(
7069 !pending_path_in_roots(&cwd_file, &[cwd.clone()]),
7070 "drive-relative spelling must be rejected even when the drive CWD is inside the root"
7071 );
7072 assert!(
7073 !pending_path_in_roots(Path::new(r"\root-relative.rs"), &[cwd]),
7074 "root-relative spelling must be rejected"
7075 );
7076
7077 let project = TempDir::new().expect("project tempdir");
7078 let ctx = AppContext::new(
7079 Box::new(TreeSitterProvider::new()),
7080 Config {
7081 project_root: Some(project.path().to_path_buf()),
7082 ..Config::default()
7083 },
7084 );
7085 let kept = project.path().join("kept.rs");
7086 ctx.add_pending_callgraph_store_paths([
7087 PathBuf::from("C:drive-relative.rs"),
7088 PathBuf::from(r"\root-relative.rs"),
7089 kept.clone(),
7090 ]);
7091
7092 assert_eq!(
7093 ctx.take_pending_callgraph_store_paths(),
7094 vec![kept],
7095 "drive-relative and root-relative spellings must be rejected"
7096 );
7097 }
7098
7099 #[test]
7100 fn take_pending_callgraph_store_paths_keeps_relative_and_deleted_paths() {
7101 let project = TempDir::new().expect("project tempdir");
7102 let ctx = AppContext::new(
7103 Box::new(TreeSitterProvider::new()),
7104 Config {
7105 project_root: Some(project.path().to_path_buf()),
7106 ..Config::default()
7107 },
7108 );
7109 let relative = PathBuf::from("src/relative.rs");
7112 let deleted = project.path().join("never-created.rs");
7113 ctx.add_pending_callgraph_store_paths([relative.clone(), deleted.clone()]);
7114
7115 let mut taken = ctx.take_pending_callgraph_store_paths();
7116 taken.sort();
7117 let mut expected = vec![relative, deleted];
7118 expected.sort();
7119 assert_eq!(
7120 taken, expected,
7121 "root-relative and deleted in-root paths must survive the filter"
7122 );
7123 }
7124
7125 #[test]
7126 fn concurrent_cold_callgraph_store_for_ops_spawns_one_build() {
7127 let _env_guard = force_async_callgraph_builds();
7128 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
7129
7130 let project = TempDir::new().expect("project tempdir");
7131 let storage = TempDir::new().expect("storage tempdir");
7132 let source_dir = project.path().join("src");
7133 std::fs::create_dir_all(&source_dir).expect("source dir");
7134 std::fs::write(
7135 source_dir.join("lib.rs"),
7136 "pub fn caller() { callee(); }\npub fn callee() {}\n",
7137 )
7138 .expect("source file");
7139
7140 let ctx = Arc::new(AppContext::new(
7141 Box::new(TreeSitterProvider::new()),
7142 Config {
7143 project_root: Some(project.path().to_path_buf()),
7144 storage_dir: Some(storage.path().to_path_buf()),
7145 callgraph_chunk_size: 1,
7146 ..Config::default()
7147 },
7148 ));
7149
7150 let barrier = Arc::new(Barrier::new(3));
7151 let handles = (0..2)
7152 .map(|_| {
7153 let ctx = Arc::clone(&ctx);
7154 let barrier = Arc::clone(&barrier);
7155 std::thread::spawn(move || {
7156 barrier.wait();
7157 matches!(
7158 ctx.callgraph_store_for_ops(),
7159 CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
7160 )
7161 })
7162 })
7163 .collect::<Vec<_>>();
7164
7165 barrier.wait();
7166 for handle in handles {
7167 assert!(
7168 handle.join().expect("callgraph caller thread"),
7169 "cold callgraph ops should report Building or observe the installed store"
7170 );
7171 }
7172
7173 assert_eq!(
7174 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
7175 1,
7176 "concurrent cold callers must share one background build"
7177 );
7178
7179 let rx = ctx
7180 .callgraph_store_rx
7181 .lock()
7182 .as_ref()
7183 .cloned()
7184 .expect("in-flight receiver installed before spawn");
7185 rx.recv_timeout(Duration::from_secs(30))
7186 .expect("background cold build should complete");
7187 *ctx.callgraph_store_rx.lock() = None;
7188 }
7189
7190 #[test]
7191 fn watcher_gap_invalidation_gates_resident_artifacts_and_forces_strict_verify() {
7192 let root = TempDir::new().expect("project tempdir");
7193 let canonical_root = std::fs::canonicalize(root.path()).expect("canonical project root");
7194 let ctx = AppContext::new(
7195 Box::new(TreeSitterProvider::new()),
7196 Config {
7197 project_root: Some(canonical_root.clone()),
7198 ..Config::default()
7199 },
7200 );
7201 *ctx.search_index
7202 .write()
7203 .unwrap_or_else(std::sync::PoisonError::into_inner) =
7204 Some(SearchIndex::build(&canonical_root));
7205 *ctx.semantic_index
7206 .write()
7207 .unwrap_or_else(std::sync::PoisonError::into_inner) =
7208 Some(SemanticIndex::new(canonical_root.clone(), 3));
7209 *ctx.semantic_index_status
7210 .write()
7211 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
7212
7213 let artifact = canonical_root.join("verify-artifact.bin");
7214 std::fs::write(&artifact, b"same-size").expect("write verification artifact");
7215 let generation =
7216 crate::cache_freshness::artifact_generation(&artifact).expect("artifact generation");
7217 crate::cache_freshness::record_verify_completed(
7218 &canonical_root,
7219 crate::cache_freshness::VerifyArtifact::Search,
7220 Some(generation),
7221 );
7222 assert_eq!(
7223 crate::cache_freshness::warm_verify_plan(
7224 &canonical_root,
7225 crate::cache_freshness::VerifyArtifact::Search,
7226 Some(generation),
7227 ),
7228 crate::cache_freshness::WarmVerifyPlan::Skip
7229 );
7230
7231 ctx.invalidate_artifacts_after_watcher_gap();
7232
7233 assert!(ctx
7234 .search_index
7235 .read()
7236 .unwrap_or_else(std::sync::PoisonError::into_inner)
7237 .is_none());
7238 assert!(ctx
7239 .semantic_index
7240 .read()
7241 .unwrap_or_else(std::sync::PoisonError::into_inner)
7242 .is_none());
7243 assert!(ctx.pending_callgraph_store_force_token().is_some());
7244 assert_eq!(
7245 crate::cache_freshness::warm_verify_plan(
7246 &canonical_root,
7247 crate::cache_freshness::VerifyArtifact::Search,
7248 Some(generation),
7249 ),
7250 crate::cache_freshness::WarmVerifyPlan::Strict
7251 );
7252 }
7253
7254 #[test]
7255 fn cancelled_semantic_refresh_transfers_refreshing_files_to_pending() {
7256 let root = TempDir::new().expect("project tempdir");
7257 let ctx = AppContext::new(
7258 Box::new(TreeSitterProvider::new()),
7259 Config {
7260 project_root: Some(root.path().to_path_buf()),
7261 semantic_search: true,
7262 ..Config::default()
7263 },
7264 );
7265 *ctx.semantic_index
7266 .write()
7267 .unwrap_or_else(std::sync::PoisonError::into_inner) =
7268 Some(SemanticIndex::new(root.path().to_path_buf(), 3));
7269 let refreshing_path = root.path().join("src/lib.rs");
7270 {
7271 let mut status = ctx
7272 .semantic_index_status
7273 .write()
7274 .unwrap_or_else(std::sync::PoisonError::into_inner);
7275 *status = SemanticIndexStatus::ready();
7276 status.start_refreshing_file(refreshing_path.clone());
7277 }
7278 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
7279 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
7280 ctx.install_semantic_refresh_worker_for_build_epoch(
7281 request_tx,
7282 event_rx,
7283 Arc::new(Mutex::new(None)),
7284 ctx.semantic_index_rx_epoch(),
7285 );
7286
7287 ctx.cancel_unbound_artifact_work();
7288
7289 assert_eq!(
7292 ctx.pending_semantic_index_paths
7293 .lock()
7294 .iter()
7295 .cloned()
7296 .collect::<Vec<_>>(),
7297 vec![refreshing_path],
7298 "cancelled in-flight refresh files must transfer to the pending set"
7299 );
7300 assert!(matches!(
7301 &*ctx
7302 .semantic_index_status
7303 .read()
7304 .unwrap_or_else(std::sync::PoisonError::into_inner),
7305 SemanticIndexStatus::Ready { refreshing, .. } if refreshing.is_empty()
7306 ));
7307 }
7308
7309 #[test]
7310 fn unbind_before_corpus_started_preserves_corpus_intent() {
7311 let root = TempDir::new().expect("project tempdir");
7316 let ctx = AppContext::new(
7317 Box::new(TreeSitterProvider::new()),
7318 Config {
7319 project_root: Some(root.path().to_path_buf()),
7320 semantic_search: true,
7321 ..Config::default()
7322 },
7323 );
7324 *ctx.semantic_index
7325 .write()
7326 .unwrap_or_else(std::sync::PoisonError::into_inner) =
7327 Some(SemanticIndex::new(root.path().to_path_buf(), 3));
7328 *ctx.semantic_index_status
7329 .write()
7330 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
7331 stage: "refreshing_corpus".to_string(),
7332 files: None,
7333 entries_done: None,
7334 entries_total: None,
7335 };
7336 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
7337 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
7338 ctx.install_semantic_refresh_worker_for_build_epoch(
7339 request_tx,
7340 event_rx,
7341 Arc::new(Mutex::new(None)),
7342 ctx.semantic_index_rx_epoch(),
7343 );
7344
7345 ctx.cancel_unbound_artifact_work();
7346
7347 assert!(
7348 *ctx.pending_semantic_corpus_refresh.lock(),
7349 "corpus intent stamped before CorpusStarted must survive the cancellation"
7350 );
7351 }
7352
7353 #[test]
7354 fn cancelled_search_corpus_refresh_drops_nonready_resident_index() {
7355 let root = TempDir::new().expect("project tempdir");
7356 let ctx = AppContext::new(
7357 Box::new(TreeSitterProvider::new()),
7358 Config {
7359 project_root: Some(root.path().to_path_buf()),
7360 ..Config::default()
7361 },
7362 );
7363 let mut refreshing = SearchIndex::new();
7367 refreshing.ready = false;
7368 *ctx.search_index
7369 .write()
7370 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(refreshing);
7371 let (_tx, rx) = crossbeam_channel::unbounded();
7372 ctx.install_search_index_rx(rx, ctx.configure_generation());
7373
7374 ctx.cancel_unbound_artifact_work();
7375
7376 assert!(
7377 ctx.search_index
7378 .read()
7379 .unwrap_or_else(std::sync::PoisonError::into_inner)
7380 .is_none(),
7381 "a cancelled corpus refresh must drop the non-ready resident so rebind reloads it"
7382 );
7383 assert!(ctx
7384 .search_index_rx
7385 .read()
7386 .unwrap_or_else(std::sync::PoisonError::into_inner)
7387 .is_none());
7388 }
7389
7390 #[test]
7391 fn active_semantic_file_refresh_blocks_idle_eviction_until_completion() {
7392 let root = TempDir::new().expect("project tempdir");
7393 let ctx = AppContext::new(
7394 Box::new(TreeSitterProvider::new()),
7395 Config {
7396 project_root: Some(root.path().to_path_buf()),
7397 ..Config::default()
7398 },
7399 );
7400 *ctx.semantic_index
7401 .write()
7402 .unwrap_or_else(std::sync::PoisonError::into_inner) =
7403 Some(SemanticIndex::new(root.path().to_path_buf(), 3));
7404 let refreshing_path = root.path().join("src/lib.rs");
7405 {
7406 let mut status = ctx
7407 .semantic_index_status
7408 .write()
7409 .unwrap_or_else(std::sync::PoisonError::into_inner);
7410 *status = SemanticIndexStatus::ready();
7411 status.start_refreshing_file(refreshing_path.clone());
7412 }
7413
7414 assert!(ctx.artifact_eviction_blocked());
7415 assert!(!ctx.evict_idle_artifacts());
7416 assert!(ctx
7417 .semantic_index
7418 .read()
7419 .unwrap_or_else(std::sync::PoisonError::into_inner)
7420 .is_some());
7421
7422 ctx.semantic_index_status
7423 .write()
7424 .unwrap_or_else(std::sync::PoisonError::into_inner)
7425 .complete_refreshing_file(&refreshing_path);
7426 assert!(ctx.evict_idle_artifacts());
7427 assert!(ctx
7428 .semantic_index
7429 .read()
7430 .unwrap_or_else(std::sync::PoisonError::into_inner)
7431 .is_none());
7432 }
7433}
7434
7435#[cfg(test)]
7436mod status_emitter_tests {
7437 use super::*;
7438 use crate::parser::TreeSitterProvider;
7439
7440 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
7441 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7442 let (tx, rx) = mpsc::channel();
7443 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
7444 let _ = tx.send(frame);
7445 }))));
7446 (ctx, rx)
7447 }
7448
7449 #[test]
7450 fn status_emitter_signal_triggers_push() {
7451 let (ctx, rx) = ctx_with_frame_rx();
7452 ctx.status_emitter().signal(ctx.build_status_snapshot());
7453 let frame = rx
7454 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
7455 .expect("status_changed push");
7456 assert!(matches!(frame, PushFrame::StatusChanged(_)));
7457 }
7458
7459 #[test]
7460 fn status_emitter_debounces_burst() {
7461 let (ctx, rx) = ctx_with_frame_rx();
7462 for _ in 0..10 {
7463 ctx.status_emitter().signal(ctx.build_status_snapshot());
7464 }
7465 let frame = rx
7466 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
7467 .expect("status_changed push");
7468 assert!(matches!(frame, PushFrame::StatusChanged(_)));
7469 assert!(rx.try_recv().is_err());
7470 }
7471
7472 #[test]
7473 fn status_emitter_separate_windows_separate_pushes() {
7474 let (ctx, rx) = ctx_with_frame_rx();
7475 ctx.status_emitter().signal(ctx.build_status_snapshot());
7476 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
7477 .expect("first push");
7478 ctx.status_emitter().signal(ctx.build_status_snapshot());
7479 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
7480 .expect("second push");
7481 }
7482
7483 #[test]
7484 fn status_emitter_no_signal_no_push() {
7485 let (_ctx, rx) = ctx_with_frame_rx();
7486 assert!(rx
7487 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
7488 .is_err());
7489 }
7490
7491 #[test]
7492 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
7493 let (ctx, rx) = ctx_with_frame_rx();
7494 drop(ctx);
7495 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
7496 }
7497
7498 #[test]
7499 fn progress_sender_slot_is_per_context_for_shared_app() {
7500 let app = App::default_shared();
7501 let ctx_a = AppContext::from_app(Arc::clone(&app), Config::default());
7502 let ctx_b = AppContext::from_app(app, Config::default());
7503 let (tx_a, rx_a) = mpsc::channel();
7504 let (tx_b, rx_b) = mpsc::channel();
7505
7506 ctx_a.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
7507 let _ = tx_a.send(frame);
7508 }))));
7509 ctx_b.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
7510 let _ = tx_b.send(frame);
7511 }))));
7512
7513 ctx_a.emit_progress(ProgressFrame {
7514 frame_type: "progress",
7515 request_id: "ctx-a".to_string(),
7516 kind: crate::protocol::ProgressKind::Stdout,
7517 chunk: "a".to_string(),
7518 });
7519 ctx_b.emit_progress(ProgressFrame {
7520 frame_type: "progress",
7521 request_id: "ctx-b".to_string(),
7522 kind: crate::protocol::ProgressKind::Stdout,
7523 chunk: "b".to_string(),
7524 });
7525
7526 match rx_a
7527 .recv_timeout(Duration::from_millis(50))
7528 .expect("ctx A progress frame")
7529 {
7530 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-a"),
7531 other => panic!("unexpected frame for ctx A: {other:?}"),
7532 }
7533 assert!(rx_a.try_recv().is_err());
7534
7535 match rx_b
7536 .recv_timeout(Duration::from_millis(50))
7537 .expect("ctx B progress frame")
7538 {
7539 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-b"),
7540 other => panic!("unexpected frame for ctx B: {other:?}"),
7541 }
7542 assert!(rx_b.try_recv().is_err());
7543 }
7544}
7545
7546#[cfg(test)]
7547mod status_bar_tests {
7548 use super::*;
7549 use crate::parser::TreeSitterProvider;
7550
7551 fn ctx() -> AppContext {
7552 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
7553 }
7554
7555 #[test]
7556 fn status_bar_counts_none_until_tier2_populated() {
7557 let ctx = ctx();
7558 assert!(ctx.status_bar_counts().is_none());
7560
7561 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
7562 let counts = ctx.status_bar_counts().expect("populated");
7563 assert_eq!(counts.dead_code, 5);
7564 assert_eq!(counts.unused_exports, 3);
7565 assert_eq!(counts.duplicates, 7);
7566 assert_eq!(counts.todos, 2);
7567 assert!(!counts.tier2_stale);
7568 assert_eq!(counts.errors, 0);
7570 assert_eq!(counts.warnings, 0);
7571 }
7572
7573 #[test]
7574 fn changing_root_clears_project_scoped_status_counts() {
7575 let temp = tempfile::tempdir().expect("tempdir");
7576 let first_root = temp.path().join("first");
7577 let second_root = temp.path().join("second");
7578 std::fs::create_dir_all(&first_root).expect("create first root");
7579 std::fs::create_dir_all(&second_root).expect("create second root");
7580 let ctx = ctx();
7581 ctx.set_canonical_cache_root(first_root);
7582 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
7583 assert!(ctx.status_bar_counts().is_some());
7584
7585 ctx.set_canonical_cache_root(second_root);
7586
7587 assert!(
7588 ctx.status_bar_counts().is_none(),
7589 "counts from the previous root must not appear in a newly bound root"
7590 );
7591 }
7592
7593 #[test]
7594 fn partial_tier2_does_not_fabricate_zeros() {
7595 let ctx = ctx();
7596 ctx.update_status_bar_tier2(Some(5), None, None, None, true);
7600 assert!(
7601 ctx.status_bar_counts().is_none(),
7602 "bar must not surface until all three Tier-2 categories are real"
7603 );
7604
7605 ctx.update_status_bar_tier2(None, Some(3), None, None, true);
7607 assert!(ctx.status_bar_counts().is_none());
7608
7609 ctx.update_status_bar_tier2(None, None, Some(7), None, false);
7612 let counts = ctx.status_bar_counts().expect("all three real now");
7613 assert_eq!(counts.dead_code, 5);
7614 assert_eq!(counts.unused_exports, 3);
7615 assert_eq!(counts.duplicates, 7);
7616 }
7617
7618 #[test]
7619 fn update_with_none_todos_preserves_last_known_todos() {
7620 let ctx = ctx();
7621 ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
7622 ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
7624 let counts = ctx.status_bar_counts().expect("populated");
7625 assert_eq!(counts.todos, 9);
7626 assert_eq!(counts.dead_code, 2);
7627 }
7628
7629 #[test]
7630 fn update_with_none_count_preserves_last_known_count() {
7631 let ctx = ctx();
7632 ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
7633 ctx.update_status_bar_tier2(Some(11), None, None, None, false);
7636 let counts = ctx.status_bar_counts().expect("populated");
7637 assert_eq!(counts.dead_code, 11);
7638 assert_eq!(counts.unused_exports, 20);
7639 assert_eq!(counts.duplicates, 30);
7640 }
7641
7642 #[test]
7643 fn mark_stale_sets_flag_only_after_populate() {
7644 let ctx = ctx();
7645 ctx.mark_status_bar_tier2_stale();
7647 assert!(ctx.status_bar_counts().is_none());
7648
7649 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
7650 ctx.mark_status_bar_tier2_stale();
7651 assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
7652
7653 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
7655 assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
7656 }
7657
7658 #[test]
7663 fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
7664 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
7665 use crate::lsp::registry::ServerKind;
7666 use crate::lsp::roots::ServerKey;
7667
7668 let ctx = ctx();
7669 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); let file = std::path::PathBuf::from("/proj/gone.ts");
7672 {
7673 let mut lsp = ctx.lsp();
7674 lsp.diagnostics_store_mut_for_test().publish(
7675 ServerKey {
7676 kind: ServerKind::TypeScript,
7677 root: std::path::PathBuf::from("/proj"),
7678 },
7679 file.clone(),
7680 vec![StoredDiagnostic {
7681 file: file.clone(),
7682 line: 1,
7683 column: 1,
7684 end_line: 1,
7685 end_column: 2,
7686 severity: DiagnosticSeverity::Error,
7687 message: "boom".into(),
7688 code: None,
7689 source: None,
7690 }],
7691 );
7692 }
7693
7694 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
7696
7697 let removed = ctx.lsp_clear_diagnostics_for_file(&file);
7699 assert!(removed);
7700 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
7701 }
7702
7703 #[test]
7704 fn status_bar_filtered_counts_ignore_environmental_flap() {
7705 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
7706 use crate::lsp::registry::ServerKind;
7707 use crate::lsp::roots::ServerKey;
7708
7709 let ctx = ctx();
7710 let root = if cfg!(windows) {
7711 std::path::PathBuf::from(r"C:\proj")
7712 } else {
7713 std::path::PathBuf::from("/proj")
7714 };
7715 ctx.set_canonical_cache_root(root.clone());
7716 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
7717
7718 let file = root.join("aft.jsonc");
7719 let key = ServerKey {
7720 kind: ServerKind::TypeScript,
7721 root: root.clone(),
7722 };
7723 let env = StoredDiagnostic {
7724 file: file.clone(),
7725 line: 1,
7726 column: 1,
7727 end_line: 1,
7728 end_column: 2,
7729 severity: DiagnosticSeverity::Error,
7730 message: "Failed to load schema from https://example.com/schema.json".into(),
7731 code: None,
7732 source: Some("json".into()),
7733 };
7734
7735 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
7736
7737 {
7738 let mut lsp = ctx.lsp();
7739 lsp.diagnostics_store_mut_for_test()
7740 .publish(key.clone(), file.clone(), vec![env]);
7741 }
7742 assert_eq!(
7743 ctx.status_bar_counts().expect("populated").errors,
7744 0,
7745 "environmental publish must not change status-bar E"
7746 );
7747
7748 {
7749 let mut lsp = ctx.lsp();
7750 lsp.diagnostics_store_mut_for_test()
7751 .publish(key, file, vec![]);
7752 }
7753 assert_eq!(
7754 ctx.status_bar_counts().expect("populated").errors,
7755 0,
7756 "environmental clear must not change status-bar E"
7757 );
7758 }
7759}
7760
7761#[cfg(test)]
7762mod harness_path_tests {
7763 use super::*;
7764 use crate::harness::Harness;
7765 use crate::parser::TreeSitterProvider;
7766
7767 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
7768 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7769 ctx.update_config(|config| {
7770 config.storage_dir = Some(storage_dir);
7771 });
7772 ctx.set_harness(harness);
7773 ctx
7774 }
7775
7776 #[test]
7777 fn harness_dir_resolves_correctly() {
7778 let storage = PathBuf::from("/tmp/cortexkit/aft");
7779 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
7780
7781 assert_eq!(ctx.harness_dir(), storage.join("pi"));
7782 }
7783
7784 #[test]
7785 fn bash_tasks_dir_uses_hash_session() {
7786 let storage = PathBuf::from("/tmp/cortexkit/aft");
7787 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
7788
7789 assert_eq!(
7790 ctx.bash_tasks_dir("ses_abc"),
7791 storage
7792 .join("opencode")
7793 .join("bash-tasks")
7794 .join(hash_session("ses_abc"))
7795 );
7796 }
7797
7798 #[test]
7799 fn backups_dir_includes_path_hash() {
7800 let storage = PathBuf::from("/tmp/cortexkit/aft");
7801 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
7802
7803 assert_eq!(
7804 ctx.backups_dir("ses_abc", "pathhash"),
7805 storage
7806 .join("pi")
7807 .join("backups")
7808 .join(hash_session("ses_abc"))
7809 .join("pathhash")
7810 );
7811 }
7812
7813 #[test]
7814 fn filters_dir_under_harness() {
7815 let storage = PathBuf::from("/tmp/cortexkit/aft");
7816 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
7817
7818 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
7819 }
7820
7821 #[test]
7822 fn trust_file_is_host_global() {
7823 let storage = PathBuf::from("/tmp/cortexkit/aft");
7824 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
7825
7826 assert_eq!(
7827 ctx.trust_file(),
7828 storage.join("trusted-filter-projects.json")
7829 );
7830 }
7831
7832 #[test]
7833 fn same_session_different_harness_resolve_different_paths() {
7834 let storage = PathBuf::from("/tmp/cortexkit/aft");
7835 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
7836 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
7837
7838 assert_ne!(
7839 opencode.bash_tasks_dir("ses_same"),
7840 pi.bash_tasks_dir("ses_same")
7841 );
7842 }
7843
7844 #[test]
7845 fn callgraph_and_inspect_dirs_are_root_keyed() {
7846 let temp = tempfile::tempdir().expect("tempdir");
7847 let storage = temp.path().join("storage");
7848 let root = temp.path().join("checkout");
7849 std::fs::create_dir_all(&root).expect("create root");
7850 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
7851 ctx.set_canonical_cache_root(root.clone());
7852
7853 assert_eq!(
7854 ctx.callgraph_store_dir(),
7855 storage
7856 .join("callgraph")
7857 .join(crate::search_index::artifact_cache_key(&root))
7858 );
7859 assert_eq!(
7860 ctx.inspect_dir(),
7861 storage
7862 .join("inspect")
7863 .join(crate::path_identity::project_scope_key(&root))
7864 );
7865 assert!(!ctx
7866 .callgraph_store_dir()
7867 .starts_with(storage.join("opencode")));
7868 assert!(!ctx.inspect_dir().starts_with(storage.join("opencode")));
7869 }
7870
7871 #[test]
7872 fn per_domain_capability_allows_inspect_writer_when_callgraph_read_only() {
7873 let storage = PathBuf::from("/tmp/cortexkit/aft");
7874 let ctx = ctx_with_storage_and_harness(storage, Harness::Opencode);
7875 ctx.set_cache_writer_capabilities(false, true);
7876
7877 assert!(ctx.shared_artifacts_read_only());
7878 assert!(!ctx.callgraph_writer());
7879 assert!(ctx.inspect_writer());
7880 }
7881}
7882
7883#[cfg(test)]
7884mod shared_db_tests {
7885 use super::*;
7886 use tempfile::tempdir;
7887
7888 #[test]
7889 fn app_contexts_share_one_database_connection() {
7890 let storage = tempdir().expect("storage tempdir");
7891 let root_one = tempdir().expect("first root tempdir");
7892 let root_two = tempdir().expect("second root tempdir");
7893 let app = App::default_shared();
7894 let ctx_one = AppContext::from_app(
7895 Arc::clone(&app),
7896 Config {
7897 project_root: Some(root_one.path().to_path_buf()),
7898 ..Config::default()
7899 },
7900 );
7901 let ctx_two = AppContext::from_app(
7902 Arc::clone(&app),
7903 Config {
7904 project_root: Some(root_two.path().to_path_buf()),
7905 ..Config::default()
7906 },
7907 );
7908 let path = storage.path().join("aft.db");
7909
7910 let first = app.open_db(&path).expect("open shared database");
7911 let second = app.open_db(&path).expect("reuse shared database");
7912
7913 assert!(Arc::ptr_eq(&first, &second));
7914 assert!(Arc::ptr_eq(
7915 &ctx_one.db().expect("first context database"),
7916 &ctx_two.db().expect("second context database")
7917 ));
7918 }
7919}
7920
7921#[cfg(test)]
7922mod gitignore_tests {
7923 use super::*;
7924 use std::fs;
7925 use std::path::Path;
7926 use tempfile::TempDir;
7927
7928 fn make_ctx_with_root(root: &Path) -> AppContext {
7929 let provider = Box::new(crate::parser::TreeSitterProvider::new());
7930 let config = Config {
7931 project_root: Some(root.to_path_buf()),
7932 ..Config::default()
7933 };
7934 AppContext::new(provider, config)
7935 }
7936
7937 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
7944 let Some(matcher) = ctx.gitignore() else {
7945 return false;
7946 };
7947 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
7948 if !canonical.starts_with(matcher.path()) {
7949 return false;
7950 }
7951 let is_dir = canonical.is_dir();
7952 matcher
7953 .matched_path_or_any_parents(&canonical, is_dir)
7954 .is_ignore()
7955 }
7956
7957 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
7970 let _guard = crate::test_env::process_env_lock();
7971 let tmp = TempDir::new().unwrap();
7972 let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
7973 let prev_home = std::env::var_os("HOME");
7974 let prev_userprofile = std::env::var_os("USERPROFILE");
7975 unsafe {
7978 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
7979 std::env::set_var("HOME", tmp.path());
7980 std::env::set_var("USERPROFILE", tmp.path());
7981 }
7982 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
7983 unsafe {
7984 match prev_xdg {
7985 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
7986 None => std::env::remove_var("XDG_CONFIG_HOME"),
7987 }
7988 match prev_home {
7989 Some(v) => std::env::set_var("HOME", v),
7990 None => std::env::remove_var("HOME"),
7991 }
7992 match prev_userprofile {
7993 Some(v) => std::env::set_var("USERPROFILE", v),
7994 None => std::env::remove_var("USERPROFILE"),
7995 }
7996 }
7997 match result {
7998 Ok(r) => r,
7999 Err(p) => std::panic::resume_unwind(p),
8000 }
8001 }
8002
8003 #[test]
8004 fn rebuild_gitignore_returns_none_without_project_root() {
8005 let provider = Box::new(crate::parser::TreeSitterProvider::new());
8006 let ctx = AppContext::new(provider, Config::default());
8007 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
8008 assert!(ctx.gitignore().is_none());
8009 }
8010
8011 #[test]
8012 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
8013 let tmp = TempDir::new().unwrap();
8014 let ctx = make_ctx_with_root(tmp.path());
8015 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
8016 assert!(ctx.gitignore().is_none());
8017 }
8018
8019 #[test]
8020 fn matcher_filters_files_in_ignored_dist_dir() {
8021 let tmp = TempDir::new().unwrap();
8022 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
8023 fs::create_dir_all(tmp.path().join("dist")).unwrap();
8024 fs::create_dir_all(tmp.path().join("src")).unwrap();
8025 let dist_file = tmp.path().join("dist").join("bundle.js");
8026 let src_file = tmp.path().join("src").join("app.ts");
8027 fs::write(&dist_file, "x").unwrap();
8028 fs::write(&src_file, "y").unwrap();
8029
8030 let ctx = make_ctx_with_root(tmp.path());
8031 ctx.rebuild_gitignore();
8032
8033 assert!(ctx.gitignore().is_some());
8034 assert!(
8035 is_ignored(&ctx, &dist_file),
8036 "dist/bundle.js should be ignored"
8037 );
8038 assert!(
8039 !is_ignored(&ctx, &src_file),
8040 "src/app.ts should NOT be ignored"
8041 );
8042 }
8043
8044 #[test]
8045 fn matcher_handles_node_modules_and_target() {
8046 let tmp = TempDir::new().unwrap();
8047 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
8048 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
8049 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
8050 let nm_file = tmp.path().join("node_modules/foo/index.js");
8051 let target_file = tmp.path().join("target/debug/aft");
8052 fs::write(&nm_file, "x").unwrap();
8053 fs::write(&target_file, "x").unwrap();
8054
8055 let ctx = make_ctx_with_root(tmp.path());
8056 ctx.rebuild_gitignore();
8057
8058 assert!(is_ignored(&ctx, &nm_file));
8059 assert!(is_ignored(&ctx, &target_file));
8060 }
8061
8062 #[test]
8063 fn matcher_honors_negation_pattern() {
8064 let tmp = TempDir::new().unwrap();
8066 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
8067 let random_log = tmp.path().join("random.log");
8068 let important_log = tmp.path().join("important.log");
8069 fs::write(&random_log, "x").unwrap();
8070 fs::write(&important_log, "y").unwrap();
8071
8072 let ctx = make_ctx_with_root(tmp.path());
8073 ctx.rebuild_gitignore();
8074
8075 assert!(is_ignored(&ctx, &random_log));
8076 assert!(
8077 !is_ignored(&ctx, &important_log),
8078 "negation pattern should un-ignore important.log"
8079 );
8080 }
8081
8082 #[test]
8083 fn rebuild_picks_up_gitignore_changes() {
8084 let tmp = TempDir::new().unwrap();
8085 let ignore_path = tmp.path().join(".gitignore");
8086 fs::write(&ignore_path, "foo.txt\n").unwrap();
8087 let foo = tmp.path().join("foo.txt");
8088 let bar = tmp.path().join("bar.txt");
8089 fs::write(&foo, "").unwrap();
8090 fs::write(&bar, "").unwrap();
8091
8092 let ctx = make_ctx_with_root(tmp.path());
8093 ctx.rebuild_gitignore();
8094 assert!(is_ignored(&ctx, &foo));
8095 assert!(!is_ignored(&ctx, &bar));
8096
8097 fs::write(&ignore_path, "bar.txt\n").unwrap();
8099 ctx.rebuild_gitignore();
8100 assert!(!is_ignored(&ctx, &foo));
8101 assert!(is_ignored(&ctx, &bar));
8102 }
8103
8104 #[test]
8105 fn gitignore_loads_info_exclude_when_present() {
8106 let tmp = TempDir::new().unwrap();
8107 let info_dir = tmp.path().join(".git/info");
8108 fs::create_dir_all(&info_dir).unwrap();
8109 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
8110 let secrets = tmp.path().join("secrets.txt");
8111 let public = tmp.path().join("public.txt");
8112 fs::write(&secrets, "token").unwrap();
8113 fs::write(&public, "ok").unwrap();
8114
8115 let ctx = make_ctx_with_root(tmp.path());
8116 ctx.rebuild_gitignore();
8117
8118 assert!(is_ignored(&ctx, &secrets));
8119 assert!(!is_ignored(&ctx, &public));
8120 }
8121
8122 #[test]
8123 fn matcher_picks_up_nested_gitignore() {
8124 let tmp = TempDir::new().unwrap();
8125 fs::write(tmp.path().join(".gitignore"), "").unwrap();
8127 let sub = tmp.path().join("packages/foo");
8128 fs::create_dir_all(&sub).unwrap();
8129 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
8130 let generated_file = sub.join("generated").join("out.js");
8131 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
8132 fs::write(&generated_file, "x").unwrap();
8133
8134 let ctx = make_ctx_with_root(tmp.path());
8135 ctx.rebuild_gitignore();
8136
8137 assert!(
8138 is_ignored(&ctx, &generated_file),
8139 "nested gitignore in packages/foo/.gitignore should ignore generated/"
8140 );
8141 }
8142}
8143
8144#[cfg(test)]
8145mod verify_memo_watcher_tests {
8146 use super::*;
8147
8148 #[test]
8149 fn pending_watcher_path_invalidates_root_verify_memo() {
8150 let root_dir = tempfile::tempdir().unwrap();
8151 let root = std::fs::canonicalize(root_dir.path()).unwrap();
8152 let artifact = root.join("cache.bin");
8153 std::fs::write(&artifact, b"generation").unwrap();
8154 let generation = crate::cache_freshness::artifact_generation(&artifact).unwrap();
8155 crate::cache_freshness::record_verify_completed(
8156 &root,
8157 crate::cache_freshness::VerifyArtifact::Search,
8158 Some(generation),
8159 );
8160 assert_eq!(
8161 crate::cache_freshness::warm_verify_plan(
8162 &root,
8163 crate::cache_freshness::VerifyArtifact::Search,
8164 Some(generation),
8165 ),
8166 crate::cache_freshness::WarmVerifyPlan::Skip
8167 );
8168
8169 let ctx = AppContext::from_app(
8170 App::default_shared(),
8171 Config {
8172 project_root: Some(root.clone()),
8173 ..Config::default()
8174 },
8175 );
8176 ctx.set_canonical_cache_root(root.clone());
8177 ctx.add_pending_search_index_paths([root.join("changed.rs")]);
8178 assert_eq!(
8179 crate::cache_freshness::warm_verify_plan(
8180 &root,
8181 crate::cache_freshness::VerifyArtifact::Search,
8182 Some(generation),
8183 ),
8184 crate::cache_freshness::WarmVerifyPlan::StatFirst
8185 );
8186 }
8187}
8188
8189#[cfg(test)]
8190mod watcher_runtime_state_tests {
8191 use super::*;
8192 use crate::language::StubProvider;
8193
8194 fn test_context() -> AppContext {
8195 AppContext::new(Box::new(StubProvider), Config::default())
8196 }
8197
8198 #[test]
8199 fn finished_watcher_thread_reports_inactive_and_is_reclaimed_with_invalidation() {
8200 let root = tempfile::tempdir().expect("project tempdir");
8201 let canonical_root = std::fs::canonicalize(root.path()).expect("canonical root");
8202 let ctx = AppContext::new(
8203 Box::new(StubProvider),
8204 Config {
8205 project_root: Some(canonical_root.clone()),
8206 ..Config::default()
8207 },
8208 );
8209 ctx.set_canonical_cache_root(canonical_root.clone());
8210 struct DisableWatcherGuard;
8214 impl Drop for DisableWatcherGuard {
8215 fn drop(&mut self) {
8216 unsafe { std::env::remove_var("AFT_TEST_DISABLE_FILE_WATCHER") };
8217 }
8218 }
8219 let _env_lock = crate::test_env::process_env_lock();
8220 unsafe { std::env::set_var("AFT_TEST_DISABLE_FILE_WATCHER", "1") };
8221 let _disable_watcher = DisableWatcherGuard;
8222 *ctx.search_index
8225 .write()
8226 .unwrap_or_else(std::sync::PoisonError::into_inner) =
8227 Some(crate::search_index::SearchIndex::new());
8228 let artifact = canonical_root.join("artifact.bin");
8229 std::fs::write(&artifact, b"artifact").expect("artifact");
8230 let generation = crate::cache_freshness::artifact_generation(&artifact);
8231 crate::cache_freshness::record_verify_completed(
8232 &canonical_root,
8233 crate::cache_freshness::VerifyArtifact::Search,
8234 generation,
8235 );
8236
8237 let (dispatch_tx, dispatch_rx) = crate::watcher_filter::watcher_dispatch_channel();
8238 let _dispatch_tx = dispatch_tx;
8239 let join = std::thread::spawn(|| {});
8242 ctx.install_watcher_runtime(
8243 dispatch_rx,
8244 WatcherThreadHandle::new(Arc::new(AtomicBool::new(false)), join),
8245 );
8246 let deadline = std::time::Instant::now() + Duration::from_secs(2);
8247 while ctx.watcher_runtime_active() {
8248 assert!(
8249 std::time::Instant::now() < deadline,
8250 "a finished watcher thread must report the runtime inactive"
8251 );
8252 std::thread::yield_now();
8253 }
8254
8255 crate::commands::configure::ensure_project_watcher(&ctx);
8258
8259 assert!(
8260 ctx.search_index
8261 .read()
8262 .unwrap_or_else(std::sync::PoisonError::into_inner)
8263 .is_none(),
8264 "corpse reclaim must drop resident artifacts (events since the failure are lost)"
8265 );
8266 assert_eq!(
8267 crate::cache_freshness::warm_verify_plan(
8268 &canonical_root,
8269 crate::cache_freshness::VerifyArtifact::Search,
8270 generation,
8271 ),
8272 crate::cache_freshness::WarmVerifyPlan::Strict,
8273 "corpse reclaim must force strict re-verification"
8274 );
8275 assert!(
8276 !ctx.take_finished_watcher_runtime(),
8277 "reclaim is one-shot; the corpse is gone after ensure_project_watcher"
8278 );
8279 }
8280
8281 #[test]
8282 fn watcher_runtime_requires_both_thread_and_dispatch_receiver() {
8283 let ctx = test_context();
8284 let (dispatch_tx, dispatch_rx) = crate::watcher_filter::watcher_dispatch_channel();
8285 let shutdown = Arc::new(AtomicBool::new(false));
8286 let thread_shutdown = Arc::clone(&shutdown);
8287 let join = std::thread::spawn(move || {
8288 while !thread_shutdown.load(Ordering::SeqCst) {
8289 std::thread::sleep(Duration::from_millis(1));
8290 }
8291 drop(dispatch_tx);
8292 });
8293 ctx.install_watcher_runtime(
8294 dispatch_rx,
8295 WatcherThreadHandle::new(Arc::clone(&shutdown), join),
8296 );
8297 assert!(ctx.watcher_runtime_active());
8298
8299 *ctx.watcher_rx.lock() = None;
8300 assert!(
8301 !ctx.watcher_runtime_active(),
8302 "a thread without its dispatch receiver is not a usable watcher runtime"
8303 );
8304 ctx.stop_watcher_runtime();
8305 }
8306}
8307
8308#[cfg(test)]
8309mod semantic_probe_tests {
8310 use super::*;
8311
8312 #[test]
8313 fn cleared_semantic_worker_invalidates_orphaned_probe_timer() {
8314 let root = tempfile::tempdir().unwrap();
8315 let ctx = AppContext::new(
8316 default_language_provider_factory(),
8317 Config {
8318 project_root: Some(root.path().to_path_buf()),
8319 ..Config::default()
8320 },
8321 );
8322 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
8323 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
8324 let worker_slot = Arc::new(Mutex::new(None));
8325 ctx.install_semantic_refresh_worker_for_build_epoch(
8326 request_tx,
8327 event_rx,
8328 worker_slot,
8329 ctx.semantic_index_rx_epoch(),
8330 );
8331
8332 ctx.ensure_semantic_refresh_probe_scheduled(Duration::from_millis(20));
8333 assert!(ctx.semantic_refresh_probe_is_scheduled());
8334 ctx.clear_semantic_refresh_worker();
8335 std::thread::sleep(Duration::from_millis(50));
8336
8337 assert!(!ctx.semantic_refresh_probe_ready());
8338 assert!(!ctx.semantic_refresh_probe_is_scheduled());
8339 assert!(!ctx.completion_drains_have_work());
8340 }
8341}