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