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