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