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