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: Arc<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: Arc::new(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 previous_authoritative = self
1880 .status_bar_cached
1881 .read()
1882 .unwrap_or_else(std::sync::PoisonError::into_inner)
1883 .counts
1884 .as_ref()
1885 .map(|counts| (counts.errors, counts.warnings));
1886 let counts = match (tier2.dead_code, tier2.unused_exports, tier2.duplicates) {
1887 (Some(dead_code), Some(unused_exports), Some(duplicates)) => {
1888 let ((current_errors, current_warnings), provisional) =
1889 match self.canonical_cache_root_opt() {
1890 Some(root) => {
1891 let root = crate::inspect::job::normalize_path(&root);
1896 let mut membership = self.tsconfig_membership.lock();
1897 lsp.filtered_error_warning_counts_with_provisional(|file| {
1898 file.starts_with(&root) && !membership.should_skip_diagnostics(file)
1899 })
1900 }
1901 None => lsp.warm_error_warning_counts_with_provisional(),
1902 };
1903 let (errors, warnings) = if provisional {
1907 previous_authoritative.unwrap_or((current_errors, current_warnings))
1908 } else {
1909 (current_errors, current_warnings)
1910 };
1911 Some(StatusBarCounts {
1912 errors,
1913 warnings,
1914 dead_code,
1915 unused_exports,
1916 duplicates,
1917 todos: tier2.todos.unwrap_or(0),
1918 tier2_stale: tier2.stale,
1919 })
1920 }
1921 _ => None,
1922 };
1923
1924 *self
1925 .status_bar_cached
1926 .write()
1927 .unwrap_or_else(std::sync::PoisonError::into_inner) = StatusBarCache {
1928 valid: true,
1929 diagnostics_generation,
1930 tier2_generation: tier2.generation,
1931 tsconfig_generation,
1932 counts: counts.clone(),
1933 };
1934 counts
1935 }
1936
1937 pub fn try_health_snapshot(&self, project_root: &Path) -> RootHealthSnapshot {
1938 let heavy_root_work_allowed = match self.try_heavy_root_work_allowed() {
1942 Some(allowed) => allowed,
1943 None => return RootHealthSnapshot::busy(project_root),
1944 };
1945 let config = match self.config.try_read() {
1946 Ok(guard) => Arc::clone(&*guard),
1947 Err(_) => return RootHealthSnapshot::busy(project_root),
1948 };
1949 let search_index = match self.search_index.try_read() {
1950 Ok(guard) => guard,
1951 Err(_) => return RootHealthSnapshot::busy(project_root),
1952 };
1953 let search_index_rx = match self.search_index_rx.try_read() {
1954 Ok(guard) => guard,
1955 Err(_) => return RootHealthSnapshot::busy(project_root),
1956 };
1957 let semantic_status = match self.semantic_index_status.try_read() {
1958 Ok(guard) => guard,
1959 Err(_) => return RootHealthSnapshot::busy(project_root),
1960 };
1961 let callgraph_store = match self.callgraph_store.try_read() {
1962 Ok(guard) => guard,
1963 Err(_) => return RootHealthSnapshot::busy(project_root),
1964 };
1965 let callgraph_store_rx = match self.callgraph_store_rx.try_lock() {
1966 Some(guard) => guard,
1967 None => return RootHealthSnapshot::busy(project_root),
1968 };
1969 let tier2 = match self.status_bar_tier2.try_read() {
1970 Ok(guard) => guard,
1971 Err(_) => return RootHealthSnapshot::busy(project_root),
1972 };
1973 let bash = match self.bash_background.try_health_counts() {
1974 Some(counts) => counts,
1975 None => return RootHealthSnapshot::busy(project_root),
1976 };
1977
1978 let borrows_shared_artifacts = self.shared_artifacts_read_only.load(Ordering::SeqCst);
1984 let search_index_status = if search_index
1985 .as_ref()
1986 .is_some_and(|index| index.ready || index.build_denied)
1987 || (borrows_shared_artifacts && config.search_index)
1988 {
1989 "ready"
1990 } else if config.search_index
1991 || search_index.as_ref().is_some()
1992 || search_index_rx.as_ref().is_some()
1993 {
1994 "building"
1995 } else {
1996 "disabled"
1997 };
1998 let semantic_index_status = match &*semantic_status {
1999 SemanticIndexStatus::Ready { .. } => "ready",
2000 SemanticIndexStatus::Building { .. } => "building",
2001 SemanticIndexStatus::Disabled => "disabled",
2002 SemanticIndexStatus::Failed(_) => "degraded",
2003 };
2004 let callgraph_writer = self.callgraph_writer.load(Ordering::SeqCst);
2005 let callgraph_store_status = if !heavy_root_work_allowed {
2006 "disabled"
2007 } else if callgraph_store.as_ref().is_some() {
2008 "ready"
2009 } else if !callgraph_writer && config.callgraph_store {
2010 "ready"
2014 } else if callgraph_store_rx.is_some() || config.callgraph_store {
2015 "building"
2016 } else {
2017 "disabled"
2018 };
2019 let dead_code_blocked_on_callgraph = tier2.dead_code_blocked_on_callgraph;
2027 let tier2_complete = (tier2.dead_code.is_some() || dead_code_blocked_on_callgraph)
2028 && tier2.unused_exports.is_some()
2029 && tier2.duplicates.is_some()
2030 && !tier2.stale;
2031 let tier2_has_aggregates = tier2.dead_code.is_some()
2032 || tier2.unused_exports.is_some()
2033 || tier2.duplicates.is_some();
2034 let tier2_refresh_gated = borrows_shared_artifacts
2035 || !heavy_root_work_allowed
2036 || !self.inspect_writer.load(Ordering::SeqCst)
2037 || !self.inspect_manager.automatic_tier2_refresh_enabled();
2038 let tier2_status = if tier2_complete {
2039 "ready"
2040 } else if !config.inspect.enabled || !tier2_has_aggregates || tier2_refresh_gated {
2041 "disabled"
2044 } else {
2045 "building"
2046 };
2047
2048 RootHealthSnapshot {
2049 project_root: project_root.display().to_string(),
2050 actor_count: 1,
2051 state: RootHealthState::Ready,
2052 search_index: Some(HealthComponentSnapshot {
2053 status: search_index_status,
2054 }),
2055 semantic_index: Some(HealthComponentSnapshot {
2056 status: semantic_index_status,
2057 }),
2058 callgraph_store: Some(HealthComponentSnapshot {
2059 status: callgraph_store_status,
2060 }),
2061 tier2: Some(Tier2HealthSnapshot {
2062 status: tier2_status,
2063 }),
2064 bash: Some(bash),
2065 }
2066 }
2067
2068 pub fn should_emit_status_bar(&self, counts: &StatusBarCounts) -> bool {
2069 let mut last = self
2070 .status_bar_last_emitted
2071 .write()
2072 .unwrap_or_else(std::sync::PoisonError::into_inner);
2073 if last.as_ref() == Some(counts) {
2074 return false;
2075 }
2076 *last = Some(counts.clone());
2077 true
2078 }
2079
2080 pub fn clear_tsconfig_membership_cache(&self) {
2084 self.tsconfig_membership.lock().clear();
2085 }
2086
2087 #[cfg(test)]
2088 pub fn tsconfig_membership_clear_generation_for_test(&self) -> u64 {
2089 self.tsconfig_membership.lock().generation()
2090 }
2091
2092 pub fn mark_status_bar_tier2_stale(&self) -> bool {
2098 let mut tier2 = self
2099 .status_bar_tier2
2100 .write()
2101 .unwrap_or_else(std::sync::PoisonError::into_inner);
2102 if tier2.dead_code.is_some() && tier2.unused_exports.is_some() && tier2.duplicates.is_some()
2104 {
2105 let changed = !tier2.stale;
2106 tier2.stale = true;
2107 if changed {
2108 tier2.generation = tier2.generation.wrapping_add(1);
2109 }
2110 return changed;
2111 }
2112 false
2113 }
2114
2115 pub fn update_status_bar_tier2(
2121 &self,
2122 dead_code: Option<usize>,
2123 unused_exports: Option<usize>,
2124 duplicates: Option<usize>,
2125 todos: Option<usize>,
2126 stale: bool,
2127 ) {
2128 let mut tier2 = self
2129 .status_bar_tier2
2130 .write()
2131 .unwrap_or_else(std::sync::PoisonError::into_inner);
2132 let previous = (
2133 tier2.dead_code,
2134 tier2.unused_exports,
2135 tier2.duplicates,
2136 tier2.todos,
2137 tier2.stale,
2138 );
2139 if let Some(dead_code) = dead_code {
2140 tier2.dead_code = Some(dead_code);
2141 }
2142 if let Some(unused_exports) = unused_exports {
2143 tier2.unused_exports = Some(unused_exports);
2144 }
2145 if let Some(duplicates) = duplicates {
2146 tier2.duplicates = Some(duplicates);
2147 }
2148 if let Some(todos) = todos {
2149 tier2.todos = Some(todos);
2150 }
2151 tier2.stale = stale;
2152 let current = (
2153 tier2.dead_code,
2154 tier2.unused_exports,
2155 tier2.duplicates,
2156 tier2.todos,
2157 tier2.stale,
2158 );
2159 if current != previous {
2160 tier2.generation = tier2.generation.wrapping_add(1);
2161 }
2162 }
2163
2164 pub(crate) fn set_status_bar_tier2_dead_code_blocked_on_callgraph(&self, blocked: bool) {
2170 let mut tier2 = self
2171 .status_bar_tier2
2172 .write()
2173 .unwrap_or_else(std::sync::PoisonError::into_inner);
2174 tier2.dead_code_blocked_on_callgraph = blocked;
2175 }
2176
2177 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
2180 self.gitignore
2181 .read()
2182 .unwrap_or_else(|poisoned| poisoned.into_inner())
2183 .clone()
2184 }
2185
2186 pub fn shared_gitignore(&self) -> SharedGitignore {
2188 Arc::clone(&self.gitignore)
2189 }
2190
2191 pub fn gitignore_generation(&self) -> Arc<AtomicU64> {
2195 Arc::clone(&self.gitignore_generation)
2196 }
2197
2198 fn set_gitignore(&self, matcher: Option<Arc<ignore::gitignore::Gitignore>>) {
2199 *self
2200 .gitignore
2201 .write()
2202 .unwrap_or_else(|poisoned| poisoned.into_inner()) = matcher;
2203 self.gitignore_generation.fetch_add(1, Ordering::SeqCst);
2204 }
2205
2206 pub fn clear_gitignore(&self) {
2228 self.set_gitignore(None);
2229 }
2230
2231 pub fn rebuild_gitignore(&self) {
2232 use ignore::gitignore::GitignoreBuilder;
2233 use std::path::Path;
2234 let root_raw = match self.config().project_root.clone() {
2235 Some(r) => r,
2236 None => {
2237 self.set_gitignore(None);
2238 return;
2239 }
2240 };
2241 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
2249 let mut builder = GitignoreBuilder::new(&root);
2250 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
2255 if global_ignore.is_file() {
2256 if let Some(err) = builder.add(&global_ignore) {
2257 crate::slog_warn!(
2258 "global gitignore parse error in {}: {}",
2259 global_ignore.display(),
2260 err
2261 );
2262 }
2263 }
2264 }
2265 let root_ignore = Path::new(&root).join(".gitignore");
2267 if root_ignore.exists() {
2268 if let Some(err) = builder.add(&root_ignore) {
2269 crate::slog_warn!(
2270 "gitignore parse error in {}: {}",
2271 root_ignore.display(),
2272 err
2273 );
2274 }
2275 }
2276 let root_aftignore = Path::new(&root).join(".aftignore");
2281 if root_aftignore.exists() {
2282 if let Some(err) = builder.add(&root_aftignore) {
2283 crate::slog_warn!(
2284 "aftignore parse error in {}: {}",
2285 root_aftignore.display(),
2286 err
2287 );
2288 }
2289 }
2290 let info_exclude = self
2295 .git_common_dir
2296 .lock()
2297 .clone()
2298 .unwrap_or_else(|| Path::new(&root).join(".git"))
2299 .join("info")
2300 .join("exclude");
2301 if info_exclude.exists() {
2302 if let Some(err) = builder.add(&info_exclude) {
2303 crate::slog_warn!(
2304 "gitignore parse error in {}: {}",
2305 info_exclude.display(),
2306 err
2307 );
2308 }
2309 }
2310 let walker = ignore::WalkBuilder::new(&root)
2316 .standard_filters(true)
2317 .hidden(false)
2325 .filter_entry(|entry| {
2326 let name = entry.file_name().to_string_lossy();
2327 !matches!(
2328 name.as_ref(),
2329 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
2330 )
2331 })
2332 .build();
2333 for entry in walker.flatten() {
2334 let file_name = entry.file_name();
2335 let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
2336 let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
2337 if is_nested_gitignore || is_nested_aftignore {
2338 if let Some(err) = builder.add(entry.path()) {
2339 crate::slog_warn!(
2340 "nested ignore parse error in {}: {}",
2341 entry.path().display(),
2342 err
2343 );
2344 }
2345 }
2346 }
2347 match builder.build() {
2348 Ok(gi) => {
2349 let count = gi.num_ignores();
2350 if count > 0 {
2351 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
2352 self.set_gitignore(Some(Arc::new(gi)));
2353 } else {
2354 self.set_gitignore(None);
2355 }
2356 }
2357 Err(err) => {
2358 crate::slog_warn!("gitignore matcher build failed: {}", err);
2359 self.set_gitignore(None);
2360 }
2361 }
2362 }
2363
2364 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
2367 Arc::clone(&self.bash_compress_flag)
2368 }
2369
2370 pub fn sync_bash_compress_flag(&self) {
2374 let value = self.config().experimental_bash_compress;
2375 self.bash_compress_flag
2376 .store(value, std::sync::atomic::Ordering::Relaxed);
2377 }
2378
2379 pub fn set_bash_compress_enabled(&self, enabled: bool) {
2380 self.update_config(|config| {
2381 config.experimental_bash_compress = enabled;
2382 });
2383 self.bash_compress_flag
2384 .store(enabled, std::sync::atomic::Ordering::Relaxed);
2385 }
2386
2387 pub fn filter_registry(
2391 &self,
2392 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
2393 self.ensure_filter_registry_loaded();
2394 match self.filter_registry.read() {
2395 Ok(g) => g,
2396 Err(poisoned) => poisoned.into_inner(),
2397 }
2398 }
2399
2400 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
2404 self.ensure_filter_registry_loaded();
2405 Arc::clone(&self.filter_registry)
2406 }
2407
2408 pub fn reset_filter_registry(&self) {
2412 let new_registry = crate::compress::build_registry_for_context(self);
2413 self.filter_registry_rebuild_count
2414 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2415 match self.filter_registry.write() {
2416 Ok(mut slot) => *slot = new_registry,
2417 Err(poisoned) => *poisoned.into_inner() = new_registry,
2418 }
2419 self.filter_registry_loaded
2420 .store(true, std::sync::atomic::Ordering::Release);
2421 }
2422
2423 fn ensure_filter_registry_loaded(&self) {
2424 use std::sync::atomic::Ordering;
2425 if self.filter_registry_loaded.load(Ordering::Acquire) {
2426 return;
2427 }
2428 let new_registry = crate::compress::build_registry_for_context(self);
2431 self.filter_registry_rebuild_count
2432 .fetch_add(1, Ordering::SeqCst);
2433 if let Ok(mut slot) = self.filter_registry.write() {
2434 *slot = new_registry;
2435 self.filter_registry_loaded.store(true, Ordering::Release);
2436 }
2437 }
2438
2439 #[cfg(test)]
2440 pub fn filter_registry_rebuild_count_for_test(&self) -> u64 {
2441 self.filter_registry_rebuild_count.load(Ordering::SeqCst)
2442 }
2443
2444 pub fn app(&self) -> Arc<App> {
2445 Arc::clone(&self.app)
2446 }
2447
2448 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
2451 self.app.lsp_child_registry()
2452 }
2453
2454 pub fn stdout_writer(&self) -> SharedStdoutWriter {
2455 self.app.stdout_writer()
2456 }
2457
2458 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
2459 if let Ok(mut progress_sender) = self.progress_sender.lock() {
2460 *progress_sender = sender;
2461 }
2462 }
2463
2464 pub fn emit_progress(&self, frame: ProgressFrame) {
2465 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
2466 return;
2467 };
2468 if let Some(sender) = progress_sender.as_ref() {
2469 sender(PushFrame::Progress(frame));
2470 }
2471 }
2472
2473 pub fn status_emitter(&self) -> &StatusEmitter {
2474 &self.status_emitter
2475 }
2476
2477 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
2485 self.progress_sender
2486 .lock()
2487 .ok()
2488 .and_then(|sender| sender.clone())
2489 }
2490
2491 pub fn advance_configure_generation(&self) -> u64 {
2492 self.subc_lifecycle
2493 .advance_generation(self.configure_generation.as_ref())
2494 }
2495
2496 pub(crate) fn mark_subc_bound(&self) {
2497 self.subc_lifecycle.mark_bound();
2498 }
2499
2500 pub(crate) fn mark_subc_unbound(&self) {
2501 self.subc_lifecycle
2502 .mark_unbound(self.configure_generation.as_ref());
2503 }
2504
2505 #[doc(hidden)]
2506 pub fn subc_unbound_quiesced(&self) -> bool {
2507 self.subc_lifecycle.is_unbound()
2508 }
2509
2510 pub(crate) fn subc_lifecycle_admission(&self) -> SubcLifecycleAdmission {
2511 self.subc_lifecycle.clone()
2512 }
2513
2514 pub(crate) fn run_if_subc_bound_generation<R>(
2515 &self,
2516 expected_generation: u64,
2517 action: impl FnOnce() -> R,
2518 ) -> Option<R> {
2519 self.subc_lifecycle.run_if_current(
2520 self.configure_generation.as_ref(),
2521 expected_generation,
2522 action,
2523 )
2524 }
2525
2526 pub fn note_configure_warm_key(&self, key: String) -> (u64, bool) {
2537 let mut state = self.configure_warm_state.lock();
2538 let equivalent = state.key.as_ref().is_some_and(|previous| *previous == key);
2539 let generation = if equivalent {
2540 self.configure_generation()
2541 } else {
2542 self.configure_content_generation
2543 .fetch_add(1, Ordering::SeqCst);
2544 self.advance_configure_generation()
2545 };
2546 state.generation = generation;
2547 state.key = Some(key);
2548 (generation, equivalent)
2549 }
2550
2551 pub(crate) fn configure_warm_key_matches(&self, key: &str) -> bool {
2552 self.configure_warm_state
2553 .lock()
2554 .key
2555 .as_deref()
2556 .is_some_and(|current| current == key)
2557 }
2558
2559 pub(crate) fn invalidate_configure_warm_state(&self) {
2560 self.configure_warm_state.lock().key = None;
2561 }
2562
2563 pub fn note_configure_session_binding(&self, root: PathBuf, session_id: String) -> bool {
2564 self.configured_session_roots
2565 .lock()
2566 .insert((root, session_id))
2567 }
2568
2569 pub fn forget_configure_session_binding(&self, root: &Path, session_id: &str) {
2573 self.configured_session_roots
2574 .lock()
2575 .remove(&(root.to_path_buf(), session_id.to_string()));
2576 }
2577
2578 pub fn watcher_drain_has_work(&self) -> bool {
2584 let receiver_pending = self
2585 .watcher_rx
2586 .lock()
2587 .as_ref()
2588 .is_some_and(|rx| !rx.is_empty());
2589 receiver_pending
2590 || self
2591 .watcher_drain_slice
2592 .lock()
2593 .as_ref()
2594 .is_some_and(WatcherDrainSliceState::has_pending_work)
2595 }
2596
2597 pub fn lsp_drain_has_work(&self) -> bool {
2598 match self.lsp_manager.try_lock() {
2599 Some(lsp) => lsp.has_pending_events(),
2600 None => true,
2602 }
2603 }
2604
2605 pub fn completion_drains_have_work(&self) -> bool {
2606 let search_pending = self
2607 .search_index_rx
2608 .try_read()
2609 .map(|slot| {
2610 slot.as_ref().is_some_and(|receiver| {
2611 !receiver.is_empty()
2612 || self.search_index_rx_terminal_epoch.load(Ordering::SeqCst)
2613 == self.search_index_rx_epoch()
2614 })
2615 })
2616 .unwrap_or(true);
2617 if search_pending {
2618 return true;
2619 }
2620 if self
2621 .callgraph_store_rx
2622 .lock()
2623 .as_ref()
2624 .is_some_and(|rx| !rx.is_empty())
2625 {
2626 return true;
2627 }
2628 if self
2629 .semantic_index_rx
2630 .lock()
2631 .as_ref()
2632 .is_some_and(|receiver| {
2633 !receiver.is_empty()
2634 || self.semantic_index_rx_terminal_epoch.load(Ordering::SeqCst)
2635 == self.semantic_index_rx_epoch()
2636 })
2637 {
2638 return true;
2639 }
2640 if self
2641 .semantic_refresh_event_rx
2642 .lock()
2643 .as_ref()
2644 .is_some_and(|rx| !rx.is_empty())
2645 {
2646 return true;
2647 }
2648 if self.semantic_refresh_probe_ready() && self.semantic_refresh_event_rx.lock().is_some() {
2649 return true;
2650 }
2651 if self
2652 .semantic_refresh_worker
2653 .lock()
2654 .as_ref()
2655 .is_some_and(|worker_slot| match worker_slot.try_lock() {
2656 Ok(handle) => handle
2657 .as_ref()
2658 .is_some_and(std::thread::JoinHandle::is_finished),
2659 Err(std::sync::TryLockError::WouldBlock) => true,
2660 Err(std::sync::TryLockError::Poisoned(_)) => true,
2661 })
2662 {
2663 return true;
2664 }
2665 self.inspect_manager().has_pending_completions() || self.has_new_reuse_completions()
2666 }
2667
2668 pub fn configure_tail_has_work(&self) -> bool {
2669 !self.configure_maintenance_jobs.lock().is_empty() || !self.configure_warnings_rx.is_empty()
2670 }
2671
2672 pub(crate) fn enqueue_configure_maintenance(&self, job: ConfigureMaintenanceJob) {
2673 self.configure_maintenance_jobs.lock().push_back(job);
2674 }
2675
2676 pub(crate) fn drain_configure_maintenance(&self) -> Vec<ConfigureMaintenanceJob> {
2677 self.configure_maintenance_jobs.lock().drain(..).collect()
2678 }
2679
2680 #[cfg(test)]
2681 pub(crate) fn configure_maintenance_job_count_for_test(&self) -> usize {
2682 self.configure_maintenance_jobs.lock().len()
2683 }
2684
2685 pub fn cached_artifact_cache_key(&self, canonical_root: &Path) -> Option<String> {
2688 self.artifact_cache_keys.lock().get(canonical_root).cloned()
2689 }
2690
2691 pub(crate) fn cached_worktree_bridge(
2694 &self,
2695 canonical_root: &Path,
2696 ) -> Option<(bool, Option<PathBuf>)> {
2697 #[cfg(test)]
2698 if self.force_worktree_bridge_reprobe.load(Ordering::SeqCst) {
2699 return None;
2700 }
2701
2702 let signature = git_entry_signature(canonical_root);
2703 self.worktree_bridge_cache
2704 .lock()
2705 .get(canonical_root)
2706 .filter(|entry| entry.git_entry == signature)
2707 .map(|entry| (entry.is_worktree_bridge, entry.git_common_dir.clone()))
2708 }
2709
2710 pub(crate) fn cache_worktree_bridge(
2713 &self,
2714 canonical_root: &Path,
2715 is_worktree_bridge: bool,
2716 git_common_dir: PathBuf,
2717 ) {
2718 self.worktree_bridge_cache.lock().insert(
2719 canonical_root.to_path_buf(),
2720 WorktreeBridgeCacheEntry {
2721 git_entry: git_entry_signature(canonical_root),
2722 is_worktree_bridge,
2723 git_common_dir: Some(git_common_dir),
2724 },
2725 );
2726 }
2727
2728 #[cfg(test)]
2729 pub(crate) fn record_worktree_bridge_probe_spawn_for_test(&self) {
2730 self.worktree_bridge_probe_spawns
2731 .fetch_add(1, Ordering::SeqCst);
2732 }
2733
2734 #[cfg(test)]
2735 pub(crate) fn worktree_bridge_probe_spawns_for_test(&self) -> u64 {
2736 self.worktree_bridge_probe_spawns.load(Ordering::SeqCst)
2737 }
2738
2739 #[cfg(test)]
2740 pub(crate) fn force_worktree_bridge_reprobe_for_test(&self, enabled: bool) {
2741 self.force_worktree_bridge_reprobe
2742 .store(enabled, Ordering::SeqCst);
2743 }
2744
2745 pub fn memoized_artifact_cache_key(&self, canonical_root: &Path) -> String {
2746 let mut keys = self.artifact_cache_keys.lock();
2747 if let Some(key) = keys.get(canonical_root).cloned() {
2748 return key;
2749 }
2750 let key = crate::search_index::artifact_cache_key(canonical_root);
2751 self.artifact_cache_key_derivations
2752 .fetch_add(1, Ordering::SeqCst);
2753 keys.insert(canonical_root.to_path_buf(), key.clone());
2754 key
2755 }
2756
2757 pub fn memoized_artifact_cache_key_for_configure(
2758 &self,
2759 raw_root: &Path,
2760 canonical_root: &Path,
2761 storage_root: &Path,
2762 git_common_dir: Option<&Path>,
2763 ) -> Result<String, crate::search_index::ArtifactCacheKeyProbeError> {
2764 {
2765 let keys = self.artifact_cache_keys.lock();
2766 if let Some(key) = keys
2767 .get(canonical_root)
2768 .or_else(|| keys.get(raw_root))
2769 .cloned()
2770 {
2771 return Ok(key);
2772 }
2773 }
2774
2775 let key = crate::search_index::artifact_cache_key_with_memo(
2776 canonical_root,
2777 raw_root,
2778 storage_root,
2779 git_common_dir,
2780 )?;
2781 self.artifact_cache_key_derivations
2782 .fetch_add(1, Ordering::SeqCst);
2783 let mut keys = self.artifact_cache_keys.lock();
2784 keys.insert(canonical_root.to_path_buf(), key.clone());
2785 keys.insert(raw_root.to_path_buf(), key.clone());
2786 Ok(key)
2787 }
2788
2789 #[cfg(test)]
2790 pub fn artifact_cache_key_derivation_count_for_test(&self) -> u64 {
2791 self.artifact_cache_key_derivations.load(Ordering::SeqCst)
2792 }
2793
2794 pub(crate) fn resolve_external_git_root(
2795 &self,
2796 project_root: &Path,
2797 requested_path: &str,
2798 ) -> Result<PathBuf, crate::readonly_artifacts::GitRootResolutionError> {
2799 let raw_path = Path::new(requested_path);
2800 let canonical_requested = if raw_path.is_absolute() {
2801 std::fs::canonicalize(raw_path).ok()
2802 } else {
2803 None
2804 };
2805 if let Some(root) = canonical_requested
2806 .as_deref()
2807 .and_then(|root| self.borrowed_index_cache.lock().resolved_root(root))
2808 {
2809 return Ok(root);
2810 }
2811
2812 let root = crate::readonly_artifacts::resolve_git_root_from_user_path(
2813 project_root,
2814 requested_path,
2815 )?;
2816 if canonical_requested.as_deref() == Some(root.as_path()) {
2817 self.borrowed_index_cache
2818 .lock()
2819 .remember_resolved_root(root.clone());
2820 }
2821 Ok(root)
2822 }
2823
2824 pub(crate) fn open_borrowed_search_index(
2825 &self,
2826 external_root: &Path,
2827 storage_dir: Option<&Path>,
2828 ) -> crate::readonly_artifacts::ReadOnlyArtifact<Arc<SearchIndex>> {
2829 let canonical_root =
2830 std::fs::canonicalize(external_root).unwrap_or_else(|_| external_root.to_path_buf());
2831 let project_key = self.memoized_artifact_cache_key(&canonical_root);
2832 let Some(artifact) = crate::readonly_artifacts::search_index_artifact_generation_with_key(
2833 &project_key,
2834 storage_dir,
2835 ) else {
2836 return crate::readonly_artifacts::ReadOnlyArtifact::Absent;
2837 };
2838 let key = BorrowedIndexCacheKey {
2839 canonical_root: canonical_root.clone(),
2840 artifact,
2841 };
2842 let mut cache = self.borrowed_index_cache.lock();
2843 if let Some(index) = cache.search(&key) {
2844 return index;
2845 }
2846
2847 let opened = crate::readonly_artifacts::open_search_index_read_only_with_key(
2848 &canonical_root,
2849 storage_dir,
2850 &project_key,
2851 )
2852 .map(Arc::new);
2853 if !matches!(opened, crate::readonly_artifacts::ReadOnlyArtifact::Absent) {
2854 cache.insert(key, BorrowedIndexCacheValue::Search(opened.clone()));
2855 }
2856 opened
2857 }
2858
2859 pub(crate) fn open_borrowed_semantic_index(
2860 &self,
2861 external_root: &Path,
2862 storage_dir: Option<&Path>,
2863 ) -> crate::readonly_artifacts::ReadOnlyArtifact<Arc<SemanticIndex>> {
2864 let canonical_root =
2865 std::fs::canonicalize(external_root).unwrap_or_else(|_| external_root.to_path_buf());
2866 let project_key = self.memoized_artifact_cache_key(&canonical_root);
2867 let Some(artifact) = crate::readonly_artifacts::semantic_index_artifact_generation_with_key(
2868 &project_key,
2869 storage_dir,
2870 ) else {
2871 return crate::readonly_artifacts::ReadOnlyArtifact::Absent;
2872 };
2873 let key = BorrowedIndexCacheKey {
2874 canonical_root: canonical_root.clone(),
2875 artifact,
2876 };
2877 let mut cache = self.borrowed_index_cache.lock();
2878 if let Some(index) = cache.semantic(&key) {
2879 return index;
2880 }
2881
2882 let opened = crate::readonly_artifacts::open_semantic_index_read_only_with_key(
2883 &canonical_root,
2884 storage_dir,
2885 &project_key,
2886 )
2887 .map(Arc::new);
2888 if !matches!(opened, crate::readonly_artifacts::ReadOnlyArtifact::Absent) {
2889 cache.insert(key, BorrowedIndexCacheValue::Semantic(opened.clone()));
2890 }
2891 opened
2892 }
2893
2894 #[cfg(test)]
2895 pub(crate) fn borrowed_index_cache_len_for_test(&self) -> usize {
2896 self.borrowed_index_cache.lock().entries.len()
2897 }
2898
2899 pub fn configure_generation(&self) -> u64 {
2900 self.configure_generation.load(Ordering::SeqCst)
2901 }
2902
2903 pub fn configure_generation_flag(&self) -> Arc<AtomicU64> {
2904 Arc::clone(&self.configure_generation)
2905 }
2906
2907 pub(crate) fn configure_content_generation(&self) -> u64 {
2908 self.configure_content_generation.load(Ordering::SeqCst)
2909 }
2910
2911 pub(crate) fn configure_content_generation_flag(&self) -> Arc<AtomicU64> {
2912 Arc::clone(&self.configure_content_generation)
2913 }
2914
2915 pub(crate) fn begin_configure_ack_phase(&self, phase: &'static str) {
2916 let now = Instant::now();
2917 let mut timing = self.configure_phase_timing.lock();
2918 if phase == "canonicalize" {
2919 timing.completed.clear();
2920 } else if timing.phase != "idle" && timing.phase != "ack_ready" {
2921 let previous = timing.phase;
2922 let elapsed = now.saturating_duration_since(timing.started_at);
2923 timing.completed.push((previous, elapsed));
2924 }
2925 timing.phase = phase;
2926 timing.started_at = now;
2927 }
2928
2929 pub(crate) fn configure_ack_phase_snapshot(&self) -> String {
2930 let timing = self.configure_phase_timing.lock();
2931 let mut parts = timing
2932 .completed
2933 .iter()
2934 .map(|(phase, elapsed)| format!("{phase}={}ms", elapsed.as_millis()))
2935 .collect::<Vec<_>>();
2936 parts.push(format!(
2937 "{}={}ms",
2938 timing.phase,
2939 timing.started_at.elapsed().as_millis()
2940 ));
2941 parts.join(",")
2942 }
2943
2944 pub fn advance_semantic_fingerprint_generation(&self) -> u64 {
2945 self.semantic_fingerprint_generation
2946 .fetch_add(1, Ordering::SeqCst)
2947 .wrapping_add(1)
2948 }
2949
2950 pub fn semantic_fingerprint_generation(&self) -> u64 {
2951 self.semantic_fingerprint_generation.load(Ordering::SeqCst)
2952 }
2953
2954 pub fn semantic_fingerprint_generation_flag(&self) -> Arc<AtomicU64> {
2955 Arc::clone(&self.semantic_fingerprint_generation)
2956 }
2957
2958 pub fn configure_warnings_sender(
2959 &self,
2960 ) -> crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)> {
2961 self.configure_warnings_tx.clone()
2962 }
2963
2964 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
2965 let mut warnings = Vec::new();
2966 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
2967 warnings.push(warning);
2968 }
2969 warnings
2970 }
2971
2972 pub fn bash_background(&self) -> &BgTaskRegistry {
2973 &self.bash_background
2974 }
2975
2976 #[cfg(unix)]
2977 pub(crate) fn escalation_grants(
2978 &self,
2979 ) -> &parking_lot::Mutex<crate::sandbox_spawn::EscalationGrantStore> {
2980 &self.escalation_grants
2981 }
2982
2983 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
2984 self.bash_background.drain_completions()
2985 }
2986
2987 pub fn provider(&self) -> &dyn LanguageProvider {
2989 self.provider.as_ref()
2990 }
2991
2992 pub fn backup(&self) -> &parking_lot::Mutex<BackupStore> {
2994 &self.backup
2995 }
2996
2997 pub fn checkpoint(&self) -> &parking_lot::Mutex<CheckpointStore> {
2999 &self.checkpoint
3000 }
3001
3002 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
3003 self.app.set_db(conn);
3004 self.compression_aggregates.clear();
3005 }
3006
3007 pub fn clear_db(&self) {
3008 self.app.clear_db();
3009 self.compression_aggregates.clear();
3010 }
3011
3012 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
3013 self.app.db()
3014 }
3015
3016 pub(crate) fn compression_aggregate_cache(
3017 &self,
3018 ) -> &crate::db::compression_events::CompressionAggregateCache {
3019 self.compression_aggregates.as_ref()
3020 }
3021
3022 pub fn config(&self) -> Arc<Config> {
3024 let guard = match self.config.read() {
3025 Ok(guard) => guard,
3026 Err(poisoned) => poisoned.into_inner(),
3027 };
3028 Arc::clone(&*guard)
3029 }
3030
3031 pub fn set_config(&self, config: Config) {
3033 let next = Arc::new(config);
3034 match self.config.write() {
3035 Ok(mut guard) => *guard = next,
3036 Err(poisoned) => *poisoned.into_inner() = next,
3037 }
3038 }
3039
3040 pub fn update_config(&self, update: impl FnOnce(&mut Config)) {
3042 let mut next = self.config().as_ref().clone();
3043 update(&mut next);
3044 self.set_config(next);
3045 }
3046
3047 pub fn force_restrict_guard(&self, req_id: &str) -> ForceRestrictGuard<'_> {
3048 let mut requests = self.force_restrict_requests.lock();
3049 *requests.entry(req_id.to_string()).or_insert(0) += 1;
3050 ForceRestrictGuard {
3051 ctx: self,
3052 req_id: req_id.to_string(),
3053 }
3054 }
3055
3056 pub fn with_force_restrict<R>(&self, req_id: &str, f: impl FnOnce() -> R) -> R {
3057 let _guard = self.force_restrict_guard(req_id);
3058 f()
3059 }
3060
3061 pub fn request_force_restrict(&self, req_id: &str) -> bool {
3062 self.force_restrict_requests.lock().contains_key(req_id)
3063 }
3064
3065 fn release_force_restrict(&self, req_id: &str) {
3066 let mut requests = self.force_restrict_requests.lock();
3067 match requests.get_mut(req_id) {
3068 Some(count) if *count > 1 => *count -= 1,
3069 Some(_) => {
3070 requests.remove(req_id);
3071 }
3072 None => {}
3073 }
3074 }
3075
3076 pub fn set_harness(&self, harness: Harness) {
3077 self.bash_background.set_harness(harness.clone());
3078 *self.harness.lock() = Some(harness);
3079 }
3080
3081 pub fn harness_opt(&self) -> Option<Harness> {
3082 self.harness.lock().clone()
3083 }
3084
3085 pub fn harness(&self) -> Harness {
3086 self.harness_opt()
3087 .expect("harness set by configure before any tool call")
3088 }
3089
3090 pub fn storage_dir(&self) -> PathBuf {
3091 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
3092 }
3093
3094 pub fn harness_dir(&self) -> PathBuf {
3095 self.storage_dir().join(self.harness().storage_segment())
3096 }
3097
3098 pub fn inspect_dir(&self) -> PathBuf {
3099 if let Some(root) = self
3100 .canonical_cache_root_opt()
3101 .or_else(|| self.config().project_root.clone())
3102 {
3103 self.storage_dir()
3104 .join("inspect")
3105 .join(crate::path_identity::project_scope_key(&root))
3106 } else {
3107 self.storage_dir().join("inspect").join("unconfigured")
3108 }
3109 }
3110
3111 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
3112 self.harness_dir()
3113 .join("bash-tasks")
3114 .join(hash_session(session_id))
3115 }
3116
3117 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
3118 self.harness_dir()
3119 .join("backups")
3120 .join(hash_session(session_id))
3121 .join(path_hash)
3122 }
3123
3124 pub fn filters_dir(&self) -> PathBuf {
3125 self.harness_dir().join("filters")
3126 }
3127
3128 pub fn trust_file(&self) -> PathBuf {
3130 self.storage_dir().join("trusted-filter-projects.json")
3131 }
3132
3133 pub fn set_canonical_cache_root(&self, root: PathBuf) {
3134 debug_assert!(root.is_absolute());
3135 let root_changed = {
3136 let mut current = self.canonical_cache_root.lock();
3137 let changed = current.as_deref() != Some(root.as_path());
3138 *current = Some(root);
3139 changed
3140 };
3141 if root_changed {
3142 let mut tier2 = self
3143 .status_bar_tier2
3144 .write()
3145 .unwrap_or_else(std::sync::PoisonError::into_inner);
3146 let generation = tier2.generation.wrapping_add(1);
3147 *tier2 = StatusBarTier2 {
3148 generation,
3149 ..StatusBarTier2::default()
3150 };
3151 *self
3152 .status_bar_last_emitted
3153 .write()
3154 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
3155 }
3156 }
3157
3158 pub fn canonical_cache_root(&self) -> PathBuf {
3159 self.canonical_cache_root
3160 .lock()
3161 .clone()
3162 .expect("canonical_cache_root accessed before handle_configure")
3163 }
3164
3165 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
3166 self.canonical_cache_root.lock().clone()
3167 }
3168
3169 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
3170 *self.is_worktree_bridge.lock() = is_worktree_bridge;
3171 *self.git_common_dir.lock() = git_common_dir;
3172 self.inspect_manager
3176 .set_automatic_tier2_refresh_allowed(!is_worktree_bridge);
3177 let artifact_read_only = self.shared_artifacts_read_only.load(Ordering::SeqCst);
3178 self.callgraph_writer
3179 .store(!is_worktree_bridge && !artifact_read_only, Ordering::SeqCst);
3180 }
3181
3182 pub fn set_artifact_owner(
3183 &self,
3184 status: Option<ArtifactOwnerStatus>,
3185 lease: Option<ArtifactOwnerLease>,
3186 ) {
3187 let read_only = status
3188 .as_ref()
3189 .is_some_and(|status| status.mode == ArtifactOwnerMode::ReadOnly);
3190 self.shared_artifacts_read_only
3191 .store(read_only, Ordering::SeqCst);
3192 self.callgraph_writer
3193 .store(!self.is_worktree_bridge() && !read_only, Ordering::SeqCst);
3194 self.inspect_writer.store(true, Ordering::SeqCst);
3195 *self.artifact_owner_status.lock() = status;
3196 *self.artifact_owner_lease.lock() = lease.map(crate::artifact_owner::register_heartbeat);
3197 }
3198
3199 pub fn set_cache_writer_capabilities(&self, callgraph_writer: bool, inspect_writer: bool) {
3200 self.callgraph_writer
3201 .store(callgraph_writer, Ordering::SeqCst);
3202 self.inspect_writer.store(inspect_writer, Ordering::SeqCst);
3203 }
3204
3205 pub fn callgraph_writer(&self) -> bool {
3206 self.callgraph_writer.load(Ordering::SeqCst)
3207 }
3208
3209 pub fn inspect_writer(&self) -> bool {
3210 self.inspect_writer.load(Ordering::SeqCst)
3211 }
3212
3213 pub fn shared_artifacts_read_only(&self) -> bool {
3214 !self.callgraph_writer()
3215 }
3216
3217 pub fn artifact_owner_status(&self) -> Option<ArtifactOwnerStatus> {
3218 self.artifact_owner_status.lock().clone()
3219 }
3220
3221 pub fn is_worktree_bridge(&self) -> bool {
3222 *self.is_worktree_bridge.lock()
3223 }
3224
3225 pub fn git_common_dir(&self) -> Option<PathBuf> {
3226 self.git_common_dir.lock().clone()
3227 }
3228
3229 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
3233 *self.degraded_reasons.lock() = reasons;
3234 }
3235
3236 pub fn set_heavy_root_work_allowed(&self, allowed: bool) {
3237 self.heavy_root_work_allowed
3238 .store(allowed, Ordering::SeqCst);
3239 }
3240
3241 pub fn heavy_root_work_allowed(&self) -> bool {
3242 self.heavy_root_work_allowed.load(Ordering::SeqCst) && !self.subc_lifecycle.is_unbound()
3243 }
3244
3245 fn try_heavy_root_work_allowed(&self) -> Option<bool> {
3246 if !self.heavy_root_work_allowed.load(Ordering::SeqCst) {
3247 return Some(false);
3248 }
3249 self.subc_lifecycle.try_is_bound()
3250 }
3251
3252 pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
3253 let reason = reason.into();
3254 let mut reasons = self.degraded_reasons.lock();
3255 if reasons.iter().any(|existing| existing == &reason) {
3256 return false;
3257 }
3258 reasons.push(reason);
3259 true
3260 }
3261
3262 pub fn degraded_reasons(&self) -> Vec<String> {
3266 self.degraded_reasons.lock().clone()
3267 }
3268
3269 pub fn is_degraded(&self) -> bool {
3271 !self.degraded_reasons.lock().is_empty()
3272 }
3273
3274 pub fn cache_role(&self) -> &'static str {
3275 if self.canonical_cache_root.lock().is_none() {
3276 "not_initialized"
3277 } else if self.is_worktree_bridge() {
3278 "worktree"
3279 } else if self.shared_artifacts_read_only.load(Ordering::SeqCst) {
3280 "read_only"
3281 } else {
3282 "main"
3283 }
3284 }
3285
3286 pub fn callgraph_store(&self) -> &RwLock<Option<Arc<ReadonlyCallGraphStore>>> {
3288 self.callgraph_store.as_ref()
3289 }
3290
3291 pub fn mark_callgraph_store_force_rebuild(&self) -> u64 {
3292 self.callgraph_store_force_requested
3293 .fetch_add(1, Ordering::SeqCst)
3294 .wrapping_add(1)
3295 }
3296
3297 pub(crate) fn pending_callgraph_store_force_token(&self) -> Option<u64> {
3298 let requested = self.callgraph_store_force_requested.load(Ordering::SeqCst);
3299 let fulfilled = self.callgraph_store_force_fulfilled.load(Ordering::SeqCst);
3300 (requested > fulfilled).then_some(requested)
3301 }
3302
3303 pub fn fulfill_callgraph_store_force_token(&self, token: u64) {
3304 self.callgraph_store_force_fulfilled
3305 .fetch_max(token, Ordering::SeqCst);
3306 }
3307
3308 pub fn callgraph_store_dir(&self) -> PathBuf {
3309 if let Some(root) = self.callgraph_project_root() {
3310 self.storage_dir()
3311 .join("callgraph")
3312 .join(self.memoized_artifact_cache_key(&root))
3313 } else {
3314 self.storage_dir().join("callgraph").join("unconfigured")
3315 }
3316 }
3317
3318 pub fn ensure_callgraph_store(
3319 &self,
3320 ) -> Result<Option<Arc<ReadonlyCallGraphStore>>, CallGraphStoreError> {
3321 self.ensure_callgraph_store_with_flag(true)
3322 }
3323
3324 fn ensure_callgraph_store_with_flag(
3325 &self,
3326 respect_config_flag: bool,
3327 ) -> Result<Option<Arc<ReadonlyCallGraphStore>>, CallGraphStoreError> {
3328 if respect_config_flag && !self.config().callgraph_store {
3329 return Ok(None);
3330 }
3331 if !self.heavy_root_work_allowed() {
3332 return Ok(None);
3333 }
3334 self.revalidate_callgraph_store_generation();
3335 let force_token = self.pending_callgraph_store_force_token();
3336 if force_token.is_none() {
3337 if let Some(store) = {
3338 let guard = self
3339 .callgraph_store
3340 .read()
3341 .unwrap_or_else(std::sync::PoisonError::into_inner);
3342 guard.as_ref().map(Arc::clone)
3343 } {
3344 self.schedule_legacy_callgraph_migration_if_needed(
3345 store.as_ref(),
3346 store.project_root().to_path_buf(),
3347 self.callgraph_store_dir(),
3348 );
3349 return Ok(Some(store));
3350 }
3351 }
3352
3353 let Some(project_root) = self.callgraph_project_root() else {
3354 return Ok(None);
3355 };
3356 let callgraph_dir = self.callgraph_store_dir();
3357
3358 if force_token.is_none() {
3362 if let Some(store) =
3363 CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone())?
3364 {
3365 let store = Arc::new(store);
3366 {
3367 let mut guard = self
3368 .callgraph_store
3369 .write()
3370 .unwrap_or_else(std::sync::PoisonError::into_inner);
3371 *guard = Some(Arc::clone(&store));
3372 }
3373 self.schedule_legacy_callgraph_migration_if_needed(
3374 store.as_ref(),
3375 project_root,
3376 callgraph_dir,
3377 );
3378 return Ok(Some(store));
3379 }
3380 }
3381
3382 if !self.callgraph_writer() {
3383 return Ok(None);
3384 }
3385 let build_generation = self.configure_generation();
3386 let persist_epoch_flag = self.callgraph_persist_epoch_flag();
3387 let Some(persist_epoch) = self
3388 .run_if_subc_bound_generation(build_generation, || self.next_callgraph_persist_epoch())
3389 else {
3390 return Ok(None);
3391 };
3392 let files = crate::callgraph::walk_project_files(&project_root).collect::<Vec<_>>();
3393 let (store, _stats) = crate::callgraph_store::with_publish_epoch(
3394 persist_epoch_flag.clone(),
3395 persist_epoch,
3396 || {
3397 if force_token.is_some() {
3398 CallGraphStore::force_cold_build_with_lease_chunked(
3399 callgraph_dir.clone(),
3400 project_root.clone(),
3401 &files,
3402 self.config().callgraph_chunk_size,
3403 )
3404 .map(|(store, _stats)| (store, ()))
3405 } else {
3406 CallGraphStore::ensure_built_with_lease_chunked(
3407 callgraph_dir.clone(),
3408 project_root.clone(),
3409 &files,
3410 self.config().callgraph_chunk_size,
3411 )
3412 .map(|(store, _stats)| (store, ()))
3413 }
3414 },
3415 )?;
3416 drop(store);
3417
3418 let Some(store) = CallGraphStore::open_readonly(callgraph_dir, project_root)? else {
3419 return Ok(None);
3420 };
3421 let store = Arc::new(store);
3422 self.run_if_subc_bound_generation(build_generation, || {
3423 if persist_epoch_flag.current() != persist_epoch {
3424 return None;
3425 }
3426 let mut guard = self
3427 .callgraph_store
3428 .write()
3429 .unwrap_or_else(std::sync::PoisonError::into_inner);
3430 *guard = Some(Arc::clone(&store));
3431 if let Some(force_token) = force_token {
3432 self.fulfill_callgraph_store_force_token(force_token);
3433 }
3434 Some(Arc::clone(&store))
3435 })
3436 .flatten()
3437 .map_or(Ok(None), |store| Ok(Some(store)))
3438 }
3439
3440 pub fn callgraph_project_root(&self) -> Option<PathBuf> {
3443 self.canonical_cache_root_opt().or_else(|| {
3444 self.config()
3445 .project_root
3446 .clone()
3447 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
3448 })
3449 }
3450
3451 pub fn revalidate_callgraph_store_generation(&self) {
3455 let (superseded, legacy_fallback) = {
3456 let guard = self
3457 .callgraph_store
3458 .read()
3459 .unwrap_or_else(std::sync::PoisonError::into_inner);
3460 guard
3461 .as_ref()
3462 .map(|store| (!store.is_current(), store.is_legacy_fallback()))
3463 .unwrap_or((false, false))
3464 };
3465 if !superseded {
3466 return;
3467 }
3468 if legacy_fallback && self.callgraph_store_rx.lock().is_some() {
3472 return;
3473 }
3474 let mut guard = self
3475 .callgraph_store
3476 .write()
3477 .unwrap_or_else(std::sync::PoisonError::into_inner);
3478 *guard = None;
3479 }
3480
3481 pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess {
3482 if !self.heavy_root_work_allowed() {
3483 return CallgraphStoreAccess::Unavailable;
3484 }
3485 let operation_generation = self.configure_generation();
3486
3487 self.revalidate_callgraph_store_generation();
3491 let force_token = self.pending_callgraph_store_force_token();
3492 if force_token.is_none() {
3493 if let Some(store) = {
3494 let guard = self
3495 .callgraph_store
3496 .read()
3497 .unwrap_or_else(std::sync::PoisonError::into_inner);
3498 guard.as_ref().map(Arc::clone)
3499 } {
3500 self.schedule_legacy_callgraph_migration_if_needed(
3501 store.as_ref(),
3502 store.project_root().to_path_buf(),
3503 self.callgraph_store_dir(),
3504 );
3505 return CallgraphStoreAccess::Ready(store);
3506 }
3507 }
3508
3509 if self.callgraph_store_rx.lock().is_some() {
3511 return CallgraphStoreAccess::Building;
3512 }
3513
3514 let Some(project_root) = self.callgraph_project_root() else {
3515 return CallgraphStoreAccess::Unavailable;
3516 };
3517 let callgraph_dir = self.callgraph_store_dir();
3518
3519 if force_token.is_none() {
3520 match CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone()) {
3521 Ok(Some(store)) => {
3522 let store = Arc::new(store);
3523 let installed = self.run_if_subc_bound_generation(operation_generation, || {
3524 let mut guard = self
3525 .callgraph_store
3526 .write()
3527 .unwrap_or_else(std::sync::PoisonError::into_inner);
3528 *guard = Some(Arc::clone(&store));
3529 Arc::clone(&store)
3530 });
3531 let Some(store) = installed else {
3532 return CallgraphStoreAccess::Unavailable;
3533 };
3534 self.schedule_legacy_callgraph_migration_if_needed(
3535 store.as_ref(),
3536 project_root.clone(),
3537 callgraph_dir.clone(),
3538 );
3539 return CallgraphStoreAccess::Ready(store);
3540 }
3541 Ok(None) => {
3542 if !self.callgraph_writer() {
3543 return CallgraphStoreAccess::Unavailable;
3544 }
3545 }
3546 Err(error) => {
3547 if !self.callgraph_writer() {
3548 return CallgraphStoreAccess::Unavailable;
3549 }
3550 crate::slog_warn!(
3551 "callgraph read-only open failed before writer promotion: {}",
3552 error
3553 );
3554 }
3555 }
3556 } else if !self.callgraph_writer() {
3557 return CallgraphStoreAccess::Unavailable;
3558 }
3559
3560 if self.semantic_cold_seed_active() {
3561 self.defer_callgraph_store_warm_for_semantic_cold_seed();
3562 return CallgraphStoreAccess::Building;
3563 }
3564
3565 let work = if let Some(force_token) = force_token {
3573 CallgraphBackgroundWork::ForceRebuild(force_token)
3574 } else {
3575 CallgraphBackgroundWork::Ensure
3576 };
3577 if !self.spawn_callgraph_store_cold_build(project_root.clone(), callgraph_dir.clone(), work)
3578 {
3579 return CallgraphStoreAccess::Building;
3580 }
3581
3582 let wait = callgraph_build_wait_window();
3583 if !wait.is_zero() {
3584 let (received, receiver_generation, receiver_epoch) = {
3585 let rx_ref = self.callgraph_store_rx.lock();
3586 let Some(rx) = rx_ref.as_ref() else {
3587 return CallgraphStoreAccess::Building;
3588 };
3589 (
3590 rx.recv_timeout(wait),
3591 self.callgraph_store_rx_generation(),
3592 self.callgraph_store_rx_epoch(),
3593 )
3594 };
3595 match received {
3596 Ok(CallGraphStoreBuildEvent::Ready {
3597 store,
3598 fulfilled_force_token,
3599 publication_epoch,
3600 }) => {
3601 if self.callgraph_persist_epoch_flag().current() != publication_epoch {
3602 drop(store);
3606 let _ = self.with_current_callgraph_store_rx(
3607 receiver_generation,
3608 receiver_epoch,
3609 |receiver| {
3610 *receiver = None;
3611 },
3612 );
3613 return CallgraphStoreAccess::Building;
3614 }
3615 remove_callgraph_pointer_before_inline_reopen_for_test(&callgraph_dir, &store);
3618 drop(store);
3619 let reopened =
3620 CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone());
3621 let mut pending = Vec::new();
3622 let outcome = self.with_current_callgraph_store_rx(
3623 receiver_generation,
3624 receiver_epoch,
3625 |receiver| {
3626 *receiver = None;
3627 match reopened {
3628 Ok(Some(store)) => {
3629 let ready = Arc::new(store);
3630 *self
3631 .callgraph_store
3632 .write()
3633 .unwrap_or_else(std::sync::PoisonError::into_inner) =
3634 Some(Arc::clone(&ready));
3635 pending = self.take_pending_callgraph_store_paths();
3640 if let Some(force_token) = fulfilled_force_token {
3641 self.fulfill_callgraph_store_force_token(force_token);
3642 }
3643 CallgraphStoreAccess::Ready(ready)
3644 }
3645 Ok(None) => CallgraphStoreAccess::Building,
3646 Err(error) => CallgraphStoreAccess::Error(error),
3647 }
3648 },
3649 );
3650 let Some(outcome) = outcome else {
3651 return if self.subc_unbound_quiesced()
3652 || self.configure_generation() != receiver_generation
3653 {
3654 CallgraphStoreAccess::Unavailable
3655 } else {
3656 CallgraphStoreAccess::Building
3657 };
3658 };
3659 if !pending.is_empty() {
3660 let _ = self.enqueue_callgraph_store_refresh(pending);
3661 }
3662 if matches!(&outcome, CallgraphStoreAccess::Ready(_)) {
3663 let _ = self.request_tier2_refresh_pull();
3664 }
3665 return outcome;
3666 }
3667 Ok(CallGraphStoreBuildEvent::Settled) => {
3668 let _ = self.with_current_callgraph_store_rx(
3669 receiver_generation,
3670 receiver_epoch,
3671 |receiver| *receiver = None,
3672 );
3673 return CallgraphStoreAccess::Building;
3674 }
3675 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
3676 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
3677 let _ = self.with_current_callgraph_store_rx(
3678 receiver_generation,
3679 receiver_epoch,
3680 |receiver| *receiver = None,
3681 );
3682 }
3683 }
3684 }
3685 CallgraphStoreAccess::Building
3686 }
3687
3688 fn schedule_legacy_callgraph_migration_if_needed(
3689 &self,
3690 store: &ReadonlyCallGraphStore,
3691 project_root: PathBuf,
3692 callgraph_dir: PathBuf,
3693 ) {
3694 if !store.is_legacy_fallback()
3695 || !self.callgraph_writer()
3696 || !self.heavy_root_work_allowed()
3697 {
3698 return;
3699 }
3700 if self.semantic_cold_seed_active() {
3701 self.defer_callgraph_store_warm_for_semantic_cold_seed();
3702 return;
3703 }
3704 let _ = self.spawn_callgraph_store_cold_build(
3705 project_root,
3706 callgraph_dir,
3707 CallgraphBackgroundWork::LegacyMigration,
3708 );
3709 }
3710
3711 fn configured_callgraph_keys(&self, current_root: &Path) -> BTreeSet<String> {
3712 let mut roots = self
3713 .configured_session_roots
3714 .lock()
3715 .iter()
3716 .map(|(root, _session)| root.clone())
3717 .collect::<BTreeSet<_>>();
3718 roots.insert(current_root.to_path_buf());
3719 roots
3720 .iter()
3721 .map(|root| crate::search_index::artifact_cache_key(root))
3722 .collect()
3723 }
3724
3725 fn spawn_callgraph_store_cold_build(
3730 &self,
3731 project_root: PathBuf,
3732 callgraph_dir: PathBuf,
3733 work: CallgraphBackgroundWork,
3734 ) -> bool {
3735 if !self.heavy_root_work_allowed() || !self.callgraph_writer() {
3736 return false;
3737 }
3738 let generation = self.configure_generation();
3739 self.run_if_subc_bound_generation(generation, || {
3740 self.spawn_callgraph_store_cold_build_admitted(project_root, callgraph_dir, work)
3741 })
3742 .unwrap_or(false)
3743 }
3744
3745 fn spawn_callgraph_store_cold_build_admitted(
3747 &self,
3748 project_root: PathBuf,
3749 callgraph_dir: PathBuf,
3750 work: CallgraphBackgroundWork,
3751 ) -> bool {
3752 let session_id = crate::log_ctx::current_session();
3753 let chunk_size = self.config().callgraph_chunk_size;
3754 let build_generation = self.configure_generation();
3755 let generation_flag = self.configure_generation_flag();
3756 let configured_keys = self.configured_callgraph_keys(&project_root);
3757 let summary_logged = Arc::clone(&self.callgraph_legacy_migration_summary_logged);
3758
3759 let mut rx_guard = self.callgraph_store_rx.lock();
3760 if rx_guard.is_some() {
3761 return false;
3762 }
3763
3764 let Some(permit) = crate::cold_build_limiter::try_acquire() else {
3765 crate::slog_info!(
3766 "callgraph store background work deferred by cold build limit ({})",
3767 crate::cold_build_limiter::limit()
3768 );
3769 return false;
3770 };
3771
3772 let force_token = match work {
3773 CallgraphBackgroundWork::ForceRebuild(token) => Some(token),
3774 CallgraphBackgroundWork::Ensure | CallgraphBackgroundWork::LegacyMigration => None,
3775 };
3776 let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStoreBuildEvent>();
3777 self.note_callgraph_store_rx_generation(build_generation);
3778 self.next_callgraph_store_rx_epoch();
3779 *rx_guard = Some(rx);
3780 let persist_epoch = self.next_callgraph_persist_epoch();
3781 let persist_epoch_flag = self.callgraph_persist_epoch_flag();
3782
3783 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.fetch_add(1, Ordering::SeqCst);
3784
3785 std::thread::spawn(move || {
3786 let _permit = permit;
3787 let mut settlement = CallGraphStoreBuildSettlement::new(tx, force_token, persist_epoch);
3788 crate::log_ctx::with_session(session_id, || {
3789 wait_on_callgraph_build_start_gate(&project_root);
3790 if persist_epoch_flag.current() != persist_epoch {
3791 crate::slog_info!(
3792 "callgraph store background work skipped for superseded epoch {}",
3793 persist_epoch
3794 );
3795 return;
3796 }
3797 let built = crate::callgraph_store::with_publish_epoch(
3798 persist_epoch_flag,
3799 persist_epoch,
3800 || match work {
3801 CallgraphBackgroundWork::LegacyMigration => {
3802 CallGraphStore::migrate_legacy_with_lease(
3803 callgraph_dir.clone(),
3804 project_root.clone(),
3805 )
3806 }
3807 CallgraphBackgroundWork::ForceRebuild(_) => {
3808 let files = crate::callgraph::walk_project_files(&project_root)
3809 .collect::<Vec<_>>();
3810 CallGraphStore::force_cold_build_with_lease_chunked(
3811 callgraph_dir.clone(),
3812 project_root.clone(),
3813 &files,
3814 chunk_size,
3815 )
3816 .map(|(store, _)| Some(store))
3817 }
3818 CallgraphBackgroundWork::Ensure => {
3819 let files = crate::callgraph::walk_project_files(&project_root)
3820 .collect::<Vec<_>>();
3821 CallGraphStore::ensure_built_with_lease_chunked(
3822 callgraph_dir.clone(),
3823 project_root.clone(),
3824 &files,
3825 chunk_size,
3826 )
3827 .map(|(store, _)| Some(store))
3828 }
3829 },
3830 );
3831 match built {
3832 Ok(Some(store)) => {
3833 if store.is_legacy_migration() {
3834 match crate::callgraph_store::all_legacy_partitions_migrated_for_keys(
3835 &callgraph_dir,
3836 &configured_keys,
3837 ) {
3838 Ok(true)
3839 if summary_logged
3840 .compare_exchange(
3841 false,
3842 true,
3843 Ordering::SeqCst,
3844 Ordering::SeqCst,
3845 )
3846 .is_ok() =>
3847 {
3848 crate::slog_info!(
3849 "all legacy callgraph partitions migrated for configured roots"
3850 );
3851 }
3852 Ok(_) => {}
3853 Err(error) => crate::slog_warn!(
3854 "failed to inspect legacy callgraph migration completion: {}",
3855 error
3856 ),
3857 }
3858 }
3859 if generation_flag.load(Ordering::SeqCst) == build_generation {
3860 settlement.ready(store);
3861 } else {
3862 crate::slog_info!(
3863 "callgraph store warm build result discarded for stale generation {}",
3864 build_generation
3865 );
3866 }
3867 }
3868 Ok(None) => {}
3869 Err(crate::callgraph_store::CallGraphStoreError::Superseded) => {
3870 crate::slog_info!(
3871 "callgraph store disk publication skipped for superseded epoch {}",
3872 persist_epoch
3873 );
3874 }
3875 Err(error) => {
3876 crate::slog_warn!("callgraph store background work failed: {}", error);
3877 }
3878 }
3879 });
3880 });
3881 true
3882 }
3883
3884 pub fn callgraph_store_rx(
3887 &self,
3888 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>> {
3889 &self.callgraph_store_rx
3890 }
3891
3892 #[doc(hidden)]
3896 pub fn with_current_callgraph_store_rx<R>(
3897 &self,
3898 generation: u64,
3899 epoch: u64,
3900 action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>) -> R,
3901 ) -> Option<R> {
3902 self.run_if_subc_bound_generation(generation, || {
3903 let mut receiver = self.callgraph_store_rx.lock();
3904 if receiver.is_none()
3905 || self.callgraph_store_rx_generation() != generation
3906 || self.callgraph_store_rx_epoch() != epoch
3907 {
3908 return None;
3909 }
3910 Some(action(&mut receiver))
3911 })
3912 .flatten()
3913 }
3914
3915 pub(crate) fn retire_callgraph_store_rx(&self) {
3916 let mut receiver = self.callgraph_store_rx.lock();
3917 *receiver = None;
3918 self.next_callgraph_store_rx_epoch();
3919 }
3920
3921 pub(crate) fn note_callgraph_store_rx_generation(&self, generation: u64) {
3922 self.callgraph_store_rx_generation
3923 .store(generation, Ordering::SeqCst);
3924 }
3925
3926 #[doc(hidden)]
3927 pub fn callgraph_store_rx_generation(&self) -> u64 {
3928 self.callgraph_store_rx_generation.load(Ordering::SeqCst)
3929 }
3930
3931 pub(crate) fn next_callgraph_store_rx_epoch(&self) -> u64 {
3932 self.callgraph_store_rx_epoch
3933 .fetch_add(1, Ordering::SeqCst)
3934 .wrapping_add(1)
3935 }
3936
3937 #[doc(hidden)]
3938 pub fn callgraph_store_rx_epoch(&self) -> u64 {
3939 self.callgraph_store_rx_epoch.load(Ordering::SeqCst)
3940 }
3941
3942 pub(crate) fn next_callgraph_persist_epoch(&self) -> u64 {
3943 self.callgraph_persist_epoch.next()
3944 }
3945
3946 #[doc(hidden)]
3947 pub fn callgraph_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
3948 self.callgraph_persist_epoch.clone()
3949 }
3950
3951 pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
3954 where
3955 I: IntoIterator<Item = PathBuf>,
3956 {
3957 self.pending_callgraph_store_paths.lock().extend(paths);
3958 }
3959
3960 pub fn enqueue_callgraph_store_refresh<I>(&self, paths: I) -> bool
3961 where
3962 I: IntoIterator<Item = PathBuf>,
3963 {
3964 let generation = self.configure_generation();
3965 self.enqueue_callgraph_store_refresh_for_generation(paths, generation)
3966 }
3967
3968 pub(crate) fn enqueue_callgraph_store_refresh_for_generation<I>(
3969 &self,
3970 paths: I,
3971 generation: u64,
3972 ) -> bool
3973 where
3974 I: IntoIterator<Item = PathBuf>,
3975 {
3976 let paths = paths.into_iter().collect::<Vec<_>>();
3977 if paths.is_empty() {
3978 return true;
3979 }
3980 self.run_if_subc_bound_generation(generation, || {
3981 if !self.callgraph_writer() {
3982 self.add_pending_callgraph_store_paths(paths);
3983 return false;
3984 }
3985 let Some(project_root) = self.callgraph_project_root() else {
3986 self.add_pending_callgraph_store_paths(paths);
3987 return false;
3988 };
3989
3990 let ticket = crate::callgraph_store::CallgraphRefreshTicket::new(
3995 self.subc_lifecycle_admission(),
3996 self.configure_generation_flag(),
3997 generation,
3998 self.callgraph_persist_epoch_flag(),
3999 self.callgraph_persist_epoch_flag().current(),
4000 );
4001 crate::callgraph_store::enqueue_callgraph_store_refresh_fenced_with_state(
4002 self.callgraph_store_dir(),
4003 project_root,
4004 paths,
4005 Arc::clone(&self.pending_callgraph_store_paths),
4006 crate::callgraph_store::CallgraphRefreshState::new(
4007 Arc::clone(&self.callgraph_store),
4008 Arc::clone(&self.heavy_root_work_allowed),
4009 ),
4010 ticket,
4011 )
4012 })
4013 .unwrap_or(false)
4014 }
4015
4016 pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
4024 let roots: Vec<PathBuf> = [
4025 self.canonical_cache_root_opt(),
4026 self.config().project_root.clone(),
4027 ]
4028 .into_iter()
4029 .flatten()
4030 .collect();
4031 std::mem::take(&mut *self.pending_callgraph_store_paths.lock())
4032 .into_iter()
4033 .filter(|path| {
4034 let in_root = pending_path_in_roots(path, &roots);
4035 if !in_root {
4036 crate::slog_debug!(
4037 "dropping pending callgraph path outside current root: {}",
4038 path.display()
4039 );
4040 }
4041 in_root
4042 })
4043 .collect()
4044 }
4045
4046 pub fn search_index(&self) -> &RwLock<Option<SearchIndex>> {
4048 &self.search_index
4049 }
4050
4051 pub fn search_index_rx(&self) -> &RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>> {
4053 &self.search_index_rx
4054 }
4055
4056 pub(crate) fn install_search_index_rx(
4057 &self,
4058 receiver: crossbeam_channel::Receiver<SearchIndex>,
4059 generation: u64,
4060 ) -> u64 {
4061 let mut slot = self
4062 .search_index_rx
4063 .write()
4064 .unwrap_or_else(std::sync::PoisonError::into_inner);
4065 self.note_search_index_rx_generation(generation);
4066 let epoch = self.next_search_index_rx_epoch();
4067 *slot = Some(receiver);
4068 epoch
4069 }
4070
4071 pub(crate) fn search_index_rx_terminal_guard(&self, epoch: u64) -> ReceiverTerminalGuard {
4072 ReceiverTerminalGuard::new(Arc::clone(&self.search_index_rx_terminal_epoch), epoch)
4073 }
4074
4075 pub(crate) fn with_current_search_index_rx<R>(
4078 &self,
4079 generation: u64,
4080 epoch: u64,
4081 action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<SearchIndex>>) -> R,
4082 ) -> Option<R> {
4083 self.run_if_subc_bound_generation(generation, || {
4084 let mut receiver = self
4085 .search_index_rx
4086 .write()
4087 .unwrap_or_else(std::sync::PoisonError::into_inner);
4088 if receiver.is_none()
4089 || self.search_index_rx_generation() != generation
4090 || self.search_index_rx_epoch() != epoch
4091 {
4092 return None;
4093 }
4094 Some(action(&mut receiver))
4095 })
4096 .flatten()
4097 }
4098
4099 pub(crate) fn retire_search_index_rx(&self) {
4100 let mut receiver = self
4101 .search_index_rx
4102 .write()
4103 .unwrap_or_else(std::sync::PoisonError::into_inner);
4104 *receiver = None;
4105 self.next_search_index_rx_epoch();
4106 }
4107
4108 pub(crate) fn note_search_index_rx_generation(&self, generation: u64) {
4109 self.search_index_rx_generation
4110 .store(generation, Ordering::SeqCst);
4111 }
4112
4113 pub(crate) fn search_index_rx_generation(&self) -> u64 {
4114 self.search_index_rx_generation.load(Ordering::SeqCst)
4115 }
4116
4117 pub(crate) fn next_search_index_rx_epoch(&self) -> u64 {
4118 self.search_index_rx_epoch
4119 .fetch_add(1, Ordering::SeqCst)
4120 .wrapping_add(1)
4121 }
4122
4123 pub(crate) fn search_index_rx_epoch(&self) -> u64 {
4124 self.search_index_rx_epoch.load(Ordering::SeqCst)
4125 }
4126
4127 pub(crate) fn allow_search_index_disconnect_reschedule(&self) -> bool {
4134 const MAX_REPLACEMENTS_PER_GENERATION: u32 = 1;
4135 let generation = self.configure_generation();
4136 let mut state = self.search_index_disconnect_reschedule.lock();
4137 if state.0 != generation {
4138 *state = (generation, 0);
4139 }
4140 if state.1 >= MAX_REPLACEMENTS_PER_GENERATION {
4141 return false;
4142 }
4143 state.1 += 1;
4144 true
4145 }
4146
4147 pub(crate) fn next_search_persist_epoch(&self) -> u64 {
4148 self.search_persist_epoch.next()
4149 }
4150
4151 pub(crate) fn search_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
4152 self.search_persist_epoch.clone()
4153 }
4154
4155 pub fn add_pending_search_index_paths<I>(&self, paths: I)
4156 where
4157 I: IntoIterator<Item = PathBuf>,
4158 {
4159 let paths = paths.into_iter().collect::<Vec<_>>();
4160 if !paths.is_empty() {
4161 self.invalidate_warm_verify_memo();
4162 self.pending_search_index_paths.lock().extend(paths);
4163 }
4164 }
4165
4166 pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
4167 std::mem::take(&mut *self.pending_search_index_paths.lock())
4168 .into_iter()
4169 .collect()
4170 }
4171
4172 pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
4173 where
4174 I: IntoIterator<Item = PathBuf>,
4175 {
4176 let paths = paths.into_iter().collect::<Vec<_>>();
4177 if !paths.is_empty() {
4178 self.invalidate_warm_verify_memo();
4179 self.pending_semantic_index_paths.lock().extend(paths);
4180 }
4181 }
4182
4183 pub(crate) fn invalidate_warm_verify_memo(&self) {
4184 if let Some(root) = self.canonical_cache_root_opt() {
4185 crate::cache_freshness::invalidate_verify_memo(&root);
4186 }
4187 }
4188
4189 pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
4190 std::mem::take(&mut *self.pending_semantic_index_paths.lock())
4191 .into_iter()
4192 .collect()
4193 }
4194
4195 pub fn mark_pending_semantic_corpus_refresh(&self) {
4196 *self.pending_semantic_corpus_refresh.lock() = true;
4197 }
4198
4199 pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
4200 std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock())
4201 }
4202
4203 pub fn clear_pending_index_updates(&self) {
4204 self.pending_search_index_paths.lock().clear();
4205 self.pending_callgraph_store_paths.lock().clear();
4206 self.pending_tier2_paths.lock().clear();
4207 self.pending_semantic_index_paths.lock().clear();
4208 *self.pending_semantic_corpus_refresh.lock() = false;
4209 }
4210
4211 pub(crate) fn take_pending_reconciliation_state(&self) -> PendingReconciliationState {
4219 PendingReconciliationState {
4220 search: std::mem::take(&mut *self.pending_search_index_paths.lock()),
4221 callgraph: std::mem::take(&mut *self.pending_callgraph_store_paths.lock()),
4222 tier2: std::mem::take(&mut *self.pending_tier2_paths.lock()),
4223 semantic: std::mem::take(&mut *self.pending_semantic_index_paths.lock()),
4224 corpus_refresh: std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock()),
4225 }
4226 }
4227
4228 pub(crate) fn restore_pending_reconciliation_state(&self, state: PendingReconciliationState) {
4229 self.pending_search_index_paths.lock().extend(state.search);
4230 self.pending_callgraph_store_paths
4231 .lock()
4232 .extend(state.callgraph);
4233 self.pending_tier2_paths.lock().extend(state.tier2);
4234 self.pending_semantic_index_paths
4235 .lock()
4236 .extend(state.semantic);
4237 if state.corpus_refresh {
4238 *self.pending_semantic_corpus_refresh.lock() = true;
4239 }
4240 }
4241
4242 pub(crate) fn cancel_unbound_artifact_work(&self) {
4256 let search_refresh_cancelled = self
4262 .search_index_rx
4263 .read()
4264 .unwrap_or_else(std::sync::PoisonError::into_inner)
4265 .is_some();
4266 self.retire_search_index_rx();
4267 if search_refresh_cancelled {
4268 let mut resident = self
4269 .search_index
4270 .write()
4271 .unwrap_or_else(std::sync::PoisonError::into_inner);
4272 if resident.as_ref().is_some_and(|index| !index.ready) {
4273 *resident = None;
4274 }
4275 }
4276 self.retire_callgraph_store_rx();
4277 let semantic_cancelled = self.semantic_index_rx.lock().is_some();
4278 self.retire_semantic_index_rx();
4279 let semantic_refresh_cancelled = self.semantic_refresh_event_rx.lock().is_some();
4280 self.clear_semantic_refresh_worker();
4281 self.reset_semantic_cold_seed_gate_for_configure();
4282 let _ = self.inspect_manager.discard_completions();
4283 let _ = self.take_new_reuse_completions();
4284 if semantic_cancelled || semantic_refresh_cancelled {
4285 let has_index = self
4286 .semantic_index
4287 .read()
4288 .unwrap_or_else(std::sync::PoisonError::into_inner)
4289 .is_some();
4290 {
4294 let mut status = self
4295 .semantic_index_status
4296 .write()
4297 .unwrap_or_else(std::sync::PoisonError::into_inner);
4298 let refreshing = status.take_refreshing_files();
4299 if !refreshing.is_empty() {
4300 self.pending_semantic_index_paths.lock().extend(refreshing);
4301 }
4302 if status.corpus_refresh_in_flight() {
4303 *self.pending_semantic_corpus_refresh.lock() = true;
4304 }
4305 *status = if has_index {
4306 SemanticIndexStatus::ready()
4307 } else {
4308 SemanticIndexStatus::Disabled
4309 };
4310 }
4311 }
4312 }
4313
4314 pub(crate) fn invalidate_artifacts_after_watcher_gap(&self) {
4318 self.next_search_persist_epoch();
4319 self.next_semantic_persist_epoch();
4320 self.next_callgraph_persist_epoch();
4321
4322 self.search_index
4323 .write()
4324 .unwrap_or_else(std::sync::PoisonError::into_inner)
4325 .take();
4326 self.semantic_index
4327 .write()
4328 .unwrap_or_else(std::sync::PoisonError::into_inner)
4329 .take();
4330 self.callgraph_store
4331 .write()
4332 .unwrap_or_else(std::sync::PoisonError::into_inner)
4333 .take();
4334 *self
4340 .semantic_index_status
4341 .write()
4342 .unwrap_or_else(std::sync::PoisonError::into_inner) = if self.config().semantic_search {
4343 SemanticIndexStatus::ready()
4344 } else {
4345 SemanticIndexStatus::Disabled
4346 };
4347 if self.callgraph_writer() {
4351 self.mark_callgraph_store_force_rebuild();
4352 }
4353
4354 if let Some(root) = self
4355 .canonical_cache_root_opt()
4356 .or_else(|| self.config().project_root.clone())
4357 {
4358 crate::cache_freshness::invalidate_verify_memo_strict(&root);
4359 }
4360 self.borrowed_index_cache.lock().clear();
4361 self.inspect_manager.evict_idle_caches();
4362 self.reset_symbol_cache();
4363 self.clear_tsconfig_membership_cache();
4364 }
4365
4366 fn drain_search_index_events_for_graceful_shutdown(&self) {
4367 crate::runtime_drain::drain_watcher_events(self);
4368 crate::runtime_drain::drain_search_index_events(self);
4369 }
4370
4371 fn search_index_build_in_progress(&self) -> bool {
4372 self.search_index_rx()
4373 .read()
4374 .unwrap_or_else(std::sync::PoisonError::into_inner)
4375 .is_some()
4376 }
4377
4378 fn wait_for_search_index_build_to_settle_on_graceful_shutdown(&self) {
4382 crate::runtime_drain::note_search_rebuild_shutdown_wait_for_test();
4383 let deadline = Instant::now() + GRACEFUL_SHUTDOWN_SEARCH_BUILD_WAIT;
4384 while self.search_index_build_in_progress() && Instant::now() < deadline {
4385 let remaining = deadline.saturating_duration_since(Instant::now());
4386 std::thread::sleep(remaining.min(GRACEFUL_SHUTDOWN_SEARCH_BUILD_POLL));
4387 self.drain_search_index_events_for_graceful_shutdown();
4388 }
4389 }
4390
4391 #[doc(hidden)]
4395 pub fn flush_search_index_on_graceful_shutdown(&self) -> bool {
4396 if self.shared_artifacts_read_only() {
4397 return false;
4398 }
4399
4400 self.drain_search_index_events_for_graceful_shutdown();
4401 if self.search_index_build_in_progress() {
4402 self.wait_for_search_index_build_to_settle_on_graceful_shutdown();
4403 self.drain_search_index_events_for_graceful_shutdown();
4404 }
4405
4406 if self.search_index_build_in_progress() {
4407 return false;
4408 }
4409
4410 let Some(canonical_root) = self.canonical_cache_root_opt() else {
4411 return false;
4412 };
4413 let config = self.config();
4414 let project_key = self.memoized_artifact_cache_key(&canonical_root);
4415 let cache_dir = crate::search_index::resolve_cache_dir_with_key(
4416 &project_key,
4417 config.storage_dir.as_deref(),
4418 );
4419
4420 {
4421 let search_index = self
4422 .search_index()
4423 .read()
4424 .unwrap_or_else(std::sync::PoisonError::into_inner);
4425 let Some(index) = search_index.as_ref() else {
4426 return false;
4427 };
4428 if !index.ready || !index.has_pending_disk_changes() {
4429 return false;
4430 }
4431 }
4432
4433 let _cache_lock = match crate::search_index::CacheLock::try_acquire_for_shutdown(
4434 &cache_dir,
4435 &canonical_root,
4436 ) {
4437 Ok(lock) => lock,
4438 Err(error) => {
4439 crate::slog_warn!(
4440 "search index: skipped shutdown flush because cache lock was unavailable: {}",
4441 error
4442 );
4443 return false;
4444 }
4445 };
4446
4447 let mut search_index = self
4448 .search_index()
4449 .write()
4450 .unwrap_or_else(std::sync::PoisonError::into_inner);
4451 let Some(index) = search_index.as_mut() else {
4452 return false;
4453 };
4454 if !index.ready || !index.has_pending_disk_changes() {
4455 return false;
4456 }
4457
4458 let git_head = index.stored_git_head().map(str::to_owned);
4459 index.write_to_disk(&cache_dir, git_head.as_deref())
4460 }
4461
4462 pub fn inspect_manager(&self) -> Arc<InspectManager> {
4463 Arc::clone(&self.inspect_manager)
4464 }
4465
4466 pub fn add_pending_tier2_paths<I>(&self, paths: I)
4467 where
4468 I: IntoIterator<Item = PathBuf>,
4469 {
4470 self.pending_tier2_paths.lock().extend(paths);
4471 }
4472
4473 pub fn pending_tier2_paths(&self) -> Vec<PathBuf> {
4474 self.pending_tier2_paths.lock().iter().cloned().collect()
4475 }
4476
4477 pub fn remove_pending_tier2_paths<I>(&self, paths: I)
4478 where
4479 I: IntoIterator<Item = PathBuf>,
4480 {
4481 let mut pending = self.pending_tier2_paths.lock();
4482 for path in paths {
4483 pending.remove(&path);
4484 }
4485 }
4486
4487 pub fn has_new_reuse_completions(&self) -> bool {
4495 self.inspect_manager.reuse_completion_count()
4496 != self.last_seen_reuse_completions.load(Ordering::SeqCst)
4497 }
4498
4499 pub fn take_new_reuse_completions(&self) -> bool {
4500 let current = self.inspect_manager.reuse_completion_count();
4501 let previous = self
4502 .last_seen_reuse_completions
4503 .swap(current, Ordering::SeqCst);
4504 current != previous
4505 }
4506
4507 pub fn reset_tier2_refresh_scheduler(&self) {
4508 self.reset_tier2_refresh_scheduler_at(Instant::now());
4509 }
4510
4511 #[doc(hidden)]
4512 pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
4513 self.tier2_refresh_scheduler
4514 .lock()
4515 .reset_after_configure(now);
4516 }
4517
4518 pub fn request_tier2_refresh_pull(&self) -> bool {
4519 let can_schedule = self.inspect_writer()
4520 && self.heavy_root_work_allowed()
4521 && self.inspect_manager.automatic_tier2_refresh_allowed();
4522 self.tier2_refresh_scheduler
4523 .lock()
4524 .request_pull(can_schedule)
4525 }
4526
4527 pub fn tick_tier2_refresh_scheduler(
4528 &self,
4529 changed_path_count: usize,
4530 ) -> Option<Tier2TriggerReason> {
4531 self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
4532 }
4533
4534 #[doc(hidden)]
4535 pub fn tick_tier2_refresh_scheduler_at(
4536 &self,
4537 now: Instant,
4538 changed_path_count: usize,
4539 ) -> Option<Tier2TriggerReason> {
4540 let manager = self.inspect_manager();
4541 let can_write = self.inspect_writer()
4542 && self.heavy_root_work_allowed()
4543 && manager.automatic_tier2_refresh_allowed();
4544 let in_flight = manager.tier2_any_in_flight();
4545 let semantic_cold_seed_active = self.semantic_cold_seed_active();
4546 let decision = self.tier2_refresh_scheduler.lock().tick_with_semantic_gate(
4547 now,
4548 changed_path_count,
4549 can_write,
4550 in_flight,
4551 semantic_cold_seed_active,
4552 );
4553
4554 if let Some(reason) = decision {
4555 self.start_tier2_refresh(reason, manager);
4556 }
4557
4558 decision
4559 }
4560
4561 pub fn note_tier2_refresh_started(&self) {
4562 self.note_tier2_refresh_started_at(Instant::now());
4563 }
4564
4565 #[doc(hidden)]
4566 pub fn note_tier2_refresh_started_at(&self, now: Instant) {
4567 self.tier2_refresh_scheduler
4568 .lock()
4569 .note_external_scan_started(now);
4570 }
4571
4572 pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
4573 self.tier2_refresh_scheduler
4574 .lock()
4575 .last_trigger_reason()
4576 .map(Tier2TriggerReason::as_str)
4577 }
4578
4579 #[doc(hidden)]
4580 pub fn tier2_pull_demand_pending(&self) -> bool {
4581 self.tier2_refresh_scheduler.lock().pull_demand_pending()
4582 }
4583
4584 fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
4585 let generation = self.configure_generation();
4586 if !self.inspect_writer()
4587 || !self.heavy_root_work_allowed()
4588 || !manager.automatic_tier2_refresh_allowed()
4589 || !self.config().inspect.enabled
4590 {
4591 return;
4592 }
4593 let _ = self.run_if_subc_bound_generation(generation, || {
4594 self.start_tier2_refresh_admitted(reason, manager);
4595 });
4596 }
4597
4598 fn start_tier2_refresh_admitted(
4599 &self,
4600 reason: Tier2TriggerReason,
4601 manager: Arc<InspectManager>,
4602 ) {
4603 let Some(snapshot) = self.tier2_refresh_snapshot() else {
4604 return;
4605 };
4606 let categories = InspectCategory::active()
4607 .iter()
4608 .copied()
4609 .filter(|category| category.is_tier2())
4610 .collect::<Vec<_>>();
4611 let submission =
4612 manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
4613 if !submission.deferred_categories.is_empty() {
4614 self.tier2_refresh_scheduler.lock().note_dispatch_deferred();
4615 crate::slog_info!(
4616 "tier2 refresh deferred by cold build limit: categories={:?}",
4617 submission
4618 .deferred_categories
4619 .iter()
4620 .map(|category| category.as_str())
4621 .collect::<Vec<_>>()
4622 );
4623 }
4624 if submission.has_new_work() {
4625 crate::slog_info!(
4626 "tier2 refresh scheduled: reason={}, categories={:?}",
4627 reason.as_str(),
4628 submission
4629 .newly_queued_categories
4630 .iter()
4631 .map(|category| category.as_str())
4632 .collect::<Vec<_>>()
4633 );
4634 }
4635 for error in submission.errors {
4636 crate::slog_warn!(
4637 "tier2 refresh schedule failed for {}: {}",
4638 error.category,
4639 error.message
4640 );
4641 }
4642 }
4643
4644 fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
4645 self.harness_opt()?;
4646 let config = self.config();
4647 let project_root = config
4648 .project_root
4649 .clone()
4650 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
4651 let project_root = crate::inspect::job::canonicalize_normalized(&project_root);
4655 Some(InspectSnapshot::new(
4656 project_root,
4657 self.inspect_dir(),
4658 config,
4659 self.symbol_cache(),
4660 ))
4661 }
4662
4663 pub fn symbol_cache(&self) -> SharedSymbolCache {
4665 Arc::clone(&self.symbol_cache)
4666 }
4667
4668 pub fn reset_symbol_cache(&self) -> u64 {
4670 self.symbol_cache
4671 .write()
4672 .map(|mut cache| cache.reset())
4673 .unwrap_or(0)
4674 }
4675
4676 pub fn semantic_index(&self) -> &RwLock<Option<SemanticIndex>> {
4678 &self.semantic_index
4679 }
4680
4681 pub fn semantic_index_rx(
4683 &self,
4684 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
4685 &self.semantic_index_rx
4686 }
4687
4688 pub(crate) fn install_semantic_index_rx(
4689 &self,
4690 receiver: crossbeam_channel::Receiver<SemanticIndexEvent>,
4691 generation: u64,
4692 ) -> u64 {
4693 let mut slot = self.semantic_index_rx.lock();
4694 self.note_semantic_index_rx_generation(generation);
4695 let epoch = self.next_semantic_index_rx_epoch();
4696 *slot = Some(receiver);
4697 epoch
4698 }
4699
4700 pub(crate) fn semantic_index_rx_terminal_guard(&self, epoch: u64) -> ReceiverTerminalGuard {
4701 ReceiverTerminalGuard::new(Arc::clone(&self.semantic_index_rx_terminal_epoch), epoch)
4702 }
4703
4704 pub(crate) fn with_current_semantic_index_rx<R>(
4707 &self,
4708 generation: u64,
4709 epoch: u64,
4710 action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<SemanticIndexEvent>>) -> R,
4711 ) -> Option<R> {
4712 self.run_if_subc_bound_generation(generation, || {
4713 let mut receiver = self.semantic_index_rx.lock();
4714 if receiver.is_none()
4715 || self.semantic_index_rx_generation() != generation
4716 || self.semantic_index_rx_epoch() != epoch
4717 {
4718 return None;
4719 }
4720 Some(action(&mut receiver))
4721 })
4722 .flatten()
4723 }
4724
4725 pub(crate) fn retire_semantic_index_rx(&self) {
4726 let mut receiver = self.semantic_index_rx.lock();
4727 *receiver = None;
4728 self.next_semantic_index_rx_epoch();
4729 }
4730
4731 pub(crate) fn retire_semantic_index_rx_if_epoch(&self, expected_epoch: u64) -> Option<bool> {
4735 let mut receiver = self.semantic_index_rx.lock();
4736 if self.semantic_index_rx_epoch() != expected_epoch {
4737 return None;
4738 }
4739 let retired = receiver.take().is_some();
4740 if retired {
4741 self.next_semantic_index_rx_epoch();
4742 }
4743 Some(retired)
4744 }
4745
4746 pub(crate) fn note_semantic_index_rx_generation(&self, generation: u64) {
4747 self.semantic_index_rx_generation
4748 .store(generation, Ordering::SeqCst);
4749 }
4750
4751 pub(crate) fn semantic_index_rx_generation(&self) -> u64 {
4752 self.semantic_index_rx_generation.load(Ordering::SeqCst)
4753 }
4754
4755 pub(crate) fn next_semantic_index_rx_epoch(&self) -> u64 {
4756 self.semantic_index_rx_epoch
4757 .fetch_add(1, Ordering::SeqCst)
4758 .wrapping_add(1)
4759 }
4760
4761 pub(crate) fn semantic_index_rx_epoch(&self) -> u64 {
4762 self.semantic_index_rx_epoch.load(Ordering::SeqCst)
4763 }
4764
4765 pub(crate) fn next_semantic_persist_epoch(&self) -> u64 {
4766 self.semantic_persist_epoch.next()
4767 }
4768
4769 pub(crate) fn semantic_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
4770 self.semantic_persist_epoch.clone()
4771 }
4772
4773 pub(crate) fn semantic_persist_lock(&self) -> Arc<parking_lot::Mutex<()>> {
4774 Arc::clone(&self.semantic_persist_lock)
4775 }
4776
4777 pub fn semantic_index_status(&self) -> &RwLock<SemanticIndexStatus> {
4778 &self.semantic_index_status
4779 }
4780
4781 pub(crate) fn artifact_reload_guard(&self) -> parking_lot::MutexGuard<'_, ()> {
4782 self.artifact_reload_lock.lock()
4783 }
4784
4785 pub fn reset_semantic_cold_seed_gate_for_configure(&self) -> u64 {
4788 self.semantic_cold_seed_active
4789 .store(false, Ordering::SeqCst);
4790 self.semantic_callgraph_warm_deferred
4791 .store(false, Ordering::SeqCst);
4792 self.semantic_cold_seed_generation
4793 .fetch_add(1, Ordering::SeqCst)
4794 .wrapping_add(1)
4795 }
4796
4797 pub fn semantic_cold_seed_active_flag(&self) -> Arc<AtomicBool> {
4798 Arc::clone(&self.semantic_cold_seed_active)
4799 }
4800
4801 pub fn semantic_cold_seed_generation_flag(&self) -> Arc<AtomicU64> {
4802 Arc::clone(&self.semantic_cold_seed_generation)
4803 }
4804
4805 pub fn semantic_cold_seed_generation(&self) -> u64 {
4806 self.semantic_cold_seed_generation.load(Ordering::SeqCst)
4807 }
4808
4809 pub fn semantic_cold_seed_active(&self) -> bool {
4810 self.semantic_cold_seed_active.load(Ordering::SeqCst)
4811 }
4812
4813 pub fn schedule_semantic_cold_seed_gate_for_configure(&self) {
4814 self.semantic_cold_seed_active.store(true, Ordering::SeqCst);
4815 }
4816
4817 pub fn defer_callgraph_store_warm_for_semantic_cold_seed(&self) {
4818 self.semantic_callgraph_warm_deferred
4819 .store(true, Ordering::SeqCst);
4820 }
4821
4822 fn semantic_callgraph_warm_deferred(&self) -> bool {
4823 self.semantic_callgraph_warm_deferred.load(Ordering::SeqCst)
4824 }
4825
4826 pub fn clear_semantic_cold_seed_gate_and_resume_deferred_work(&self) {
4830 self.resume_semantic_cold_seed_deferred_work(false);
4831 }
4832
4833 pub fn resume_deferred_work_after_semantic_cold_seed_gate_cleared(&self) {
4836 self.resume_semantic_cold_seed_deferred_work(true);
4837 }
4838
4839 pub(crate) fn take_semantic_cold_seed_resume(&self, force: bool) -> SemanticColdSeedResume {
4840 let was_active = self.semantic_cold_seed_active.swap(false, Ordering::SeqCst);
4841 let warm_callgraph = self
4842 .semantic_callgraph_warm_deferred
4843 .swap(false, Ordering::SeqCst);
4844 SemanticColdSeedResume {
4845 request_tier2: force || was_active || warm_callgraph,
4846 warm_callgraph,
4847 }
4848 }
4849
4850 pub(crate) fn apply_semantic_cold_seed_resume(&self, resume: SemanticColdSeedResume) {
4851 if resume.request_tier2 {
4852 let _ = self.request_tier2_refresh_pull();
4853 }
4854
4855 if !resume.warm_callgraph
4856 || !self.config().callgraph_store
4857 || !self.heavy_root_work_allowed()
4858 {
4859 return;
4860 }
4861
4862 match self.callgraph_store_for_ops() {
4863 CallgraphStoreAccess::Ready(_) => {
4864 crate::slog_debug!(
4865 "deferred callgraph store warm completed after semantic cold seed gate cleared"
4866 );
4867 }
4868 CallgraphStoreAccess::Building => {
4869 crate::slog_info!(
4870 "deferred callgraph store warm scheduled after semantic cold seed gate cleared"
4871 );
4872 }
4873 CallgraphStoreAccess::Unavailable => {
4874 crate::slog_info!(
4875 "deferred callgraph store warm unavailable after semantic cold seed gate cleared"
4876 );
4877 }
4878 CallgraphStoreAccess::Error(error) => {
4879 crate::slog_warn!(
4880 "deferred callgraph store warm failed after semantic cold seed gate cleared: {}",
4881 error
4882 );
4883 }
4884 }
4885 }
4886
4887 fn resume_semantic_cold_seed_deferred_work(&self, force: bool) {
4888 let resume = self.take_semantic_cold_seed_resume(force);
4889 self.apply_semantic_cold_seed_resume(resume);
4890 }
4891
4892 #[doc(hidden)]
4893 pub fn set_semantic_cold_seed_active_for_test(&self, active: bool) {
4894 self.semantic_cold_seed_active
4895 .store(active, Ordering::SeqCst);
4896 }
4897
4898 #[doc(hidden)]
4899 pub fn semantic_callgraph_warm_deferred_for_test(&self) -> bool {
4900 self.semantic_callgraph_warm_deferred()
4901 }
4902
4903 pub fn install_semantic_refresh_worker(
4904 &self,
4905 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
4906 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
4907 worker_slot: SemanticRefreshWorkerSlot,
4908 ) {
4909 self.install_semantic_refresh_worker_for_build_epoch(
4910 sender,
4911 event_rx,
4912 worker_slot,
4913 self.semantic_index_rx_epoch(),
4914 );
4915 }
4916
4917 pub(crate) fn install_semantic_refresh_worker_for_build_epoch(
4918 &self,
4919 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
4920 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
4921 worker_slot: SemanticRefreshWorkerSlot,
4922 build_epoch: u64,
4923 ) {
4924 self.clear_semantic_refresh_worker();
4925 {
4926 let mut receiver = self.semantic_refresh_event_rx.lock();
4927 let mut request = self.semantic_refresh_tx.lock();
4928 let mut worker = self.semantic_refresh_worker.lock();
4929 self.semantic_refresh_generation
4930 .store(self.configure_generation(), Ordering::SeqCst);
4931 self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
4932 self.semantic_refresh_build_epoch
4933 .store(build_epoch, Ordering::SeqCst);
4934 *receiver = Some(event_rx);
4935 *request = Some(sender);
4936 *worker = Some(worker_slot);
4937 }
4938 }
4939
4940 pub(crate) fn semantic_refresh_generation(&self) -> u64 {
4941 self.semantic_refresh_generation.load(Ordering::SeqCst)
4942 }
4943
4944 pub(crate) fn semantic_refresh_epoch(&self) -> u64 {
4945 self.semantic_refresh_epoch.load(Ordering::SeqCst)
4946 }
4947
4948 pub(crate) fn with_current_semantic_refresh_rx<R>(
4951 &self,
4952 generation: u64,
4953 epoch: u64,
4954 action: impl FnOnce() -> R,
4955 ) -> Option<R> {
4956 self.run_if_subc_bound_generation(generation, || {
4957 let receiver = self.semantic_refresh_event_rx.lock();
4958 if receiver.is_none()
4959 || self.semantic_refresh_generation() != generation
4960 || self.semantic_refresh_epoch() != epoch
4961 {
4962 return None;
4963 }
4964 Some(action())
4965 })
4966 .flatten()
4967 }
4968
4969 pub(crate) fn clear_semantic_refresh_worker_if_current(
4970 &self,
4971 generation: u64,
4972 epoch: u64,
4973 ) -> Option<u64> {
4974 let worker_slot = {
4975 let mut receiver = self.semantic_refresh_event_rx.lock();
4976 if receiver.is_none()
4977 || self.semantic_refresh_generation() != generation
4978 || self.semantic_refresh_epoch() != epoch
4979 {
4980 return None;
4981 }
4982 let disconnected_build_epoch = self.semantic_refresh_build_epoch.load(Ordering::SeqCst);
4983 self.semantic_refresh_build_epoch.store(0, Ordering::SeqCst);
4984 let mut request = self.semantic_refresh_tx.lock();
4985 let mut worker = self.semantic_refresh_worker.lock();
4986 *receiver = None;
4987 *request = None;
4988 self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
4989 self.invalidate_semantic_refresh_probe();
4990 (worker.take(), disconnected_build_epoch)
4991 };
4992 if let Some(worker_slot) = worker_slot.0 {
4993 if let Ok(mut handle) = worker_slot.lock() {
4994 drop(handle.take());
4995 }
4996 }
4997 Some(worker_slot.1)
4998 }
4999
5000 pub fn clear_semantic_refresh_worker(&self) {
5001 let worker_slot = {
5002 let mut receiver = self.semantic_refresh_event_rx.lock();
5003 let mut request = self.semantic_refresh_tx.lock();
5004 let mut worker = self.semantic_refresh_worker.lock();
5005 *receiver = None;
5006 *request = None;
5007 self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
5008 self.semantic_refresh_build_epoch.store(0, Ordering::SeqCst);
5009 self.invalidate_semantic_refresh_probe();
5010 worker.take()
5011 };
5012 if let Some(worker_slot) = worker_slot {
5013 if let Ok(mut handle) = worker_slot.lock() {
5014 drop(handle.take());
5015 }
5016 }
5017 }
5018
5019 pub fn semantic_refresh_sender(
5020 &self,
5021 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
5022 self.semantic_refresh_tx.lock().clone()
5023 }
5024
5025 pub(crate) fn semantic_refresh_retry_slots(
5026 &self,
5027 ) -> (
5028 Arc<parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>>,
5029 Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>,
5030 ) {
5031 (
5032 Arc::clone(&self.semantic_refresh_tx),
5033 Arc::clone(&self.pending_semantic_index_paths),
5034 )
5035 }
5036
5037 pub fn semantic_refresh_event_rx(
5038 &self,
5039 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
5040 &self.semantic_refresh_event_rx
5041 }
5042
5043 pub fn with_semantic_refresh_retry_attempts_mut<R>(
5044 &self,
5045 f: impl FnOnce(&mut BTreeMap<PathBuf, usize>) -> R,
5046 ) -> R {
5047 let mut attempts = self.semantic_refresh_retry_attempts.lock();
5048 f(&mut attempts)
5049 }
5050
5051 pub fn clear_semantic_refresh_retry_attempts(&self, paths: &[PathBuf]) {
5052 let mut attempts = self.semantic_refresh_retry_attempts.lock();
5053 for path in paths {
5054 attempts.remove(path);
5055 }
5056 }
5057
5058 pub fn clear_all_semantic_refresh_retry_attempts(&self) {
5059 self.semantic_refresh_retry_attempts.lock().clear();
5060 }
5061
5062 pub fn semantic_refresh_circuit_is_open(&self) -> bool {
5063 self.semantic_refresh_circuit.open.load(Ordering::SeqCst)
5064 }
5065
5066 pub fn record_semantic_refresh_transient_failure(&self, trip_threshold: usize) -> bool {
5067 let failures = self
5068 .semantic_refresh_circuit
5069 .consecutive_transient_failures
5070 .fetch_add(1, Ordering::SeqCst)
5071 .saturating_add(1);
5072 if failures >= trip_threshold
5073 && !self
5074 .semantic_refresh_circuit
5075 .open
5076 .swap(true, Ordering::SeqCst)
5077 {
5078 crate::slog_warn!(
5079 "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
5080 );
5081 }
5082 self.semantic_refresh_circuit_is_open()
5083 }
5084
5085 pub fn trip_semantic_refresh_circuit(&self, trip_threshold: usize) {
5086 self.semantic_refresh_circuit
5087 .consecutive_transient_failures
5088 .store(trip_threshold, Ordering::SeqCst);
5089 if !self
5090 .semantic_refresh_circuit
5091 .open
5092 .swap(true, Ordering::SeqCst)
5093 {
5094 crate::slog_warn!(
5095 "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
5096 );
5097 }
5098 }
5099
5100 pub fn reset_semantic_refresh_transient_failure_count(&self) {
5101 self.semantic_refresh_circuit
5102 .consecutive_transient_failures
5103 .store(0, Ordering::SeqCst);
5104 }
5105
5106 pub fn reset_semantic_refresh_circuit_after_success(&self) {
5107 self.reset_semantic_refresh_transient_failure_count();
5108 self.semantic_refresh_circuit
5109 .probe_ready
5110 .store(false, Ordering::SeqCst);
5111 if self
5112 .semantic_refresh_circuit
5113 .open
5114 .swap(false, Ordering::SeqCst)
5115 {
5116 crate::slog_info!("embedding backend recovered; resuming normal refresh retries");
5117 }
5118 }
5119
5120 pub fn semantic_refresh_transient_failure_count(&self) -> usize {
5121 self.semantic_refresh_circuit
5122 .consecutive_transient_failures
5123 .load(Ordering::SeqCst)
5124 }
5125
5126 pub fn semantic_refresh_probe_is_scheduled(&self) -> bool {
5127 self.semantic_refresh_circuit
5128 .probe_in_flight
5129 .load(Ordering::SeqCst)
5130 || self.semantic_refresh_probe_ready()
5131 }
5132
5133 pub fn semantic_refresh_probe_ready(&self) -> bool {
5134 self.semantic_refresh_circuit
5135 .probe_ready
5136 .load(Ordering::SeqCst)
5137 }
5138
5139 pub fn take_semantic_refresh_probe_ready(&self) -> bool {
5140 self.semantic_refresh_circuit
5141 .probe_ready
5142 .swap(false, Ordering::SeqCst)
5143 }
5144
5145 fn invalidate_semantic_refresh_probe(&self) {
5146 self.semantic_refresh_circuit
5147 .probe_token
5148 .fetch_add(1, Ordering::SeqCst);
5149 self.semantic_refresh_circuit
5150 .probe_ready
5151 .store(false, Ordering::SeqCst);
5152 self.semantic_refresh_circuit
5153 .probe_in_flight
5154 .store(false, Ordering::SeqCst);
5155 }
5156
5157 pub fn ensure_semantic_refresh_probe_scheduled(&self, delay: Duration) {
5158 let receiver = self.semantic_refresh_event_rx.lock();
5159 if receiver.is_none()
5160 || self
5161 .semantic_refresh_circuit
5162 .probe_ready
5163 .load(Ordering::SeqCst)
5164 || self
5165 .semantic_refresh_circuit
5166 .probe_in_flight
5167 .swap(true, Ordering::SeqCst)
5168 {
5169 return;
5170 }
5171 let probe_token = self
5172 .semantic_refresh_circuit
5173 .probe_token
5174 .fetch_add(1, Ordering::SeqCst)
5175 .wrapping_add(1);
5176 drop(receiver);
5177
5178 let circuit = Arc::clone(&self.semantic_refresh_circuit);
5179 let session_id = crate::log_ctx::current_session();
5180 std::thread::spawn(move || {
5181 crate::log_ctx::with_session(session_id, || {
5182 std::thread::sleep(delay);
5183 if circuit.probe_token.load(Ordering::SeqCst) == probe_token {
5184 circuit.probe_ready.store(true, Ordering::SeqCst);
5185 circuit.probe_in_flight.store(false, Ordering::SeqCst);
5186 }
5187 });
5188 });
5189 }
5190
5191 pub fn semantic_embedding_model(
5193 &self,
5194 ) -> &parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>> {
5195 &self.semantic_embedding_model
5196 }
5197
5198 pub fn watcher(&self) -> &parking_lot::Mutex<Option<RecommendedWatcher>> {
5200 &self.watcher
5201 }
5202
5203 pub fn watcher_rx(
5205 &self,
5206 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>> {
5207 &self.watcher_rx
5208 }
5209
5210 pub(crate) fn watcher_drain_slice(
5212 &self,
5213 ) -> &parking_lot::Mutex<Option<WatcherDrainSliceState>> {
5214 &self.watcher_drain_slice
5215 }
5216
5217 pub fn watcher_drain_pending_path_count(&self) -> usize {
5219 self.watcher_drain_slice.lock().as_ref().map_or(0, |state| {
5220 let active_paths = match &state.phase {
5221 WatcherDrainPhase::Collect => 0,
5222 WatcherDrainPhase::Apply { paths, .. } => paths.len(),
5223 };
5224 active_paths + state.pending_paths.len()
5225 })
5226 }
5227
5228 pub fn watcher_drain_path_slice_count(&self) -> usize {
5230 self.watcher_drain_slice
5231 .lock()
5232 .as_ref()
5233 .map_or(0, |state| state.path_slice_count)
5234 }
5235
5236 pub fn install_watcher_runtime(
5239 &self,
5240 rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
5241 runtime: WatcherThreadHandle,
5242 ) {
5243 let _runtime_guard = self.watcher_runtime_lock.lock();
5244 let replaced = self.watcher_thread.lock().replace(runtime);
5245 self.app.watcher_started();
5246 if let Some(runtime) = replaced {
5247 Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
5248 }
5249 *self.watcher_rx.lock() = Some(rx);
5250 *self.watcher_drain_slice.lock() = None;
5251 }
5252
5253 fn watcher_root_path(&self) -> PathBuf {
5254 self.canonical_cache_root_opt()
5255 .or_else(|| self.config().project_root.clone())
5256 .unwrap_or_else(|| PathBuf::from("<unconfigured>"))
5257 }
5258
5259 fn spawn_watcher_shutdown(app: Arc<App>, root: PathBuf, runtime: WatcherThreadHandle) {
5260 const JOIN_TIMEOUT: Duration = Duration::from_secs(2);
5261 runtime.request_shutdown();
5264 std::thread::spawn(
5265 move || match runtime.shutdown_and_join_timeout(JOIN_TIMEOUT) {
5266 WatcherJoinOutcome::Joined => {
5267 app.watcher_stopped();
5268 crate::slog_info!("watcher stopped: {}", root.display());
5269 }
5270 WatcherJoinOutcome::TimedOut(join) => {
5271 crate::slog_warn!(
5272 "watcher stop timed out after {} ms: {}",
5273 JOIN_TIMEOUT.as_millis(),
5274 root.display()
5275 );
5276 std::thread::spawn(move || {
5277 let _ = join.join();
5278 app.watcher_stopped();
5279 crate::slog_info!("watcher stopped: {}", root.display());
5280 });
5281 }
5282 },
5283 );
5284 }
5285
5286 fn take_watcher_runtime(&self) -> Option<WatcherThreadHandle> {
5287 let _runtime_guard = self.watcher_runtime_lock.lock();
5288 let runtime = self.watcher_thread.lock().take();
5289 *self.watcher_rx.lock() = None;
5290 *self.watcher_drain_slice.lock() = None;
5291 *self.watcher.lock() = None;
5292 runtime
5293 }
5294
5295 pub fn stop_watcher_runtime(&self) {
5299 if let Some(runtime) = self.take_watcher_runtime() {
5300 Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
5301 }
5302 }
5303
5304 pub fn stop_watcher_runtime_in_background(&self) {
5306 self.stop_watcher_runtime();
5307 }
5308
5309 pub(crate) fn take_finished_watcher_runtime(&self) -> bool {
5314 let runtime = {
5315 let _runtime_guard = self.watcher_runtime_lock.lock();
5316 let finished = self
5317 .watcher_thread
5318 .lock()
5319 .as_ref()
5320 .is_some_and(|runtime| runtime.is_finished());
5321 if !finished {
5322 return false;
5323 }
5324 let runtime = self.watcher_thread.lock().take();
5325 *self.watcher_rx.lock() = None;
5326 *self.watcher_drain_slice.lock() = None;
5327 *self.watcher.lock() = None;
5328 runtime
5329 };
5330 if let Some(runtime) = runtime {
5331 Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
5332 }
5333 true
5334 }
5335
5336 pub fn watcher_registry_count(&self) -> usize {
5339 self.app.watcher_count()
5340 }
5341
5342 pub(crate) fn watcher_runtime_active(&self) -> bool {
5343 let _runtime_guard = self.watcher_runtime_lock.lock();
5344 let thread_live = self
5349 .watcher_thread
5350 .lock()
5351 .as_ref()
5352 .is_some_and(|runtime| !runtime.is_finished());
5353 thread_live && self.watcher_rx.lock().is_some()
5354 }
5355
5356 pub fn artifact_eviction_blocked(&self) -> bool {
5360 let semantic_refresh_in_flight = match &*self
5361 .semantic_index_status
5362 .read()
5363 .unwrap_or_else(std::sync::PoisonError::into_inner)
5364 {
5365 SemanticIndexStatus::Building { .. } => true,
5366 SemanticIndexStatus::Ready { refreshing, .. } => !refreshing.is_empty(),
5367 SemanticIndexStatus::Disabled | SemanticIndexStatus::Failed(_) => false,
5368 };
5369 if crate::runtime_drain::any_build_in_flight(self)
5370 || semantic_refresh_in_flight
5371 || self.inspect_manager.tier2_any_in_flight()
5372 || !self.bash_background.running_tasks().is_empty()
5373 || !self.pending_callgraph_store_paths.lock().is_empty()
5374 || !self.pending_search_index_paths.lock().is_empty()
5375 || !self.pending_tier2_paths.lock().is_empty()
5376 || !self.pending_semantic_index_paths.lock().is_empty()
5377 || *self.pending_semantic_corpus_refresh.lock()
5378 {
5379 return true;
5380 }
5381
5382 let search_has_pending_disk_changes = self
5383 .search_index
5384 .read()
5385 .unwrap_or_else(std::sync::PoisonError::into_inner)
5386 .as_ref()
5387 .is_some_and(SearchIndex::has_pending_disk_changes);
5388 search_has_pending_disk_changes
5389 }
5390
5391 pub fn evict_idle_artifacts(&self) -> bool {
5396 if self.artifact_eviction_blocked() {
5397 return false;
5398 }
5399
5400 self.callgraph_store
5401 .write()
5402 .unwrap_or_else(std::sync::PoisonError::into_inner)
5403 .take();
5404 self.search_index
5405 .write()
5406 .unwrap_or_else(std::sync::PoisonError::into_inner)
5407 .take();
5408 self.semantic_index
5409 .write()
5410 .unwrap_or_else(std::sync::PoisonError::into_inner)
5411 .take();
5412 self.borrowed_index_cache.lock().clear();
5413 self.inspect_manager.evict_idle_caches();
5414 self.reset_symbol_cache();
5415 self.clear_tsconfig_membership_cache();
5416 true
5417 }
5418
5419 #[doc(hidden)]
5422 pub fn force_idle_teardown_for_test(self: &Arc<Self>) -> bool {
5423 if std::env::var("AFT_TEST_ALLOW_FORCE_IDLE_REAP").as_deref() != Ok("1") {
5424 return false;
5425 }
5426 if !self.evict_idle_artifacts() {
5427 return false;
5428 }
5429 self.stop_watcher_runtime_in_background();
5430 self.invalidate_artifacts_after_watcher_gap();
5431 true
5432 }
5433
5434 pub(crate) fn release_idle_reopenable_resources_in_background(self: &Arc<Self>) {
5438 let ctx = Arc::clone(self);
5439 std::thread::spawn(move || {
5440 if !ctx.subc_unbound_quiesced() {
5441 return;
5442 }
5443 {
5444 let mut lsp = ctx.lsp_manager.lock();
5445 if !ctx.subc_unbound_quiesced() {
5446 return;
5447 }
5448 lsp.shutdown_all();
5449 }
5450 let _ = ctx.subc_lifecycle.run_if_unbound(|| {
5451 ctx.bash_background.clear_db_pool();
5452 ctx.backup.lock().clear_db_pool();
5453 });
5454 });
5455 }
5456
5457 pub(crate) fn teardown_deleted_root(&self) {
5461 self.bash_background.detach();
5462 self.bash_background.clear_db_pool();
5463 self.backup.lock().clear_db_pool();
5464 self.lsp_manager.lock().shutdown_all();
5465 }
5466
5467 pub fn lsp(&self) -> parking_lot::MutexGuard<'_, LspManager> {
5469 self.lsp_manager.lock()
5470 }
5471
5472 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
5475 let config = self.config();
5476 if let Some(mut lsp) = self.lsp_manager.try_lock() {
5477 if let Err(e) = lsp.notify_file_changed_if_running(file_path, content, &config) {
5478 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
5479 }
5480 }
5481 }
5482
5483 pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
5489 if let Some(mut lsp) = self.lsp_manager.try_lock() {
5490 lsp.clear_diagnostics_for_file(file_path)
5491 } else {
5492 false
5493 }
5494 }
5495
5496 pub fn lsp_mark_diagnostics_stale_for_file(&self, file_path: &Path) -> StaleDiagnosticsMark {
5500 if let Some(mut lsp) = self.lsp_manager.try_lock() {
5501 lsp.mark_diagnostics_stale_for_file(file_path)
5502 } else {
5503 StaleDiagnosticsMark::default()
5504 }
5505 }
5506
5507 pub fn lsp_resync_changed_file_for_diagnostics(&self, file_path: &Path) -> bool {
5515 if !file_path.is_file() {
5516 return false;
5517 }
5518
5519 let content = match std::fs::read_to_string(file_path) {
5520 Ok(content) => content,
5521 Err(err) => {
5522 crate::slog_warn!(
5523 "skipping LSP resync for {} after external edit: {}",
5524 file_path.display(),
5525 err
5526 );
5527 return false;
5528 }
5529 };
5530
5531 let config = self.config();
5532 if let Some(mut lsp) = self.lsp_manager.try_lock() {
5533 if let Err(err) = lsp.notify_file_changed(file_path, &content, &config) {
5534 crate::slog_warn!(
5535 "LSP resync failed for {} after external edit: {}",
5536 file_path.display(),
5537 err
5538 );
5539 return false;
5540 }
5541 true
5542 } else {
5543 false
5544 }
5545 }
5546
5547 pub fn lsp_notify_and_collect_diagnostics(
5558 &self,
5559 file_path: &Path,
5560 content: &str,
5561 timeout: std::time::Duration,
5562 ) -> crate::lsp::manager::PostEditWaitOutcome {
5563 let config = self.config();
5564 let Some(mut lsp) = self.lsp_manager.try_lock() else {
5565 return crate::lsp::manager::PostEditWaitOutcome::default();
5566 };
5567
5568 lsp.drain_events();
5571
5572 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
5576
5577 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
5579 {
5580 Ok(v) => v,
5581 Err(e) => {
5582 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
5583 return crate::lsp::manager::PostEditWaitOutcome::default();
5584 }
5585 };
5586
5587 if expected_versions.is_empty() {
5590 return crate::lsp::manager::PostEditWaitOutcome::default();
5591 }
5592
5593 lsp.wait_for_post_edit_diagnostics(
5594 file_path,
5595 &config,
5596 &expected_versions,
5597 &pre_snapshot,
5598 timeout,
5599 )
5600 }
5601
5602 fn custom_lsp_root_markers(&self) -> Vec<String> {
5605 self.config()
5606 .lsp_servers
5607 .iter()
5608 .flat_map(|s| s.root_markers.iter().cloned())
5609 .collect()
5610 }
5611
5612 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
5613 let custom_markers = self.custom_lsp_root_markers();
5614 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
5615 .iter()
5616 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
5617 .cloned()
5618 .map(|path| {
5619 let change_type = if path.exists() {
5620 FileChangeType::CHANGED
5621 } else {
5622 FileChangeType::DELETED
5623 };
5624 (path, change_type)
5625 })
5626 .collect();
5627
5628 self.notify_watched_config_events(&config_paths);
5629 }
5630
5631 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
5632 let paths = params
5633 .get("multi_file_write_paths")
5634 .and_then(|value| value.as_array())?
5635 .iter()
5636 .filter_map(|value| value.as_str())
5637 .map(PathBuf::from)
5638 .collect::<Vec<_>>();
5639
5640 (!paths.is_empty()).then_some(paths)
5641 }
5642
5643 fn watched_file_events_from_params(
5655 params: &serde_json::Value,
5656 extra_markers: &[String],
5657 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
5658 let events = params
5659 .get("multi_file_write_paths")
5660 .and_then(|value| value.as_array())?
5661 .iter()
5662 .filter_map(|entry| {
5663 let path = entry
5665 .get("path")
5666 .and_then(|value| value.as_str())
5667 .map(PathBuf::from)?;
5668
5669 if !is_config_file_path_with_custom(&path, extra_markers) {
5670 return None;
5671 }
5672
5673 let change_type = entry
5674 .get("type")
5675 .and_then(|value| value.as_str())
5676 .and_then(Self::parse_file_change_type)
5677 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
5678
5679 Some((path, change_type))
5680 })
5681 .collect::<Vec<_>>();
5682
5683 (!events.is_empty()).then_some(events)
5684 }
5685
5686 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
5687 match value {
5688 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
5689 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
5690 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
5691 _ => None,
5692 }
5693 }
5694
5695 fn change_type_from_current_state(path: &Path) -> FileChangeType {
5696 if path.exists() {
5697 FileChangeType::CHANGED
5698 } else {
5699 FileChangeType::DELETED
5700 }
5701 }
5702
5703 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
5704 if config_paths.is_empty() {
5705 return;
5706 }
5707
5708 let config = self.config();
5709 if let Some(mut lsp) = self.lsp_manager.try_lock() {
5710 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
5711 crate::slog_warn!("watched-file sync error: {}", e);
5712 }
5713 }
5714 }
5715
5716 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
5717 let custom_markers = self.custom_lsp_root_markers();
5718 if !is_config_file_path_with_custom(file_path, &custom_markers) {
5719 return;
5720 }
5721
5722 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
5723 }
5724
5725 pub fn lsp_post_multi_file_write(
5730 &self,
5731 file_path: &Path,
5732 content: &str,
5733 file_paths: &[PathBuf],
5734 params: &serde_json::Value,
5735 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
5736 self.notify_watched_config_files(file_paths);
5737 self.add_pending_tier2_paths(file_paths.iter().cloned());
5738 let _ = self.mark_status_bar_tier2_stale();
5739
5740 let wants_diagnostics = params
5741 .get("diagnostics")
5742 .and_then(|v| v.as_bool())
5743 .unwrap_or(false);
5744
5745 if !wants_diagnostics {
5746 self.lsp_notify_file_changed(file_path, content);
5747 return None;
5748 }
5749
5750 let wait_ms = params
5751 .get("wait_ms")
5752 .and_then(|v| v.as_u64())
5753 .unwrap_or(3000)
5754 .min(10_000);
5755
5756 Some(self.lsp_notify_and_collect_diagnostics(
5757 file_path,
5758 content,
5759 std::time::Duration::from_millis(wait_ms),
5760 ))
5761 }
5762
5763 pub fn lsp_post_write(
5780 &self,
5781 file_path: &Path,
5782 content: &str,
5783 params: &serde_json::Value,
5784 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
5785 let wants_diagnostics = params
5786 .get("diagnostics")
5787 .and_then(|v| v.as_bool())
5788 .unwrap_or(false);
5789
5790 let custom_markers = self.custom_lsp_root_markers();
5791 if let Some(file_paths) = Self::multi_file_write_paths(params) {
5792 self.add_pending_tier2_paths(file_paths);
5793 } else {
5794 self.add_pending_tier2_paths([file_path.to_path_buf()]);
5795 }
5796 let _ = self.mark_status_bar_tier2_stale();
5797
5798 if !wants_diagnostics {
5799 if let Some(file_paths) = Self::multi_file_write_paths(params) {
5800 self.notify_watched_config_files(&file_paths);
5801 } else if let Some(config_events) =
5802 Self::watched_file_events_from_params(params, &custom_markers)
5803 {
5804 self.notify_watched_config_events(&config_events);
5805 }
5806 self.lsp_notify_file_changed(file_path, content);
5807 return None;
5808 }
5809
5810 let wait_ms = params
5811 .get("wait_ms")
5812 .and_then(|v| v.as_u64())
5813 .unwrap_or(3000)
5814 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
5817 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
5818 }
5819
5820 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
5821 {
5822 self.notify_watched_config_events(&config_events);
5823 }
5824
5825 Some(self.lsp_notify_and_collect_diagnostics(
5826 file_path,
5827 content,
5828 std::time::Duration::from_millis(wait_ms),
5829 ))
5830 }
5831
5832 fn path_restriction_context(
5833 &self,
5834 req_id: &str,
5835 path: &Path,
5836 ) -> Result<Option<PathRestrictionContext>, crate::protocol::Response> {
5837 let config = self.config();
5838 let force_restrict = self.request_force_restrict(req_id);
5839 if !config.restrict_to_project_root && !force_restrict {
5840 return Ok(None);
5841 }
5842 let root = match &config.project_root {
5843 Some(root) => root.clone(),
5844 None if force_restrict => {
5845 return Err(crate::protocol::Response::error(
5846 req_id,
5847 "path_outside_root",
5848 "project root is required when path restriction is forced",
5849 ));
5850 }
5851 None => return Ok(None),
5852 };
5853 drop(config);
5854
5855 let raw_root = root.clone();
5856 let resolved_root = std::fs::canonicalize(&root).unwrap_or(root);
5857 let path_for_resolution = if path.is_relative() {
5858 raw_root.join(path)
5859 } else {
5860 path.to_path_buf()
5861 };
5862 Ok(Some(PathRestrictionContext {
5863 raw_root,
5864 resolved_root,
5865 path_for_resolution,
5866 }))
5867 }
5868
5869 pub fn validate_path(
5878 &self,
5879 req_id: &str,
5880 path: &Path,
5881 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
5882 self.validate_path_with_artifact_session(req_id, path, None)
5883 }
5884
5885 pub fn validate_write_location(
5892 &self,
5893 req_id: &str,
5894 path: &Path,
5895 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
5896 let Some(PathRestrictionContext {
5897 raw_root,
5898 resolved_root,
5899 path_for_resolution,
5900 }) = self.path_restriction_context(req_id, path)?
5901 else {
5902 return Ok(path.to_path_buf());
5903 };
5904 let normalized = normalize_path(&path_for_resolution);
5905 let Some(file_name) = normalized.file_name() else {
5906 return self.validate_path(req_id, path);
5907 };
5908 let parent = normalized.parent().unwrap_or_else(|| Path::new(""));
5909 let resolved_parent = match std::fs::canonicalize(parent) {
5910 Ok(resolved) => resolved,
5911 Err(_) => {
5912 reject_escaping_symlink(req_id, path, parent, &resolved_root, &raw_root)?;
5913 resolve_with_existing_ancestors(parent)
5914 }
5915 };
5916 let resolved = normalize_path(&resolved_parent.join(file_name));
5917
5918 if !resolved.starts_with(&resolved_root) {
5919 return Err(path_error_response(req_id, path, &resolved_root));
5920 }
5921
5922 Ok(resolved)
5923 }
5924
5925 pub fn validate_read_path(
5931 &self,
5932 req_id: &str,
5933 session_id: &str,
5934 path: &Path,
5935 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
5936 self.validate_path_with_artifact_session(req_id, path, Some(session_id))
5937 }
5938
5939 fn validate_path_with_artifact_session(
5940 &self,
5941 req_id: &str,
5942 path: &Path,
5943 artifact_session_id: Option<&str>,
5944 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
5945 let Some(PathRestrictionContext {
5946 raw_root,
5947 resolved_root,
5948 path_for_resolution,
5949 }) = self.path_restriction_context(req_id, path)?
5950 else {
5951 return Ok(path.to_path_buf());
5954 };
5955
5956 let resolved = match std::fs::canonicalize(&path_for_resolution) {
5961 Ok(resolved) => resolved,
5962 Err(_) => {
5963 let normalized = normalize_path(&path_for_resolution);
5964 reject_escaping_symlink(
5965 req_id,
5966 &path_for_resolution,
5967 &normalized,
5968 &resolved_root,
5969 &raw_root,
5970 )?;
5971 resolve_with_existing_ancestors(&normalized)
5972 }
5973 };
5974
5975 if !resolved.starts_with(&resolved_root) {
5976 let is_owned_bash_artifact = artifact_session_id.is_some_and(|session_id| {
5977 self.bash_background
5978 .is_session_owned_artifact_path(session_id, &resolved)
5979 });
5980 if !is_owned_bash_artifact {
5981 return Err(path_error_response(req_id, path, &resolved_root));
5982 }
5983 }
5984
5985 Ok(resolved)
5986 }
5987
5988 pub fn lsp_server_count(&self) -> usize {
5990 self.lsp_manager
5991 .try_lock()
5992 .map(|lsp| lsp.server_count())
5993 .unwrap_or(0)
5994 }
5995
5996 pub fn symbol_cache_stats(&self) -> serde_json::Value {
5998 let entries = self
5999 .symbol_cache
6000 .read()
6001 .map(|cache| cache.len())
6002 .unwrap_or(0);
6003 serde_json::json!({
6004 "local_entries": entries,
6005 "warm_entries": 0,
6006 })
6007 }
6008
6009 pub fn memory_root_snapshot(&self) -> crate::memory::RootMemorySnapshot {
6013 let semantic = match self.semantic_index.try_read() {
6014 Ok(index) => index
6015 .as_ref()
6016 .map(SemanticIndex::estimated_memory)
6017 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("entries", 0)),
6018 Err(TryLockError::Poisoned(error)) => error
6019 .into_inner()
6020 .as_ref()
6021 .map(SemanticIndex::estimated_memory)
6022 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("entries", 0)),
6023 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
6024 };
6025 let trigram = match self.search_index.try_read() {
6026 Ok(index) => index
6027 .as_ref()
6028 .map(SearchIndex::estimated_memory)
6029 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("files", 0)),
6030 Err(TryLockError::Poisoned(error)) => error
6031 .into_inner()
6032 .as_ref()
6033 .map(SearchIndex::estimated_memory)
6034 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("files", 0)),
6035 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
6036 };
6037 let symbols = match self.symbol_cache.try_read() {
6038 Ok(cache) => cache.estimated_memory(),
6039 Err(TryLockError::Poisoned(error)) => error.into_inner().estimated_memory(),
6040 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
6041 };
6042 let callgraph = match self.callgraph_store.try_read() {
6043 Ok(store) => store
6044 .as_ref()
6045 .map(|store| store.estimated_memory())
6046 .unwrap_or_else(|| {
6047 crate::memory::MemoryEstimate::estimated(0).count("open_generation_handles", 0)
6048 }),
6049 Err(TryLockError::Poisoned(error)) => error
6050 .into_inner()
6051 .as_ref()
6052 .map(|store| store.estimated_memory())
6053 .unwrap_or_else(|| {
6054 crate::memory::MemoryEstimate::estimated(0).count("open_generation_handles", 0)
6055 }),
6056 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
6057 };
6058 let inspect = self.inspect_manager.estimated_memory();
6059 let bash = self.bash_background.estimated_memory();
6060 let lsp = self
6061 .lsp_manager
6062 .try_lock()
6063 .map(|lsp| lsp.estimated_memory())
6064 .unwrap_or_else(crate::memory::MemoryEstimate::busy);
6065 let parser_pool = crate::memory::MemoryEstimate::not_estimated()
6069 .count("pooled_parsers", 0)
6070 .gap("tree_sitter_parser_bytes");
6071 crate::memory::RootMemorySnapshot::new(
6072 semantic,
6073 trigram,
6074 symbols,
6075 callgraph,
6076 inspect,
6077 bash,
6078 lsp,
6079 parser_pool,
6080 )
6081 }
6082
6083 pub fn memory_snapshot(&self, current_root: Option<&Path>) -> crate::memory::MemorySnapshot {
6086 let mut roots = BTreeMap::new();
6087 let (roots_status, contexts) = match self.app.try_memory_contexts() {
6088 Some(contexts) => ("ready", contexts),
6089 None => ("busy", Vec::new()),
6090 };
6091 for (root, context) in contexts {
6092 roots.insert(root.display().to_string(), context.memory_root_snapshot());
6093 }
6094 let current_label = current_root
6098 .map(|root| {
6099 cortexkit_paths::ProjectRootId::from_path(root)
6100 .map(|id| id.as_path().display().to_string())
6101 .unwrap_or_else(|_| root.display().to_string())
6102 })
6103 .unwrap_or_else(|| "<unconfigured>".to_string());
6104 roots
6105 .entry(current_label)
6106 .or_insert_with(|| self.memory_root_snapshot());
6107 crate::memory::MemorySnapshot::new(roots_status, roots)
6108 }
6109}
6110
6111#[cfg(test)]
6112mod subc_lifecycle_admission_tests {
6113 use super::*;
6114
6115 #[test]
6116 fn route_teardown_does_not_supersede_disk_artifact_compatibility() {
6117 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6118 ctx.note_configure_warm_key("config-a".to_string());
6119 let content_generation = ctx.configure_content_generation();
6120 let lifecycle_generation = ctx.configure_generation();
6121 let search_epoch = ctx.next_search_persist_epoch();
6122 let semantic_epoch = ctx.next_semantic_persist_epoch();
6123 let search_persist_epoch = ctx.search_persist_epoch_flag();
6124 let semantic_persist_epoch = ctx.semantic_persist_epoch_flag();
6125
6126 ctx.mark_subc_unbound();
6127 assert!(ctx.configure_generation() > lifecycle_generation);
6128 assert_eq!(ctx.configure_content_generation(), content_generation);
6129 assert_eq!(search_persist_epoch.current(), search_epoch);
6130 assert_eq!(semantic_persist_epoch.current(), semantic_epoch);
6131
6132 ctx.mark_subc_bound();
6133 ctx.note_configure_warm_key("config-b".to_string());
6134 assert!(ctx.configure_content_generation() > content_generation);
6135 let replacement_search_epoch = ctx.next_search_persist_epoch();
6136 let replacement_semantic_epoch = ctx.next_semantic_persist_epoch();
6137 assert!(replacement_search_epoch > search_epoch);
6138 assert!(replacement_semantic_epoch > semantic_epoch);
6139 assert_eq!(search_persist_epoch.current(), replacement_search_epoch);
6140 assert_eq!(semantic_persist_epoch.current(), replacement_semantic_epoch);
6141 }
6142
6143 #[test]
6144 fn lifecycle_gate_serializes_unbind_with_worker_start_commit() {
6145 let admission = SubcLifecycleAdmission::default();
6146 let generation = Arc::new(AtomicU64::new(11));
6147 let expected = generation.load(Ordering::SeqCst);
6148 let starts = Arc::new(AtomicUsize::new(0));
6149 let (entered_tx, entered_rx) = std::sync::mpsc::channel();
6150 let (release_tx, release_rx) = std::sync::mpsc::channel();
6151
6152 let worker_admission = admission.clone();
6153 let worker_generation = Arc::clone(&generation);
6154 let worker_starts = Arc::clone(&starts);
6155 let worker = std::thread::spawn(move || {
6156 worker_admission.run_if_current(&worker_generation, expected, || {
6157 entered_tx.send(()).unwrap();
6158 release_rx.recv().unwrap();
6159 worker_starts.fetch_add(1, Ordering::SeqCst);
6160 })
6161 });
6162 entered_rx.recv().unwrap();
6163
6164 let unbind_admission = admission.clone();
6165 let unbind_generation = Arc::clone(&generation);
6166 let (unbound_tx, unbound_rx) = std::sync::mpsc::channel();
6167 let unbind = std::thread::spawn(move || {
6168 unbind_admission.mark_unbound(&unbind_generation);
6169 unbound_tx.send(()).unwrap();
6170 });
6171
6172 assert!(
6173 unbound_rx
6174 .recv_timeout(std::time::Duration::from_millis(50))
6175 .is_err(),
6176 "unbind must wait for an admitted worker-start commit"
6177 );
6178 release_tx.send(()).unwrap();
6179 assert!(worker.join().unwrap().is_some());
6180 unbound_rx
6181 .recv_timeout(std::time::Duration::from_secs(1))
6182 .unwrap();
6183 unbind.join().unwrap();
6184 assert_eq!(starts.load(Ordering::SeqCst), 1);
6185 assert!(
6186 admission
6187 .run_if_current(&generation, generation.load(Ordering::SeqCst), || {
6188 starts.fetch_add(1, Ordering::SeqCst);
6189 })
6190 .is_none(),
6191 "worker starts after unbind must be denied"
6192 );
6193 }
6194
6195 #[test]
6196 fn health_snapshot_returns_busy_before_locking_artifact_receivers() {
6197 let ctx = Arc::new(AppContext::new(
6198 default_language_provider_factory(),
6199 Config::default(),
6200 ));
6201 let lifecycle_guard = ctx.subc_lifecycle.unbound.lock();
6202 let (started_tx, started_rx) = std::sync::mpsc::channel();
6203 let (snapshot_tx, snapshot_rx) = std::sync::mpsc::channel();
6204 let worker_ctx = Arc::clone(&ctx);
6205 let worker = std::thread::spawn(move || {
6206 started_tx.send(()).unwrap();
6207 snapshot_tx
6208 .send(worker_ctx.try_health_snapshot(Path::new("health-root")))
6209 .unwrap();
6210 });
6211 started_rx
6212 .recv_timeout(Duration::from_secs(1))
6213 .expect("health snapshot worker should start");
6214
6215 let snapshot = snapshot_rx.recv_timeout(Duration::from_secs(2));
6216 let callgraph_receiver_available = ctx.callgraph_store_rx.try_lock().is_some();
6217 drop(lifecycle_guard);
6218 worker.join().unwrap();
6219
6220 assert!(
6221 matches!(
6222 snapshot,
6223 Ok(RootHealthSnapshot {
6224 state: RootHealthState::Busy,
6225 ..
6226 })
6227 ),
6228 "health snapshots must report busy instead of waiting for lifecycle admission"
6229 );
6230 assert!(
6231 callgraph_receiver_available,
6232 "health snapshots must not hold the callgraph receiver while lifecycle admission is busy"
6233 );
6234 }
6235
6236 #[test]
6237 fn borrow_only_root_with_partial_tier2_aggregates_reports_disabled() {
6238 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6239 ctx.set_artifact_owner(
6240 Some(crate::artifact_owner::ArtifactOwnerStatus {
6241 mode: crate::artifact_owner::ArtifactOwnerMode::ReadOnly,
6242 project_key: "borrowed".to_string(),
6243 manifest_path: "manifest.json".to_string(),
6244 owner_project_scope_key: "owner".to_string(),
6245 owner_checkout_path: "/owner".to_string(),
6246 note: None,
6247 }),
6248 None,
6249 );
6250 ctx.update_status_bar_tier2(Some(4), None, None, None, true);
6251
6252 let snapshot = ctx.try_health_snapshot(Path::new("borrow-only-root"));
6253
6254 assert_eq!(snapshot.tier2.expect("tier2 health").status, "disabled");
6255 }
6256
6257 #[test]
6258 fn worktree_guard_prevents_partial_tier2_from_reporting_building() {
6259 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6260 ctx.set_cache_writer_capabilities(true, true);
6261 ctx.update_status_bar_tier2(Some(4), None, None, None, true);
6262 assert_eq!(
6263 ctx.try_health_snapshot(Path::new("writer-root"))
6264 .tier2
6265 .expect("tier2 health")
6266 .status,
6267 "building"
6268 );
6269
6270 ctx.set_cache_role(true, None);
6271
6272 assert_eq!(
6273 ctx.try_health_snapshot(Path::new("worktree-root"))
6274 .tier2
6275 .expect("tier2 health")
6276 .status,
6277 "disabled"
6278 );
6279 }
6280
6281 #[test]
6282 fn unbound_artifact_cancellation_clears_semantic_refresh_state() {
6283 let temp = tempfile::tempdir().unwrap();
6284 let ctx = AppContext::new(
6285 default_language_provider_factory(),
6286 Config {
6287 project_root: Some(temp.path().to_path_buf()),
6288 semantic_search: true,
6289 ..Config::default()
6290 },
6291 );
6292 *ctx.semantic_index()
6293 .write()
6294 .unwrap_or_else(std::sync::PoisonError::into_inner) =
6295 Some(SemanticIndex::new(temp.path().to_path_buf(), 3));
6296 let mut status = SemanticIndexStatus::ready();
6297 status.add_refreshing_file(temp.path().join("changed.rs"));
6298 *ctx.semantic_index_status()
6299 .write()
6300 .unwrap_or_else(std::sync::PoisonError::into_inner) = status;
6301 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
6302 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
6303 ctx.install_semantic_refresh_worker_for_build_epoch(
6304 request_tx,
6305 event_rx,
6306 Arc::new(Mutex::new(None)),
6307 ctx.semantic_index_rx_epoch(),
6308 );
6309
6310 ctx.cancel_unbound_artifact_work();
6311
6312 assert!(ctx.semantic_refresh_event_rx().lock().is_none());
6313 assert!(matches!(
6314 &*ctx
6315 .semantic_index_status()
6316 .read()
6317 .unwrap_or_else(std::sync::PoisonError::into_inner),
6318 SemanticIndexStatus::Ready { refreshing, .. } if refreshing.is_empty()
6319 ));
6320 }
6321
6322 #[test]
6323 fn terminal_empty_search_receiver_reports_completion_work() {
6324 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6325 let (sender, receiver) = crossbeam_channel::unbounded();
6326 let epoch = ctx.install_search_index_rx(receiver, ctx.configure_generation());
6327 let terminal_guard = ctx.search_index_rx_terminal_guard(epoch);
6328 drop(sender);
6329 drop(terminal_guard);
6330
6331 assert!(
6332 ctx.completion_drains_have_work(),
6333 "an empty disconnected one-shot receiver must wake the completion drain"
6334 );
6335 }
6336
6337 #[test]
6338 fn conditional_semantic_receiver_retire_preserves_replacement_epoch() {
6339 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6340 let (_old_sender, old_receiver) = crossbeam_channel::unbounded();
6341 let old_epoch = ctx.install_semantic_index_rx(old_receiver, ctx.configure_generation());
6342 let (_replacement_sender, replacement_receiver) = crossbeam_channel::unbounded();
6343 let replacement_epoch =
6344 ctx.install_semantic_index_rx(replacement_receiver, ctx.configure_generation());
6345
6346 assert!(replacement_epoch > old_epoch);
6347 assert_eq!(ctx.retire_semantic_index_rx_if_epoch(old_epoch), None);
6348 assert!(ctx.semantic_index_rx().lock().is_some());
6349 assert_eq!(ctx.semantic_index_rx_epoch(), replacement_epoch);
6350 }
6351
6352 #[test]
6353 fn stale_terminal_guard_cannot_hide_newer_finished_receiver() {
6354 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6355 let (old_sender, old_receiver) = crossbeam_channel::unbounded();
6356 let old_epoch = ctx.install_search_index_rx(old_receiver, ctx.configure_generation());
6357 let old_guard = ctx.search_index_rx_terminal_guard(old_epoch);
6358 let (current_sender, current_receiver) = crossbeam_channel::unbounded();
6359 let current_epoch =
6360 ctx.install_search_index_rx(current_receiver, ctx.configure_generation());
6361 let current_guard = ctx.search_index_rx_terminal_guard(current_epoch);
6362 drop(old_sender);
6363 drop(current_sender);
6364
6365 drop(current_guard);
6366 drop(old_guard);
6367
6368 assert!(current_epoch > old_epoch);
6369 assert_eq!(
6370 ctx.search_index_rx_terminal_epoch.load(Ordering::SeqCst),
6371 current_epoch,
6372 "a stale worker must not move the terminal watermark backward"
6373 );
6374 assert!(ctx.completion_drains_have_work());
6375 }
6376
6377 #[test]
6378 fn finished_semantic_refresh_worker_reports_completion_work() {
6379 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6380 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
6381 let (event_tx, event_rx) = crossbeam_channel::unbounded();
6382 let worker_slot = Arc::new(Mutex::new(Some(std::thread::spawn(|| {}))));
6383 ctx.install_semantic_refresh_worker_for_build_epoch(
6384 request_tx,
6385 event_rx,
6386 Arc::clone(&worker_slot),
6387 ctx.semantic_index_rx_epoch(),
6388 );
6389 drop(event_tx);
6390 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
6391 while !worker_slot
6392 .lock()
6393 .unwrap_or_else(std::sync::PoisonError::into_inner)
6394 .as_ref()
6395 .is_some_and(std::thread::JoinHandle::is_finished)
6396 {
6397 assert!(
6398 std::time::Instant::now() < deadline,
6399 "worker did not finish"
6400 );
6401 std::thread::yield_now();
6402 }
6403
6404 assert!(
6405 ctx.completion_drains_have_work(),
6406 "a finished refresh worker must wake the completion drain after its event queue empties"
6407 );
6408 }
6409
6410 #[test]
6411 fn unbound_lifecycle_rejects_all_deferred_worker_starts() {
6412 let admission = SubcLifecycleAdmission::default();
6413 let generation = Arc::new(AtomicU64::new(7));
6414 admission.mark_unbound(&generation);
6415 let expected = generation.load(Ordering::SeqCst);
6416 let starts = Arc::new(AtomicUsize::new(0));
6417
6418 let workers = (0..16)
6419 .map(|_| {
6420 let admission = admission.clone();
6421 let generation = Arc::clone(&generation);
6422 let starts = Arc::clone(&starts);
6423 std::thread::spawn(move || {
6424 admission.run_if_current(&generation, expected, || {
6425 starts.fetch_add(1, Ordering::SeqCst);
6426 })
6427 })
6428 })
6429 .collect::<Vec<_>>();
6430
6431 for worker in workers {
6432 assert!(worker.join().unwrap().is_none());
6433 }
6434 assert_eq!(starts.load(Ordering::SeqCst), 0);
6435 }
6436}
6437
6438#[cfg(test)]
6439mod force_restrict_tests {
6440 use super::*;
6441 use crate::language::StubProvider;
6442 use tempfile::TempDir;
6443
6444 fn test_context(project_root: Option<PathBuf>, restrict_to_project_root: bool) -> AppContext {
6445 AppContext::new(
6446 Box::new(StubProvider),
6447 Config {
6448 project_root,
6449 restrict_to_project_root,
6450 ..Config::default()
6451 },
6452 )
6453 }
6454
6455 #[test]
6456 fn standalone_validate_path_parity_without_force_restrict() {
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
6461 let unrestricted = test_context(Some(root.path().to_path_buf()), false);
6462 assert_eq!(
6463 unrestricted
6464 .validate_path("standalone-unrestricted", &outside_path)
6465 .expect("unrestricted standalone validates"),
6466 outside_path
6467 );
6468
6469 let restricted = test_context(Some(root.path().to_path_buf()), true);
6470 let err = restricted
6471 .validate_path("standalone-restricted", &outside_path)
6472 .expect_err("restricted standalone rejects outside root");
6473 assert_eq!(
6474 serde_json::to_value(err).unwrap()["code"],
6475 "path_outside_root"
6476 );
6477 }
6478
6479 #[test]
6480 fn force_restrict_guard_refcounts_duplicate_request_ids() {
6481 let root = TempDir::new().expect("root tempdir");
6482 let outside = TempDir::new().expect("outside tempdir");
6483 let outside_path = outside.path().join("outside.txt");
6484 let ctx = test_context(Some(root.path().to_path_buf()), false);
6485
6486 assert!(ctx.validate_path("dup", &outside_path).is_ok());
6487 let guard1 = ctx.force_restrict_guard("dup");
6488 let guard2 = ctx.force_restrict_guard("dup");
6489 assert!(ctx.validate_path("dup", &outside_path).is_err());
6490 drop(guard1);
6491 assert!(
6492 ctx.validate_path("dup", &outside_path).is_err(),
6493 "duplicate guard must keep the request over-restricted"
6494 );
6495 drop(guard2);
6496 assert!(ctx.validate_path("dup", &outside_path).is_ok());
6497 }
6498
6499 #[test]
6500 fn with_force_restrict_cleans_up_after_normal_completion_and_panic() {
6501 let root = TempDir::new().expect("root tempdir");
6502 let outside = TempDir::new().expect("outside tempdir");
6503 let outside_path = outside.path().join("outside.txt");
6504 let ctx = test_context(Some(root.path().to_path_buf()), false);
6505
6506 ctx.with_force_restrict("normal", || {
6507 assert!(ctx.validate_path("normal", &outside_path).is_err());
6508 });
6509 assert!(!ctx.request_force_restrict("normal"));
6510 assert!(ctx.validate_path("normal", &outside_path).is_ok());
6511
6512 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
6513 ctx.with_force_restrict("panic", || {
6514 assert!(ctx.validate_path("panic", &outside_path).is_err());
6515 panic!("intentional force-restrict cleanup panic");
6516 });
6517 }));
6518 assert!(panicked.is_err());
6519 assert!(!ctx.request_force_restrict("panic"));
6520 assert!(ctx.validate_path("panic", &outside_path).is_ok());
6521 }
6522
6523 #[cfg(unix)]
6524 #[test]
6525 fn validate_write_location_keeps_final_symlink_as_the_authorized_location() {
6526 let root = TempDir::new().expect("root tempdir");
6527 let outside = tempfile::NamedTempFile::new().expect("outside file");
6528 let link = root.path().join("file.txt");
6529 std::os::unix::fs::symlink(outside.path(), &link).expect("create final symlink");
6530 let ctx = test_context(Some(root.path().to_path_buf()), false);
6531 let _guard = ctx.force_restrict_guard("write-location-final-link");
6532
6533 let validated = ctx
6534 .validate_write_location("write-location-final-link", &link)
6535 .expect("the in-root link location is writable");
6536
6537 assert_eq!(
6538 validated,
6539 std::fs::canonicalize(root.path()).unwrap().join("file.txt")
6540 );
6541 }
6542
6543 #[cfg(unix)]
6544 #[test]
6545 fn validate_write_location_rejects_symlinked_parent_escape() {
6546 let root = TempDir::new().expect("root tempdir");
6547 let outside = TempDir::new().expect("outside tempdir");
6548 let linked_parent = root.path().join("linked-parent");
6549 std::os::unix::fs::symlink(outside.path(), &linked_parent).expect("create parent symlink");
6550 let candidate = linked_parent.join("file.txt");
6551 let ctx = test_context(Some(root.path().to_path_buf()), false);
6552 let _guard = ctx.force_restrict_guard("write-location-parent-link");
6553
6554 let error = ctx
6555 .validate_write_location("write-location-parent-link", &candidate)
6556 .expect_err("a symlinked parent must not escape the project root");
6557
6558 assert_eq!(
6559 serde_json::to_value(error).unwrap()["code"],
6560 "path_outside_root"
6561 );
6562 }
6563
6564 #[cfg(unix)]
6565 #[test]
6566 fn validate_write_location_rejects_outside_link_to_inside_file() {
6567 let root = TempDir::new().expect("root tempdir");
6568 let outside = TempDir::new().expect("outside tempdir");
6569 let inside = root.path().join("inside.txt");
6570 std::fs::write(&inside, "inside").unwrap();
6571 let outside_link = outside.path().join("outside-link.txt");
6572 std::os::unix::fs::symlink(&inside, &outside_link).expect("create outside symlink");
6573 let ctx = test_context(Some(root.path().to_path_buf()), false);
6574 let _guard = ctx.force_restrict_guard("write-location-outside-link");
6575
6576 let error = ctx
6577 .validate_write_location("write-location-outside-link", &outside_link)
6578 .expect_err("an out-of-root lexical location must remain blocked");
6579
6580 assert_eq!(
6581 serde_json::to_value(error).unwrap()["code"],
6582 "path_outside_root"
6583 );
6584 }
6585
6586 #[test]
6587 fn forced_restrict_without_project_root_fails_closed() {
6588 let ctx = test_context(None, false);
6589 let _guard = ctx.force_restrict_guard("missing-root");
6590 let err = ctx
6591 .validate_path("missing-root", Path::new("relative.txt"))
6592 .expect_err("forced restriction without a root must fail closed");
6593 assert_eq!(
6594 serde_json::to_value(err).unwrap()["code"],
6595 "path_outside_root"
6596 );
6597
6598 let write_err = ctx
6599 .validate_write_location("missing-root", Path::new("relative.txt"))
6600 .expect_err("write-location validation must also fail closed");
6601 assert_eq!(
6602 serde_json::to_value(write_err).unwrap()["code"],
6603 "path_outside_root"
6604 );
6605 }
6606}
6607
6608#[cfg(test)]
6609mod callgraph_store_for_ops_tests {
6610 use super::*;
6611 use crate::inspect::{InspectCategory, InspectSnapshot, JobOutcome, JobScope};
6612 use crate::parser::TreeSitterProvider;
6613 use crate::protocol::RawRequest;
6614 use serde_json::json;
6615 use std::ffi::OsString;
6616 use std::path::Path;
6617 use std::sync::{Barrier, Mutex as StdMutex, MutexGuard, OnceLock};
6618 use tempfile::TempDir;
6619
6620 struct CallgraphWaitWindowEnvGuard {
6621 _guard: MutexGuard<'static, ()>,
6622 previous: Option<OsString>,
6623 }
6624
6625 impl Drop for CallgraphWaitWindowEnvGuard {
6626 fn drop(&mut self) {
6627 unsafe {
6630 match &self.previous {
6631 Some(value) => std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", value),
6632 None => std::env::remove_var("AFT_CALLGRAPH_BUILD_WAIT_MS"),
6633 }
6634 }
6635 }
6636 }
6637
6638 fn callgraph_build_wait_ms(ms: u64) -> CallgraphWaitWindowEnvGuard {
6639 static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
6640 let guard = LOCK
6641 .get_or_init(|| StdMutex::new(()))
6642 .lock()
6643 .unwrap_or_else(|error| error.into_inner());
6644 let previous = std::env::var_os("AFT_CALLGRAPH_BUILD_WAIT_MS");
6645 unsafe {
6647 std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", ms.to_string());
6648 }
6649 CallgraphWaitWindowEnvGuard {
6650 _guard: guard,
6651 previous,
6652 }
6653 }
6654
6655 fn force_async_callgraph_builds() -> CallgraphWaitWindowEnvGuard {
6656 callgraph_build_wait_ms(0)
6657 }
6658
6659 fn cold_build_context() -> Arc<AppContext> {
6660 let project = TempDir::new().expect("project tempdir");
6661 let storage = TempDir::new().expect("storage tempdir");
6662 let source_dir = project.path().join("src");
6663 std::fs::create_dir_all(&source_dir).expect("source dir");
6664 std::fs::write(
6665 source_dir.join("lib.rs"),
6666 "pub fn caller() { callee(); }\npub fn callee() {}\n",
6667 )
6668 .expect("source file");
6669
6670 Arc::new(AppContext::new(
6671 Box::new(TreeSitterProvider::new()),
6672 Config {
6673 project_root: Some(project.keep()),
6674 storage_dir: Some(storage.keep()),
6675 callgraph_chunk_size: 1,
6676 ..Config::default()
6677 },
6678 ))
6679 }
6680
6681 fn with_fake_home_env<R>(home: &Path, f: impl FnOnce() -> R) -> R {
6682 let _guard = crate::test_env::process_env_lock();
6683 let prev_home = std::env::var_os("HOME");
6684 let prev_userprofile = std::env::var_os("USERPROFILE");
6685 unsafe {
6686 std::env::set_var("HOME", home);
6687 std::env::set_var("USERPROFILE", home);
6688 }
6689 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
6690 unsafe {
6691 match prev_home {
6692 Some(value) => std::env::set_var("HOME", value),
6693 None => std::env::remove_var("HOME"),
6694 }
6695 match prev_userprofile {
6696 Some(value) => std::env::set_var("USERPROFILE", value),
6697 None => std::env::remove_var("USERPROFILE"),
6698 }
6699 }
6700 match result {
6701 Ok(value) => value,
6702 Err(payload) => std::panic::resume_unwind(payload),
6703 }
6704 }
6705
6706 fn configure_request_with_params(params: serde_json::Value) -> RawRequest {
6707 RawRequest {
6708 id: "cfg".to_string(),
6709 command: "configure".to_string(),
6710 lsp_hints: None,
6711 session_id: None,
6712 params,
6713 }
6714 }
6715
6716 fn user_tier(doc: serde_json::Value) -> serde_json::Value {
6717 json!({
6718 "tier": "user",
6719 "source": "/u/aft.jsonc",
6720 "doc": doc.to_string(),
6721 })
6722 }
6723
6724 fn configure_context(project_root: &Path, storage_dir: &Path) -> AppContext {
6725 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
6726 let response = crate::commands::configure::handle_configure(
6727 &configure_request_with_params(json!({
6728 "project_root": project_root,
6729 "harness": "opencode",
6730 "storage_dir": storage_dir,
6731 "config": [user_tier(json!({
6732 "callgraph_store": true,
6733 "search_index": true,
6734 "semantic_search": true,
6735 }))],
6736 })),
6737 &ctx,
6738 );
6739 assert!(response.success, "configure should succeed: {response:?}");
6740 ctx
6741 }
6742
6743 fn inspect_snapshot(ctx: &AppContext) -> InspectSnapshot {
6744 InspectSnapshot::new(
6745 ctx.canonical_cache_root(),
6746 ctx.inspect_dir(),
6747 ctx.config(),
6748 ctx.symbol_cache(),
6749 )
6750 }
6751
6752 fn empty_semantic_index_for_ctx(ctx: &AppContext) -> SemanticIndex {
6753 let project_root = ctx
6754 .config()
6755 .project_root
6756 .clone()
6757 .expect("test context has a project root");
6758 let files: Vec<PathBuf> = Vec::new();
6759 let mut embed = |_texts: Vec<String>| -> Result<Vec<Vec<f32>>, String> { Ok(Vec::new()) };
6760 SemanticIndex::build(&project_root, &files, &mut embed, 1)
6761 .expect("empty semantic index should build")
6762 }
6763
6764 #[test]
6765 fn home_root_gate_blocks_callgraph_store_entry_points() {
6766 let _wait_guard = force_async_callgraph_builds();
6767 let home = TempDir::new().expect("home tempdir");
6768 let storage = TempDir::new().expect("storage tempdir");
6769 let source_dir = home.path().join("src");
6770 std::fs::create_dir_all(&source_dir).expect("source dir");
6771 std::fs::write(
6772 source_dir.join("lib.rs"),
6773 "pub fn caller() { callee(); }\npub fn callee() {}\n",
6774 )
6775 .expect("source file");
6776
6777 with_fake_home_env(home.path(), || {
6778 let ctx = configure_context(home.path(), storage.path());
6779 assert!(
6780 !ctx.heavy_root_work_allowed(),
6781 "HOME root configure must close the heavy-root-work gate"
6782 );
6783 assert_eq!(
6784 ctx.try_health_snapshot(home.path())
6785 .callgraph_store
6786 .as_ref()
6787 .map(|component| component.status),
6788 Some("disabled"),
6789 "HOME root health must not advertise callgraph building"
6790 );
6791
6792 reset_callgraph_cold_build_spawn_count_for_test();
6793 assert!(matches!(
6794 ctx.callgraph_store_for_ops(),
6795 CallgraphStoreAccess::Unavailable
6796 ));
6797 assert!(
6798 ctx.ensure_callgraph_store()
6799 .expect("ensure_callgraph_store should not error")
6800 .is_none(),
6801 "shared gate must also block synchronous standalone callgraph builds"
6802 );
6803 assert_eq!(
6804 callgraph_cold_build_spawn_count_for_test(),
6805 0,
6806 "HOME root gate must not spawn a cold callgraph build"
6807 );
6808 });
6809 }
6810
6811 #[test]
6812 fn home_root_gate_blocks_inspect_manager_submit_paths() {
6813 let home = TempDir::new().expect("home tempdir");
6814 let storage = TempDir::new().expect("storage tempdir");
6815 let source_dir = home.path().join("src");
6816 std::fs::create_dir_all(&source_dir).expect("source dir");
6817 std::fs::write(source_dir.join("lib.rs"), "pub fn one() {}\n").expect("source file");
6818
6819 with_fake_home_env(home.path(), || {
6820 let ctx = configure_context(home.path(), storage.path());
6821 let snapshot = inspect_snapshot(&ctx);
6822 let scope = JobScope::for_project(snapshot.project_root.clone());
6823 let manager = ctx.inspect_manager();
6824
6825 assert!(matches!(
6826 manager.submit_category(snapshot.clone(), InspectCategory::Metrics, scope.clone()),
6827 JobOutcome::Failed { .. }
6828 ));
6829
6830 let submission = manager.submit_tier2_run_with_reuse_serial_background(
6831 snapshot,
6832 vec![InspectCategory::DeadCode],
6833 );
6834 assert!(submission.queued_categories.is_empty());
6835 assert!(submission.newly_queued_categories.is_empty());
6836 assert!(submission.deferred_categories.is_empty());
6837 assert_eq!(submission.errors.len(), 1);
6838 assert!(
6839 !manager.tier2_any_in_flight(),
6840 "HOME root gate must reject Tier-2 submission before any job is queued"
6841 );
6842 });
6843 }
6844
6845 #[test]
6846 fn non_home_root_still_allows_callgraph_cold_builds() {
6847 let _env_guard = force_async_callgraph_builds();
6848 reset_callgraph_cold_build_spawn_count_for_test();
6849 let ctx = cold_build_context();
6850
6851 assert!(ctx.heavy_root_work_allowed());
6852 assert!(matches!(
6853 ctx.callgraph_store_for_ops(),
6854 CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
6855 ));
6856 assert_eq!(
6857 callgraph_cold_build_spawn_count_for_test(),
6858 1,
6859 "non-home roots must still be able to cold-build the callgraph store"
6860 );
6861
6862 let rx = ctx
6863 .callgraph_store_rx
6864 .lock()
6865 .as_ref()
6866 .cloned()
6867 .expect("non-home cold build should install an in-flight receiver");
6868 rx.recv_timeout(Duration::from_secs(30))
6869 .expect("background cold build should complete");
6870 *ctx.callgraph_store_rx.lock() = None;
6871 }
6872
6873 #[test]
6874 fn semantic_ready_event_resumes_deferred_callgraph_and_tier2() {
6875 let _env_guard = force_async_callgraph_builds();
6876 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
6877 let ctx = cold_build_context();
6878 let (tx, rx) = crossbeam_channel::unbounded();
6879 *ctx.semantic_index_rx().lock() = Some(rx);
6880 ctx.schedule_semantic_cold_seed_gate_for_configure();
6881
6882 assert!(matches!(
6883 ctx.callgraph_store_for_ops(),
6884 CallgraphStoreAccess::Building
6885 ));
6886 assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
6887 tx.send(SemanticIndexEvent::Ready(empty_semantic_index_for_ctx(
6888 &ctx,
6889 )))
6890 .expect("send ready event");
6891
6892 crate::runtime_drain::drain_semantic_index_events(&ctx);
6893
6894 assert!(
6895 !ctx.semantic_cold_seed_active(),
6896 "semantic Ready must clear the scheduled cold gate"
6897 );
6898 assert!(
6899 ctx.tier2_pull_demand_pending(),
6900 "semantic Ready must resume deferred Tier-2 work"
6901 );
6902 assert_eq!(
6903 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
6904 1,
6905 "semantic Ready must resume the deferred callgraph warm"
6906 );
6907 let rx = ctx
6908 .callgraph_store_rx
6909 .lock()
6910 .as_ref()
6911 .cloned()
6912 .expect("ready resume should install an in-flight callgraph receiver");
6913 rx.recv_timeout(Duration::from_secs(30))
6914 .expect("background cold build should complete");
6915 *ctx.callgraph_store_rx.lock() = None;
6916 }
6917
6918 #[test]
6919 fn semantic_gate_cleared_event_resumes_deferred_callgraph_and_tier2() {
6920 let _env_guard = force_async_callgraph_builds();
6921 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
6922 let ctx = cold_build_context();
6923 ctx.schedule_semantic_cold_seed_gate_for_configure();
6924
6925 assert!(matches!(
6926 ctx.callgraph_store_for_ops(),
6927 CallgraphStoreAccess::Building
6928 ));
6929 assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
6930 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
6931
6932 assert!(
6933 !ctx.semantic_cold_seed_active(),
6934 "cached-load or retry-wait clear must reopen the semantic cold gate"
6935 );
6936 assert!(
6937 ctx.tier2_pull_demand_pending(),
6938 "cached-load or retry-wait clear must resume deferred Tier-2 work"
6939 );
6940 assert_eq!(
6941 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
6942 1,
6943 "cached-load or retry-wait clear must resume deferred callgraph warm"
6944 );
6945 let rx = ctx
6946 .callgraph_store_rx
6947 .lock()
6948 .as_ref()
6949 .cloned()
6950 .expect("gate-clear resume should install an in-flight callgraph receiver");
6951 rx.recv_timeout(Duration::from_secs(30))
6952 .expect("background cold build should complete");
6953 *ctx.callgraph_store_rx.lock() = None;
6954 }
6955
6956 #[test]
6957 fn semantic_cold_seed_gate_defers_callgraph_cold_spawn_until_resume() {
6958 let _env_guard = force_async_callgraph_builds();
6959 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
6960 let ctx = cold_build_context();
6961
6962 ctx.set_semantic_cold_seed_active_for_test(true);
6963 assert!(
6964 matches!(
6965 ctx.callgraph_store_for_ops(),
6966 CallgraphStoreAccess::Building
6967 ),
6968 "callgraph ops should degrade as building while the semantic cold gate is active"
6969 );
6970 assert_eq!(
6971 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
6972 0,
6973 "semantic cold gate must not spawn a competing callgraph cold build"
6974 );
6975 assert!(ctx.semantic_callgraph_warm_deferred_for_test());
6976
6977 ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
6978 assert_eq!(
6979 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
6980 1,
6981 "clearing the semantic cold gate should resume the deferred callgraph warm"
6982 );
6983
6984 let rx = ctx
6985 .callgraph_store_rx
6986 .lock()
6987 .as_ref()
6988 .cloned()
6989 .expect("deferred warm should install an in-flight receiver");
6990 rx.recv_timeout(Duration::from_secs(30))
6991 .expect("background cold build should complete");
6992 *ctx.callgraph_store_rx.lock() = None;
6993 }
6994
6995 #[test]
6996 fn semantic_cold_seed_gate_clear_requests_tier2_pull() {
6997 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
6998 ctx.schedule_semantic_cold_seed_gate_for_configure();
6999
7000 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
7001
7002 assert!(
7003 !ctx.semantic_cold_seed_active(),
7004 "retry-wait or cached-load events must reopen the semantic cold gate"
7005 );
7006 assert!(
7007 ctx.tier2_pull_demand_pending(),
7008 "clearing the semantic cold gate should kick a Tier-2 pull refresh"
7009 );
7010 }
7011
7012 #[test]
7013 fn semantic_failed_event_clears_scheduled_gate_and_requests_tier2_pull() {
7014 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7015 let (tx, rx) = crossbeam_channel::unbounded();
7016 *ctx.semantic_index_rx().lock() = Some(rx);
7017 ctx.schedule_semantic_cold_seed_gate_for_configure();
7018 tx.send(SemanticIndexEvent::Failed(
7019 "embedding backend failed".to_string(),
7020 ))
7021 .expect("send failed event");
7022
7023 crate::runtime_drain::drain_semantic_index_events(&ctx);
7024
7025 assert!(
7026 !ctx.semantic_cold_seed_active(),
7027 "semantic Failed must clear the scheduled cold gate"
7028 );
7029 assert!(
7030 ctx.tier2_pull_demand_pending(),
7031 "semantic Failed must resume deferred Tier-2 work"
7032 );
7033 }
7034
7035 #[test]
7036 fn semantic_disconnect_clears_scheduled_gate_and_requests_tier2_pull() {
7037 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7038 let (tx, rx) = crossbeam_channel::unbounded::<SemanticIndexEvent>();
7039 *ctx.semantic_index_rx().lock() = Some(rx);
7040 ctx.schedule_semantic_cold_seed_gate_for_configure();
7041 drop(tx);
7042
7043 crate::runtime_drain::drain_semantic_index_events(&ctx);
7044
7045 assert!(
7046 !ctx.semantic_cold_seed_active(),
7047 "semantic worker disconnect must clear the scheduled cold gate"
7048 );
7049 assert!(
7050 ctx.tier2_pull_demand_pending(),
7051 "semantic worker disconnect must resume deferred Tier-2 work"
7052 );
7053 }
7054
7055 #[test]
7056 fn semantic_cold_seed_gate_is_per_context_for_tier2_scheduler() {
7057 let ctx_a = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7058 let ctx_b = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7059 let base = Instant::now();
7060 ctx_a.reset_tier2_refresh_scheduler_at(base);
7061 ctx_b.reset_tier2_refresh_scheduler_at(base);
7062 ctx_a.set_semantic_cold_seed_active_for_test(true);
7063
7064 assert_eq!(
7065 ctx_a.tick_tier2_refresh_scheduler_at(
7066 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
7067 0,
7068 ),
7069 None,
7070 "root A should defer Tier-2 while its semantic cold seed is active"
7071 );
7072 assert_eq!(
7073 ctx_b.tick_tier2_refresh_scheduler_at(
7074 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
7075 0,
7076 ),
7077 Some(Tier2TriggerReason::ConfigureWarm),
7078 "root B must not inherit root A's semantic cold gate"
7079 );
7080 }
7081
7082 #[test]
7083 fn inline_wait_settled_event_clears_superseded_receiver() {
7084 let _env_guard = callgraph_build_wait_ms(2_000);
7085 let project = TempDir::new().expect("project tempdir");
7086 let storage = TempDir::new().expect("storage tempdir");
7087 std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
7088 let project_root = std::fs::canonicalize(project.path()).expect("canonical project root");
7089 let ctx = Arc::new(AppContext::new(
7090 Box::new(TreeSitterProvider::new()),
7091 Config {
7092 project_root: Some(project.path().to_path_buf()),
7093 storage_dir: Some(storage.path().to_path_buf()),
7094 callgraph_chunk_size: 1,
7095 ..Config::default()
7096 },
7097 ));
7098 let (reached, release) = install_callgraph_build_start_gate(project_root);
7099 let request_ctx = Arc::clone(&ctx);
7100 let request = std::thread::spawn(move || request_ctx.callgraph_store_for_ops());
7101 reached
7102 .recv_timeout(Duration::from_secs(2))
7103 .expect("callgraph worker did not reach start barrier");
7104
7105 ctx.next_callgraph_persist_epoch();
7106 release.send(()).unwrap();
7107 assert!(matches!(
7108 request.join().expect("callgraph request thread"),
7109 CallgraphStoreAccess::Building
7110 ));
7111 assert!(
7112 ctx.callgraph_store_rx().lock().is_none(),
7113 "inline Settled handling must retire the matching receiver"
7114 );
7115 assert!(
7116 ctx.callgraph_store()
7117 .read()
7118 .unwrap_or_else(std::sync::PoisonError::into_inner)
7119 .is_none(),
7120 "Settled must not reopen and install an older persisted store"
7121 );
7122 }
7123
7124 #[test]
7125 fn inline_ready_without_published_pointer_settles_and_preserves_pending_paths() {
7126 let _env_guard = callgraph_build_wait_ms(2_000);
7127 let project = TempDir::new().expect("project tempdir");
7128 let storage = TempDir::new().expect("storage tempdir");
7129 std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
7130 let ctx = AppContext::new(
7131 Box::new(TreeSitterProvider::new()),
7132 Config {
7133 project_root: Some(project.path().to_path_buf()),
7134 storage_dir: Some(storage.path().to_path_buf()),
7135 callgraph_chunk_size: 1,
7136 ..Config::default()
7137 },
7138 );
7139 let pending = project.path().join("pending.rs");
7140 ctx.add_pending_callgraph_store_paths([pending.clone()]);
7141 REMOVE_CALLGRAPH_POINTER_BEFORE_INLINE_REOPEN.store(true, Ordering::SeqCst);
7142 let _remove_pointer_guard = RemoveCallgraphPointerBeforeInlineReopenGuard;
7143
7144 assert!(matches!(
7145 ctx.callgraph_store_for_ops(),
7146 CallgraphStoreAccess::Building
7147 ));
7148 assert!(
7149 ctx.callgraph_store_rx().lock().is_none(),
7150 "inline Ready must settle after the published pointer disappears"
7151 );
7152 assert_eq!(
7153 ctx.take_pending_callgraph_store_paths(),
7154 vec![pending],
7155 "inline reopen failure must preserve pending watcher paths"
7156 );
7157 }
7158
7159 #[test]
7160 fn take_pending_callgraph_store_paths_drops_paths_outside_current_root() {
7161 let project = TempDir::new().expect("project tempdir");
7162 let foreign = TempDir::new().expect("foreign tempdir");
7163 let ctx = AppContext::new(
7164 Box::new(TreeSitterProvider::new()),
7165 Config {
7166 project_root: Some(project.path().to_path_buf()),
7167 ..Config::default()
7168 },
7169 );
7170 let inside = project.path().join("kept.rs");
7171 let outside = foreign.path().join("previous-root-file.rs");
7175 let dotdot_escape = project
7178 .path()
7179 .join("..")
7180 .join(
7181 foreign
7182 .path()
7183 .file_name()
7184 .expect("foreign tempdir has a name"),
7185 )
7186 .join("escaped.rs");
7187 ctx.add_pending_callgraph_store_paths([inside.clone(), outside, dotdot_escape]);
7188
7189 assert_eq!(
7190 ctx.take_pending_callgraph_store_paths(),
7191 vec![inside],
7192 "pending replay must drop foreign and dot-dot-escaping paths"
7193 );
7194 }
7195
7196 #[test]
7197 fn watcher_gap_invalidation_keeps_semantic_reloadable_and_skips_readonly_force_token() {
7198 let project = TempDir::new().expect("project tempdir");
7199 let ctx = AppContext::new(
7200 Box::new(TreeSitterProvider::new()),
7201 Config {
7202 project_root: Some(project.path().to_path_buf()),
7203 semantic_search: true,
7204 ..Config::default()
7205 },
7206 );
7207 ctx.set_canonical_cache_root(project.path().to_path_buf());
7208 ctx.set_cache_writer_capabilities(false, true);
7211 *ctx.semantic_index_status()
7212 .write()
7213 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
7214
7215 ctx.invalidate_artifacts_after_watcher_gap();
7216
7217 assert!(
7218 matches!(
7219 &*ctx
7220 .semantic_index_status()
7221 .read()
7222 .unwrap_or_else(std::sync::PoisonError::into_inner),
7223 SemanticIndexStatus::Ready { .. }
7224 ),
7225 "semantic-enabled root must stay reloadable (Disabled has no self-healing path)"
7226 );
7227 assert_eq!(
7228 ctx.pending_callgraph_store_force_token(),
7229 None,
7230 "read-only root must not be stuck behind an unfulfillable force token"
7231 );
7232 }
7233
7234 #[test]
7235 fn watcher_gap_invalidation_marks_force_rebuild_for_writer_roots() {
7236 let project = TempDir::new().expect("project tempdir");
7237 let ctx = AppContext::new(
7238 Box::new(TreeSitterProvider::new()),
7239 Config {
7240 project_root: Some(project.path().to_path_buf()),
7241 ..Config::default()
7242 },
7243 );
7244 ctx.set_canonical_cache_root(project.path().to_path_buf());
7245 ctx.set_cache_writer_capabilities(true, true);
7246
7247 ctx.invalidate_artifacts_after_watcher_gap();
7248
7249 assert!(
7250 ctx.pending_callgraph_store_force_token().is_some(),
7251 "writer roots must still reconcile the store after the unobserved interval"
7252 );
7253 assert!(
7254 matches!(
7255 &*ctx
7256 .semantic_index_status()
7257 .read()
7258 .unwrap_or_else(std::sync::PoisonError::into_inner),
7259 SemanticIndexStatus::Disabled
7260 ),
7261 "semantic-disabled config maps to Disabled status"
7262 );
7263 }
7264
7265 #[cfg(unix)]
7266 #[test]
7267 fn take_pending_callgraph_store_paths_drops_symlink_dotdot_escape() {
7268 let project = TempDir::new().expect("project tempdir");
7269 let foreign = TempDir::new().expect("foreign tempdir");
7270 std::fs::create_dir_all(foreign.path().join("dir")).expect("foreign dir");
7271 std::fs::write(foreign.path().join("secret.rs"), "pub fn s() {}\n").expect("secret");
7272 let ctx = AppContext::new(
7273 Box::new(TreeSitterProvider::new()),
7274 Config {
7275 project_root: Some(project.path().to_path_buf()),
7276 ..Config::default()
7277 },
7278 );
7279 std::os::unix::fs::symlink(foreign.path().join("dir"), project.path().join("link"))
7284 .expect("plant symlink");
7285 let escape = project.path().join("link").join("..").join("secret.rs");
7286 let dead_component_escape = project
7291 .path()
7292 .join("link")
7293 .join("dead")
7294 .join("..")
7295 .join("..")
7296 .join("deep-secret.rs");
7297 std::fs::write(foreign.path().join("reentry-secret.rs"), "pub fn r() {}\n")
7302 .expect("reentry secret");
7303 let reentry_escape = project
7304 .path()
7305 .join("dead")
7306 .join("..")
7307 .join("link")
7308 .join("..")
7309 .join("reentry-secret.rs");
7310 std::os::unix::fs::symlink(
7315 foreign.path().join("nonexistent-target"),
7316 project.path().join("dangling"),
7317 )
7318 .expect("plant dangling symlink");
7319 let dangling_reentry = project
7320 .path()
7321 .join("dangling")
7322 .join("..")
7323 .join("via-dangling.rs");
7324 std::fs::write(project.path().join("plain.rs"), "pub fn p() {}\n").expect("plain file");
7327 let through_file = project
7328 .path()
7329 .join("plain.rs")
7330 .join("..")
7331 .join("via-file.rs");
7332 let kept = project.path().join("kept.rs");
7333 ctx.add_pending_callgraph_store_paths([
7334 escape,
7335 dead_component_escape,
7336 reentry_escape,
7337 dangling_reentry,
7338 through_file,
7339 kept.clone(),
7340 ]);
7341
7342 assert_eq!(
7343 ctx.take_pending_callgraph_store_paths(),
7344 vec![kept],
7345 "symlink-plus-dotdot escapes must be dropped with filesystem-first semantics"
7346 );
7347 }
7348
7349 #[cfg(windows)]
7350 #[test]
7351 fn take_pending_callgraph_store_paths_drops_drive_relative_paths() {
7352 let cwd = std::env::current_dir().expect("drive cwd");
7359 let cwd_file = PathBuf::from(format!(
7360 "{}under-drive-cwd.rs",
7361 cwd.components()
7362 .next()
7363 .map(|prefix| prefix.as_os_str().to_string_lossy().into_owned())
7364 .expect("drive prefix")
7365 ));
7366 assert!(cwd_file.is_relative(), "C:foo must classify as relative");
7367 assert!(
7368 !pending_path_in_roots(&cwd_file, &[cwd.clone()]),
7369 "drive-relative spelling must be rejected even when the drive CWD is inside the root"
7370 );
7371 assert!(
7372 !pending_path_in_roots(Path::new(r"\root-relative.rs"), &[cwd]),
7373 "root-relative spelling must be rejected"
7374 );
7375
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 kept = project.path().join("kept.rs");
7385 ctx.add_pending_callgraph_store_paths([
7386 PathBuf::from("C:drive-relative.rs"),
7387 PathBuf::from(r"\root-relative.rs"),
7388 kept.clone(),
7389 ]);
7390
7391 assert_eq!(
7392 ctx.take_pending_callgraph_store_paths(),
7393 vec![kept],
7394 "drive-relative and root-relative spellings must be rejected"
7395 );
7396 }
7397
7398 #[test]
7399 fn take_pending_callgraph_store_paths_keeps_relative_and_deleted_paths() {
7400 let project = TempDir::new().expect("project tempdir");
7401 let ctx = AppContext::new(
7402 Box::new(TreeSitterProvider::new()),
7403 Config {
7404 project_root: Some(project.path().to_path_buf()),
7405 ..Config::default()
7406 },
7407 );
7408 let relative = PathBuf::from("src/relative.rs");
7411 let deleted = project.path().join("never-created.rs");
7412 ctx.add_pending_callgraph_store_paths([relative.clone(), deleted.clone()]);
7413
7414 let mut taken = ctx.take_pending_callgraph_store_paths();
7415 taken.sort();
7416 let mut expected = vec![relative, deleted];
7417 expected.sort();
7418 assert_eq!(
7419 taken, expected,
7420 "root-relative and deleted in-root paths must survive the filter"
7421 );
7422 }
7423
7424 #[test]
7425 fn concurrent_cold_callgraph_store_for_ops_spawns_one_build() {
7426 let _env_guard = force_async_callgraph_builds();
7427 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
7428
7429 let project = TempDir::new().expect("project tempdir");
7430 let storage = TempDir::new().expect("storage tempdir");
7431 let source_dir = project.path().join("src");
7432 std::fs::create_dir_all(&source_dir).expect("source dir");
7433 std::fs::write(
7434 source_dir.join("lib.rs"),
7435 "pub fn caller() { callee(); }\npub fn callee() {}\n",
7436 )
7437 .expect("source file");
7438
7439 let ctx = Arc::new(AppContext::new(
7440 Box::new(TreeSitterProvider::new()),
7441 Config {
7442 project_root: Some(project.path().to_path_buf()),
7443 storage_dir: Some(storage.path().to_path_buf()),
7444 callgraph_chunk_size: 1,
7445 ..Config::default()
7446 },
7447 ));
7448
7449 let barrier = Arc::new(Barrier::new(3));
7450 let handles = (0..2)
7451 .map(|_| {
7452 let ctx = Arc::clone(&ctx);
7453 let barrier = Arc::clone(&barrier);
7454 std::thread::spawn(move || {
7455 barrier.wait();
7456 matches!(
7457 ctx.callgraph_store_for_ops(),
7458 CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
7459 )
7460 })
7461 })
7462 .collect::<Vec<_>>();
7463
7464 barrier.wait();
7465 for handle in handles {
7466 assert!(
7467 handle.join().expect("callgraph caller thread"),
7468 "cold callgraph ops should report Building or observe the installed store"
7469 );
7470 }
7471
7472 assert_eq!(
7473 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
7474 1,
7475 "concurrent cold callers must share one background build"
7476 );
7477
7478 let rx = ctx
7479 .callgraph_store_rx
7480 .lock()
7481 .as_ref()
7482 .cloned()
7483 .expect("in-flight receiver installed before spawn");
7484 rx.recv_timeout(Duration::from_secs(30))
7485 .expect("background cold build should complete");
7486 *ctx.callgraph_store_rx.lock() = None;
7487 }
7488
7489 #[test]
7490 fn watcher_gap_invalidation_gates_resident_artifacts_and_forces_strict_verify() {
7491 let root = TempDir::new().expect("project tempdir");
7492 let canonical_root = std::fs::canonicalize(root.path()).expect("canonical project root");
7493 let ctx = AppContext::new(
7494 Box::new(TreeSitterProvider::new()),
7495 Config {
7496 project_root: Some(canonical_root.clone()),
7497 ..Config::default()
7498 },
7499 );
7500 *ctx.search_index
7501 .write()
7502 .unwrap_or_else(std::sync::PoisonError::into_inner) =
7503 Some(SearchIndex::build(&canonical_root));
7504 *ctx.semantic_index
7505 .write()
7506 .unwrap_or_else(std::sync::PoisonError::into_inner) =
7507 Some(SemanticIndex::new(canonical_root.clone(), 3));
7508 *ctx.semantic_index_status
7509 .write()
7510 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
7511
7512 let artifact = canonical_root.join("verify-artifact.bin");
7513 std::fs::write(&artifact, b"same-size").expect("write verification artifact");
7514 let generation =
7515 crate::cache_freshness::artifact_generation(&artifact).expect("artifact generation");
7516 crate::cache_freshness::record_verify_completed(
7517 &canonical_root,
7518 crate::cache_freshness::VerifyArtifact::Search,
7519 Some(generation),
7520 );
7521 assert_eq!(
7522 crate::cache_freshness::warm_verify_plan(
7523 &canonical_root,
7524 crate::cache_freshness::VerifyArtifact::Search,
7525 Some(generation),
7526 ),
7527 crate::cache_freshness::WarmVerifyPlan::Skip
7528 );
7529
7530 ctx.invalidate_artifacts_after_watcher_gap();
7531
7532 assert!(ctx
7533 .search_index
7534 .read()
7535 .unwrap_or_else(std::sync::PoisonError::into_inner)
7536 .is_none());
7537 assert!(ctx
7538 .semantic_index
7539 .read()
7540 .unwrap_or_else(std::sync::PoisonError::into_inner)
7541 .is_none());
7542 assert!(ctx.pending_callgraph_store_force_token().is_some());
7543 assert_eq!(
7544 crate::cache_freshness::warm_verify_plan(
7545 &canonical_root,
7546 crate::cache_freshness::VerifyArtifact::Search,
7547 Some(generation),
7548 ),
7549 crate::cache_freshness::WarmVerifyPlan::Strict
7550 );
7551 }
7552
7553 #[test]
7554 fn cancelled_semantic_refresh_transfers_refreshing_files_to_pending() {
7555 let root = TempDir::new().expect("project tempdir");
7556 let ctx = AppContext::new(
7557 Box::new(TreeSitterProvider::new()),
7558 Config {
7559 project_root: Some(root.path().to_path_buf()),
7560 semantic_search: true,
7561 ..Config::default()
7562 },
7563 );
7564 *ctx.semantic_index
7565 .write()
7566 .unwrap_or_else(std::sync::PoisonError::into_inner) =
7567 Some(SemanticIndex::new(root.path().to_path_buf(), 3));
7568 let refreshing_path = root.path().join("src/lib.rs");
7569 {
7570 let mut status = ctx
7571 .semantic_index_status
7572 .write()
7573 .unwrap_or_else(std::sync::PoisonError::into_inner);
7574 *status = SemanticIndexStatus::ready();
7575 status.start_refreshing_file(refreshing_path.clone());
7576 }
7577 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
7578 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
7579 ctx.install_semantic_refresh_worker_for_build_epoch(
7580 request_tx,
7581 event_rx,
7582 Arc::new(Mutex::new(None)),
7583 ctx.semantic_index_rx_epoch(),
7584 );
7585
7586 ctx.cancel_unbound_artifact_work();
7587
7588 assert_eq!(
7591 ctx.pending_semantic_index_paths
7592 .lock()
7593 .iter()
7594 .cloned()
7595 .collect::<Vec<_>>(),
7596 vec![refreshing_path],
7597 "cancelled in-flight refresh files must transfer to the pending set"
7598 );
7599 assert!(matches!(
7600 &*ctx
7601 .semantic_index_status
7602 .read()
7603 .unwrap_or_else(std::sync::PoisonError::into_inner),
7604 SemanticIndexStatus::Ready { refreshing, .. } if refreshing.is_empty()
7605 ));
7606 }
7607
7608 #[test]
7609 fn unbind_before_corpus_started_preserves_corpus_intent() {
7610 let root = TempDir::new().expect("project tempdir");
7615 let ctx = AppContext::new(
7616 Box::new(TreeSitterProvider::new()),
7617 Config {
7618 project_root: Some(root.path().to_path_buf()),
7619 semantic_search: true,
7620 ..Config::default()
7621 },
7622 );
7623 *ctx.semantic_index
7624 .write()
7625 .unwrap_or_else(std::sync::PoisonError::into_inner) =
7626 Some(SemanticIndex::new(root.path().to_path_buf(), 3));
7627 *ctx.semantic_index_status
7628 .write()
7629 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
7630 stage: "refreshing_corpus".to_string(),
7631 files: None,
7632 entries_done: None,
7633 entries_total: None,
7634 };
7635 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
7636 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
7637 ctx.install_semantic_refresh_worker_for_build_epoch(
7638 request_tx,
7639 event_rx,
7640 Arc::new(Mutex::new(None)),
7641 ctx.semantic_index_rx_epoch(),
7642 );
7643
7644 ctx.cancel_unbound_artifact_work();
7645
7646 assert!(
7647 *ctx.pending_semantic_corpus_refresh.lock(),
7648 "corpus intent stamped before CorpusStarted must survive the cancellation"
7649 );
7650 }
7651
7652 #[test]
7653 fn cancelled_search_corpus_refresh_drops_nonready_resident_index() {
7654 let root = TempDir::new().expect("project tempdir");
7655 let ctx = AppContext::new(
7656 Box::new(TreeSitterProvider::new()),
7657 Config {
7658 project_root: Some(root.path().to_path_buf()),
7659 ..Config::default()
7660 },
7661 );
7662 let mut refreshing = SearchIndex::new();
7666 refreshing.ready = false;
7667 *ctx.search_index
7668 .write()
7669 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(refreshing);
7670 let (_tx, rx) = crossbeam_channel::unbounded();
7671 ctx.install_search_index_rx(rx, ctx.configure_generation());
7672
7673 ctx.cancel_unbound_artifact_work();
7674
7675 assert!(
7676 ctx.search_index
7677 .read()
7678 .unwrap_or_else(std::sync::PoisonError::into_inner)
7679 .is_none(),
7680 "a cancelled corpus refresh must drop the non-ready resident so rebind reloads it"
7681 );
7682 assert!(ctx
7683 .search_index_rx
7684 .read()
7685 .unwrap_or_else(std::sync::PoisonError::into_inner)
7686 .is_none());
7687 }
7688
7689 #[test]
7690 fn active_semantic_file_refresh_blocks_idle_eviction_until_completion() {
7691 let root = TempDir::new().expect("project tempdir");
7692 let ctx = AppContext::new(
7693 Box::new(TreeSitterProvider::new()),
7694 Config {
7695 project_root: Some(root.path().to_path_buf()),
7696 ..Config::default()
7697 },
7698 );
7699 *ctx.semantic_index
7700 .write()
7701 .unwrap_or_else(std::sync::PoisonError::into_inner) =
7702 Some(SemanticIndex::new(root.path().to_path_buf(), 3));
7703 let refreshing_path = root.path().join("src/lib.rs");
7704 {
7705 let mut status = ctx
7706 .semantic_index_status
7707 .write()
7708 .unwrap_or_else(std::sync::PoisonError::into_inner);
7709 *status = SemanticIndexStatus::ready();
7710 status.start_refreshing_file(refreshing_path.clone());
7711 }
7712
7713 assert!(ctx.artifact_eviction_blocked());
7714 assert!(!ctx.evict_idle_artifacts());
7715 assert!(ctx
7716 .semantic_index
7717 .read()
7718 .unwrap_or_else(std::sync::PoisonError::into_inner)
7719 .is_some());
7720
7721 ctx.semantic_index_status
7722 .write()
7723 .unwrap_or_else(std::sync::PoisonError::into_inner)
7724 .complete_refreshing_file(&refreshing_path);
7725 assert!(ctx.evict_idle_artifacts());
7726 assert!(ctx
7727 .semantic_index
7728 .read()
7729 .unwrap_or_else(std::sync::PoisonError::into_inner)
7730 .is_none());
7731 }
7732}
7733
7734#[cfg(test)]
7735mod status_emitter_tests {
7736 use super::*;
7737 use crate::parser::TreeSitterProvider;
7738
7739 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
7740 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7741 let (tx, rx) = mpsc::channel();
7742 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
7743 let _ = tx.send(frame);
7744 }))));
7745 (ctx, rx)
7746 }
7747
7748 #[test]
7749 fn status_emitter_signal_triggers_push() {
7750 let (ctx, rx) = ctx_with_frame_rx();
7751 ctx.status_emitter().signal(ctx.build_status_snapshot());
7752 let frame = rx
7753 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
7754 .expect("status_changed push");
7755 assert!(matches!(frame, PushFrame::StatusChanged(_)));
7756 }
7757
7758 #[test]
7759 fn status_emitter_debounces_burst() {
7760 let (ctx, rx) = ctx_with_frame_rx();
7761 for _ in 0..10 {
7762 ctx.status_emitter().signal(ctx.build_status_snapshot());
7763 }
7764 let frame = rx
7765 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
7766 .expect("status_changed push");
7767 assert!(matches!(frame, PushFrame::StatusChanged(_)));
7768 assert!(rx.try_recv().is_err());
7769 }
7770
7771 #[test]
7772 fn status_emitter_separate_windows_separate_pushes() {
7773 let (ctx, rx) = ctx_with_frame_rx();
7774 ctx.status_emitter().signal(ctx.build_status_snapshot());
7775 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
7776 .expect("first push");
7777 ctx.status_emitter().signal(ctx.build_status_snapshot());
7778 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
7779 .expect("second push");
7780 }
7781
7782 #[test]
7783 fn status_emitter_no_signal_no_push() {
7784 let (_ctx, rx) = ctx_with_frame_rx();
7785 assert!(rx
7786 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
7787 .is_err());
7788 }
7789
7790 #[test]
7791 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
7792 let (ctx, rx) = ctx_with_frame_rx();
7793 drop(ctx);
7794 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
7795 }
7796
7797 #[test]
7798 fn progress_sender_slot_is_per_context_for_shared_app() {
7799 let app = App::default_shared();
7800 let ctx_a = AppContext::from_app(Arc::clone(&app), Config::default());
7801 let ctx_b = AppContext::from_app(app, Config::default());
7802 let (tx_a, rx_a) = mpsc::channel();
7803 let (tx_b, rx_b) = mpsc::channel();
7804
7805 ctx_a.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
7806 let _ = tx_a.send(frame);
7807 }))));
7808 ctx_b.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
7809 let _ = tx_b.send(frame);
7810 }))));
7811
7812 ctx_a.emit_progress(ProgressFrame {
7813 frame_type: "progress",
7814 request_id: "ctx-a".to_string(),
7815 kind: crate::protocol::ProgressKind::Stdout,
7816 chunk: "a".to_string(),
7817 });
7818 ctx_b.emit_progress(ProgressFrame {
7819 frame_type: "progress",
7820 request_id: "ctx-b".to_string(),
7821 kind: crate::protocol::ProgressKind::Stdout,
7822 chunk: "b".to_string(),
7823 });
7824
7825 match rx_a
7826 .recv_timeout(Duration::from_millis(50))
7827 .expect("ctx A progress frame")
7828 {
7829 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-a"),
7830 other => panic!("unexpected frame for ctx A: {other:?}"),
7831 }
7832 assert!(rx_a.try_recv().is_err());
7833
7834 match rx_b
7835 .recv_timeout(Duration::from_millis(50))
7836 .expect("ctx B progress frame")
7837 {
7838 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-b"),
7839 other => panic!("unexpected frame for ctx B: {other:?}"),
7840 }
7841 assert!(rx_b.try_recv().is_err());
7842 }
7843}
7844
7845#[cfg(test)]
7846mod health_warming_honesty_tests {
7847 use super::*;
7848 use crate::parser::TreeSitterProvider;
7849
7850 fn ctx_with_config(config: Config) -> AppContext {
7851 AppContext::new(Box::new(TreeSitterProvider::new()), config)
7852 }
7853
7854 fn health_search_status(ctx: &AppContext) -> &'static str {
7855 let root = std::path::Path::new("/tmp/health-warming-honesty-test");
7856 ctx.try_health_snapshot(root)
7857 .search_index
7858 .expect("search_index component present")
7859 .status
7860 }
7861
7862 fn health_tier2_status(ctx: &AppContext) -> &'static str {
7863 let root = std::path::Path::new("/tmp/health-warming-honesty-test");
7864 ctx.try_health_snapshot(root)
7865 .tier2
7866 .expect("tier2 component present")
7867 .status
7868 }
7869
7870 #[test]
7871 fn write_denied_search_index_reports_ready_not_building() {
7872 let config = Config {
7876 search_index: true,
7877 ..Config::default()
7878 };
7879 let ctx = ctx_with_config(config);
7880 let mut index = SearchIndex::new();
7881 index.build_denied = true;
7882 *ctx.search_index()
7883 .write()
7884 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
7885
7886 assert_eq!(
7887 health_search_status(&ctx),
7888 "ready",
7889 "a build-denied index is a terminal settled state and must not report building forever"
7890 );
7891 }
7892
7893 #[test]
7894 fn in_progress_search_index_still_reports_building() {
7895 let config = Config {
7899 search_index: true,
7900 ..Config::default()
7901 };
7902 let ctx = ctx_with_config(config);
7903 let index = SearchIndex::new(); *ctx.search_index()
7905 .write()
7906 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
7907
7908 assert_eq!(health_search_status(&ctx), "building");
7909 }
7910
7911 #[test]
7912 fn tier2_blocked_on_callgraph_reports_ready_not_building() {
7913 let ctx = ctx_with_config(Config::default()); ctx.update_status_bar_tier2(None, Some(3), Some(2), None, false);
7919 ctx.set_status_bar_tier2_dead_code_blocked_on_callgraph(true);
7920
7921 assert_eq!(
7922 health_tier2_status(&ctx),
7923 "ready",
7924 "tier2 complete except dead_code-blocked-on-callgraph must not stay building"
7925 );
7926 }
7927
7928 #[test]
7929 fn tier2_missing_dead_code_without_callgraph_block_reports_building() {
7930 let ctx = ctx_with_config(Config::default());
7933 ctx.update_status_bar_tier2(None, Some(3), Some(2), None, false);
7934 ctx.set_status_bar_tier2_dead_code_blocked_on_callgraph(false);
7935
7936 assert_eq!(health_tier2_status(&ctx), "building");
7937 }
7938}
7939
7940#[cfg(test)]
7941mod status_bar_tests {
7942 use super::*;
7943 use crate::parser::TreeSitterProvider;
7944
7945 fn ctx() -> AppContext {
7946 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
7947 }
7948
7949 #[test]
7950 fn status_bar_counts_none_until_tier2_populated() {
7951 let ctx = ctx();
7952 assert!(ctx.status_bar_counts().is_none());
7954
7955 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
7956 let counts = ctx.status_bar_counts().expect("populated");
7957 assert_eq!(counts.dead_code, 5);
7958 assert_eq!(counts.unused_exports, 3);
7959 assert_eq!(counts.duplicates, 7);
7960 assert_eq!(counts.todos, 2);
7961 assert!(!counts.tier2_stale);
7962 assert_eq!(counts.errors, 0);
7964 assert_eq!(counts.warnings, 0);
7965 }
7966
7967 #[test]
7968 fn changing_root_clears_project_scoped_status_counts() {
7969 let temp = tempfile::tempdir().expect("tempdir");
7970 let first_root = temp.path().join("first");
7971 let second_root = temp.path().join("second");
7972 std::fs::create_dir_all(&first_root).expect("create first root");
7973 std::fs::create_dir_all(&second_root).expect("create second root");
7974 let ctx = ctx();
7975 ctx.set_canonical_cache_root(first_root);
7976 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
7977 assert!(ctx.status_bar_counts().is_some());
7978
7979 ctx.set_canonical_cache_root(second_root);
7980
7981 assert!(
7982 ctx.status_bar_counts().is_none(),
7983 "counts from the previous root must not appear in a newly bound root"
7984 );
7985 }
7986
7987 #[test]
7988 fn partial_tier2_does_not_fabricate_zeros() {
7989 let ctx = ctx();
7990 ctx.update_status_bar_tier2(Some(5), None, None, None, true);
7994 assert!(
7995 ctx.status_bar_counts().is_none(),
7996 "bar must not surface until all three Tier-2 categories are real"
7997 );
7998
7999 ctx.update_status_bar_tier2(None, Some(3), None, None, true);
8001 assert!(ctx.status_bar_counts().is_none());
8002
8003 ctx.update_status_bar_tier2(None, None, Some(7), None, false);
8006 let counts = ctx.status_bar_counts().expect("all three real now");
8007 assert_eq!(counts.dead_code, 5);
8008 assert_eq!(counts.unused_exports, 3);
8009 assert_eq!(counts.duplicates, 7);
8010 }
8011
8012 #[test]
8013 fn update_with_none_todos_preserves_last_known_todos() {
8014 let ctx = ctx();
8015 ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
8016 ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
8018 let counts = ctx.status_bar_counts().expect("populated");
8019 assert_eq!(counts.todos, 9);
8020 assert_eq!(counts.dead_code, 2);
8021 }
8022
8023 #[test]
8024 fn update_with_none_count_preserves_last_known_count() {
8025 let ctx = ctx();
8026 ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
8027 ctx.update_status_bar_tier2(Some(11), None, None, None, false);
8030 let counts = ctx.status_bar_counts().expect("populated");
8031 assert_eq!(counts.dead_code, 11);
8032 assert_eq!(counts.unused_exports, 20);
8033 assert_eq!(counts.duplicates, 30);
8034 }
8035
8036 #[test]
8037 fn mark_stale_sets_flag_only_after_populate() {
8038 let ctx = ctx();
8039 ctx.mark_status_bar_tier2_stale();
8041 assert!(ctx.status_bar_counts().is_none());
8042
8043 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), Some(0), false);
8044 ctx.mark_status_bar_tier2_stale();
8045 assert!(ctx.status_bar_counts().expect("populated").tier2_stale);
8046
8047 ctx.update_status_bar_tier2(Some(4), Some(0), Some(0), None, false);
8049 assert!(!ctx.status_bar_counts().expect("populated").tier2_stale);
8050 }
8051
8052 #[test]
8057 fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
8058 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
8059 use crate::lsp::registry::ServerKind;
8060 use crate::lsp::roots::ServerKey;
8061
8062 let ctx = ctx();
8063 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); let file = std::path::PathBuf::from("/proj/gone.ts");
8066 {
8067 let mut lsp = ctx.lsp();
8068 lsp.diagnostics_store_mut_for_test().publish(
8069 ServerKey {
8070 kind: ServerKind::TypeScript,
8071 root: std::path::PathBuf::from("/proj"),
8072 },
8073 file.clone(),
8074 vec![StoredDiagnostic {
8075 file: file.clone(),
8076 line: 1,
8077 column: 1,
8078 end_line: 1,
8079 end_column: 2,
8080 severity: DiagnosticSeverity::Error,
8081 message: "boom".into(),
8082 code: None,
8083 source: None,
8084 }],
8085 );
8086 }
8087
8088 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
8090
8091 let removed = ctx.lsp_clear_diagnostics_for_file(&file);
8093 assert!(removed);
8094 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
8095 }
8096
8097 #[test]
8098 fn status_bar_preserves_authoritative_counts_during_provisional_publish() {
8099 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
8100 use crate::lsp::registry::ServerKind;
8101 use crate::lsp::roots::ServerKey;
8102
8103 let ctx = ctx();
8104 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
8105 let root = std::path::PathBuf::from("/proj");
8106 let file = root.join("src/main.rs");
8107 let key = ServerKey {
8108 kind: ServerKind::Rust,
8109 root,
8110 };
8111 let diagnostic = || StoredDiagnostic {
8112 file: file.clone(),
8113 line: 1,
8114 column: 1,
8115 end_line: 1,
8116 end_column: 2,
8117 severity: DiagnosticSeverity::Error,
8118 message: "analyzer result".into(),
8119 code: None,
8120 source: None,
8121 };
8122
8123 {
8124 let mut lsp = ctx.lsp();
8125 lsp.diagnostics_store_mut_for_test().publish(
8126 key.clone(),
8127 file.clone(),
8128 vec![diagnostic()],
8129 );
8130 }
8131 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
8132
8133 {
8134 let mut lsp = ctx.lsp();
8135 lsp.diagnostics_store_mut_for_test()
8136 .publish_full_with_provisional(
8137 key.clone(),
8138 file.clone(),
8139 vec![diagnostic()],
8140 None,
8141 None,
8142 true,
8143 );
8144 }
8145 assert_eq!(
8146 ctx.status_bar_counts().expect("populated").errors,
8147 1,
8148 "warming diagnostics must not replace the last authoritative E count"
8149 );
8150
8151 {
8152 let mut lsp = ctx.lsp();
8153 assert!(lsp
8154 .diagnostics_store_mut_for_test()
8155 .mark_provisional_for_server_stale(&key));
8156 }
8157 assert_eq!(
8158 ctx.status_bar_counts().expect("populated").errors,
8159 1,
8160 "invalidating warming entries must retain the last authoritative E count"
8161 );
8162
8163 {
8164 let mut lsp = ctx.lsp();
8165 lsp.diagnostics_store_mut_for_test()
8166 .publish(key, file.clone(), vec![diagnostic()]);
8167 }
8168 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
8169 }
8170
8171 #[test]
8172 fn status_bar_filtered_counts_ignore_environmental_flap() {
8173 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
8174 use crate::lsp::registry::ServerKind;
8175 use crate::lsp::roots::ServerKey;
8176
8177 let ctx = ctx();
8178 let root = if cfg!(windows) {
8179 std::path::PathBuf::from(r"C:\proj")
8180 } else {
8181 std::path::PathBuf::from("/proj")
8182 };
8183 ctx.set_canonical_cache_root(root.clone());
8184 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
8185
8186 let file = root.join("aft.jsonc");
8187 let key = ServerKey {
8188 kind: ServerKind::TypeScript,
8189 root: root.clone(),
8190 };
8191 let env = StoredDiagnostic {
8192 file: file.clone(),
8193 line: 1,
8194 column: 1,
8195 end_line: 1,
8196 end_column: 2,
8197 severity: DiagnosticSeverity::Error,
8198 message: "Failed to load schema from https://example.com/schema.json".into(),
8199 code: None,
8200 source: Some("json".into()),
8201 };
8202
8203 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
8204
8205 {
8206 let mut lsp = ctx.lsp();
8207 lsp.diagnostics_store_mut_for_test()
8208 .publish(key.clone(), file.clone(), vec![env]);
8209 }
8210 assert_eq!(
8211 ctx.status_bar_counts().expect("populated").errors,
8212 0,
8213 "environmental publish must not change status-bar E"
8214 );
8215
8216 {
8217 let mut lsp = ctx.lsp();
8218 lsp.diagnostics_store_mut_for_test()
8219 .publish(key, file, vec![]);
8220 }
8221 assert_eq!(
8222 ctx.status_bar_counts().expect("populated").errors,
8223 0,
8224 "environmental clear must not change status-bar E"
8225 );
8226 }
8227}
8228
8229#[cfg(test)]
8230mod harness_path_tests {
8231 use super::*;
8232 use crate::harness::Harness;
8233 use crate::parser::TreeSitterProvider;
8234
8235 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
8236 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
8237 ctx.update_config(|config| {
8238 config.storage_dir = Some(storage_dir);
8239 });
8240 ctx.set_harness(harness);
8241 ctx
8242 }
8243
8244 #[test]
8245 fn harness_dir_resolves_correctly() {
8246 let storage = PathBuf::from("/tmp/cortexkit/aft");
8247 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
8248
8249 assert_eq!(ctx.harness_dir(), storage.join("pi"));
8250 }
8251
8252 #[test]
8253 fn bash_tasks_dir_uses_hash_session() {
8254 let storage = PathBuf::from("/tmp/cortexkit/aft");
8255 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
8256
8257 assert_eq!(
8258 ctx.bash_tasks_dir("ses_abc"),
8259 storage
8260 .join("opencode")
8261 .join("bash-tasks")
8262 .join(hash_session("ses_abc"))
8263 );
8264 }
8265
8266 #[test]
8267 fn backups_dir_includes_path_hash() {
8268 let storage = PathBuf::from("/tmp/cortexkit/aft");
8269 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
8270
8271 assert_eq!(
8272 ctx.backups_dir("ses_abc", "pathhash"),
8273 storage
8274 .join("pi")
8275 .join("backups")
8276 .join(hash_session("ses_abc"))
8277 .join("pathhash")
8278 );
8279 }
8280
8281 #[test]
8282 fn filters_dir_under_harness() {
8283 let storage = PathBuf::from("/tmp/cortexkit/aft");
8284 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
8285
8286 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
8287 }
8288
8289 #[test]
8290 fn trust_file_is_host_global() {
8291 let storage = PathBuf::from("/tmp/cortexkit/aft");
8292 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
8293
8294 assert_eq!(
8295 ctx.trust_file(),
8296 storage.join("trusted-filter-projects.json")
8297 );
8298 }
8299
8300 #[test]
8301 fn same_session_different_harness_resolve_different_paths() {
8302 let storage = PathBuf::from("/tmp/cortexkit/aft");
8303 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
8304 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
8305
8306 assert_ne!(
8307 opencode.bash_tasks_dir("ses_same"),
8308 pi.bash_tasks_dir("ses_same")
8309 );
8310 }
8311
8312 #[test]
8313 fn callgraph_and_inspect_dirs_are_root_keyed() {
8314 let temp = tempfile::tempdir().expect("tempdir");
8315 let storage = temp.path().join("storage");
8316 let root = temp.path().join("checkout");
8317 std::fs::create_dir_all(&root).expect("create root");
8318 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
8319 ctx.set_canonical_cache_root(root.clone());
8320
8321 assert_eq!(
8322 ctx.callgraph_store_dir(),
8323 storage
8324 .join("callgraph")
8325 .join(crate::search_index::artifact_cache_key(&root))
8326 );
8327 assert_eq!(
8328 ctx.inspect_dir(),
8329 storage
8330 .join("inspect")
8331 .join(crate::path_identity::project_scope_key(&root))
8332 );
8333 assert!(!ctx
8334 .callgraph_store_dir()
8335 .starts_with(storage.join("opencode")));
8336 assert!(!ctx.inspect_dir().starts_with(storage.join("opencode")));
8337 }
8338
8339 #[test]
8340 fn per_domain_capability_allows_inspect_writer_when_callgraph_read_only() {
8341 let storage = PathBuf::from("/tmp/cortexkit/aft");
8342 let ctx = ctx_with_storage_and_harness(storage, Harness::Opencode);
8343 ctx.set_cache_writer_capabilities(false, true);
8344
8345 assert!(ctx.shared_artifacts_read_only());
8346 assert!(!ctx.callgraph_writer());
8347 assert!(ctx.inspect_writer());
8348 }
8349}
8350
8351#[cfg(test)]
8352mod shared_db_tests {
8353 use super::*;
8354 use tempfile::tempdir;
8355
8356 #[test]
8357 fn app_contexts_share_one_database_connection() {
8358 let storage = tempdir().expect("storage tempdir");
8359 let root_one = tempdir().expect("first root tempdir");
8360 let root_two = tempdir().expect("second root tempdir");
8361 let app = App::default_shared();
8362 let ctx_one = AppContext::from_app(
8363 Arc::clone(&app),
8364 Config {
8365 project_root: Some(root_one.path().to_path_buf()),
8366 ..Config::default()
8367 },
8368 );
8369 let ctx_two = AppContext::from_app(
8370 Arc::clone(&app),
8371 Config {
8372 project_root: Some(root_two.path().to_path_buf()),
8373 ..Config::default()
8374 },
8375 );
8376 let path = storage.path().join("aft.db");
8377
8378 let first = app.open_db(&path).expect("open shared database");
8379 let second = app.open_db(&path).expect("reuse shared database");
8380
8381 assert!(Arc::ptr_eq(&first, &second));
8382 assert!(Arc::ptr_eq(
8383 &ctx_one.db().expect("first context database"),
8384 &ctx_two.db().expect("second context database")
8385 ));
8386 }
8387}
8388
8389#[cfg(test)]
8390mod gitignore_tests {
8391 use super::*;
8392 use std::fs;
8393 use std::path::Path;
8394 use tempfile::TempDir;
8395
8396 fn make_ctx_with_root(root: &Path) -> AppContext {
8397 let provider = Box::new(crate::parser::TreeSitterProvider::new());
8398 let config = Config {
8399 project_root: Some(root.to_path_buf()),
8400 ..Config::default()
8401 };
8402 AppContext::new(provider, config)
8403 }
8404
8405 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
8412 let Some(matcher) = ctx.gitignore() else {
8413 return false;
8414 };
8415 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
8416 if !canonical.starts_with(matcher.path()) {
8417 return false;
8418 }
8419 let is_dir = canonical.is_dir();
8420 matcher
8421 .matched_path_or_any_parents(&canonical, is_dir)
8422 .is_ignore()
8423 }
8424
8425 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
8438 let _guard = crate::test_env::process_env_lock();
8439 let tmp = TempDir::new().unwrap();
8440 let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
8441 let prev_home = std::env::var_os("HOME");
8442 let prev_userprofile = std::env::var_os("USERPROFILE");
8443 unsafe {
8446 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
8447 std::env::set_var("HOME", tmp.path());
8448 std::env::set_var("USERPROFILE", tmp.path());
8449 }
8450 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
8451 unsafe {
8452 match prev_xdg {
8453 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
8454 None => std::env::remove_var("XDG_CONFIG_HOME"),
8455 }
8456 match prev_home {
8457 Some(v) => std::env::set_var("HOME", v),
8458 None => std::env::remove_var("HOME"),
8459 }
8460 match prev_userprofile {
8461 Some(v) => std::env::set_var("USERPROFILE", v),
8462 None => std::env::remove_var("USERPROFILE"),
8463 }
8464 }
8465 match result {
8466 Ok(r) => r,
8467 Err(p) => std::panic::resume_unwind(p),
8468 }
8469 }
8470
8471 #[test]
8472 fn rebuild_gitignore_returns_none_without_project_root() {
8473 let provider = Box::new(crate::parser::TreeSitterProvider::new());
8474 let ctx = AppContext::new(provider, Config::default());
8475 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
8476 assert!(ctx.gitignore().is_none());
8477 }
8478
8479 #[test]
8480 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
8481 let tmp = TempDir::new().unwrap();
8482 let ctx = make_ctx_with_root(tmp.path());
8483 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
8484 assert!(ctx.gitignore().is_none());
8485 }
8486
8487 #[test]
8488 fn matcher_filters_files_in_ignored_dist_dir() {
8489 let tmp = TempDir::new().unwrap();
8490 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
8491 fs::create_dir_all(tmp.path().join("dist")).unwrap();
8492 fs::create_dir_all(tmp.path().join("src")).unwrap();
8493 let dist_file = tmp.path().join("dist").join("bundle.js");
8494 let src_file = tmp.path().join("src").join("app.ts");
8495 fs::write(&dist_file, "x").unwrap();
8496 fs::write(&src_file, "y").unwrap();
8497
8498 let ctx = make_ctx_with_root(tmp.path());
8499 ctx.rebuild_gitignore();
8500
8501 assert!(ctx.gitignore().is_some());
8502 assert!(
8503 is_ignored(&ctx, &dist_file),
8504 "dist/bundle.js should be ignored"
8505 );
8506 assert!(
8507 !is_ignored(&ctx, &src_file),
8508 "src/app.ts should NOT be ignored"
8509 );
8510 }
8511
8512 #[test]
8513 fn matcher_handles_node_modules_and_target() {
8514 let tmp = TempDir::new().unwrap();
8515 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
8516 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
8517 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
8518 let nm_file = tmp.path().join("node_modules/foo/index.js");
8519 let target_file = tmp.path().join("target/debug/aft");
8520 fs::write(&nm_file, "x").unwrap();
8521 fs::write(&target_file, "x").unwrap();
8522
8523 let ctx = make_ctx_with_root(tmp.path());
8524 ctx.rebuild_gitignore();
8525
8526 assert!(is_ignored(&ctx, &nm_file));
8527 assert!(is_ignored(&ctx, &target_file));
8528 }
8529
8530 #[test]
8531 fn matcher_honors_negation_pattern() {
8532 let tmp = TempDir::new().unwrap();
8534 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
8535 let random_log = tmp.path().join("random.log");
8536 let important_log = tmp.path().join("important.log");
8537 fs::write(&random_log, "x").unwrap();
8538 fs::write(&important_log, "y").unwrap();
8539
8540 let ctx = make_ctx_with_root(tmp.path());
8541 ctx.rebuild_gitignore();
8542
8543 assert!(is_ignored(&ctx, &random_log));
8544 assert!(
8545 !is_ignored(&ctx, &important_log),
8546 "negation pattern should un-ignore important.log"
8547 );
8548 }
8549
8550 #[test]
8551 fn rebuild_picks_up_gitignore_changes() {
8552 let tmp = TempDir::new().unwrap();
8553 let ignore_path = tmp.path().join(".gitignore");
8554 fs::write(&ignore_path, "foo.txt\n").unwrap();
8555 let foo = tmp.path().join("foo.txt");
8556 let bar = tmp.path().join("bar.txt");
8557 fs::write(&foo, "").unwrap();
8558 fs::write(&bar, "").unwrap();
8559
8560 let ctx = make_ctx_with_root(tmp.path());
8561 ctx.rebuild_gitignore();
8562 assert!(is_ignored(&ctx, &foo));
8563 assert!(!is_ignored(&ctx, &bar));
8564
8565 fs::write(&ignore_path, "bar.txt\n").unwrap();
8567 ctx.rebuild_gitignore();
8568 assert!(!is_ignored(&ctx, &foo));
8569 assert!(is_ignored(&ctx, &bar));
8570 }
8571
8572 #[test]
8573 fn gitignore_loads_info_exclude_when_present() {
8574 let tmp = TempDir::new().unwrap();
8575 let info_dir = tmp.path().join(".git/info");
8576 fs::create_dir_all(&info_dir).unwrap();
8577 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
8578 let secrets = tmp.path().join("secrets.txt");
8579 let public = tmp.path().join("public.txt");
8580 fs::write(&secrets, "token").unwrap();
8581 fs::write(&public, "ok").unwrap();
8582
8583 let ctx = make_ctx_with_root(tmp.path());
8584 ctx.rebuild_gitignore();
8585
8586 assert!(is_ignored(&ctx, &secrets));
8587 assert!(!is_ignored(&ctx, &public));
8588 }
8589
8590 #[test]
8591 fn matcher_picks_up_nested_gitignore() {
8592 let tmp = TempDir::new().unwrap();
8593 fs::write(tmp.path().join(".gitignore"), "").unwrap();
8595 let sub = tmp.path().join("packages/foo");
8596 fs::create_dir_all(&sub).unwrap();
8597 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
8598 let generated_file = sub.join("generated").join("out.js");
8599 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
8600 fs::write(&generated_file, "x").unwrap();
8601
8602 let ctx = make_ctx_with_root(tmp.path());
8603 ctx.rebuild_gitignore();
8604
8605 assert!(
8606 is_ignored(&ctx, &generated_file),
8607 "nested gitignore in packages/foo/.gitignore should ignore generated/"
8608 );
8609 }
8610}
8611
8612#[cfg(test)]
8613mod verify_memo_watcher_tests {
8614 use super::*;
8615
8616 #[test]
8617 fn pending_watcher_path_invalidates_root_verify_memo() {
8618 let root_dir = tempfile::tempdir().unwrap();
8619 let root = std::fs::canonicalize(root_dir.path()).unwrap();
8620 let artifact = root.join("cache.bin");
8621 std::fs::write(&artifact, b"generation").unwrap();
8622 let generation = crate::cache_freshness::artifact_generation(&artifact).unwrap();
8623 crate::cache_freshness::record_verify_completed(
8624 &root,
8625 crate::cache_freshness::VerifyArtifact::Search,
8626 Some(generation),
8627 );
8628 assert_eq!(
8629 crate::cache_freshness::warm_verify_plan(
8630 &root,
8631 crate::cache_freshness::VerifyArtifact::Search,
8632 Some(generation),
8633 ),
8634 crate::cache_freshness::WarmVerifyPlan::Skip
8635 );
8636
8637 let ctx = AppContext::from_app(
8638 App::default_shared(),
8639 Config {
8640 project_root: Some(root.clone()),
8641 ..Config::default()
8642 },
8643 );
8644 ctx.set_canonical_cache_root(root.clone());
8645 ctx.add_pending_search_index_paths([root.join("changed.rs")]);
8646 assert_eq!(
8647 crate::cache_freshness::warm_verify_plan(
8648 &root,
8649 crate::cache_freshness::VerifyArtifact::Search,
8650 Some(generation),
8651 ),
8652 crate::cache_freshness::WarmVerifyPlan::StatFirst
8653 );
8654 }
8655}
8656
8657#[cfg(test)]
8658mod watcher_runtime_state_tests {
8659 use super::*;
8660 use crate::language::StubProvider;
8661
8662 fn test_context() -> AppContext {
8663 AppContext::new(Box::new(StubProvider), Config::default())
8664 }
8665
8666 #[test]
8667 fn finished_watcher_thread_reports_inactive_and_is_reclaimed_with_invalidation() {
8668 let root = tempfile::tempdir().expect("project tempdir");
8669 let canonical_root = std::fs::canonicalize(root.path()).expect("canonical root");
8670 let ctx = AppContext::new(
8671 Box::new(StubProvider),
8672 Config {
8673 project_root: Some(canonical_root.clone()),
8674 ..Config::default()
8675 },
8676 );
8677 ctx.set_canonical_cache_root(canonical_root.clone());
8678 struct DisableWatcherGuard;
8682 impl Drop for DisableWatcherGuard {
8683 fn drop(&mut self) {
8684 unsafe { std::env::remove_var("AFT_TEST_DISABLE_FILE_WATCHER") };
8685 }
8686 }
8687 let _env_lock = crate::test_env::process_env_lock();
8688 unsafe { std::env::set_var("AFT_TEST_DISABLE_FILE_WATCHER", "1") };
8689 let _disable_watcher = DisableWatcherGuard;
8690 *ctx.search_index
8693 .write()
8694 .unwrap_or_else(std::sync::PoisonError::into_inner) =
8695 Some(crate::search_index::SearchIndex::new());
8696 let artifact = canonical_root.join("artifact.bin");
8697 std::fs::write(&artifact, b"artifact").expect("artifact");
8698 let generation = crate::cache_freshness::artifact_generation(&artifact);
8699 crate::cache_freshness::record_verify_completed(
8700 &canonical_root,
8701 crate::cache_freshness::VerifyArtifact::Search,
8702 generation,
8703 );
8704
8705 let (dispatch_tx, dispatch_rx) = crate::watcher_filter::watcher_dispatch_channel();
8706 let _dispatch_tx = dispatch_tx;
8707 let join = std::thread::spawn(|| {});
8710 ctx.install_watcher_runtime(
8711 dispatch_rx,
8712 WatcherThreadHandle::new(Arc::new(AtomicBool::new(false)), join),
8713 );
8714 let deadline = std::time::Instant::now() + Duration::from_secs(2);
8715 while ctx.watcher_runtime_active() {
8716 assert!(
8717 std::time::Instant::now() < deadline,
8718 "a finished watcher thread must report the runtime inactive"
8719 );
8720 std::thread::yield_now();
8721 }
8722
8723 crate::commands::configure::ensure_project_watcher(&ctx);
8726
8727 assert!(
8728 ctx.search_index
8729 .read()
8730 .unwrap_or_else(std::sync::PoisonError::into_inner)
8731 .is_none(),
8732 "corpse reclaim must drop resident artifacts (events since the failure are lost)"
8733 );
8734 assert_eq!(
8735 crate::cache_freshness::warm_verify_plan(
8736 &canonical_root,
8737 crate::cache_freshness::VerifyArtifact::Search,
8738 generation,
8739 ),
8740 crate::cache_freshness::WarmVerifyPlan::Strict,
8741 "corpse reclaim must force strict re-verification"
8742 );
8743 assert!(
8744 !ctx.take_finished_watcher_runtime(),
8745 "reclaim is one-shot; the corpse is gone after ensure_project_watcher"
8746 );
8747 }
8748
8749 #[test]
8750 fn watcher_runtime_requires_both_thread_and_dispatch_receiver() {
8751 let ctx = test_context();
8752 let (dispatch_tx, dispatch_rx) = crate::watcher_filter::watcher_dispatch_channel();
8753 let shutdown = Arc::new(AtomicBool::new(false));
8754 let thread_shutdown = Arc::clone(&shutdown);
8755 let join = std::thread::spawn(move || {
8756 while !thread_shutdown.load(Ordering::SeqCst) {
8757 std::thread::sleep(Duration::from_millis(1));
8758 }
8759 drop(dispatch_tx);
8760 });
8761 ctx.install_watcher_runtime(
8762 dispatch_rx,
8763 WatcherThreadHandle::new(Arc::clone(&shutdown), join),
8764 );
8765 assert!(ctx.watcher_runtime_active());
8766
8767 *ctx.watcher_rx.lock() = None;
8768 assert!(
8769 !ctx.watcher_runtime_active(),
8770 "a thread without its dispatch receiver is not a usable watcher runtime"
8771 );
8772 ctx.stop_watcher_runtime();
8773 }
8774}
8775
8776#[cfg(test)]
8777mod semantic_probe_tests {
8778 use super::*;
8779
8780 #[test]
8781 fn cleared_semantic_worker_invalidates_orphaned_probe_timer() {
8782 let root = tempfile::tempdir().unwrap();
8783 let ctx = AppContext::new(
8784 default_language_provider_factory(),
8785 Config {
8786 project_root: Some(root.path().to_path_buf()),
8787 ..Config::default()
8788 },
8789 );
8790 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
8791 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
8792 let worker_slot = Arc::new(Mutex::new(None));
8793 ctx.install_semantic_refresh_worker_for_build_epoch(
8794 request_tx,
8795 event_rx,
8796 worker_slot,
8797 ctx.semantic_index_rx_epoch(),
8798 );
8799
8800 ctx.ensure_semantic_refresh_probe_scheduled(Duration::from_millis(20));
8801 assert!(ctx.semantic_refresh_probe_is_scheduled());
8802 ctx.clear_semantic_refresh_worker();
8803 std::thread::sleep(Duration::from_millis(50));
8804
8805 assert!(!ctx.semantic_refresh_probe_ready());
8806 assert!(!ctx.semantic_refresh_probe_is_scheduled());
8807 assert!(!ctx.completion_drains_have_work());
8808 }
8809}