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