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