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