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