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, UNIX_EPOCH};
7
8use lsp_types::FileChangeType;
9use notify::RecommendedWatcher;
10use rusqlite::Connection;
11use serde::Serialize;
12
13use crate::alert_state::{
14 AcceptedObservationBatch, AcceptedObservationResult, AlertDeltaState, ObservationError,
15};
16use crate::artifact_owner::{
17 ArtifactOwnerLease, ArtifactOwnerLeaseRegistration, ArtifactOwnerMode, ArtifactOwnerStatus,
18};
19use crate::backup::hash_session;
20use crate::backup::BackupStore;
21use crate::bash_background::{BgCompletion, BgTaskHealthCounts, BgTaskRegistry};
22use crate::callgraph_store::{CallGraphStore, CallGraphStoreError, ReadonlyCallGraphStore};
23use crate::checkpoint::CheckpointStore;
24use crate::config::Config;
25use crate::harness::Harness;
26use crate::inspect::{
27 InspectCategory, InspectManager, InspectSnapshot, Tier2RefreshScheduler, Tier2TriggerReason,
28};
29use crate::language::LanguageProvider;
30use crate::lsp::manager::{LspManager, StaleDiagnosticsMark};
31use crate::lsp::registry::is_config_file_path_with_custom;
32use crate::parser::{SharedSymbolCache, SymbolCache, TreeSitterProvider};
33use crate::protocol::{
34 ConfigureWarningsFrame, ProgressFrame, PushFrame, StatusChangedFrame, StatusPayload,
35};
36use crate::watcher_filter::WatcherJoinOutcome;
37use crate::watcher_filter::{SharedGitignore, WatcherDispatchEvent, WatcherThreadHandle};
38
39pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
40pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
41pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
42const STATUS_DEBOUNCE_MS: u64 = 1_000;
43
44fn canonicalize_lenient(path: &Path) -> Option<PathBuf> {
71 use std::path::Component;
72 if let Ok(canonical) = std::fs::canonicalize(path) {
73 return Some(canonical);
74 }
75 let mut resolved = PathBuf::new();
76 let mut missing: Vec<std::ffi::OsString> = Vec::new();
77 for component in path.components() {
78 match component {
79 Component::Prefix(_) | Component::RootDir => {
80 resolved.push(component.as_os_str());
81 if let Ok(canonical_anchor) = std::fs::canonicalize(&resolved) {
85 resolved = canonical_anchor;
86 }
87 }
88 Component::CurDir => {}
89 Component::ParentDir => {
90 if missing.pop().is_none() {
91 if !resolved.as_os_str().is_empty() && !resolved.is_dir() {
92 return None;
94 }
95 resolved.pop();
96 }
97 }
98 Component::Normal(name) => {
99 if missing.is_empty() {
100 let candidate = resolved.join(name);
101 match std::fs::canonicalize(&candidate) {
102 Ok(canonical) => resolved = canonical,
103 Err(_) => match std::fs::symlink_metadata(&candidate) {
104 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
107 missing.push(name.to_owned())
108 }
109 _ => return None,
112 },
113 }
114 } else {
115 missing.push(name.to_owned());
116 }
117 }
118 }
119 }
120 for name in missing {
121 resolved.push(name);
122 }
123 Some(resolved)
124}
125
126fn pending_path_in_roots(path: &Path, roots: &[PathBuf]) -> bool {
136 if path.is_relative() {
137 let has_prefix_or_root = path.components().next().is_some_and(|component| {
142 matches!(
143 component,
144 std::path::Component::Prefix(_) | std::path::Component::RootDir
145 )
146 });
147 if has_prefix_or_root {
148 return false;
149 }
150 return roots.iter().any(|root| {
153 let joined = root.join(path);
154 match (canonicalize_lenient(&joined), canonicalize_lenient(root)) {
155 (Some(path), Some(root)) => path.starts_with(&root),
156 _ => false,
157 }
158 });
159 }
160 let Some(canonical_path) = canonicalize_lenient(path) else {
161 return false;
162 };
163 roots.iter().any(|root| {
164 canonicalize_lenient(root)
165 .is_some_and(|canonical_root| canonical_path.starts_with(&canonical_root))
166 })
167}
168
169#[derive(Clone, Default)]
173pub(crate) struct SubcLifecycleAdmission {
174 unbound: Arc<parking_lot::Mutex<bool>>,
175}
176
177impl SubcLifecycleAdmission {
178 fn mark_bound(&self) {
179 *self.unbound.lock() = false;
180 }
181
182 fn mark_unbound(&self, configure_generation: &AtomicU64) {
183 let mut unbound = self.unbound.lock();
184 if !*unbound {
185 *unbound = true;
186 configure_generation.fetch_add(1, Ordering::SeqCst);
187 }
188 }
189
190 pub(crate) fn is_current(&self, generation: &AtomicU64, expected: u64) -> bool {
191 let unbound = self.unbound.lock();
192 !*unbound && generation.load(Ordering::SeqCst) == expected
193 }
194
195 fn advance_generation(&self, generation: &AtomicU64) -> u64 {
196 let _unbound = self.unbound.lock();
197 generation.fetch_add(1, Ordering::SeqCst).wrapping_add(1)
198 }
199
200 pub(crate) fn run_if_current<R>(
201 &self,
202 generation: &AtomicU64,
203 expected: u64,
204 action: impl FnOnce() -> R,
205 ) -> Option<R> {
206 let unbound = self.unbound.lock();
207 if *unbound || generation.load(Ordering::SeqCst) != expected {
208 return None;
209 }
210 Some(action())
211 }
212
213 pub(crate) fn is_bound(&self) -> bool {
214 !*self.unbound.lock()
215 }
216
217 fn try_is_bound(&self) -> Option<bool> {
218 self.unbound.try_lock().map(|unbound| !*unbound)
219 }
220
221 fn is_unbound(&self) -> bool {
222 !self.is_bound()
223 }
224
225 fn run_if_unbound<R>(&self, action: impl FnOnce() -> R) -> Option<R> {
226 let unbound = self.unbound.lock();
227 if !*unbound {
228 return None;
229 }
230 Some(action())
231 }
232}
233
234const GRACEFUL_SHUTDOWN_SEARCH_BUILD_WAIT: Duration = Duration::from_secs(5);
235const GRACEFUL_SHUTDOWN_SEARCH_BUILD_POLL: Duration = Duration::from_millis(10);
236
237#[derive(Debug, Clone, Default, PartialEq, Eq)]
241pub struct StatusBarCounts {
242 pub errors: usize,
243 pub warnings: usize,
244 pub dead_code: usize,
245 pub unused_exports: usize,
246 pub duplicates: usize,
247 pub todos: usize,
248 pub tier2_stale: bool,
249}
250
251#[derive(Debug, Clone, Default, PartialEq, Eq)]
254pub struct StatusBarCountValues {
255 pub errors: Option<usize>,
256 pub warnings: Option<usize>,
257 pub dead_code: Option<usize>,
258 pub unused_exports: Option<usize>,
259 pub duplicates: Option<usize>,
260 pub todos: Option<usize>,
261 pub tier2_stale: bool,
262}
263
264impl StatusBarCountValues {
265 fn legacy_projection(&self) -> Option<StatusBarCounts> {
266 let [Some(dead_code), Some(unused_exports), Some(duplicates)] =
267 [self.dead_code, self.unused_exports, self.duplicates]
268 else {
269 return None;
270 };
271
272 Some(StatusBarCounts {
273 errors: self.errors.unwrap_or_default(),
274 warnings: self.warnings.unwrap_or_default(),
275 dead_code,
276 unused_exports,
277 duplicates,
278 todos: self.todos.unwrap_or_default(),
279 tier2_stale: self.tier2_stale,
280 })
281 }
282}
283
284#[derive(Debug, Clone, Default)]
293struct StatusBarTier2 {
294 dead_code: Option<usize>,
295 unused_exports: Option<usize>,
296 duplicates: Option<usize>,
297 todos: Option<usize>,
298 stale: bool,
299 generation: u64,
300 dead_code_blocked_on_callgraph: bool,
307}
308
309#[derive(Debug, Clone, Default)]
310struct StatusBarCache {
311 valid: bool,
312 diagnostics_generation: u64,
313 tier2_generation: u64,
314 tsconfig_generation: u64,
315 counts: Option<StatusBarCountValues>,
316}
317
318#[derive(Debug, Default)]
322struct LegacyStatusBarEmission(RwLock<Option<StatusBarCounts>>);
323
324impl LegacyStatusBarEmission {
325 fn should_emit(&self, counts: &StatusBarCounts) -> bool {
326 let mut last = self
327 .0
328 .write()
329 .unwrap_or_else(std::sync::PoisonError::into_inner);
330 if last.as_ref() == Some(counts) {
331 return false;
332 }
333 *last = Some(counts.clone());
334 true
335 }
336
337 fn clear(&self) {
338 *self
339 .0
340 .write()
341 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
342 }
343}
344
345#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
346#[serde(rename_all = "snake_case")]
347pub enum RootHealthState {
348 Ready,
349 Busy,
350}
351
352#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
353pub struct HealthComponentSnapshot {
354 pub status: &'static str,
355}
356
357#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
358pub struct Tier2HealthSnapshot {
359 pub status: &'static str,
360}
361
362#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
363pub struct SuspendedDomainHealthSnapshot {
364 pub domain: String,
365 pub reason: String,
366 pub death_count: u64,
367 pub age_s: u64,
368}
369
370#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
371pub struct RootHealthSnapshot {
372 pub project_root: String,
373 pub actor_count: usize,
374 pub state: RootHealthState,
375 #[serde(skip_serializing_if = "Option::is_none")]
376 pub search_index: Option<HealthComponentSnapshot>,
377 #[serde(skip_serializing_if = "Option::is_none")]
378 pub semantic_index: Option<HealthComponentSnapshot>,
379 #[serde(skip_serializing_if = "Option::is_none")]
380 pub callgraph_store: Option<HealthComponentSnapshot>,
381 #[serde(skip_serializing_if = "Option::is_none")]
382 pub callgraph_repair_entries_60s: Option<u64>,
383 #[serde(skip_serializing_if = "Option::is_none")]
384 pub callgraph_commits_60s: Option<u64>,
385 #[serde(skip_serializing_if = "Option::is_none")]
386 pub callgraph_pages_or_bytes_written_60s: Option<u64>,
387 #[serde(skip_serializing_if = "Option::is_none")]
388 pub tier2: Option<Tier2HealthSnapshot>,
389 #[serde(skip_serializing_if = "Option::is_none")]
390 pub bash: Option<BgTaskHealthCounts>,
391 #[serde(skip_serializing_if = "Vec::is_empty")]
392 pub suspended_domains: Vec<SuspendedDomainHealthSnapshot>,
393}
394
395#[derive(Debug, Clone, PartialEq, Eq)]
396pub(crate) struct RootHealthSummary {
397 state: RootHealthState,
398 search_index_status: Option<&'static str>,
399 semantic_index_status: Option<&'static str>,
400 callgraph_store_status: Option<&'static str>,
401 tier2_status: Option<&'static str>,
402 bash: Option<BgTaskHealthCounts>,
403 suspended_domains: Vec<SuspendedDomainHealthSnapshot>,
404}
405
406impl RootHealthSummary {
407 fn busy() -> Self {
408 Self {
409 state: RootHealthState::Busy,
410 search_index_status: None,
411 semantic_index_status: None,
412 callgraph_store_status: None,
413 tier2_status: None,
414 bash: None,
415 suspended_domains: Vec::new(),
416 }
417 }
418
419 pub(crate) fn is_busy(&self) -> bool {
420 matches!(self.state, RootHealthState::Busy)
421 }
422
423 pub(crate) fn is_fully_ready(&self) -> bool {
424 let component_is_satisfied = |status: &str| matches!(status, "ready" | "disabled");
425 matches!(self.state, RootHealthState::Ready)
426 && self.search_index_status.is_some_and(component_is_satisfied)
427 && self
428 .semantic_index_status
429 .is_some_and(component_is_satisfied)
430 && self
431 .callgraph_store_status
432 .is_some_and(component_is_satisfied)
433 && self.tier2_status.is_some_and(component_is_satisfied)
434 }
435
436 pub(crate) fn into_snapshot(self, project_root: &Path) -> RootHealthSnapshot {
437 if self.is_busy() {
438 return RootHealthSnapshot::busy(project_root);
439 }
440 let callgraph_write_metrics =
444 crate::search_index::artifact_cache_key_memoized_only(project_root)
445 .map(|key| crate::callgraph_store::callgraph_write_metrics_for_project(&key));
446 let (callgraph_commits_60s, callgraph_pages_or_bytes_written_60s) =
447 match callgraph_write_metrics {
448 Some(metrics)
449 if metrics.commits_60s > 0 || metrics.pages_or_bytes_written_60s > 0 =>
450 {
451 (
452 Some(metrics.commits_60s),
453 Some(metrics.pages_or_bytes_written_60s),
454 )
455 }
456 _ => (None, None),
457 };
458 RootHealthSnapshot {
459 project_root: project_root.display().to_string(),
460 actor_count: 1,
461 state: self.state,
462 search_index: self
463 .search_index_status
464 .map(|status| HealthComponentSnapshot { status }),
465 semantic_index: self
466 .semantic_index_status
467 .map(|status| HealthComponentSnapshot { status }),
468 callgraph_store: self
469 .callgraph_store_status
470 .map(|status| HealthComponentSnapshot { status }),
471 callgraph_repair_entries_60s: None,
472 callgraph_commits_60s,
473 callgraph_pages_or_bytes_written_60s,
474 tier2: self
475 .tier2_status
476 .map(|status| Tier2HealthSnapshot { status }),
477 bash: self.bash,
478 suspended_domains: self.suspended_domains,
479 }
480 }
481}
482
483impl RootHealthSnapshot {
484 fn busy(project_root: &Path) -> Self {
485 Self {
486 project_root: project_root.display().to_string(),
487 actor_count: 1,
488 state: RootHealthState::Busy,
489 search_index: None,
490 semantic_index: None,
491 callgraph_store: None,
492 callgraph_repair_entries_60s: None,
493 callgraph_commits_60s: None,
494 callgraph_pages_or_bytes_written_60s: None,
495 tier2: None,
496 bash: None,
497 suspended_domains: Vec::new(),
498 }
499 }
500
501 pub fn is_fully_ready(&self) -> bool {
502 let component_is_satisfied =
503 |status: &HealthComponentSnapshot| matches!(status.status, "ready" | "disabled");
504 let tier2_is_satisfied =
505 |tier2: &Tier2HealthSnapshot| matches!(tier2.status, "ready" | "disabled");
506
507 matches!(self.state, RootHealthState::Ready)
508 && self
509 .search_index
510 .as_ref()
511 .is_some_and(component_is_satisfied)
512 && self
513 .semantic_index
514 .as_ref()
515 .is_some_and(component_is_satisfied)
516 && self
517 .callgraph_store
518 .as_ref()
519 .is_some_and(component_is_satisfied)
520 && self.tier2.as_ref().is_some_and(tier2_is_satisfied)
521 }
522}
523
524pub struct StatusEmitter {
525 latest: Arc<Mutex<Option<StatusPayload>>>,
526 notify: mpsc::Sender<()>,
527}
528
529#[derive(Clone, Debug, Default)]
530struct ConfigureWarmState {
531 generation: u64,
532 key: Option<String>,
533}
534
535#[derive(Debug)]
536struct ConfigurePhaseTiming {
537 phase: &'static str,
538 started_at: Instant,
539 completed: Vec<(&'static str, Duration)>,
540}
541
542impl Default for ConfigurePhaseTiming {
543 fn default() -> Self {
544 Self {
545 phase: "idle",
546 started_at: Instant::now(),
547 completed: Vec::new(),
548 }
549 }
550}
551
552#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
553pub(crate) enum WatcherDrainApplyPhase {
554 #[default]
555 PendingTier2,
556 PendingIndexes,
557 SymbolCache,
558 Callgraph,
559 SearchIndex,
560 SemanticIndex,
561 LspDiagnostics,
562 Complete,
563}
564
565#[derive(Debug, Default)]
566pub(crate) enum WatcherDrainPhase {
567 #[default]
568 Collect,
569 Apply {
570 stage: WatcherDrainApplyPhase,
571 paths: VecDeque<PathBuf>,
572 remaining: usize,
573 oversized_inline_batch: bool,
574 },
575}
576
577#[derive(Debug)]
578pub(crate) struct WatcherDrainSliceState {
579 pub(crate) configure_generation: u64,
580 pub(crate) configure_content_generation: u64,
586 pub(crate) phase: WatcherDrainPhase,
587 pub(crate) pending_paths: VecDeque<PathBuf>,
588 pub(crate) ignore_changed: bool,
589 pub(crate) rescan_required: bool,
590 pub(crate) status_changed: bool,
591 pub(crate) scheduler_changed_path_count: usize,
592 pub(crate) semantic_refresh_paths: Vec<PathBuf>,
593 pub(crate) path_slice_count: usize,
594}
595
596pub(crate) struct PendingReconciliationState {
600 search: BTreeSet<PathBuf>,
601 callgraph: BTreeSet<PathBuf>,
602 tier2: BTreeSet<PathBuf>,
603 semantic: BTreeSet<PathBuf>,
604 corpus_refresh: bool,
605}
606
607impl WatcherDrainSliceState {
608 pub(crate) fn new(configure_generation: u64, configure_content_generation: u64) -> Self {
609 Self {
610 configure_generation,
611 configure_content_generation,
612 phase: WatcherDrainPhase::Collect,
613 pending_paths: VecDeque::new(),
614 ignore_changed: false,
615 rescan_required: false,
616 status_changed: false,
617 scheduler_changed_path_count: 0,
618 semantic_refresh_paths: Vec::new(),
619 path_slice_count: 0,
620 }
621 }
622
623 pub(crate) fn has_pending_work(&self) -> bool {
624 !matches!(self.phase, WatcherDrainPhase::Collect)
625 || !self.pending_paths.is_empty()
626 || self.ignore_changed
627 || self.rescan_required
628 }
629}
630
631#[doc(hidden)]
632pub enum CallGraphStoreBuildEvent {
633 Ready {
634 store: CallGraphStore,
635 fulfilled_force_token: Option<u64>,
636 publication_epoch: u64,
637 },
638 Denied {
639 reason: String,
640 },
641 Suspended {
642 suspension: crate::build_breaker::BuildSuspension,
643 },
644 Settled,
645}
646
647struct CallGraphStoreBuildSettlement {
648 tx: crossbeam_channel::Sender<CallGraphStoreBuildEvent>,
649 sent: bool,
650 force_token: Option<u64>,
651 publication_epoch: u64,
652}
653
654impl CallGraphStoreBuildSettlement {
655 fn new(
656 tx: crossbeam_channel::Sender<CallGraphStoreBuildEvent>,
657 force_token: Option<u64>,
658 publication_epoch: u64,
659 ) -> Self {
660 Self {
661 tx,
662 sent: false,
663 force_token,
664 publication_epoch,
665 }
666 }
667
668 fn ready(&mut self, store: CallGraphStore) {
669 let _ = self.tx.send(CallGraphStoreBuildEvent::Ready {
670 store,
671 fulfilled_force_token: self.force_token,
672 publication_epoch: self.publication_epoch,
673 });
674 self.sent = true;
675 }
676
677 fn denied(&mut self, reason: String) {
678 let _ = self.tx.send(CallGraphStoreBuildEvent::Denied { reason });
679 self.sent = true;
680 }
681
682 fn suspended(&mut self, suspension: crate::build_breaker::BuildSuspension) {
683 let _ = self
684 .tx
685 .send(CallGraphStoreBuildEvent::Suspended { suspension });
686 self.sent = true;
687 }
688}
689
690impl Drop for CallGraphStoreBuildSettlement {
691 fn drop(&mut self) {
692 if !self.sent {
693 let _ = self.tx.send(CallGraphStoreBuildEvent::Settled);
694 }
695 }
696}
697
698#[derive(Clone, Debug)]
699pub(crate) struct ConfigureMaintenanceJob {
700 pub(crate) generation: u64,
701 pub(crate) root_path: PathBuf,
702 pub(crate) canonical_cache_root: PathBuf,
703 pub(crate) harness: Harness,
704 pub(crate) storage_root: PathBuf,
705 pub(crate) harness_dir: PathBuf,
706 pub(crate) session_id: String,
707 pub(crate) home_match: bool,
708 pub(crate) format_tool_cache_clear_needed: bool,
709 pub(crate) run_bash_replay: bool,
710 pub(crate) refresh_project_runtime: bool,
711 pub(crate) sync_bash_compress_flag: bool,
712 pub(crate) reset_filter_registry: bool,
713 pub(crate) clear_failed_spawns: bool,
714 pub(crate) warm_callgraph_store: bool,
715 pub(crate) supersede_artifact_persistence: bool,
718 pub(crate) artifact_load_starts: Vec<crossbeam_channel::Sender<()>>,
721}
722
723impl StatusEmitter {
724 fn new(progress_sender: SharedProgressSender) -> Self {
725 let (notify, rx) = mpsc::channel();
726 let latest = Arc::new(Mutex::new(None));
727 let latest_for_thread = Arc::clone(&latest);
728 std::thread::spawn(move || {
729 status_debounce_loop(rx, latest_for_thread, progress_sender);
730 });
731 Self { latest, notify }
732 }
733
734 pub fn signal(&self, snapshot: StatusPayload) {
735 if let Ok(mut latest) = self.latest.lock() {
736 *latest = Some(snapshot);
737 }
738 let _ = self.notify.send(());
739 }
740}
741
742fn status_debounce_loop(
743 rx: mpsc::Receiver<()>,
744 latest: Arc<Mutex<Option<StatusPayload>>>,
745 progress_sender: SharedProgressSender,
746) {
747 while rx.recv().is_ok() {
748 let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
749 while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
750 match rx.recv_timeout(remaining) {
751 Ok(()) => continue,
752 Err(mpsc::RecvTimeoutError::Timeout) => break,
753 Err(mpsc::RecvTimeoutError::Disconnected) => return,
754 }
755 }
756
757 let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
758 let Some(snapshot) = snapshot else { continue };
759 let sender = progress_sender
760 .lock()
761 .ok()
762 .and_then(|sender| sender.clone());
763 if let Some(sender) = sender {
764 sender(PushFrame::StatusChanged(StatusChangedFrame::new(
765 None, snapshot,
766 )));
767 }
768 }
769}
770use crate::cache_freshness::FileFreshness;
771use crate::search_index::SearchIndex;
772use crate::semantic_index::{EmbeddingEntry, SemanticIndex};
773
774#[derive(Debug, Default, Clone)]
778#[doc(hidden)]
779pub struct SemanticRefreshAccounting {
780 #[doc(hidden)]
781 pub pending: usize,
782 #[doc(hidden)]
783 pub in_flight: usize,
784}
785
786#[derive(Debug, Default)]
787struct SemanticRefreshCircuit {
788 consecutive_transient_failures: AtomicUsize,
789 open: AtomicBool,
790 probe_in_flight: AtomicBool,
791 probe_ready: AtomicBool,
792 probe_token: AtomicU64,
793}
794
795#[derive(Clone, Copy, Debug, Default)]
796pub(crate) struct SemanticColdSeedResume {
797 request_tier2: bool,
798 warm_callgraph: bool,
799}
800
801fn ensure_refreshing_path(refreshing: &mut Vec<PathBuf>, path: PathBuf) {
802 if !refreshing.iter().any(|existing| existing == &path) {
803 refreshing.push(path);
804 refreshing.sort();
805 }
806}
807
808fn remove_refreshing_path(refreshing: &mut Vec<PathBuf>, path: &Path) {
809 refreshing.retain(|existing| existing != path);
810}
811
812#[derive(Debug, Clone)]
813pub enum SemanticIndexStatus {
814 Disabled,
815 Building {
816 stage: String,
818 files: Option<usize>,
819 entries_done: Option<usize>,
820 entries_total: Option<usize>,
821 },
822 Ready {
823 refreshing: Vec<PathBuf>,
826 #[doc(hidden)]
830 accounting: BTreeMap<PathBuf, SemanticRefreshAccounting>,
831 },
832 Failed(String),
833}
834
835impl SemanticIndexStatus {
836 pub fn ready() -> Self {
837 Self::Ready {
838 refreshing: Vec::new(),
839 accounting: BTreeMap::new(),
840 }
841 }
842
843 pub fn add_refreshing_file(&mut self, path: PathBuf) {
844 if let Self::Ready {
845 refreshing,
846 accounting,
847 } = self
848 {
849 let state = accounting.entry(path.clone()).or_default();
850 state.pending = state.pending.saturating_add(1);
851 ensure_refreshing_path(refreshing, path);
852 }
853 }
854
855 pub fn start_refreshing_file(&mut self, path: PathBuf) {
856 if let Self::Ready {
857 refreshing,
858 accounting,
859 } = self
860 {
861 let state = accounting.entry(path.clone()).or_default();
862 if state.pending == 0 {
863 state.pending = 1;
864 }
865 if state.in_flight == 0 {
866 state.in_flight = state.pending;
867 }
868 ensure_refreshing_path(refreshing, path);
869 }
870 }
871
872 pub fn cancel_refreshing_file(&mut self, path: &Path) {
873 self.finish_refreshing_file(path, false);
874 }
875
876 pub fn take_refreshing_files(&mut self) -> Vec<PathBuf> {
880 if let Self::Ready {
881 refreshing,
882 accounting,
883 } = self
884 {
885 accounting.clear();
886 std::mem::take(refreshing)
887 } else {
888 Vec::new()
889 }
890 }
891
892 pub fn corpus_refresh_in_flight(&self) -> bool {
894 matches!(self, Self::Building { stage, .. } if stage == "refreshing_corpus")
895 }
896
897 pub fn complete_refreshing_file(&mut self, path: &Path) {
898 self.finish_refreshing_file(path, true);
899 }
900
901 pub fn remove_refreshing_file(&mut self, path: &Path) {
902 self.complete_refreshing_file(path);
903 }
904
905 fn finish_refreshing_file(&mut self, path: &Path, complete_in_flight: bool) {
906 if let Self::Ready {
907 refreshing,
908 accounting,
909 } = self
910 {
911 let mut keep_refreshing = false;
912 if let Some(state) = accounting.get_mut(path) {
913 let finished = if complete_in_flight {
914 state.in_flight.max(1)
915 } else {
916 1
917 };
918 state.pending = state.pending.saturating_sub(finished);
919 if complete_in_flight {
920 state.in_flight = 0;
921 } else {
922 state.in_flight = state.in_flight.min(state.pending);
923 }
924 keep_refreshing = state.pending > 0;
925 if !keep_refreshing {
926 accounting.remove(path);
927 }
928 }
929
930 if !keep_refreshing {
931 remove_refreshing_path(refreshing, path);
932 }
933 }
934 }
935
936 pub fn refreshing_count(&self) -> usize {
937 match self {
938 Self::Ready { refreshing, .. } => refreshing.len(),
939 _ => 0,
940 }
941 }
942}
943
944pub enum SemanticIndexEvent {
945 Progress {
946 stage: String,
947 files: Option<usize>,
948 entries_done: Option<usize>,
949 entries_total: Option<usize>,
950 },
951 ColdSeedGateCleared,
956 Ready(SemanticIndex),
957 Failed(String),
958}
959
960#[derive(Debug, Clone)]
961pub enum SemanticRefreshRequest {
962 Files {
963 paths: Vec<PathBuf>,
964 },
965 Corpus,
969}
970
971#[derive(Debug)]
972pub enum SemanticRefreshEvent {
973 Started {
974 paths: Vec<PathBuf>,
975 },
976 CorpusStarted {
977 files: usize,
978 },
979 Completed {
980 added_entries: Vec<EmbeddingEntry>,
981 updated_metadata: Vec<(PathBuf, FileFreshness)>,
982 completed_paths: Vec<PathBuf>,
983 },
984 CorpusCompleted {
985 index: SemanticIndex,
986 changed: usize,
987 added: usize,
988 deleted: usize,
989 total_processed: usize,
990 },
991 Failed {
992 paths: Vec<PathBuf>,
993 error: String,
994 },
995 CorpusFailed {
996 error: String,
997 },
998}
999
1000pub(crate) struct ReceiverTerminalGuard {
1001 terminal_epoch: Arc<AtomicU64>,
1002 epoch: u64,
1003}
1004
1005impl ReceiverTerminalGuard {
1006 fn new(terminal_epoch: Arc<AtomicU64>, epoch: u64) -> Self {
1007 Self {
1008 terminal_epoch,
1009 epoch,
1010 }
1011 }
1012}
1013
1014impl Drop for ReceiverTerminalGuard {
1015 fn drop(&mut self) {
1016 self.terminal_epoch.fetch_max(self.epoch, Ordering::SeqCst);
1017 }
1018}
1019
1020pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
1021
1022struct PathRestrictionContext {
1023 raw_root: PathBuf,
1024 resolved_root: PathBuf,
1025 path_for_resolution: PathBuf,
1026}
1027
1028struct PathRestrictionRootMemo {
1033 configured_root: PathBuf,
1034 resolved_root: PathBuf,
1035}
1036
1037fn normalize_path(path: &Path) -> PathBuf {
1041 let mut result = PathBuf::new();
1042 for component in path.components() {
1043 match component {
1044 Component::ParentDir => {
1045 if !result.pop() {
1047 result.push(component);
1048 }
1049 }
1050 Component::CurDir => {} _ => result.push(component),
1052 }
1053 }
1054 result
1055}
1056
1057fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
1058 let mut existing = path.to_path_buf();
1059 let mut tail_segments = Vec::new();
1060
1061 while !existing.exists() {
1062 if let Some(name) = existing.file_name() {
1063 tail_segments.push(name.to_owned());
1064 } else {
1065 break;
1066 }
1067
1068 existing = match existing.parent() {
1069 Some(parent) => parent.to_path_buf(),
1070 None => break,
1071 };
1072 }
1073
1074 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
1075 for segment in tail_segments.into_iter().rev() {
1076 resolved.push(segment);
1077 }
1078
1079 resolved
1080}
1081
1082fn path_error_response(
1083 req_id: &str,
1084 path: &Path,
1085 resolved_root: &Path,
1086) -> crate::protocol::Response {
1087 crate::protocol::Response::error(
1088 req_id,
1089 "path_outside_root",
1090 format!(
1091 "path '{}' is outside the project root '{}'",
1092 path.display(),
1093 resolved_root.display()
1094 ),
1095 )
1096}
1097
1098fn reject_escaping_symlink(
1108 req_id: &str,
1109 original_path: &Path,
1110 candidate: &Path,
1111 resolved_root: &Path,
1112 raw_root: &Path,
1113) -> Result<(), crate::protocol::Response> {
1114 let mut current = PathBuf::new();
1115
1116 for component in candidate.components() {
1117 current.push(component);
1118
1119 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
1120 continue;
1121 };
1122
1123 if !metadata.file_type().is_symlink() {
1124 continue;
1125 }
1126
1127 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
1136 if !inside_root {
1137 continue;
1138 }
1139
1140 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
1141 }
1142
1143 Ok(())
1144}
1145
1146fn iterative_follow_chain(
1149 req_id: &str,
1150 original_path: &Path,
1151 start: &Path,
1152 resolved_root: &Path,
1153) -> Result<(), crate::protocol::Response> {
1154 let mut link = start.to_path_buf();
1155 let mut depth = 0usize;
1156
1157 loop {
1158 if depth > 40 {
1159 return Err(path_error_response(req_id, original_path, resolved_root));
1160 }
1161
1162 let target = match std::fs::read_link(&link) {
1163 Ok(t) => t,
1164 Err(_) => {
1165 return Err(path_error_response(req_id, original_path, resolved_root));
1167 }
1168 };
1169
1170 let resolved_target = if target.is_absolute() {
1171 normalize_path(&target)
1172 } else {
1173 let parent = link.parent().unwrap_or_else(|| Path::new(""));
1174 normalize_path(&parent.join(&target))
1175 };
1176
1177 let canonical_target =
1181 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
1182
1183 if !canonical_target.starts_with(resolved_root)
1184 && !resolved_target.starts_with(resolved_root)
1185 {
1186 return Err(path_error_response(req_id, original_path, resolved_root));
1187 }
1188
1189 match std::fs::symlink_metadata(&resolved_target) {
1191 Ok(meta) if meta.file_type().is_symlink() => {
1192 link = resolved_target;
1193 depth += 1;
1194 }
1195 _ => break, }
1197 }
1198
1199 Ok(())
1200}
1201
1202pub type LanguageProviderFactory = fn() -> Box<dyn LanguageProvider>;
1203
1204pub fn default_language_provider_factory() -> Box<dyn LanguageProvider> {
1205 Box::new(TreeSitterProvider::new())
1206}
1207
1208fn database_path_key(path: &Path) -> PathBuf {
1209 if let Ok(canonical) = std::fs::canonicalize(path) {
1210 return canonical;
1211 }
1212 let Some(parent) = path.parent() else {
1213 return path.to_path_buf();
1214 };
1215 let canonical_parent = std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf());
1216 path.file_name()
1217 .map(|name| canonical_parent.join(name))
1218 .unwrap_or_else(|| canonical_parent.join(path))
1219}
1220
1221pub struct App {
1226 db: parking_lot::Mutex<Option<(PathBuf, Arc<Mutex<Connection>>)>>,
1230 active_watchers: AtomicUsize,
1231 active_actor_roots: AtomicUsize,
1232 open_routes: AtomicUsize,
1233 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
1234 stdout_writer: SharedStdoutWriter,
1235 provider_factory: LanguageProviderFactory,
1236 memory_contexts: parking_lot::Mutex<BTreeMap<PathBuf, Weak<AppContext>>>,
1239}
1240
1241impl App {
1242 pub fn new(provider_factory: LanguageProviderFactory) -> Self {
1243 Self {
1244 db: parking_lot::Mutex::new(None),
1245 active_watchers: AtomicUsize::new(0),
1246 active_actor_roots: AtomicUsize::new(0),
1247 open_routes: AtomicUsize::new(0),
1248 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry::new(),
1249 stdout_writer: Arc::new(Mutex::new(BufWriter::new(io::stdout()))),
1250 provider_factory,
1251 memory_contexts: parking_lot::Mutex::new(BTreeMap::new()),
1252 }
1253 }
1254
1255 pub fn shared(provider_factory: LanguageProviderFactory) -> Arc<Self> {
1257 Arc::new(Self::new(provider_factory))
1258 }
1259
1260 pub fn default_shared() -> Arc<Self> {
1261 Self::shared(default_language_provider_factory)
1262 }
1263
1264 pub fn create_provider(&self) -> Box<dyn LanguageProvider> {
1265 (self.provider_factory)()
1266 }
1267
1268 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
1269 self.lsp_child_registry.clone()
1270 }
1271
1272 pub fn stdout_writer(&self) -> SharedStdoutWriter {
1273 Arc::clone(&self.stdout_writer)
1274 }
1275
1276 pub(crate) fn register_memory_context(&self, root: PathBuf, ctx: &Arc<AppContext>) {
1277 let mut contexts = self.memory_contexts.lock();
1278 contexts.retain(|_, context| context.strong_count() > 0);
1279 contexts.insert(root, Arc::downgrade(ctx));
1280 }
1281
1282 pub(crate) fn unregister_memory_context(&self, root: &Path, ctx: &Arc<AppContext>) {
1283 let mut contexts = self.memory_contexts.lock();
1284 let removes_current = contexts
1285 .get(root)
1286 .and_then(Weak::upgrade)
1287 .is_some_and(|registered| Arc::ptr_eq(®istered, ctx));
1288 if removes_current {
1289 contexts.remove(root);
1290 }
1291 }
1292
1293 pub(crate) fn try_memory_contexts(&self) -> Option<Vec<(PathBuf, Arc<AppContext>)>> {
1296 let contexts = self.memory_contexts.try_lock()?;
1297 Some(
1298 contexts
1299 .iter()
1300 .filter_map(|(root, context)| {
1301 context.upgrade().map(|context| (root.clone(), context))
1302 })
1303 .collect(),
1304 )
1305 }
1306
1307 pub fn open_db(&self, path: &Path) -> Result<Arc<Mutex<Connection>>, crate::db::OpenError> {
1312 let key = database_path_key(path);
1313 let mut slot = self.db.lock();
1314 if let Some((existing_path, conn)) = slot.as_ref() {
1315 if existing_path == &key {
1316 return Ok(Arc::clone(conn));
1317 }
1318 }
1319
1320 let conn = Arc::new(Mutex::new(crate::db::open(path)?));
1321 *slot = Some((key, Arc::clone(&conn)));
1322 Ok(conn)
1323 }
1324
1325 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
1326 *self.db.lock() = Some((PathBuf::new(), conn));
1327 }
1328
1329 pub fn clear_db(&self) {
1330 *self.db.lock() = None;
1331 }
1332
1333 pub fn clear_db_for_path(&self, path: &Path) {
1337 let key = database_path_key(path);
1338 let mut slot = self.db.lock();
1339 if slot.as_ref().is_some_and(|(existing_path, _)| {
1340 existing_path.as_os_str().is_empty() || existing_path == &key
1341 }) {
1342 *slot = None;
1343 }
1344 }
1345
1346 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
1347 self.db.lock().as_ref().map(|(_, conn)| Arc::clone(conn))
1348 }
1349
1350 pub(crate) fn watcher_started(&self) {
1351 self.active_watchers.fetch_add(1, Ordering::SeqCst);
1352 }
1353
1354 pub(crate) fn watcher_stopped(&self) {
1355 self.active_watchers
1356 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
1357 Some(count.saturating_sub(1))
1358 })
1359 .ok();
1360 }
1361
1362 pub fn watcher_count(&self) -> usize {
1365 self.active_watchers.load(Ordering::SeqCst)
1366 }
1367
1368 pub(crate) fn actor_root_registered(&self) {
1369 self.active_actor_roots.fetch_add(1, Ordering::SeqCst);
1370 }
1371
1372 pub(crate) fn actor_root_unregistered(&self) {
1373 self.active_actor_roots
1374 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
1375 Some(count.saturating_sub(1))
1376 })
1377 .ok();
1378 }
1379
1380 pub fn actor_root_count(&self) -> usize {
1381 self.active_actor_roots.load(Ordering::SeqCst)
1382 }
1383
1384 pub(crate) fn set_open_route_count(&self, count: usize) {
1385 self.open_routes.store(count, Ordering::SeqCst);
1386 }
1387
1388 pub fn open_route_count(&self) -> usize {
1389 self.open_routes.load(Ordering::SeqCst)
1390 }
1391}
1392
1393impl Default for App {
1394 fn default() -> Self {
1395 Self::new(default_language_provider_factory)
1396 }
1397}
1398
1399const _: fn() = || {
1400 fn assert_send_sync<T: Send + Sync>() {}
1401 fn assert_send<T: Send>() {}
1402
1403 assert_send_sync::<App>();
1404 assert_send_sync::<AppContext>();
1405 assert_send::<crate::lsp::manager::LspManager>();
1406 assert_send::<crate::semantic_index::EmbeddingModel>();
1407};
1408
1409#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1410enum GitEntryKind {
1411 Missing,
1412 File,
1413 Directory,
1414 Other,
1415}
1416
1417#[derive(Clone, Debug, PartialEq, Eq)]
1418struct GitEntrySignature {
1419 kind: GitEntryKind,
1420 modified: Option<SystemTime>,
1421}
1422
1423#[derive(Clone, Debug)]
1424struct WorktreeBridgeCacheEntry {
1425 git_entry: GitEntrySignature,
1426 is_worktree_bridge: bool,
1427 git_common_dir: Option<PathBuf>,
1428}
1429
1430pub(crate) const BORROWED_INDEX_CACHE_CAPACITY: usize = 4;
1431
1432#[derive(Clone, Debug, PartialEq, Eq)]
1433struct BorrowedIndexCacheKey {
1434 canonical_root: PathBuf,
1435 artifact: crate::readonly_artifacts::BorrowedArtifactGeneration,
1436}
1437
1438#[derive(Clone, Debug)]
1439enum BorrowedIndexCacheValue {
1440 Search(crate::readonly_artifacts::ReadOnlyArtifact<Arc<SearchIndex>>),
1441 Semantic(crate::readonly_artifacts::ReadOnlyArtifact<Arc<SemanticIndex>>),
1442}
1443
1444#[derive(Debug, Default)]
1445struct BorrowedIndexCache {
1446 entries: VecDeque<(BorrowedIndexCacheKey, BorrowedIndexCacheValue)>,
1447 resolved_roots: VecDeque<(PathBuf, GitEntrySignature)>,
1448}
1449
1450impl BorrowedIndexCache {
1451 fn search(
1452 &mut self,
1453 key: &BorrowedIndexCacheKey,
1454 ) -> Option<crate::readonly_artifacts::ReadOnlyArtifact<Arc<SearchIndex>>> {
1455 let position = self.entries.iter().position(|(candidate, value)| {
1456 candidate == key && matches!(value, BorrowedIndexCacheValue::Search(_))
1457 })?;
1458 let entry = self.entries.remove(position)?;
1459 let BorrowedIndexCacheValue::Search(index) = &entry.1 else {
1460 return None;
1461 };
1462 let index = (*index).clone();
1463 self.entries.push_back(entry);
1464 Some(index)
1465 }
1466
1467 fn semantic(
1468 &mut self,
1469 key: &BorrowedIndexCacheKey,
1470 ) -> Option<crate::readonly_artifacts::ReadOnlyArtifact<Arc<SemanticIndex>>> {
1471 let position = self.entries.iter().position(|(candidate, value)| {
1472 candidate == key && matches!(value, BorrowedIndexCacheValue::Semantic(_))
1473 })?;
1474 let entry = self.entries.remove(position)?;
1475 let BorrowedIndexCacheValue::Semantic(index) = &entry.1 else {
1476 return None;
1477 };
1478 let index = (*index).clone();
1479 self.entries.push_back(entry);
1480 Some(index)
1481 }
1482
1483 fn insert(&mut self, key: BorrowedIndexCacheKey, value: BorrowedIndexCacheValue) {
1484 self.entries.retain(|(candidate, _)| {
1485 candidate.canonical_root != key.canonical_root
1486 || candidate.artifact.path != key.artifact.path
1487 });
1488 self.entries.push_back((key, value));
1489 while self.entries.len() > BORROWED_INDEX_CACHE_CAPACITY {
1490 self.entries.pop_front();
1491 }
1492 }
1493
1494 fn resolved_root(&mut self, requested_root: &Path) -> Option<PathBuf> {
1495 let position = self
1496 .resolved_roots
1497 .iter()
1498 .position(|(candidate, _)| candidate == requested_root)?;
1499 let entry = self.resolved_roots.remove(position)?;
1500 if entry.1 != git_entry_signature(requested_root) {
1501 return None;
1502 }
1503 let root = entry.0.clone();
1504 self.resolved_roots.push_back(entry);
1505 Some(root)
1506 }
1507
1508 fn remember_resolved_root(&mut self, root: PathBuf) {
1509 self.resolved_roots
1510 .retain(|(candidate, _)| candidate != &root);
1511 let signature = git_entry_signature(&root);
1512 self.resolved_roots.push_back((root, signature));
1513 while self.resolved_roots.len() > BORROWED_INDEX_CACHE_CAPACITY {
1514 self.resolved_roots.pop_front();
1515 }
1516 }
1517
1518 fn clear(&mut self) {
1519 self.entries.clear();
1520 self.resolved_roots.clear();
1521 }
1522}
1523
1524fn git_entry_signature(project_root: &Path) -> GitEntrySignature {
1525 match std::fs::symlink_metadata(project_root.join(".git")) {
1526 Ok(metadata) => GitEntrySignature {
1527 kind: if metadata.file_type().is_file() {
1528 GitEntryKind::File
1529 } else if metadata.file_type().is_dir() {
1530 GitEntryKind::Directory
1531 } else {
1532 GitEntryKind::Other
1533 },
1534 modified: metadata.modified().ok(),
1535 },
1536 Err(error) if error.kind() == io::ErrorKind::NotFound => GitEntrySignature {
1537 kind: GitEntryKind::Missing,
1538 modified: None,
1539 },
1540 Err(_) => GitEntrySignature {
1541 kind: GitEntryKind::Other,
1542 modified: None,
1543 },
1544 }
1545}
1546
1547pub struct AppContext {
1559 app: Arc<App>,
1560 provider: Box<dyn LanguageProvider>,
1561 backup: parking_lot::Mutex<BackupStore>,
1562 checkpoint: parking_lot::Mutex<CheckpointStore>,
1563 config: RwLock<Arc<Config>>,
1564 path_restriction_root_memo: parking_lot::Mutex<Option<PathRestrictionRootMemo>>,
1568 #[cfg(test)]
1569 path_restriction_root_canonicalizations: AtomicUsize,
1570 force_restrict_requests: parking_lot::Mutex<BTreeMap<String, usize>>,
1571 pub harness: parking_lot::Mutex<Option<Harness>>,
1572 canonical_cache_root: parking_lot::Mutex<Option<PathBuf>>,
1573 is_worktree_bridge: parking_lot::Mutex<bool>,
1574 git_common_dir: parking_lot::Mutex<Option<PathBuf>>,
1575 shared_artifacts_read_only: AtomicBool,
1576 daemonless_query_mode: AtomicBool,
1579 callgraph_writer: AtomicBool,
1580 inspect_writer: AtomicBool,
1581 artifact_owner_status: parking_lot::Mutex<Option<ArtifactOwnerStatus>>,
1582 artifact_owner_lease: parking_lot::Mutex<Option<ArtifactOwnerLeaseRegistration>>,
1583 degraded_reasons: parking_lot::Mutex<Vec<String>>,
1590 heavy_root_work_allowed: Arc<AtomicBool>,
1595 standing_artifact_exempt: AtomicBool,
1598 cold_build_limiter: RwLock<Arc<crate::cold_build_limiter::ColdBuildLimiter>>,
1599 callgraph_store: Arc<RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
1600 callgraph_store_force_requested: AtomicU64,
1601 callgraph_store_force_fulfilled: AtomicU64,
1602 callgraph_store_rx:
1603 parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>>,
1604 callgraph_store_rx_generation: AtomicU64,
1605 callgraph_store_rx_epoch: AtomicU64,
1606 callgraph_store_build_denied: parking_lot::Mutex<Option<(u64, String)>>,
1607 callgraph_store_build_suspension:
1608 parking_lot::Mutex<Option<(u64, crate::build_breaker::BuildSuspension)>>,
1609 health_build_suspensions: RwLock<Vec<SuspendedDomainHealthSnapshot>>,
1613 callgraph_persist_epoch: crate::root_cache::ArtifactPublishEpoch,
1614 callgraph_legacy_migration_summary_logged: Arc<AtomicBool>,
1615 pending_callgraph_store_paths: crate::callgraph_store::PendingCallGraphStorePaths,
1616 search_index: RwLock<Option<SearchIndex>>,
1617 search_index_rx: RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>>,
1618 search_index_rx_generation: AtomicU64,
1619 search_index_rx_epoch: AtomicU64,
1620 search_index_rx_terminal_epoch: Arc<AtomicU64>,
1621 search_index_disconnect_reschedule: parking_lot::Mutex<(u64, u32)>,
1627 search_persist_epoch: crate::root_cache::ArtifactPublishEpoch,
1628 pending_search_index_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
1629 symbol_cache: SharedSymbolCache,
1630 inspect_manager: Arc<InspectManager>,
1631 tier2_refresh_scheduler: parking_lot::Mutex<Tier2RefreshScheduler>,
1632 pending_tier2_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
1633 semantic_index: RwLock<Option<SemanticIndex>>,
1634 semantic_index_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
1635 semantic_index_rx_generation: AtomicU64,
1636 semantic_index_rx_epoch: AtomicU64,
1637 semantic_index_rx_terminal_epoch: Arc<AtomicU64>,
1638 semantic_persist_epoch: crate::root_cache::ArtifactPublishEpoch,
1639 semantic_persist_lock: Arc<parking_lot::Mutex<()>>,
1640 semantic_index_status: RwLock<SemanticIndexStatus>,
1641 artifact_reload_lock: parking_lot::Mutex<()>,
1644 semantic_cold_seed_active: Arc<AtomicBool>,
1648 semantic_cold_seed_generation: Arc<AtomicU64>,
1651 semantic_fingerprint_generation: Arc<AtomicU64>,
1652 semantic_callgraph_warm_deferred: AtomicBool,
1653 pending_semantic_index_paths: Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>,
1654 pending_semantic_corpus_refresh: parking_lot::Mutex<bool>,
1655 semantic_refresh_tx:
1656 Arc<parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>>,
1657 semantic_refresh_event_rx:
1658 parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
1659 semantic_refresh_generation: AtomicU64,
1660 semantic_refresh_epoch: AtomicU64,
1661 semantic_refresh_build_epoch: AtomicU64,
1662 semantic_refresh_worker: parking_lot::Mutex<Option<SemanticRefreshWorkerSlot>>,
1663 semantic_refresh_retry_attempts: parking_lot::Mutex<BTreeMap<PathBuf, usize>>,
1664 semantic_refresh_circuit: Arc<SemanticRefreshCircuit>,
1665 semantic_embedding_model: parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>>,
1666 watcher_runtime_lock: parking_lot::Mutex<()>,
1667 watcher: parking_lot::Mutex<Option<RecommendedWatcher>>,
1668 watcher_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>>,
1669 watcher_drain_slice: parking_lot::Mutex<Option<WatcherDrainSliceState>>,
1670 watcher_thread: parking_lot::Mutex<Option<WatcherThreadHandle>>,
1671 lsp_manager: parking_lot::Mutex<LspManager>,
1672 configure_generation: Arc<AtomicU64>,
1673 configure_content_generation: Arc<AtomicU64>,
1677 subc_lifecycle: SubcLifecycleAdmission,
1680 configure_warm_state: parking_lot::Mutex<ConfigureWarmState>,
1681 configure_phase_timing: parking_lot::Mutex<ConfigurePhaseTiming>,
1682 configured_session_roots: parking_lot::Mutex<BTreeSet<(PathBuf, String)>>,
1683 hashline_bindings: crate::hashline::integration::BindingRegistry,
1684 configure_maintenance_jobs: parking_lot::Mutex<VecDeque<ConfigureMaintenanceJob>>,
1685 artifact_cache_keys: parking_lot::Mutex<BTreeMap<PathBuf, String>>,
1686 artifact_cache_key_derivations: AtomicU64,
1687 borrowed_index_cache: parking_lot::Mutex<BorrowedIndexCache>,
1688 worktree_bridge_cache: parking_lot::Mutex<BTreeMap<PathBuf, WorktreeBridgeCacheEntry>>,
1691 #[cfg(test)]
1692 worktree_bridge_probe_spawns: AtomicU64,
1693 #[cfg(test)]
1694 force_worktree_bridge_reprobe: AtomicBool,
1695 last_seen_reuse_completions: AtomicU64,
1699 configure_warnings_tx: crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)>,
1700 configure_warnings_rx: crossbeam_channel::Receiver<(u64, ConfigureWarningsFrame)>,
1701 progress_sender: SharedProgressSender,
1704 status_emitter: StatusEmitter,
1705 fleet_status_client: RwLock<Option<crate::fleet_status::FleetStatusClient>>,
1708 status_bar_last_emitted: LegacyStatusBarEmission,
1711 status_bar_cached: RwLock<StatusBarCache>,
1714 alert_state: parking_lot::Mutex<AlertDeltaState>,
1717 compression_aggregates: Arc<crate::db::compression_events::CompressionAggregateCache>,
1718 bash_background: BgTaskRegistry,
1719 #[cfg(unix)]
1720 escalation_grants: parking_lot::Mutex<crate::sandbox_spawn::EscalationGrantStore>,
1721 filter_registry: crate::compress::SharedFilterRegistry,
1728 filter_registry_rebuild_count: AtomicU64,
1729 filter_registry_loaded: std::sync::atomic::AtomicBool,
1732 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
1737 gitignore: SharedGitignore,
1744 gitignore_generation: Arc<AtomicU64>,
1745 status_bar_tier2: RwLock<StatusBarTier2>,
1749 tsconfig_membership:
1756 parking_lot::Mutex<crate::lsp::tsconfig_membership::TsconfigMembershipCache>,
1757}
1758
1759pub struct ForceRestrictGuard<'a> {
1765 ctx: &'a AppContext,
1766 req_id: String,
1767}
1768
1769impl Drop for ForceRestrictGuard<'_> {
1770 fn drop(&mut self) {
1771 self.ctx.release_force_restrict(&self.req_id);
1772 }
1773}
1774
1775impl Drop for AppContext {
1776 fn drop(&mut self) {
1777 self.artifact_owner_lease.get_mut().take();
1778 if let Some(runtime) = self.watcher_thread.get_mut().take() {
1779 let root = self
1780 .canonical_cache_root
1781 .get_mut()
1782 .clone()
1783 .or_else(|| {
1784 self.config
1785 .get_mut()
1786 .unwrap_or_else(std::sync::PoisonError::into_inner)
1787 .project_root
1788 .clone()
1789 })
1790 .unwrap_or_else(|| PathBuf::from("<unconfigured>"));
1791 Self::spawn_watcher_shutdown(Arc::clone(&self.app), root, runtime);
1792 }
1793 }
1794}
1795
1796pub enum CallgraphStoreAccess {
1804 Ready(Arc<ReadonlyCallGraphStore>),
1806 Building,
1808 Suspended(crate::build_breaker::BuildSuspension),
1811 Unavailable,
1813 Error(CallGraphStoreError),
1815}
1816
1817#[derive(Clone, Copy)]
1818enum CallgraphBackgroundWork {
1819 Ensure,
1820 ForceRebuild(u64),
1821 LegacyMigration,
1822}
1823
1824#[cfg(test)]
1825struct CallgraphBuildStartGate {
1826 root: PathBuf,
1827 reached: crossbeam_channel::Sender<()>,
1828 release: crossbeam_channel::Receiver<()>,
1829}
1830
1831#[cfg(test)]
1832static CALLGRAPH_BUILD_START_GATE: std::sync::OnceLock<
1833 parking_lot::Mutex<Option<CallgraphBuildStartGate>>,
1834> = std::sync::OnceLock::new();
1835
1836#[cfg(test)]
1837fn install_callgraph_build_start_gate(
1838 root: PathBuf,
1839) -> (
1840 crossbeam_channel::Receiver<()>,
1841 crossbeam_channel::Sender<()>,
1842) {
1843 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
1844 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1845 *CALLGRAPH_BUILD_START_GATE
1846 .get_or_init(|| parking_lot::Mutex::new(None))
1847 .lock() = Some(CallgraphBuildStartGate {
1848 root,
1849 reached: reached_tx,
1850 release: release_rx,
1851 });
1852 (reached_rx, release_tx)
1853}
1854
1855#[cfg(test)]
1856pub(crate) fn install_callgraph_build_start_gate_for_test(
1857 root: PathBuf,
1858) -> (
1859 crossbeam_channel::Receiver<()>,
1860 crossbeam_channel::Sender<()>,
1861) {
1862 install_callgraph_build_start_gate(root)
1863}
1864
1865#[cfg(test)]
1866static CALLGRAPH_BUILD_WAIT_MS_LOCK: std::sync::OnceLock<std::sync::Mutex<()>> =
1867 std::sync::OnceLock::new();
1868
1869#[cfg(test)]
1870pub(crate) struct CallgraphBuildWaitMsGuard {
1871 _guard: std::sync::MutexGuard<'static, ()>,
1872 previous: Option<std::ffi::OsString>,
1873}
1874
1875#[cfg(test)]
1876impl Drop for CallgraphBuildWaitMsGuard {
1877 fn drop(&mut self) {
1878 unsafe {
1881 match &self.previous {
1882 Some(value) => std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", value),
1883 None => std::env::remove_var("AFT_CALLGRAPH_BUILD_WAIT_MS"),
1884 }
1885 }
1886 }
1887}
1888
1889#[cfg(test)]
1892pub(crate) fn override_callgraph_build_wait_ms_for_test(ms: u64) -> CallgraphBuildWaitMsGuard {
1893 let guard = CALLGRAPH_BUILD_WAIT_MS_LOCK
1894 .get_or_init(|| std::sync::Mutex::new(()))
1895 .lock()
1896 .unwrap_or_else(|error| error.into_inner());
1897 let previous = std::env::var_os("AFT_CALLGRAPH_BUILD_WAIT_MS");
1898 unsafe {
1900 std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", ms.to_string());
1901 }
1902 CallgraphBuildWaitMsGuard {
1903 _guard: guard,
1904 previous,
1905 }
1906}
1907
1908#[cfg(test)]
1909fn wait_on_callgraph_build_start_gate(root: &Path) {
1910 let mut slot = CALLGRAPH_BUILD_START_GATE
1911 .get_or_init(|| parking_lot::Mutex::new(None))
1912 .lock();
1913 if !slot.as_ref().is_some_and(|gate| gate.root == root) {
1914 return;
1915 }
1916 let gate = slot.take();
1917 drop(slot);
1918 if let Some(gate) = gate {
1919 let _ = gate.reached.send(());
1920 let _ = gate.release.recv_timeout(Duration::from_secs(5));
1921 }
1922}
1923
1924#[cfg(not(test))]
1925fn wait_on_callgraph_build_start_gate(_root: &Path) {}
1926
1927#[cfg(test)]
1928static REMOVE_CALLGRAPH_POINTER_BEFORE_INLINE_REOPEN: AtomicBool = AtomicBool::new(false);
1929
1930#[cfg(test)]
1931struct RemoveCallgraphPointerBeforeInlineReopenGuard;
1932
1933#[cfg(test)]
1934impl Drop for RemoveCallgraphPointerBeforeInlineReopenGuard {
1935 fn drop(&mut self) {
1936 REMOVE_CALLGRAPH_POINTER_BEFORE_INLINE_REOPEN.store(false, Ordering::SeqCst);
1937 }
1938}
1939
1940#[cfg(test)]
1941fn remove_callgraph_pointer_before_inline_reopen_for_test(
1942 callgraph_dir: &Path,
1943 store: &CallGraphStore,
1944) {
1945 if REMOVE_CALLGRAPH_POINTER_BEFORE_INLINE_REOPEN.swap(false, Ordering::SeqCst) {
1946 let pointer = callgraph_dir.join(format!("{}.current", store.project_key()));
1947 std::fs::remove_file(pointer).expect("remove callgraph pointer before inline reopen");
1948 }
1949}
1950
1951#[cfg(not(test))]
1952fn remove_callgraph_pointer_before_inline_reopen_for_test(
1953 _callgraph_dir: &Path,
1954 _store: &CallGraphStore,
1955) {
1956}
1957
1958fn callgraph_build_wait_window() -> Duration {
1963 std::env::var("AFT_CALLGRAPH_BUILD_WAIT_MS")
1964 .ok()
1965 .and_then(|raw| raw.parse::<u64>().ok())
1966 .map(Duration::from_millis)
1967 .unwrap_or(Duration::ZERO)
1968}
1969
1970static CALLGRAPH_COLD_BUILD_SPAWN_COUNT: AtomicUsize = AtomicUsize::new(0);
1971
1972#[doc(hidden)]
1973pub fn reset_callgraph_cold_build_spawn_count_for_test() {
1974 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
1975}
1976
1977#[doc(hidden)]
1978pub fn callgraph_cold_build_spawn_count_for_test() -> usize {
1979 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst)
1980}
1981
1982impl AppContext {
1983 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
1984 Self::with_app_and_provider(App::default_shared(), provider, config)
1985 }
1986
1987 pub fn from_app(app: Arc<App>, config: Config) -> Self {
1988 let provider = app.create_provider();
1989 Self::with_app_and_provider(app, provider, config)
1990 }
1991
1992 pub fn with_app_and_provider(
1993 app: Arc<App>,
1994 provider: Box<dyn LanguageProvider>,
1995 config: Config,
1996 ) -> Self {
1997 let bash_compress_enabled = config.experimental_bash_compress;
1998 let (configure_warnings_tx, configure_warnings_rx) = crossbeam_channel::unbounded();
1999 let progress_sender: SharedProgressSender = Arc::new(Mutex::new(None));
2000 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
2001 let heavy_root_work_allowed = Arc::new(AtomicBool::new(true));
2002 let semantic_cold_seed_active = Arc::new(AtomicBool::new(false));
2003 let symbol_cache = provider
2004 .as_any()
2005 .downcast_ref::<TreeSitterProvider>()
2006 .map(|provider| provider.symbol_cache())
2007 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
2008 let mut lsp_manager = LspManager::new();
2009 lsp_manager.set_child_registry(app.lsp_child_registry());
2010 lsp_manager.set_diagnostic_capacity(config.diagnostic_cache_size);
2013 let bash_background = BgTaskRegistry::new(Arc::clone(&progress_sender));
2014 let compression_aggregates = bash_background.compression_aggregate_cache();
2015 let context = AppContext {
2016 app: Arc::clone(&app),
2017 provider,
2018 backup: parking_lot::Mutex::new(BackupStore::new()),
2019 checkpoint: parking_lot::Mutex::new(CheckpointStore::new()),
2020 config: RwLock::new(Arc::new(config)),
2021 path_restriction_root_memo: parking_lot::Mutex::new(None),
2022 #[cfg(test)]
2023 path_restriction_root_canonicalizations: AtomicUsize::new(0),
2024 force_restrict_requests: parking_lot::Mutex::new(BTreeMap::new()),
2025 harness: parking_lot::Mutex::new(None),
2026 canonical_cache_root: parking_lot::Mutex::new(None),
2027 is_worktree_bridge: parking_lot::Mutex::new(false),
2028 git_common_dir: parking_lot::Mutex::new(None),
2029 shared_artifacts_read_only: AtomicBool::new(false),
2030 daemonless_query_mode: AtomicBool::new(false),
2031 callgraph_writer: AtomicBool::new(true),
2032 inspect_writer: AtomicBool::new(true),
2033 artifact_owner_status: parking_lot::Mutex::new(None),
2034 artifact_owner_lease: parking_lot::Mutex::new(None),
2035 degraded_reasons: parking_lot::Mutex::new(Vec::new()),
2036 heavy_root_work_allowed: Arc::clone(&heavy_root_work_allowed),
2037 standing_artifact_exempt: AtomicBool::new(false),
2038 cold_build_limiter: RwLock::new(crate::cold_build_limiter::global_limiter()),
2039 callgraph_store: Arc::new(RwLock::new(None)),
2040 callgraph_store_force_requested: AtomicU64::new(0),
2041 callgraph_store_force_fulfilled: AtomicU64::new(0),
2042 callgraph_store_rx: parking_lot::Mutex::new(None),
2043 callgraph_store_rx_generation: AtomicU64::new(0),
2044 callgraph_store_rx_epoch: AtomicU64::new(0),
2045 callgraph_store_build_denied: parking_lot::Mutex::new(None),
2046 callgraph_store_build_suspension: parking_lot::Mutex::new(None),
2047 health_build_suspensions: RwLock::new(Vec::new()),
2048 callgraph_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(),
2049 callgraph_legacy_migration_summary_logged: Arc::new(AtomicBool::new(false)),
2050 pending_callgraph_store_paths: Arc::new(parking_lot::Mutex::new(BTreeSet::new())),
2051 search_index: RwLock::new(None),
2052 search_index_rx: RwLock::new(None),
2053 search_index_rx_generation: AtomicU64::new(0),
2054 search_index_rx_epoch: AtomicU64::new(0),
2055 search_index_rx_terminal_epoch: Arc::new(AtomicU64::new(0)),
2056 search_index_disconnect_reschedule: parking_lot::Mutex::new((0, 0)),
2057 search_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(),
2058 pending_search_index_paths: parking_lot::Mutex::new(BTreeSet::new()),
2059 symbol_cache,
2060 inspect_manager: Arc::new(InspectManager::with_root_work_gates(
2061 Arc::clone(&heavy_root_work_allowed),
2062 Arc::clone(&semantic_cold_seed_active),
2063 )),
2064 tier2_refresh_scheduler: parking_lot::Mutex::new(Tier2RefreshScheduler::new()),
2065 pending_tier2_paths: parking_lot::Mutex::new(BTreeSet::new()),
2066 semantic_index: RwLock::new(None),
2067 semantic_index_rx: parking_lot::Mutex::new(None),
2068 semantic_index_rx_generation: AtomicU64::new(0),
2069 semantic_index_rx_epoch: AtomicU64::new(0),
2070 semantic_index_rx_terminal_epoch: Arc::new(AtomicU64::new(0)),
2071 semantic_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(),
2072 semantic_persist_lock: Arc::new(parking_lot::Mutex::new(())),
2073 semantic_index_status: RwLock::new(SemanticIndexStatus::Disabled),
2074 artifact_reload_lock: parking_lot::Mutex::new(()),
2075 semantic_cold_seed_active,
2076 semantic_cold_seed_generation: Arc::new(AtomicU64::new(0)),
2077 semantic_fingerprint_generation: Arc::new(AtomicU64::new(0)),
2078 semantic_callgraph_warm_deferred: AtomicBool::new(false),
2079 pending_semantic_index_paths: Arc::new(parking_lot::Mutex::new(BTreeSet::new())),
2080 pending_semantic_corpus_refresh: parking_lot::Mutex::new(false),
2081 semantic_refresh_tx: Arc::new(parking_lot::Mutex::new(None)),
2082 semantic_refresh_event_rx: parking_lot::Mutex::new(None),
2083 semantic_refresh_generation: AtomicU64::new(0),
2084 semantic_refresh_epoch: AtomicU64::new(0),
2085 semantic_refresh_build_epoch: AtomicU64::new(0),
2086 semantic_refresh_worker: parking_lot::Mutex::new(None),
2087 semantic_refresh_retry_attempts: parking_lot::Mutex::new(BTreeMap::new()),
2088 semantic_refresh_circuit: Arc::new(SemanticRefreshCircuit::default()),
2089 semantic_embedding_model: parking_lot::Mutex::new(None),
2090 watcher_runtime_lock: parking_lot::Mutex::new(()),
2091 watcher: parking_lot::Mutex::new(None),
2092 watcher_rx: parking_lot::Mutex::new(None),
2093 watcher_drain_slice: parking_lot::Mutex::new(None),
2094 watcher_thread: parking_lot::Mutex::new(None),
2095 lsp_manager: parking_lot::Mutex::new(lsp_manager),
2096 configure_generation: Arc::new(AtomicU64::new(0)),
2097 configure_content_generation: Arc::new(AtomicU64::new(0)),
2098 subc_lifecycle: SubcLifecycleAdmission::default(),
2099 configure_warm_state: parking_lot::Mutex::new(ConfigureWarmState::default()),
2100 configure_phase_timing: parking_lot::Mutex::new(ConfigurePhaseTiming::default()),
2101 configured_session_roots: parking_lot::Mutex::new(BTreeSet::new()),
2102 hashline_bindings: crate::hashline::integration::BindingRegistry::new(),
2103 configure_maintenance_jobs: parking_lot::Mutex::new(VecDeque::new()),
2104 artifact_cache_keys: parking_lot::Mutex::new(BTreeMap::new()),
2105 artifact_cache_key_derivations: AtomicU64::new(0),
2106 borrowed_index_cache: parking_lot::Mutex::new(BorrowedIndexCache::default()),
2107 worktree_bridge_cache: parking_lot::Mutex::new(BTreeMap::new()),
2108 #[cfg(test)]
2109 worktree_bridge_probe_spawns: AtomicU64::new(0),
2110 #[cfg(test)]
2111 force_worktree_bridge_reprobe: AtomicBool::new(false),
2112 last_seen_reuse_completions: AtomicU64::new(0),
2113 configure_warnings_tx,
2114 configure_warnings_rx,
2115 progress_sender: Arc::clone(&progress_sender),
2116 status_emitter,
2117 fleet_status_client: RwLock::new(None),
2118 status_bar_last_emitted: LegacyStatusBarEmission::default(),
2119 status_bar_cached: RwLock::new(StatusBarCache::default()),
2120 alert_state: parking_lot::Mutex::new(AlertDeltaState::default()),
2121 compression_aggregates,
2122 bash_background,
2123 #[cfg(unix)]
2124 escalation_grants: parking_lot::Mutex::new(
2125 crate::sandbox_spawn::EscalationGrantStore::default(),
2126 ),
2127 filter_registry: Arc::new(std::sync::RwLock::new(
2128 crate::compress::toml_filter::FilterRegistry::default(),
2129 )),
2130 filter_registry_rebuild_count: AtomicU64::new(0),
2131 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
2132 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
2133 gitignore: Arc::new(std::sync::RwLock::new(None)),
2134 gitignore_generation: Arc::new(AtomicU64::new(0)),
2135 status_bar_tier2: RwLock::new(StatusBarTier2::default()),
2136 tsconfig_membership: parking_lot::Mutex::new(
2137 crate::lsp::tsconfig_membership::TsconfigMembershipCache::new(),
2138 ),
2139 };
2140 crate::logging::sync_storage_root(context.storage_dir());
2141 context
2142 }
2143
2144 pub fn status_bar_count_values(&self) -> StatusBarCountValues {
2148 let tier2 = self
2149 .status_bar_tier2
2150 .read()
2151 .unwrap_or_else(std::sync::PoisonError::into_inner)
2152 .clone();
2153 let tsconfig_generation = self.tsconfig_membership.lock().generation();
2154 let lsp = self.lsp_manager.lock();
2155 let diagnostics_generation = lsp.diagnostics_generation();
2156
2157 {
2158 let cached = self
2159 .status_bar_cached
2160 .read()
2161 .unwrap_or_else(std::sync::PoisonError::into_inner);
2162 if cached.valid
2163 && cached.diagnostics_generation == diagnostics_generation
2164 && cached.tier2_generation == tier2.generation
2165 && cached.tsconfig_generation == tsconfig_generation
2166 {
2167 return cached
2168 .counts
2169 .clone()
2170 .expect("a valid status-count cache carries truthful values");
2171 }
2172 }
2173
2174 let previous_authoritative = self
2175 .status_bar_cached
2176 .read()
2177 .unwrap_or_else(std::sync::PoisonError::into_inner)
2178 .counts
2179 .as_ref()
2180 .map(|counts| (counts.errors, counts.warnings));
2181 let ((current_errors, current_warnings), provisional) =
2182 match self.canonical_cache_root_opt() {
2183 Some(root) => {
2184 let root = crate::inspect::job::normalize_path(&root);
2188 let mut membership = self.tsconfig_membership.lock();
2189 lsp.filtered_error_warning_counts_with_provisional(|file| {
2190 file.starts_with(&root) && !membership.should_skip_diagnostics(file)
2191 })
2192 }
2193 None => lsp.warm_error_warning_counts_with_provisional(),
2194 };
2195 let (errors, warnings) = if provisional {
2196 previous_authoritative.unwrap_or((None, None))
2199 } else if lsp.has_any_diagnostic_reports() {
2200 (Some(current_errors), Some(current_warnings))
2201 } else {
2202 (None, None)
2203 };
2204 let counts = StatusBarCountValues {
2205 errors,
2206 warnings,
2207 dead_code: tier2.dead_code,
2208 unused_exports: tier2.unused_exports,
2209 duplicates: tier2.duplicates,
2210 todos: tier2.todos,
2211 tier2_stale: tier2.stale,
2212 };
2213
2214 *self
2215 .status_bar_cached
2216 .write()
2217 .unwrap_or_else(std::sync::PoisonError::into_inner) = StatusBarCache {
2218 valid: true,
2219 diagnostics_generation,
2220 tier2_generation: tier2.generation,
2221 tsconfig_generation,
2222 counts: Some(counts.clone()),
2223 };
2224 counts
2225 }
2226
2227 pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
2230 self.status_bar_count_values().legacy_projection()
2231 }
2232
2233 pub(crate) fn try_health_summary(&self) -> RootHealthSummary {
2234 let heavy_root_work_allowed = match self.try_heavy_root_work_allowed() {
2238 Some(allowed) => allowed,
2239 None => return RootHealthSummary::busy(),
2240 };
2241 let config = match self.config.try_read() {
2242 Ok(guard) => Arc::clone(&*guard),
2243 Err(_) => return RootHealthSummary::busy(),
2244 };
2245 let search_index = match self.search_index.try_read() {
2246 Ok(guard) => guard,
2247 Err(_) => return RootHealthSummary::busy(),
2248 };
2249 let search_index_rx = match self.search_index_rx.try_read() {
2250 Ok(guard) => guard,
2251 Err(_) => return RootHealthSummary::busy(),
2252 };
2253 let semantic_status = match self.semantic_index_status.try_read() {
2254 Ok(guard) => guard,
2255 Err(_) => return RootHealthSummary::busy(),
2256 };
2257 let callgraph_store = match self.callgraph_store.try_read() {
2258 Ok(guard) => guard,
2259 Err(_) => return RootHealthSummary::busy(),
2260 };
2261 let callgraph_store_rx = match self.callgraph_store_rx.try_lock() {
2262 Some(guard) => guard,
2263 None => return RootHealthSummary::busy(),
2264 };
2265 let tier2 = match self.status_bar_tier2.try_read() {
2266 Ok(guard) => guard,
2267 Err(_) => return RootHealthSummary::busy(),
2268 };
2269 let tier2_builder_busy = match self.inspect_manager.try_tier2_builder_busy() {
2274 Some(busy) => busy,
2275 None => return RootHealthSummary::busy(),
2276 };
2277 let bash = match self.bash_background.try_health_counts() {
2278 Some(counts) => counts,
2279 None => return RootHealthSummary::busy(),
2280 };
2281 let suspended_domains = match self.health_build_suspensions.try_read() {
2282 Ok(snapshot) => snapshot.clone(),
2283 Err(_) => return RootHealthSummary::busy(),
2284 };
2285
2286 let borrows_shared_artifacts = self.shared_artifacts_read_only.load(Ordering::SeqCst);
2291 let search_index_status = if search_index
2292 .as_ref()
2293 .is_some_and(|index| index.ready || index.build_denied)
2294 || (borrows_shared_artifacts && config.search_index)
2295 {
2296 "ready"
2297 } else if config.search_index
2298 || search_index.as_ref().is_some()
2299 || search_index_rx.as_ref().is_some()
2300 {
2301 "building"
2302 } else {
2303 "disabled"
2304 };
2305 let semantic_index_status = match &*semantic_status {
2306 SemanticIndexStatus::Ready { .. } => "ready",
2307 SemanticIndexStatus::Building { .. } => "building",
2308 SemanticIndexStatus::Disabled => "disabled",
2309 SemanticIndexStatus::Failed(_) => "degraded",
2310 };
2311 let callgraph_writer = self.callgraph_writer.load(Ordering::SeqCst);
2312 let callgraph_store_status = if !heavy_root_work_allowed {
2313 "disabled"
2314 } else if callgraph_store.as_ref().is_some() {
2315 "ready"
2316 } else if !callgraph_writer && config.callgraph_store {
2317 "ready"
2320 } else if callgraph_store_rx.is_some() || config.callgraph_store {
2321 "building"
2322 } else {
2323 "disabled"
2324 };
2325 let dead_code_blocked_on_callgraph = tier2.dead_code_blocked_on_callgraph;
2329 let tier2_complete = (tier2.dead_code.is_some() || dead_code_blocked_on_callgraph)
2330 && tier2.unused_exports.is_some()
2331 && tier2.duplicates.is_some()
2332 && !tier2.stale;
2333 let tier2_has_aggregates = tier2.dead_code.is_some()
2334 || tier2.unused_exports.is_some()
2335 || tier2.duplicates.is_some();
2336 let tier2_refresh_gated = borrows_shared_artifacts
2337 || !heavy_root_work_allowed
2338 || !self.inspect_writer.load(Ordering::SeqCst)
2339 || !self.inspect_manager.automatic_tier2_refresh_enabled();
2340 let tier2_status = if tier2_builder_busy {
2341 "building"
2345 } else if tier2_complete {
2346 "ready"
2347 } else if !config.inspect.enabled || !tier2_has_aggregates || tier2_refresh_gated {
2348 "disabled"
2351 } else {
2352 "building"
2353 };
2354
2355 RootHealthSummary {
2356 state: RootHealthState::Ready,
2357 search_index_status: Some(search_index_status),
2358 semantic_index_status: Some(semantic_index_status),
2359 callgraph_store_status: Some(callgraph_store_status),
2360 tier2_status: Some(tier2_status),
2361 bash: Some(bash),
2362 suspended_domains,
2363 }
2364 }
2365
2366 pub fn try_health_snapshot(&self, project_root: &Path) -> RootHealthSnapshot {
2367 self.try_health_summary().into_snapshot(project_root)
2368 }
2369
2370 pub fn should_emit_status_bar(&self, counts: &StatusBarCounts) -> bool {
2373 self.status_bar_last_emitted.should_emit(counts)
2374 }
2375
2376 pub fn accept_alert_observation_batch(
2380 &self,
2381 batch: &AcceptedObservationBatch,
2382 ) -> Result<Vec<AcceptedObservationResult>, ObservationError> {
2383 self.alert_state.lock().accept_batch(batch)
2384 }
2385
2386 pub fn clear_tsconfig_membership_cache(&self) {
2390 self.tsconfig_membership.lock().clear();
2391 }
2392
2393 #[cfg(test)]
2394 pub fn tsconfig_membership_clear_generation_for_test(&self) -> u64 {
2395 self.tsconfig_membership.lock().generation()
2396 }
2397
2398 pub fn mark_status_bar_tier2_stale(&self) -> bool {
2404 let mut tier2 = self
2405 .status_bar_tier2
2406 .write()
2407 .unwrap_or_else(std::sync::PoisonError::into_inner);
2408 if tier2.dead_code.is_some()
2410 || tier2.unused_exports.is_some()
2411 || tier2.duplicates.is_some()
2412 || tier2.todos.is_some()
2413 {
2414 let changed = !tier2.stale;
2415 tier2.stale = true;
2416 if changed {
2417 tier2.generation = tier2.generation.wrapping_add(1);
2418 }
2419 return changed;
2420 }
2421 false
2422 }
2423
2424 pub fn update_status_bar_tier2(
2430 &self,
2431 dead_code: Option<usize>,
2432 unused_exports: Option<usize>,
2433 duplicates: Option<usize>,
2434 todos: Option<usize>,
2435 stale: bool,
2436 ) {
2437 let mut tier2 = self
2438 .status_bar_tier2
2439 .write()
2440 .unwrap_or_else(std::sync::PoisonError::into_inner);
2441 let previous = (
2442 tier2.dead_code,
2443 tier2.unused_exports,
2444 tier2.duplicates,
2445 tier2.todos,
2446 tier2.stale,
2447 );
2448 if let Some(dead_code) = dead_code {
2449 tier2.dead_code = Some(dead_code);
2450 }
2451 if let Some(unused_exports) = unused_exports {
2452 tier2.unused_exports = Some(unused_exports);
2453 }
2454 if let Some(duplicates) = duplicates {
2455 tier2.duplicates = Some(duplicates);
2456 }
2457 if let Some(todos) = todos {
2458 tier2.todos = Some(todos);
2459 }
2460 tier2.stale = stale;
2461 let current = (
2462 tier2.dead_code,
2463 tier2.unused_exports,
2464 tier2.duplicates,
2465 tier2.todos,
2466 tier2.stale,
2467 );
2468 if current != previous {
2469 tier2.generation = tier2.generation.wrapping_add(1);
2470 }
2471 }
2472
2473 pub(crate) fn set_status_bar_tier2_dead_code_blocked_on_callgraph(&self, blocked: bool) {
2479 let mut tier2 = self
2480 .status_bar_tier2
2481 .write()
2482 .unwrap_or_else(std::sync::PoisonError::into_inner);
2483 tier2.dead_code_blocked_on_callgraph = blocked;
2484 }
2485
2486 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
2489 self.gitignore
2490 .read()
2491 .unwrap_or_else(|poisoned| poisoned.into_inner())
2492 .clone()
2493 }
2494
2495 pub fn shared_gitignore(&self) -> SharedGitignore {
2497 Arc::clone(&self.gitignore)
2498 }
2499
2500 pub fn gitignore_generation(&self) -> Arc<AtomicU64> {
2504 Arc::clone(&self.gitignore_generation)
2505 }
2506
2507 fn set_gitignore(&self, matcher: Option<Arc<ignore::gitignore::Gitignore>>) {
2508 *self
2509 .gitignore
2510 .write()
2511 .unwrap_or_else(|poisoned| poisoned.into_inner()) = matcher;
2512 self.gitignore_generation.fetch_add(1, Ordering::SeqCst);
2513 }
2514
2515 pub fn clear_gitignore(&self) {
2537 self.set_gitignore(None);
2538 }
2539
2540 pub fn rebuild_gitignore(&self) {
2541 use ignore::gitignore::GitignoreBuilder;
2542 use std::path::Path;
2543 let root_raw = match self.config().project_root.clone() {
2544 Some(r) => r,
2545 None => {
2546 self.set_gitignore(None);
2547 return;
2548 }
2549 };
2550 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
2558 let mut builder = GitignoreBuilder::new(&root);
2559 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
2564 if global_ignore.is_file() {
2565 if let Some(err) = builder.add(&global_ignore) {
2566 crate::slog_warn!(
2567 "global gitignore parse error in {}: {}",
2568 global_ignore.display(),
2569 err
2570 );
2571 }
2572 }
2573 }
2574 let root_ignore = Path::new(&root).join(".gitignore");
2576 if root_ignore.exists() {
2577 if let Some(err) = builder.add(&root_ignore) {
2578 crate::slog_warn!(
2579 "gitignore parse error in {}: {}",
2580 root_ignore.display(),
2581 err
2582 );
2583 }
2584 }
2585 let root_aftignore = Path::new(&root).join(".aftignore");
2590 if root_aftignore.exists() {
2591 if let Some(err) = builder.add(&root_aftignore) {
2592 crate::slog_warn!(
2593 "aftignore parse error in {}: {}",
2594 root_aftignore.display(),
2595 err
2596 );
2597 }
2598 }
2599 let info_exclude = self
2604 .git_common_dir
2605 .lock()
2606 .clone()
2607 .unwrap_or_else(|| Path::new(&root).join(".git"))
2608 .join("info")
2609 .join("exclude");
2610 if info_exclude.exists() {
2611 if let Some(err) = builder.add(&info_exclude) {
2612 crate::slog_warn!(
2613 "gitignore parse error in {}: {}",
2614 info_exclude.display(),
2615 err
2616 );
2617 }
2618 }
2619 let walker = ignore::WalkBuilder::new(&root)
2625 .standard_filters(true)
2626 .hidden(false)
2634 .filter_entry(|entry| {
2635 let name = entry.file_name().to_string_lossy();
2636 !matches!(
2637 name.as_ref(),
2638 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
2639 )
2640 })
2641 .build();
2642 for entry in walker.flatten() {
2643 let file_name = entry.file_name();
2644 let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
2645 let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
2646 if is_nested_gitignore || is_nested_aftignore {
2647 if let Some(err) = builder.add(entry.path()) {
2648 crate::slog_warn!(
2649 "nested ignore parse error in {}: {}",
2650 entry.path().display(),
2651 err
2652 );
2653 }
2654 }
2655 }
2656 match builder.build() {
2657 Ok(gi) => {
2658 let count = gi.num_ignores();
2659 if count > 0 {
2660 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
2661 self.set_gitignore(Some(Arc::new(gi)));
2662 } else {
2663 self.set_gitignore(None);
2664 }
2665 }
2666 Err(err) => {
2667 crate::slog_warn!("gitignore matcher build failed: {}", err);
2668 self.set_gitignore(None);
2669 }
2670 }
2671 }
2672
2673 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
2676 Arc::clone(&self.bash_compress_flag)
2677 }
2678
2679 pub fn sync_bash_compress_flag(&self) {
2683 let value = self.config().experimental_bash_compress;
2684 self.bash_compress_flag
2685 .store(value, std::sync::atomic::Ordering::Relaxed);
2686 }
2687
2688 pub fn set_bash_compress_enabled(&self, enabled: bool) {
2689 self.update_config(|config| {
2690 config.experimental_bash_compress = enabled;
2691 });
2692 self.bash_compress_flag
2693 .store(enabled, std::sync::atomic::Ordering::Relaxed);
2694 }
2695
2696 pub fn filter_registry(
2700 &self,
2701 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
2702 self.ensure_filter_registry_loaded();
2703 match self.filter_registry.read() {
2704 Ok(g) => g,
2705 Err(poisoned) => poisoned.into_inner(),
2706 }
2707 }
2708
2709 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
2713 self.ensure_filter_registry_loaded();
2714 Arc::clone(&self.filter_registry)
2715 }
2716
2717 pub fn reset_filter_registry(&self) {
2721 let new_registry = crate::compress::build_registry_for_context(self);
2722 self.filter_registry_rebuild_count
2723 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2724 match self.filter_registry.write() {
2725 Ok(mut slot) => *slot = new_registry,
2726 Err(poisoned) => *poisoned.into_inner() = new_registry,
2727 }
2728 self.filter_registry_loaded
2729 .store(true, std::sync::atomic::Ordering::Release);
2730 }
2731
2732 fn ensure_filter_registry_loaded(&self) {
2733 use std::sync::atomic::Ordering;
2734 if self.filter_registry_loaded.load(Ordering::Acquire) {
2735 return;
2736 }
2737 let new_registry = crate::compress::build_registry_for_context(self);
2740 self.filter_registry_rebuild_count
2741 .fetch_add(1, Ordering::SeqCst);
2742 if let Ok(mut slot) = self.filter_registry.write() {
2743 *slot = new_registry;
2744 self.filter_registry_loaded.store(true, Ordering::Release);
2745 }
2746 }
2747
2748 #[cfg(test)]
2749 pub fn filter_registry_rebuild_count_for_test(&self) -> u64 {
2750 self.filter_registry_rebuild_count.load(Ordering::SeqCst)
2751 }
2752
2753 pub fn app(&self) -> Arc<App> {
2754 Arc::clone(&self.app)
2755 }
2756
2757 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
2760 self.app.lsp_child_registry()
2761 }
2762
2763 pub fn stdout_writer(&self) -> SharedStdoutWriter {
2764 self.app.stdout_writer()
2765 }
2766
2767 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
2768 if let Ok(mut progress_sender) = self.progress_sender.lock() {
2769 *progress_sender = sender;
2770 }
2771 }
2772
2773 pub fn emit_progress(&self, frame: ProgressFrame) {
2774 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
2775 return;
2776 };
2777 if let Some(sender) = progress_sender.as_ref() {
2778 sender(PushFrame::Progress(frame));
2779 }
2780 }
2781
2782 pub fn status_emitter(&self) -> &StatusEmitter {
2783 &self.status_emitter
2784 }
2785
2786 pub(crate) fn install_fleet_status_client(
2787 &self,
2788 client: Option<crate::fleet_status::FleetStatusClient>,
2789 ) {
2790 *self
2791 .fleet_status_client
2792 .write()
2793 .unwrap_or_else(std::sync::PoisonError::into_inner) = client;
2794 }
2795
2796 pub(crate) fn fleet_status_client(&self) -> Option<crate::fleet_status::FleetStatusClient> {
2797 self.fleet_status_client
2798 .read()
2799 .unwrap_or_else(std::sync::PoisonError::into_inner)
2800 .clone()
2801 }
2802
2803 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
2811 self.progress_sender
2812 .lock()
2813 .ok()
2814 .and_then(|sender| sender.clone())
2815 }
2816
2817 pub fn advance_configure_generation(&self) -> u64 {
2818 self.subc_lifecycle
2819 .advance_generation(self.configure_generation.as_ref())
2820 }
2821
2822 pub(crate) fn mark_subc_bound(&self) {
2823 self.subc_lifecycle.mark_bound();
2824 }
2825
2826 pub(crate) fn mark_subc_unbound(&self) {
2827 self.subc_lifecycle
2828 .mark_unbound(self.configure_generation.as_ref());
2829 }
2830
2831 #[doc(hidden)]
2832 pub fn subc_unbound_quiesced(&self) -> bool {
2833 self.subc_lifecycle.is_unbound()
2834 }
2835
2836 pub(crate) fn subc_lifecycle_admission(&self) -> SubcLifecycleAdmission {
2837 self.subc_lifecycle.clone()
2838 }
2839
2840 pub(crate) fn run_if_subc_bound_generation<R>(
2841 &self,
2842 expected_generation: u64,
2843 action: impl FnOnce() -> R,
2844 ) -> Option<R> {
2845 self.subc_lifecycle.run_if_current(
2846 self.configure_generation.as_ref(),
2847 expected_generation,
2848 action,
2849 )
2850 }
2851
2852 pub fn note_configure_warm_key(&self, key: String) -> (u64, bool) {
2863 let mut state = self.configure_warm_state.lock();
2864 let equivalent = state.key.as_ref().is_some_and(|previous| *previous == key);
2865 let generation = if equivalent {
2866 self.configure_generation()
2867 } else {
2868 self.configure_content_generation
2869 .fetch_add(1, Ordering::SeqCst);
2870 self.advance_configure_generation()
2871 };
2872 state.generation = generation;
2873 state.key = Some(key);
2874 (generation, equivalent)
2875 }
2876
2877 pub(crate) fn configure_warm_key_matches(&self, key: &str) -> bool {
2878 self.configure_warm_state
2879 .lock()
2880 .key
2881 .as_deref()
2882 .is_some_and(|current| current == key)
2883 }
2884
2885 pub(crate) fn invalidate_configure_warm_state(&self) {
2886 self.configure_warm_state.lock().key = None;
2887 }
2888
2889 pub fn note_configure_session_binding(&self, root: PathBuf, session_id: String) -> bool {
2890 self.configured_session_roots
2891 .lock()
2892 .insert((root, session_id))
2893 }
2894
2895 pub(crate) fn has_configure_session_binding(&self, root: &Path, session_id: &str) -> bool {
2896 self.configured_session_roots
2897 .lock()
2898 .contains(&(root.to_path_buf(), session_id.to_string()))
2899 }
2900
2901 pub fn forget_configure_session_binding(&self, root: &Path, session_id: &str) {
2905 self.configured_session_roots
2906 .lock()
2907 .remove(&(root.to_path_buf(), session_id.to_string()));
2908 }
2909
2910 pub fn watcher_drain_has_work(&self) -> bool {
2916 let receiver_pending = self
2917 .watcher_rx
2918 .lock()
2919 .as_ref()
2920 .is_some_and(|rx| !rx.is_empty());
2921 receiver_pending
2922 || self
2923 .watcher_drain_slice
2924 .lock()
2925 .as_ref()
2926 .is_some_and(WatcherDrainSliceState::has_pending_work)
2927 }
2928
2929 pub fn lsp_drain_has_work(&self) -> bool {
2930 match self.lsp_manager.try_lock() {
2931 Some(lsp) => lsp.has_pending_events(),
2932 None => true,
2934 }
2935 }
2936
2937 pub fn completion_drains_have_work(&self) -> bool {
2938 let search_pending = self
2939 .search_index_rx
2940 .try_read()
2941 .map(|slot| {
2942 slot.as_ref().is_some_and(|receiver| {
2943 !receiver.is_empty()
2944 || self.search_index_rx_terminal_epoch.load(Ordering::SeqCst)
2945 == self.search_index_rx_epoch()
2946 })
2947 })
2948 .unwrap_or(true);
2949 if search_pending {
2950 return true;
2951 }
2952 if self
2953 .callgraph_store_rx
2954 .lock()
2955 .as_ref()
2956 .is_some_and(|rx| !rx.is_empty())
2957 {
2958 return true;
2959 }
2960 if self
2961 .semantic_index_rx
2962 .lock()
2963 .as_ref()
2964 .is_some_and(|receiver| {
2965 !receiver.is_empty()
2966 || self.semantic_index_rx_terminal_epoch.load(Ordering::SeqCst)
2967 == self.semantic_index_rx_epoch()
2968 })
2969 {
2970 return true;
2971 }
2972 if self
2973 .semantic_refresh_event_rx
2974 .lock()
2975 .as_ref()
2976 .is_some_and(|rx| !rx.is_empty())
2977 {
2978 return true;
2979 }
2980 if self.semantic_refresh_probe_ready() && self.semantic_refresh_event_rx.lock().is_some() {
2981 return true;
2982 }
2983 if self
2984 .semantic_refresh_worker
2985 .lock()
2986 .as_ref()
2987 .is_some_and(|worker_slot| match worker_slot.try_lock() {
2988 Ok(handle) => handle
2989 .as_ref()
2990 .is_some_and(std::thread::JoinHandle::is_finished),
2991 Err(std::sync::TryLockError::WouldBlock) => true,
2992 Err(std::sync::TryLockError::Poisoned(_)) => true,
2993 })
2994 {
2995 return true;
2996 }
2997 self.inspect_manager().has_pending_completions() || self.has_new_reuse_completions()
2998 }
2999
3000 pub fn configure_tail_has_work(&self) -> bool {
3001 !self.configure_maintenance_jobs.lock().is_empty() || !self.configure_warnings_rx.is_empty()
3002 }
3003
3004 pub(crate) fn configure_maintenance_has_capacity(&self) -> bool {
3005 self.configure_maintenance_jobs.lock().len() < crate::executor::MAINTENANCE_QUEUE_CAP
3006 }
3007
3008 pub(crate) fn enqueue_configure_maintenance(
3009 &self,
3010 job: ConfigureMaintenanceJob,
3011 ) -> Result<(), ConfigureMaintenanceJob> {
3012 let mut jobs = self.configure_maintenance_jobs.lock();
3013 if jobs.len() >= crate::executor::MAINTENANCE_QUEUE_CAP {
3014 return Err(job);
3015 }
3016 jobs.push_back(job);
3017 Ok(())
3018 }
3019
3020 pub(crate) fn drain_configure_maintenance(&self) -> Vec<ConfigureMaintenanceJob> {
3021 self.configure_maintenance_jobs.lock().drain(..).collect()
3022 }
3023
3024 #[cfg(test)]
3025 pub(crate) fn configure_maintenance_job_count_for_test(&self) -> usize {
3026 self.configure_maintenance_jobs.lock().len()
3027 }
3028
3029 pub fn cached_artifact_cache_key(&self, canonical_root: &Path) -> Option<String> {
3032 self.artifact_cache_keys.lock().get(canonical_root).cloned()
3033 }
3034
3035 pub(crate) fn cached_worktree_bridge(
3038 &self,
3039 canonical_root: &Path,
3040 ) -> Option<(bool, Option<PathBuf>)> {
3041 #[cfg(test)]
3042 if self.force_worktree_bridge_reprobe.load(Ordering::SeqCst) {
3043 return None;
3044 }
3045
3046 let signature = git_entry_signature(canonical_root);
3047 self.worktree_bridge_cache
3048 .lock()
3049 .get(canonical_root)
3050 .filter(|entry| entry.git_entry == signature)
3051 .map(|entry| (entry.is_worktree_bridge, entry.git_common_dir.clone()))
3052 }
3053
3054 pub(crate) fn cache_worktree_bridge(
3057 &self,
3058 canonical_root: &Path,
3059 is_worktree_bridge: bool,
3060 git_common_dir: PathBuf,
3061 ) {
3062 self.worktree_bridge_cache.lock().insert(
3063 canonical_root.to_path_buf(),
3064 WorktreeBridgeCacheEntry {
3065 git_entry: git_entry_signature(canonical_root),
3066 is_worktree_bridge,
3067 git_common_dir: Some(git_common_dir),
3068 },
3069 );
3070 }
3071
3072 #[cfg(test)]
3073 pub(crate) fn record_worktree_bridge_probe_spawn_for_test(&self) {
3074 self.worktree_bridge_probe_spawns
3075 .fetch_add(1, Ordering::SeqCst);
3076 }
3077
3078 #[cfg(test)]
3079 pub(crate) fn worktree_bridge_probe_spawns_for_test(&self) -> u64 {
3080 self.worktree_bridge_probe_spawns.load(Ordering::SeqCst)
3081 }
3082
3083 #[cfg(test)]
3084 pub(crate) fn force_worktree_bridge_reprobe_for_test(&self, enabled: bool) {
3085 self.force_worktree_bridge_reprobe
3086 .store(enabled, Ordering::SeqCst);
3087 }
3088
3089 pub fn memoized_artifact_cache_key(&self, canonical_root: &Path) -> String {
3090 let mut keys = self.artifact_cache_keys.lock();
3091 if let Some(key) = keys.get(canonical_root).cloned() {
3092 return key;
3093 }
3094 let key = crate::search_index::artifact_cache_key(canonical_root);
3095 self.artifact_cache_key_derivations
3096 .fetch_add(1, Ordering::SeqCst);
3097 keys.insert(canonical_root.to_path_buf(), key.clone());
3098 key
3099 }
3100
3101 pub fn memoized_artifact_cache_key_for_configure(
3102 &self,
3103 raw_root: &Path,
3104 canonical_root: &Path,
3105 storage_root: &Path,
3106 git_common_dir: Option<&Path>,
3107 ) -> Result<String, crate::search_index::ArtifactCacheKeyProbeError> {
3108 {
3109 let keys = self.artifact_cache_keys.lock();
3110 if let Some(key) = keys
3111 .get(canonical_root)
3112 .or_else(|| keys.get(raw_root))
3113 .cloned()
3114 {
3115 return Ok(key);
3116 }
3117 }
3118
3119 let key = crate::search_index::artifact_cache_key_with_memo(
3120 canonical_root,
3121 raw_root,
3122 storage_root,
3123 git_common_dir,
3124 )?;
3125 self.artifact_cache_key_derivations
3126 .fetch_add(1, Ordering::SeqCst);
3127 let mut keys = self.artifact_cache_keys.lock();
3128 keys.insert(canonical_root.to_path_buf(), key.clone());
3129 keys.insert(raw_root.to_path_buf(), key.clone());
3130 Ok(key)
3131 }
3132
3133 #[cfg(test)]
3134 pub fn artifact_cache_key_derivation_count_for_test(&self) -> u64 {
3135 self.artifact_cache_key_derivations.load(Ordering::SeqCst)
3136 }
3137
3138 pub(crate) fn resolve_external_git_root(
3139 &self,
3140 project_root: &Path,
3141 requested_path: &str,
3142 ) -> Result<PathBuf, crate::readonly_artifacts::GitRootResolutionError> {
3143 let raw_path = Path::new(requested_path);
3144 let canonical_requested = if raw_path.is_absolute() {
3145 std::fs::canonicalize(raw_path).ok()
3146 } else {
3147 None
3148 };
3149 if let Some(root) = canonical_requested
3150 .as_deref()
3151 .and_then(|root| self.borrowed_index_cache.lock().resolved_root(root))
3152 {
3153 return Ok(root);
3154 }
3155
3156 let root = crate::readonly_artifacts::resolve_git_root_from_user_path(
3157 project_root,
3158 requested_path,
3159 )?;
3160 if canonical_requested.as_deref() == Some(root.as_path()) {
3161 self.borrowed_index_cache
3162 .lock()
3163 .remember_resolved_root(root.clone());
3164 }
3165 Ok(root)
3166 }
3167
3168 pub(crate) fn open_borrowed_search_index(
3169 &self,
3170 external_root: &Path,
3171 storage_dir: Option<&Path>,
3172 ) -> crate::readonly_artifacts::ReadOnlyArtifact<Arc<SearchIndex>> {
3173 let canonical_root =
3174 std::fs::canonicalize(external_root).unwrap_or_else(|_| external_root.to_path_buf());
3175 let project_key = self.memoized_artifact_cache_key(&canonical_root);
3176 let Some(artifact) = crate::readonly_artifacts::search_index_artifact_generation_with_key(
3177 &project_key,
3178 storage_dir,
3179 ) else {
3180 return crate::readonly_artifacts::ReadOnlyArtifact::Absent;
3181 };
3182 let key = BorrowedIndexCacheKey {
3183 canonical_root: canonical_root.clone(),
3184 artifact,
3185 };
3186 {
3187 let mut cache = self.borrowed_index_cache.lock();
3188 if let Some(index) = cache.search(&key) {
3189 return index;
3190 }
3191 }
3192
3193 let opened = crate::readonly_artifacts::open_search_index_read_only_with_key(
3196 &canonical_root,
3197 storage_dir,
3198 &project_key,
3199 )
3200 .map(Arc::new);
3201 if !matches!(
3202 opened,
3203 crate::readonly_artifacts::ReadOnlyArtifact::Absent
3204 | crate::readonly_artifacts::ReadOnlyArtifact::Cancelled
3205 ) {
3206 self.borrowed_index_cache
3207 .lock()
3208 .insert(key, BorrowedIndexCacheValue::Search(opened.clone()));
3209 }
3210 opened
3211 }
3212
3213 pub(crate) fn open_borrowed_semantic_index(
3214 &self,
3215 external_root: &Path,
3216 storage_dir: Option<&Path>,
3217 ) -> crate::readonly_artifacts::ReadOnlyArtifact<Arc<SemanticIndex>> {
3218 let canonical_root =
3219 std::fs::canonicalize(external_root).unwrap_or_else(|_| external_root.to_path_buf());
3220 let project_key = self.memoized_artifact_cache_key(&canonical_root);
3221 let Some(artifact) = crate::readonly_artifacts::semantic_index_artifact_generation_with_key(
3222 &project_key,
3223 storage_dir,
3224 ) else {
3225 return crate::readonly_artifacts::ReadOnlyArtifact::Absent;
3226 };
3227 let key = BorrowedIndexCacheKey {
3228 canonical_root: canonical_root.clone(),
3229 artifact,
3230 };
3231 {
3232 let mut cache = self.borrowed_index_cache.lock();
3233 if let Some(index) = cache.semantic(&key) {
3234 return index;
3235 }
3236 }
3237
3238 let opened = crate::readonly_artifacts::open_semantic_index_read_only_with_key(
3241 &canonical_root,
3242 storage_dir,
3243 &project_key,
3244 )
3245 .map(Arc::new);
3246 if !matches!(
3247 opened,
3248 crate::readonly_artifacts::ReadOnlyArtifact::Absent
3249 | crate::readonly_artifacts::ReadOnlyArtifact::Cancelled
3250 ) {
3251 self.borrowed_index_cache
3252 .lock()
3253 .insert(key, BorrowedIndexCacheValue::Semantic(opened.clone()));
3254 }
3255 opened
3256 }
3257
3258 #[cfg(test)]
3259 pub(crate) fn borrowed_index_cache_len_for_test(&self) -> usize {
3260 self.borrowed_index_cache.lock().entries.len()
3261 }
3262
3263 pub fn configure_generation(&self) -> u64 {
3264 self.configure_generation.load(Ordering::SeqCst)
3265 }
3266
3267 pub fn configure_generation_flag(&self) -> Arc<AtomicU64> {
3268 Arc::clone(&self.configure_generation)
3269 }
3270
3271 pub(crate) fn configure_content_generation(&self) -> u64 {
3272 self.configure_content_generation.load(Ordering::SeqCst)
3273 }
3274
3275 pub(crate) fn configure_content_generation_flag(&self) -> Arc<AtomicU64> {
3276 Arc::clone(&self.configure_content_generation)
3277 }
3278
3279 pub(crate) fn begin_configure_ack_phase(&self, phase: &'static str) {
3280 let now = Instant::now();
3281 let mut timing = self.configure_phase_timing.lock();
3282 if phase == "canonicalize" {
3283 timing.completed.clear();
3284 } else if timing.phase != "idle" && timing.phase != "ack_ready" {
3285 let previous = timing.phase;
3286 let elapsed = now.saturating_duration_since(timing.started_at);
3287 timing.completed.push((previous, elapsed));
3288 }
3289 timing.phase = phase;
3290 timing.started_at = now;
3291 }
3292
3293 pub(crate) fn configure_ack_phase_snapshot(&self) -> String {
3294 let timing = self.configure_phase_timing.lock();
3295 let mut parts = timing
3296 .completed
3297 .iter()
3298 .map(|(phase, elapsed)| format!("{phase}={}ms", elapsed.as_millis()))
3299 .collect::<Vec<_>>();
3300 parts.push(format!(
3301 "{}={}ms",
3302 timing.phase,
3303 timing.started_at.elapsed().as_millis()
3304 ));
3305 parts.join(",")
3306 }
3307
3308 pub fn advance_semantic_fingerprint_generation(&self) -> u64 {
3309 self.semantic_fingerprint_generation
3310 .fetch_add(1, Ordering::SeqCst)
3311 .wrapping_add(1)
3312 }
3313
3314 pub fn semantic_fingerprint_generation(&self) -> u64 {
3315 self.semantic_fingerprint_generation.load(Ordering::SeqCst)
3316 }
3317
3318 pub fn semantic_fingerprint_generation_flag(&self) -> Arc<AtomicU64> {
3319 Arc::clone(&self.semantic_fingerprint_generation)
3320 }
3321
3322 pub fn configure_warnings_sender(
3323 &self,
3324 ) -> crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)> {
3325 self.configure_warnings_tx.clone()
3326 }
3327
3328 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
3329 let mut warnings = Vec::new();
3330 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
3331 warnings.push(warning);
3332 }
3333 warnings
3334 }
3335
3336 pub fn bash_background(&self) -> &BgTaskRegistry {
3337 &self.bash_background
3338 }
3339
3340 #[cfg(unix)]
3341 pub(crate) fn escalation_grants(
3342 &self,
3343 ) -> &parking_lot::Mutex<crate::sandbox_spawn::EscalationGrantStore> {
3344 &self.escalation_grants
3345 }
3346
3347 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
3348 self.bash_background.drain_completions()
3349 }
3350
3351 pub fn provider(&self) -> &dyn LanguageProvider {
3353 self.provider.as_ref()
3354 }
3355
3356 pub fn backup(&self) -> &parking_lot::Mutex<BackupStore> {
3358 &self.backup
3359 }
3360
3361 pub fn hashline_bindings(&self) -> &crate::hashline::integration::BindingRegistry {
3363 &self.hashline_bindings
3364 }
3365
3366 pub fn checkpoint(&self) -> &parking_lot::Mutex<CheckpointStore> {
3368 &self.checkpoint
3369 }
3370
3371 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
3372 self.app.set_db(conn);
3373 self.compression_aggregates.clear();
3374 }
3375
3376 pub fn clear_db(&self) {
3377 self.app.clear_db();
3378 self.compression_aggregates.clear();
3379 }
3380
3381 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
3382 self.app.db()
3383 }
3384
3385 pub(crate) fn compression_aggregate_cache(
3386 &self,
3387 ) -> &crate::db::compression_events::CompressionAggregateCache {
3388 self.compression_aggregates.as_ref()
3389 }
3390
3391 pub fn config(&self) -> Arc<Config> {
3393 let guard = match self.config.read() {
3394 Ok(guard) => guard,
3395 Err(poisoned) => poisoned.into_inner(),
3396 };
3397 Arc::clone(&*guard)
3398 }
3399
3400 pub fn set_config(&self, config: Config) {
3402 let next = Arc::new(config);
3403 let project_root_changed = {
3404 let mut guard = self
3405 .config
3406 .write()
3407 .unwrap_or_else(std::sync::PoisonError::into_inner);
3408 let changed = guard.project_root.as_ref().map(|root| root.as_os_str())
3411 != next.project_root.as_ref().map(|root| root.as_os_str());
3412 *guard = next;
3413 changed
3414 };
3415 if project_root_changed {
3416 self.path_restriction_root_memo.lock().take();
3417 }
3418 }
3419
3420 #[cfg(test)]
3421 pub(crate) fn path_restriction_root_memo_is_empty_for_test(&self) -> bool {
3422 self.path_restriction_root_memo.lock().is_none()
3423 }
3424
3425 #[cfg(test)]
3426 pub(crate) fn path_restriction_root_canonicalizations_for_test(&self) -> usize {
3427 self.path_restriction_root_canonicalizations
3428 .load(Ordering::SeqCst)
3429 }
3430
3431 pub fn update_config(&self, update: impl FnOnce(&mut Config)) {
3433 let mut next = self.config().as_ref().clone();
3434 update(&mut next);
3435 self.set_config(next);
3436 }
3437
3438 pub fn force_restrict_guard(&self, req_id: &str) -> ForceRestrictGuard<'_> {
3439 let mut requests = self.force_restrict_requests.lock();
3440 *requests.entry(req_id.to_string()).or_insert(0) += 1;
3441 ForceRestrictGuard {
3442 ctx: self,
3443 req_id: req_id.to_string(),
3444 }
3445 }
3446
3447 pub fn with_force_restrict<R>(&self, req_id: &str, f: impl FnOnce() -> R) -> R {
3448 let _guard = self.force_restrict_guard(req_id);
3449 f()
3450 }
3451
3452 pub fn request_force_restrict(&self, req_id: &str) -> bool {
3453 self.force_restrict_requests.lock().contains_key(req_id)
3454 }
3455
3456 fn release_force_restrict(&self, req_id: &str) {
3457 let mut requests = self.force_restrict_requests.lock();
3458 match requests.get_mut(req_id) {
3459 Some(count) if *count > 1 => *count -= 1,
3460 Some(_) => {
3461 requests.remove(req_id);
3462 }
3463 None => {}
3464 }
3465 }
3466
3467 pub fn set_harness(&self, harness: Harness) {
3468 self.bash_background.set_harness(harness.clone());
3469 *self.harness.lock() = Some(harness);
3470 }
3471
3472 pub fn harness_opt(&self) -> Option<Harness> {
3473 self.harness.lock().clone()
3474 }
3475
3476 pub fn harness(&self) -> Harness {
3477 self.harness_opt()
3478 .expect("harness set by configure before any tool call")
3479 }
3480
3481 pub fn storage_dir(&self) -> PathBuf {
3482 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
3483 }
3484
3485 pub fn harness_dir(&self) -> PathBuf {
3486 self.storage_dir().join(self.harness().storage_segment())
3487 }
3488
3489 pub(crate) fn refresh_build_suspensions_for_health(
3493 &self,
3494 project_root: &Path,
3495 project_key: Option<&str>,
3496 ) {
3497 let now_ms = SystemTime::now()
3498 .duration_since(UNIX_EPOCH)
3499 .unwrap_or_default()
3500 .as_millis()
3501 .min(u128::from(u64::MAX)) as u64;
3502 let suspended_domains = project_key
3503 .and_then(|key| {
3504 let path = self
3505 .storage_dir()
3506 .join("callgraph")
3507 .join(key)
3508 .join("build-breaker.sqlite");
3509 path.is_file().then_some(path)
3510 })
3511 .and_then(|path| crate::build_breaker::BuildDeathBreaker::open(path).ok())
3512 .and_then(|breaker| {
3513 breaker
3514 .active_suspensions_for_root_at(&project_root.display().to_string(), now_ms)
3515 .ok()
3516 })
3517 .unwrap_or_default()
3518 .into_iter()
3519 .map(|suspension| {
3520 let age_s = suspension.age_seconds_at(now_ms);
3521 SuspendedDomainHealthSnapshot {
3522 domain: suspension.domain.as_str().to_string(),
3523 reason: suspension.reason,
3524 death_count: suspension.death_count,
3525 age_s,
3526 }
3527 })
3528 .collect();
3529 if let Ok(mut snapshot) = self.health_build_suspensions.write() {
3530 *snapshot = suspended_domains;
3531 }
3532 }
3533
3534 pub fn inspect_dir(&self) -> PathBuf {
3535 if let Some(root) = self
3536 .canonical_cache_root_opt()
3537 .or_else(|| self.config().project_root.clone())
3538 {
3539 self.storage_dir()
3540 .join("inspect")
3541 .join(crate::path_identity::project_scope_key(&root))
3542 } else {
3543 self.storage_dir().join("inspect").join("unconfigured")
3544 }
3545 }
3546
3547 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
3548 self.harness_dir()
3549 .join("bash-tasks")
3550 .join(hash_session(session_id))
3551 }
3552
3553 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
3554 self.harness_dir()
3555 .join("backups")
3556 .join(hash_session(session_id))
3557 .join(path_hash)
3558 }
3559
3560 pub fn filters_dir(&self) -> PathBuf {
3561 self.harness_dir().join("filters")
3562 }
3563
3564 pub fn trust_file(&self) -> PathBuf {
3566 self.storage_dir().join("trusted-filter-projects.json")
3567 }
3568
3569 pub fn set_canonical_cache_root(&self, root: PathBuf) {
3570 debug_assert!(root.is_absolute());
3571 let root_changed = {
3572 let mut current = self.canonical_cache_root.lock();
3573 let changed = current.as_deref() != Some(root.as_path());
3574 *current = Some(root);
3575 changed
3576 };
3577 if root_changed {
3578 let mut tier2 = self
3579 .status_bar_tier2
3580 .write()
3581 .unwrap_or_else(std::sync::PoisonError::into_inner);
3582 let generation = tier2.generation.wrapping_add(1);
3583 *tier2 = StatusBarTier2 {
3584 generation,
3585 ..StatusBarTier2::default()
3586 };
3587 self.status_bar_last_emitted.clear();
3588 }
3589 }
3590
3591 pub fn canonical_cache_root(&self) -> PathBuf {
3592 self.canonical_cache_root
3593 .lock()
3594 .clone()
3595 .expect("canonical_cache_root accessed before handle_configure")
3596 }
3597
3598 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
3599 self.canonical_cache_root.lock().clone()
3600 }
3601
3602 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
3603 *self.is_worktree_bridge.lock() = is_worktree_bridge;
3604 *self.git_common_dir.lock() = git_common_dir;
3605 self.inspect_manager
3609 .set_automatic_tier2_refresh_allowed(!is_worktree_bridge);
3610 let artifact_read_only = self.shared_artifacts_read_only.load(Ordering::SeqCst);
3611 self.callgraph_writer
3612 .store(!is_worktree_bridge && !artifact_read_only, Ordering::SeqCst);
3613 }
3614
3615 pub fn set_artifact_owner(
3616 &self,
3617 status: Option<ArtifactOwnerStatus>,
3618 lease: Option<ArtifactOwnerLease>,
3619 ) {
3620 let read_only = status
3621 .as_ref()
3622 .is_some_and(|status| status.mode == ArtifactOwnerMode::ReadOnly);
3623 self.shared_artifacts_read_only
3624 .store(read_only, Ordering::SeqCst);
3625 self.callgraph_writer
3626 .store(!self.is_worktree_bridge() && !read_only, Ordering::SeqCst);
3627 self.inspect_writer.store(true, Ordering::SeqCst);
3628 *self.artifact_owner_status.lock() = status;
3629 *self.artifact_owner_lease.lock() = lease.map(crate::artifact_owner::register_heartbeat);
3630 }
3631
3632 pub fn set_cache_writer_capabilities(&self, callgraph_writer: bool, inspect_writer: bool) {
3633 self.callgraph_writer
3634 .store(callgraph_writer, Ordering::SeqCst);
3635 self.inspect_writer.store(inspect_writer, Ordering::SeqCst);
3636 }
3637
3638 pub fn callgraph_writer(&self) -> bool {
3639 self.callgraph_writer.load(Ordering::SeqCst)
3640 }
3641
3642 pub fn inspect_writer(&self) -> bool {
3643 self.inspect_writer.load(Ordering::SeqCst)
3644 }
3645
3646 pub fn shared_artifacts_read_only(&self) -> bool {
3647 !self.callgraph_writer()
3648 }
3649
3650 #[doc(hidden)]
3654 pub fn set_daemonless_query_mode(&self, enabled: bool) {
3655 self.daemonless_query_mode.store(enabled, Ordering::SeqCst);
3656 }
3657
3658 pub(crate) fn daemonless_query_mode(&self) -> bool {
3659 self.daemonless_query_mode.load(Ordering::SeqCst)
3660 }
3661
3662 pub fn ram_overlay_active(&self) -> bool {
3668 self.shared_artifacts_read_only() && self.config().worktree.ram_overlay
3669 }
3670
3671 pub fn artifact_owner_status(&self) -> Option<ArtifactOwnerStatus> {
3672 self.artifact_owner_status.lock().clone()
3673 }
3674
3675 pub fn is_worktree_bridge(&self) -> bool {
3676 *self.is_worktree_bridge.lock()
3677 }
3678
3679 pub fn git_common_dir(&self) -> Option<PathBuf> {
3680 self.git_common_dir.lock().clone()
3681 }
3682
3683 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
3687 *self.degraded_reasons.lock() = reasons;
3688 }
3689
3690 pub fn set_heavy_root_work_allowed(&self, allowed: bool) {
3691 self.heavy_root_work_allowed
3692 .store(allowed, Ordering::SeqCst);
3693 }
3694
3695 pub fn heavy_root_work_allowed(&self) -> bool {
3696 self.heavy_root_work_allowed.load(Ordering::SeqCst) && !self.subc_lifecycle.is_unbound()
3697 }
3698
3699 fn try_heavy_root_work_allowed(&self) -> Option<bool> {
3700 if !self.heavy_root_work_allowed.load(Ordering::SeqCst) {
3701 return Some(false);
3702 }
3703 self.subc_lifecycle.try_is_bound()
3704 }
3705
3706 pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
3707 let reason = reason.into();
3708 let mut reasons = self.degraded_reasons.lock();
3709 if reasons.iter().any(|existing| existing == &reason) {
3710 return false;
3711 }
3712 reasons.push(reason);
3713 true
3714 }
3715
3716 pub fn degraded_reasons(&self) -> Vec<String> {
3720 self.degraded_reasons.lock().clone()
3721 }
3722
3723 pub fn is_degraded(&self) -> bool {
3725 !self.degraded_reasons.lock().is_empty()
3726 }
3727
3728 pub fn is_home_root(&self) -> bool {
3732 self.degraded_reasons
3733 .lock()
3734 .iter()
3735 .any(|reason| reason == "home_root")
3736 }
3737
3738 pub fn cache_role(&self) -> &'static str {
3739 if self.canonical_cache_root.lock().is_none() {
3740 "not_initialized"
3741 } else if self.is_worktree_bridge() {
3742 "worktree"
3743 } else if self.shared_artifacts_read_only.load(Ordering::SeqCst) {
3744 "read_only"
3745 } else {
3746 "main"
3747 }
3748 }
3749
3750 pub fn callgraph_store(&self) -> &RwLock<Option<Arc<ReadonlyCallGraphStore>>> {
3752 self.callgraph_store.as_ref()
3753 }
3754
3755 pub fn mark_callgraph_store_force_rebuild(&self) -> u64 {
3756 self.callgraph_store_force_requested
3757 .fetch_add(1, Ordering::SeqCst)
3758 .wrapping_add(1)
3759 }
3760
3761 pub(crate) fn pending_callgraph_store_force_token(&self) -> Option<u64> {
3762 let requested = self.callgraph_store_force_requested.load(Ordering::SeqCst);
3763 let fulfilled = self.callgraph_store_force_fulfilled.load(Ordering::SeqCst);
3764 (requested > fulfilled).then_some(requested)
3765 }
3766
3767 pub fn fulfill_callgraph_store_force_token(&self, token: u64) {
3768 self.callgraph_store_force_fulfilled
3769 .fetch_max(token, Ordering::SeqCst);
3770 }
3771
3772 #[doc(hidden)]
3773 pub fn record_callgraph_store_build_denied(&self, generation: u64, reason: String) {
3774 *self.callgraph_store_build_denied.lock() = Some((generation, reason));
3775 }
3776
3777 #[doc(hidden)]
3778 pub fn record_callgraph_store_build_suspension(
3779 &self,
3780 generation: u64,
3781 suspension: crate::build_breaker::BuildSuspension,
3782 ) {
3783 *self.callgraph_store_build_suspension.lock() = Some((generation, suspension));
3784 }
3785
3786 #[doc(hidden)]
3787 pub fn clear_callgraph_store_build_denied(&self) {
3788 *self.callgraph_store_build_denied.lock() = None;
3789 *self.callgraph_store_build_suspension.lock() = None;
3790 }
3791
3792 fn callgraph_store_build_suspension(&self) -> Option<crate::build_breaker::BuildSuspension> {
3793 let generation = self.configure_generation();
3794 let mut suspended = self.callgraph_store_build_suspension.lock();
3795 match suspended.as_ref() {
3796 Some((suspended_generation, value)) if *suspended_generation == generation => {
3797 Some(value.clone())
3798 }
3799 Some(_) => {
3800 *suspended = None;
3801 None
3802 }
3803 None => None,
3804 }
3805 }
3806
3807 fn callgraph_store_build_denial(&self) -> Option<String> {
3808 let generation = self.configure_generation();
3809 let mut denied = self.callgraph_store_build_denied.lock();
3810 match denied.as_ref() {
3811 Some((denied_generation, reason)) if *denied_generation == generation => {
3812 Some(reason.clone())
3813 }
3814 Some(_) => {
3815 *denied = None;
3816 None
3817 }
3818 None => None,
3819 }
3820 }
3821
3822 pub fn callgraph_store_dir(&self) -> PathBuf {
3823 if let Some(root) = self.callgraph_project_root() {
3824 self.storage_dir()
3825 .join("callgraph")
3826 .join(self.memoized_artifact_cache_key(&root))
3827 } else {
3828 self.storage_dir().join("callgraph").join("unconfigured")
3829 }
3830 }
3831
3832 pub fn ensure_callgraph_store(
3833 &self,
3834 ) -> Result<Option<Arc<ReadonlyCallGraphStore>>, CallGraphStoreError> {
3835 self.ensure_callgraph_store_with_flag(true)
3836 }
3837
3838 fn ensure_callgraph_store_with_flag(
3839 &self,
3840 respect_config_flag: bool,
3841 ) -> Result<Option<Arc<ReadonlyCallGraphStore>>, CallGraphStoreError> {
3842 if respect_config_flag && !self.config().callgraph_store {
3843 return Ok(None);
3844 }
3845 if !self.heavy_root_work_allowed() {
3846 return Ok(None);
3847 }
3848 self.revalidate_callgraph_store_generation();
3849 let force_token = self.pending_callgraph_store_force_token();
3850 if force_token.is_none() {
3851 if let Some(store) = {
3852 let guard = self
3853 .callgraph_store
3854 .read()
3855 .unwrap_or_else(std::sync::PoisonError::into_inner);
3856 guard.as_ref().map(Arc::clone)
3857 } {
3858 self.schedule_legacy_callgraph_migration_if_needed(
3859 store.as_ref(),
3860 store.project_root().to_path_buf(),
3861 self.callgraph_store_dir(),
3862 );
3863 return Ok(Some(store));
3864 }
3865 }
3866
3867 let Some(project_root) = self.callgraph_project_root() else {
3868 return Ok(None);
3869 };
3870 let callgraph_dir = self.callgraph_store_dir();
3871
3872 if force_token.is_none() {
3876 if let Some(store) =
3877 CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone())?
3878 {
3879 let store = Arc::new(store);
3880 {
3881 let mut guard = self
3882 .callgraph_store
3883 .write()
3884 .unwrap_or_else(std::sync::PoisonError::into_inner);
3885 *guard = Some(Arc::clone(&store));
3886 }
3887 self.schedule_legacy_callgraph_migration_if_needed(
3888 store.as_ref(),
3889 project_root,
3890 callgraph_dir,
3891 );
3892 return Ok(Some(store));
3893 }
3894 }
3895
3896 if !self.callgraph_writer() {
3897 return Ok(None);
3898 }
3899 let build_generation = self.configure_generation();
3900 let persist_epoch_flag = self.callgraph_persist_epoch_flag();
3901 let Some(persist_epoch) = self
3902 .run_if_subc_bound_generation(build_generation, || self.next_callgraph_persist_epoch())
3903 else {
3904 return Ok(None);
3905 };
3906 let (store, _stats) = crate::callgraph_store::with_publish_epoch(
3909 persist_epoch_flag.clone(),
3910 persist_epoch,
3911 || {
3912 if force_token.is_some() {
3913 CallGraphStore::force_cold_build_with_lease_chunked(
3914 callgraph_dir.clone(),
3915 project_root.clone(),
3916 &[],
3917 self.config().callgraph_chunk_size,
3918 )
3919 .map(|(store, _stats)| (store, ()))
3920 } else {
3921 CallGraphStore::ensure_built_with_lease_chunked(
3922 callgraph_dir.clone(),
3923 project_root.clone(),
3924 &[],
3925 self.config().callgraph_chunk_size,
3926 )
3927 .map(|(store, _stats)| (store, ()))
3928 }
3929 },
3930 )?;
3931 drop(store);
3932
3933 let Some(store) = CallGraphStore::open_readonly(callgraph_dir, project_root)? else {
3934 return Ok(None);
3935 };
3936 let store = Arc::new(store);
3937 self.run_if_subc_bound_generation(build_generation, || {
3938 if persist_epoch_flag.current() != persist_epoch {
3939 return None;
3940 }
3941 let mut guard = self
3942 .callgraph_store
3943 .write()
3944 .unwrap_or_else(std::sync::PoisonError::into_inner);
3945 *guard = Some(Arc::clone(&store));
3946 if let Some(force_token) = force_token {
3947 self.fulfill_callgraph_store_force_token(force_token);
3948 }
3949 Some(Arc::clone(&store))
3950 })
3951 .flatten()
3952 .map_or(Ok(None), |store| Ok(Some(store)))
3953 }
3954
3955 pub fn callgraph_project_root(&self) -> Option<PathBuf> {
3958 self.canonical_cache_root_opt().or_else(|| {
3959 self.config()
3960 .project_root
3961 .clone()
3962 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
3963 })
3964 }
3965
3966 pub fn revalidate_callgraph_store_generation(&self) {
3970 let (superseded, legacy_fallback) = {
3971 let guard = self
3972 .callgraph_store
3973 .read()
3974 .unwrap_or_else(std::sync::PoisonError::into_inner);
3975 guard
3976 .as_ref()
3977 .map(|store| (!store.is_current(), store.is_legacy_fallback()))
3978 .unwrap_or((false, false))
3979 };
3980 if !superseded {
3981 return;
3982 }
3983 if legacy_fallback && self.callgraph_store_rx.lock().is_some() {
3987 return;
3988 }
3989 let mut guard = self
3990 .callgraph_store
3991 .write()
3992 .unwrap_or_else(std::sync::PoisonError::into_inner);
3993 *guard = None;
3994 }
3995
3996 pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess {
3997 self.callgraph_store_for_ops_with_wait(callgraph_build_wait_window())
3998 }
3999
4000 pub(crate) fn schedule_callgraph_store_warm(&self) -> CallgraphStoreAccess {
4008 self.callgraph_store_for_ops_with_wait(Duration::ZERO)
4009 }
4010
4011 fn callgraph_store_for_ops_with_wait(&self, wait: Duration) -> CallgraphStoreAccess {
4012 if !self.heavy_root_work_allowed() {
4013 return CallgraphStoreAccess::Unavailable;
4014 }
4015 let operation_generation = self.configure_generation();
4016
4017 self.revalidate_callgraph_store_generation();
4021 let force_token = self.pending_callgraph_store_force_token();
4022 if force_token.is_none() {
4023 if let Some(store) = {
4024 let guard = self
4025 .callgraph_store
4026 .read()
4027 .unwrap_or_else(std::sync::PoisonError::into_inner);
4028 guard.as_ref().map(Arc::clone)
4029 } {
4030 self.clear_callgraph_store_build_denied();
4031 self.schedule_legacy_callgraph_migration_if_needed(
4032 store.as_ref(),
4033 store.project_root().to_path_buf(),
4034 self.callgraph_store_dir(),
4035 );
4036 return CallgraphStoreAccess::Ready(store);
4037 }
4038 }
4039
4040 if let Some(suspension) = self.callgraph_store_build_suspension() {
4041 return CallgraphStoreAccess::Suspended(suspension);
4042 }
4043 if let Some(reason) = self.callgraph_store_build_denial() {
4044 return CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason));
4045 }
4046
4047 let build_in_flight = self.callgraph_store_rx.lock().is_some();
4051
4052 let Some(project_root) = self.callgraph_project_root() else {
4053 return CallgraphStoreAccess::Unavailable;
4054 };
4055 let callgraph_dir = self.callgraph_store_dir();
4056
4057 if !build_in_flight {
4058 match CallGraphStore::cold_build_suspension(&callgraph_dir, &project_root) {
4059 Ok(Some(suspension)) => return CallgraphStoreAccess::Suspended(suspension),
4060 Ok(None) => {}
4061 Err(error) => return CallgraphStoreAccess::Error(error),
4062 }
4063 }
4064
4065 if !build_in_flight {
4066 if force_token.is_none() {
4067 match CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone()) {
4068 Ok(Some(store)) => {
4069 let store = Arc::new(store);
4070 let installed =
4071 self.run_if_subc_bound_generation(operation_generation, || {
4072 let mut guard = self
4073 .callgraph_store
4074 .write()
4075 .unwrap_or_else(std::sync::PoisonError::into_inner);
4076 *guard = Some(Arc::clone(&store));
4077 Arc::clone(&store)
4078 });
4079 let Some(store) = installed else {
4080 return CallgraphStoreAccess::Unavailable;
4081 };
4082 self.clear_callgraph_store_build_denied();
4083 self.schedule_legacy_callgraph_migration_if_needed(
4084 store.as_ref(),
4085 project_root.clone(),
4086 callgraph_dir.clone(),
4087 );
4088 return CallgraphStoreAccess::Ready(store);
4089 }
4090 Ok(None) => {
4091 if !self.callgraph_writer() {
4092 return CallgraphStoreAccess::Unavailable;
4093 }
4094 }
4095 Err(error) => {
4096 if !self.callgraph_writer() {
4097 return CallgraphStoreAccess::Unavailable;
4098 }
4099 crate::slog_warn!(
4100 "callgraph read-only open failed before writer promotion: {}",
4101 error
4102 );
4103 }
4104 }
4105 } else if !self.callgraph_writer() {
4106 return CallgraphStoreAccess::Unavailable;
4107 }
4108
4109 if self.semantic_cold_seed_active() {
4110 self.defer_callgraph_store_warm_for_semantic_cold_seed();
4111 return CallgraphStoreAccess::Building;
4112 }
4113
4114 let work = if let Some(force_token) = force_token {
4122 CallgraphBackgroundWork::ForceRebuild(force_token)
4123 } else {
4124 CallgraphBackgroundWork::Ensure
4125 };
4126 let _ = self.spawn_callgraph_store_cold_build(
4130 project_root.clone(),
4131 callgraph_dir.clone(),
4132 work,
4133 );
4134 }
4135
4136 if !wait.is_zero() {
4137 let (received, receiver_generation, receiver_epoch) = {
4138 let rx_ref = self.callgraph_store_rx.lock();
4139 let Some(rx) = rx_ref.as_ref() else {
4140 return CallgraphStoreAccess::Building;
4141 };
4142 (
4143 rx.recv_timeout(wait),
4144 self.callgraph_store_rx_generation(),
4145 self.callgraph_store_rx_epoch(),
4146 )
4147 };
4148 match received {
4149 Ok(CallGraphStoreBuildEvent::Ready {
4150 store,
4151 fulfilled_force_token,
4152 publication_epoch,
4153 }) => {
4154 if self.callgraph_persist_epoch_flag().current() != publication_epoch {
4155 drop(store);
4159 let _ = self.with_current_callgraph_store_rx(
4160 receiver_generation,
4161 receiver_epoch,
4162 |receiver| {
4163 *receiver = None;
4164 },
4165 );
4166 return CallgraphStoreAccess::Building;
4167 }
4168 remove_callgraph_pointer_before_inline_reopen_for_test(&callgraph_dir, &store);
4171 drop(store);
4172 let reopened =
4173 CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone());
4174 let mut pending = Vec::new();
4175 let outcome = self.with_current_callgraph_store_rx(
4176 receiver_generation,
4177 receiver_epoch,
4178 |receiver| {
4179 *receiver = None;
4180 match reopened {
4181 Ok(Some(store)) => {
4182 let ready = Arc::new(store);
4183 self.clear_callgraph_store_build_denied();
4184 *self
4185 .callgraph_store
4186 .write()
4187 .unwrap_or_else(std::sync::PoisonError::into_inner) =
4188 Some(Arc::clone(&ready));
4189 pending = self.take_pending_callgraph_store_paths();
4194 if let Some(force_token) = fulfilled_force_token {
4195 self.fulfill_callgraph_store_force_token(force_token);
4196 }
4197 CallgraphStoreAccess::Ready(ready)
4198 }
4199 Ok(None) => CallgraphStoreAccess::Building,
4200 Err(error) => CallgraphStoreAccess::Error(error),
4201 }
4202 },
4203 );
4204 let Some(outcome) = outcome else {
4205 return if self.subc_unbound_quiesced()
4206 || self.configure_generation() != receiver_generation
4207 {
4208 CallgraphStoreAccess::Unavailable
4209 } else {
4210 CallgraphStoreAccess::Building
4211 };
4212 };
4213 if !pending.is_empty() {
4214 let _ = self.enqueue_callgraph_store_refresh(pending);
4215 }
4216 if matches!(&outcome, CallgraphStoreAccess::Ready(_)) {
4217 let _ = self.request_tier2_refresh_pull();
4218 }
4219 return outcome;
4220 }
4221 Ok(CallGraphStoreBuildEvent::Suspended { suspension }) => {
4222 let suspended = self.with_current_callgraph_store_rx(
4223 receiver_generation,
4224 receiver_epoch,
4225 |receiver| {
4226 *receiver = None;
4227 self.record_callgraph_store_build_suspension(
4228 receiver_generation,
4229 suspension.clone(),
4230 );
4231 CallgraphStoreAccess::Suspended(suspension)
4232 },
4233 );
4234 return suspended.unwrap_or(CallgraphStoreAccess::Unavailable);
4235 }
4236 Ok(CallGraphStoreBuildEvent::Denied { reason }) => {
4237 let denied = self.with_current_callgraph_store_rx(
4238 receiver_generation,
4239 receiver_epoch,
4240 |receiver| {
4241 *receiver = None;
4242 self.record_callgraph_store_build_denied(
4243 receiver_generation,
4244 reason.clone(),
4245 );
4246 CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason))
4247 },
4248 );
4249 return denied.unwrap_or(CallgraphStoreAccess::Unavailable);
4250 }
4251 Ok(CallGraphStoreBuildEvent::Settled) => {
4252 let _ = self.with_current_callgraph_store_rx(
4253 receiver_generation,
4254 receiver_epoch,
4255 |receiver| *receiver = None,
4256 );
4257 return CallgraphStoreAccess::Building;
4258 }
4259 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
4260 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
4261 let _ = self.with_current_callgraph_store_rx(
4262 receiver_generation,
4263 receiver_epoch,
4264 |receiver| *receiver = None,
4265 );
4266 }
4267 }
4268 }
4269 CallgraphStoreAccess::Building
4270 }
4271
4272 fn schedule_legacy_callgraph_migration_if_needed(
4273 &self,
4274 store: &ReadonlyCallGraphStore,
4275 project_root: PathBuf,
4276 callgraph_dir: PathBuf,
4277 ) {
4278 if !store.is_legacy_fallback()
4279 || !self.callgraph_writer()
4280 || !self.heavy_root_work_allowed()
4281 {
4282 return;
4283 }
4284 if self.semantic_cold_seed_active() {
4285 self.defer_callgraph_store_warm_for_semantic_cold_seed();
4286 return;
4287 }
4288 let _ = self.spawn_callgraph_store_cold_build(
4289 project_root,
4290 callgraph_dir,
4291 CallgraphBackgroundWork::LegacyMigration,
4292 );
4293 }
4294
4295 fn configured_callgraph_keys(&self, current_root: &Path) -> BTreeSet<String> {
4296 let mut roots = self
4297 .configured_session_roots
4298 .lock()
4299 .iter()
4300 .map(|(root, _session)| root.clone())
4301 .collect::<BTreeSet<_>>();
4302 roots.insert(current_root.to_path_buf());
4303 roots
4304 .iter()
4305 .map(|root| self.memoized_artifact_cache_key(root))
4309 .collect()
4310 }
4311
4312 fn spawn_callgraph_store_cold_build(
4317 &self,
4318 project_root: PathBuf,
4319 callgraph_dir: PathBuf,
4320 work: CallgraphBackgroundWork,
4321 ) -> bool {
4322 if !self.heavy_root_work_allowed() || !self.callgraph_writer() {
4323 return false;
4324 }
4325 let generation = self.configure_generation();
4326 self.run_if_subc_bound_generation(generation, || {
4327 self.spawn_callgraph_store_cold_build_admitted(project_root, callgraph_dir, work)
4328 })
4329 .unwrap_or(false)
4330 }
4331
4332 fn spawn_callgraph_store_cold_build_admitted(
4334 &self,
4335 project_root: PathBuf,
4336 callgraph_dir: PathBuf,
4337 work: CallgraphBackgroundWork,
4338 ) -> bool {
4339 let session_id = crate::log_ctx::current_session();
4340 let chunk_size = self.config().callgraph_chunk_size;
4341 let build_generation = self.configure_generation();
4342 let generation_flag = self.configure_generation_flag();
4343 let configured_keys = self.configured_callgraph_keys(&project_root);
4344 let summary_logged = Arc::clone(&self.callgraph_legacy_migration_summary_logged);
4345
4346 let mut rx_guard = self.callgraph_store_rx.lock();
4347 if rx_guard.is_some() {
4348 return false;
4349 }
4350
4351 let limiter = self.cold_build_limiter();
4352 let request = crate::cold_build_limiter::ColdBuildAdmissionRequest::new(
4353 "callgraph-background",
4354 crate::cold_build_limiter::ColdBuildAdmissionClass::Maintenance,
4355 );
4356 let Some(permit) =
4357 crate::cold_build_limiter::try_acquire_classified_with_limiter(&limiter, &request)
4358 else {
4359 crate::slog_info!(
4360 "callgraph store background work deferred by cold build limit ({})",
4361 limiter.limit()
4362 );
4363 return false;
4364 };
4365
4366 let force_token = match work {
4367 CallgraphBackgroundWork::ForceRebuild(token) => Some(token),
4368 CallgraphBackgroundWork::Ensure | CallgraphBackgroundWork::LegacyMigration => None,
4369 };
4370 let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStoreBuildEvent>();
4371 self.note_callgraph_store_rx_generation(build_generation);
4372 self.next_callgraph_store_rx_epoch();
4373 *rx_guard = Some(rx);
4374 let persist_epoch = self.next_callgraph_persist_epoch();
4375 let persist_epoch_flag = self.callgraph_persist_epoch_flag();
4376
4377 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.fetch_add(1, Ordering::SeqCst);
4378
4379 std::thread::spawn(move || {
4380 let _permit = permit;
4381 let mut settlement = CallGraphStoreBuildSettlement::new(tx, force_token, persist_epoch);
4382 crate::log_ctx::with_session(session_id, || {
4383 wait_on_callgraph_build_start_gate(&project_root);
4384 if persist_epoch_flag.current() != persist_epoch {
4385 crate::slog_info!(
4386 "callgraph store background work skipped for superseded epoch {}",
4387 persist_epoch
4388 );
4389 return;
4390 }
4391 let built = crate::callgraph_store::with_publish_epoch(
4392 persist_epoch_flag,
4393 persist_epoch,
4394 || match work {
4395 CallgraphBackgroundWork::LegacyMigration => {
4396 CallGraphStore::migrate_legacy_with_lease(
4397 callgraph_dir.clone(),
4398 project_root.clone(),
4399 )
4400 }
4401 CallgraphBackgroundWork::ForceRebuild(_) => {
4402 let files = crate::callgraph::walk_project_files(&project_root)
4403 .collect::<Vec<_>>();
4404 CallGraphStore::force_cold_build_with_lease_chunked(
4405 callgraph_dir.clone(),
4406 project_root.clone(),
4407 &files,
4408 chunk_size,
4409 )
4410 .map(|(store, _)| Some(store))
4411 }
4412 CallgraphBackgroundWork::Ensure => {
4413 let files = crate::callgraph::walk_project_files(&project_root)
4414 .collect::<Vec<_>>();
4415 CallGraphStore::ensure_built_with_lease_chunked(
4416 callgraph_dir.clone(),
4417 project_root.clone(),
4418 &files,
4419 chunk_size,
4420 )
4421 .map(|(store, _)| Some(store))
4422 }
4423 },
4424 );
4425 match built {
4426 Ok(Some(store)) => {
4427 if store.is_legacy_migration() {
4428 match crate::callgraph_store::all_legacy_partitions_migrated_for_keys(
4429 &callgraph_dir,
4430 &configured_keys,
4431 ) {
4432 Ok(true)
4433 if summary_logged
4434 .compare_exchange(
4435 false,
4436 true,
4437 Ordering::SeqCst,
4438 Ordering::SeqCst,
4439 )
4440 .is_ok() =>
4441 {
4442 crate::slog_info!(
4443 "all legacy callgraph partitions migrated for configured roots"
4444 );
4445 }
4446 Ok(_) => {}
4447 Err(error) => crate::slog_warn!(
4448 "failed to inspect legacy callgraph migration completion: {}",
4449 error
4450 ),
4451 }
4452 }
4453 if generation_flag.load(Ordering::SeqCst) == build_generation {
4454 settlement.ready(store);
4455 } else {
4456 crate::slog_info!(
4457 "callgraph store warm build result discarded for stale generation {}",
4458 build_generation
4459 );
4460 }
4461 }
4462 Ok(None) => {}
4463 Err(crate::callgraph_store::CallGraphStoreError::Superseded) => {
4464 crate::slog_info!(
4465 "callgraph store disk publication skipped for superseded epoch {}",
4466 persist_epoch
4467 );
4468 }
4469 Err(crate::callgraph_store::CallGraphStoreError::Suspended(suspension)) => {
4470 crate::slog_warn!(
4471 "callgraph store background work suspended: {}",
4472 suspension.reason
4473 );
4474 settlement.suspended(suspension);
4475 }
4476 Err(crate::callgraph_store::CallGraphStoreError::Unavailable(reason))
4477 if reason.ends_with("could not acquire writer capability") =>
4478 {
4479 crate::slog_warn!(
4480 "callgraph store background work denied writer capability: {}",
4481 reason
4482 );
4483 settlement.denied(reason);
4484 }
4485 Err(error) => {
4486 crate::slog_warn!("callgraph store background work failed: {}", error);
4487 }
4488 }
4489 });
4490 });
4491 true
4492 }
4493
4494 pub fn callgraph_store_rx(
4497 &self,
4498 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>> {
4499 &self.callgraph_store_rx
4500 }
4501
4502 #[doc(hidden)]
4506 pub fn with_current_callgraph_store_rx<R>(
4507 &self,
4508 generation: u64,
4509 epoch: u64,
4510 action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>) -> R,
4511 ) -> Option<R> {
4512 self.run_if_subc_bound_generation(generation, || {
4513 let mut receiver = self.callgraph_store_rx.lock();
4514 if receiver.is_none()
4515 || self.callgraph_store_rx_generation() != generation
4516 || self.callgraph_store_rx_epoch() != epoch
4517 {
4518 return None;
4519 }
4520 Some(action(&mut receiver))
4521 })
4522 .flatten()
4523 }
4524
4525 pub(crate) fn retire_callgraph_store_rx(&self) {
4526 let mut receiver = self.callgraph_store_rx.lock();
4527 *receiver = None;
4528 self.next_callgraph_store_rx_epoch();
4529 }
4530
4531 pub(crate) fn note_callgraph_store_rx_generation(&self, generation: u64) {
4532 self.callgraph_store_rx_generation
4533 .store(generation, Ordering::SeqCst);
4534 }
4535
4536 #[doc(hidden)]
4537 pub fn callgraph_store_rx_generation(&self) -> u64 {
4538 self.callgraph_store_rx_generation.load(Ordering::SeqCst)
4539 }
4540
4541 pub(crate) fn next_callgraph_store_rx_epoch(&self) -> u64 {
4542 self.callgraph_store_rx_epoch
4543 .fetch_add(1, Ordering::SeqCst)
4544 .wrapping_add(1)
4545 }
4546
4547 #[doc(hidden)]
4548 pub fn callgraph_store_rx_epoch(&self) -> u64 {
4549 self.callgraph_store_rx_epoch.load(Ordering::SeqCst)
4550 }
4551
4552 pub(crate) fn next_callgraph_persist_epoch(&self) -> u64 {
4553 self.callgraph_persist_epoch.next()
4554 }
4555
4556 #[doc(hidden)]
4557 pub fn callgraph_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
4558 self.callgraph_persist_epoch.clone()
4559 }
4560
4561 pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
4564 where
4565 I: IntoIterator<Item = PathBuf>,
4566 {
4567 self.pending_callgraph_store_paths.lock().extend(paths);
4568 }
4569
4570 pub fn enqueue_callgraph_store_refresh<I>(&self, paths: I) -> bool
4571 where
4572 I: IntoIterator<Item = PathBuf>,
4573 {
4574 let generation = self.configure_generation();
4575 self.enqueue_callgraph_store_refresh_for_generation(paths, generation)
4576 }
4577
4578 pub(crate) fn enqueue_callgraph_store_refresh_for_generation<I>(
4579 &self,
4580 paths: I,
4581 generation: u64,
4582 ) -> bool
4583 where
4584 I: IntoIterator<Item = PathBuf>,
4585 {
4586 let paths = paths.into_iter().collect::<Vec<_>>();
4587 if paths.is_empty() {
4588 return true;
4589 }
4590 if !self.config().callgraph_store || !self.heavy_root_work_allowed() {
4593 return true;
4594 }
4595 self.run_if_subc_bound_generation(generation, || {
4596 if !self.callgraph_writer() {
4597 self.add_pending_callgraph_store_paths(paths);
4598 return false;
4599 }
4600 let Some(project_root) = self.callgraph_project_root() else {
4601 self.add_pending_callgraph_store_paths(paths);
4602 return false;
4603 };
4604
4605 let ticket = crate::callgraph_store::CallgraphRefreshTicket::new(
4610 self.subc_lifecycle_admission(),
4611 self.configure_generation_flag(),
4612 generation,
4613 self.callgraph_persist_epoch_flag(),
4614 self.callgraph_persist_epoch_flag().current(),
4615 );
4616 crate::callgraph_store::enqueue_callgraph_store_refresh_fenced_with_state(
4617 self.callgraph_store_dir(),
4618 project_root,
4619 paths,
4620 Arc::clone(&self.pending_callgraph_store_paths),
4621 crate::callgraph_store::CallgraphRefreshState::new(
4622 Arc::clone(&self.callgraph_store),
4623 Arc::clone(&self.heavy_root_work_allowed),
4624 ),
4625 ticket,
4626 )
4627 })
4628 .unwrap_or(false)
4629 }
4630
4631 pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
4639 let roots: Vec<PathBuf> = [
4640 self.canonical_cache_root_opt(),
4641 self.config().project_root.clone(),
4642 ]
4643 .into_iter()
4644 .flatten()
4645 .collect();
4646 std::mem::take(&mut *self.pending_callgraph_store_paths.lock())
4647 .into_iter()
4648 .filter(|path| {
4649 let in_root = pending_path_in_roots(path, &roots);
4650 if !in_root {
4651 crate::slog_debug!(
4652 "dropping pending callgraph path outside current root: {}",
4653 path.display()
4654 );
4655 }
4656 in_root
4657 })
4658 .collect()
4659 }
4660
4661 pub fn search_index(&self) -> &RwLock<Option<SearchIndex>> {
4663 &self.search_index
4664 }
4665
4666 pub fn search_index_rx(&self) -> &RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>> {
4668 &self.search_index_rx
4669 }
4670
4671 pub(crate) fn install_search_index_rx(
4672 &self,
4673 receiver: crossbeam_channel::Receiver<SearchIndex>,
4674 generation: u64,
4675 ) -> u64 {
4676 let mut slot = self
4677 .search_index_rx
4678 .write()
4679 .unwrap_or_else(std::sync::PoisonError::into_inner);
4680 self.note_search_index_rx_generation(generation);
4681 let epoch = self.next_search_index_rx_epoch();
4682 *slot = Some(receiver);
4683 epoch
4684 }
4685
4686 pub(crate) fn search_index_rx_terminal_guard(&self, epoch: u64) -> ReceiverTerminalGuard {
4687 ReceiverTerminalGuard::new(Arc::clone(&self.search_index_rx_terminal_epoch), epoch)
4688 }
4689
4690 pub(crate) fn with_current_search_index_rx<R>(
4693 &self,
4694 generation: u64,
4695 epoch: u64,
4696 action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<SearchIndex>>) -> R,
4697 ) -> Option<R> {
4698 self.run_if_subc_bound_generation(generation, || {
4699 let mut receiver = self
4700 .search_index_rx
4701 .write()
4702 .unwrap_or_else(std::sync::PoisonError::into_inner);
4703 if receiver.is_none()
4704 || self.search_index_rx_generation() != generation
4705 || self.search_index_rx_epoch() != epoch
4706 {
4707 return None;
4708 }
4709 Some(action(&mut receiver))
4710 })
4711 .flatten()
4712 }
4713
4714 pub(crate) fn retire_search_index_rx(&self) {
4715 let mut receiver = self
4716 .search_index_rx
4717 .write()
4718 .unwrap_or_else(std::sync::PoisonError::into_inner);
4719 *receiver = None;
4720 self.next_search_index_rx_epoch();
4721 }
4722
4723 pub(crate) fn note_search_index_rx_generation(&self, generation: u64) {
4724 self.search_index_rx_generation
4725 .store(generation, Ordering::SeqCst);
4726 }
4727
4728 pub(crate) fn search_index_rx_generation(&self) -> u64 {
4729 self.search_index_rx_generation.load(Ordering::SeqCst)
4730 }
4731
4732 pub(crate) fn next_search_index_rx_epoch(&self) -> u64 {
4733 self.search_index_rx_epoch
4734 .fetch_add(1, Ordering::SeqCst)
4735 .wrapping_add(1)
4736 }
4737
4738 pub(crate) fn search_index_rx_epoch(&self) -> u64 {
4739 self.search_index_rx_epoch.load(Ordering::SeqCst)
4740 }
4741
4742 pub(crate) fn allow_search_index_disconnect_reschedule(&self) -> bool {
4749 const MAX_REPLACEMENTS_PER_GENERATION: u32 = 1;
4750 let generation = self.configure_generation();
4751 let mut state = self.search_index_disconnect_reschedule.lock();
4752 if state.0 != generation {
4753 *state = (generation, 0);
4754 }
4755 if state.1 >= MAX_REPLACEMENTS_PER_GENERATION {
4756 return false;
4757 }
4758 state.1 += 1;
4759 true
4760 }
4761
4762 pub(crate) fn next_search_persist_epoch(&self) -> u64 {
4763 self.search_persist_epoch.next()
4764 }
4765
4766 pub(crate) fn search_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
4767 self.search_persist_epoch.clone()
4768 }
4769
4770 pub fn add_pending_search_index_paths<I>(&self, paths: I)
4771 where
4772 I: IntoIterator<Item = PathBuf>,
4773 {
4774 let paths = paths.into_iter().collect::<Vec<_>>();
4775 if !paths.is_empty() {
4776 self.invalidate_warm_verify_memo();
4777 self.pending_search_index_paths.lock().extend(paths);
4778 }
4779 }
4780
4781 pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
4782 std::mem::take(&mut *self.pending_search_index_paths.lock())
4783 .into_iter()
4784 .collect()
4785 }
4786
4787 pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
4788 where
4789 I: IntoIterator<Item = PathBuf>,
4790 {
4791 let paths = paths.into_iter().collect::<Vec<_>>();
4792 if !paths.is_empty() {
4793 self.invalidate_warm_verify_memo();
4794 self.pending_semantic_index_paths.lock().extend(paths);
4795 }
4796 }
4797
4798 pub(crate) fn invalidate_warm_verify_memo(&self) {
4799 if let Some(root) = self.canonical_cache_root_opt() {
4800 crate::cache_freshness::invalidate_verify_memo(&root);
4801 }
4802 }
4803
4804 pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
4805 std::mem::take(&mut *self.pending_semantic_index_paths.lock())
4806 .into_iter()
4807 .collect()
4808 }
4809
4810 pub fn mark_pending_semantic_corpus_refresh(&self) {
4811 *self.pending_semantic_corpus_refresh.lock() = true;
4812 }
4813
4814 pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
4815 std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock())
4816 }
4817
4818 pub fn clear_pending_index_updates(&self) {
4819 self.pending_search_index_paths.lock().clear();
4820 self.pending_callgraph_store_paths.lock().clear();
4821 self.pending_tier2_paths.lock().clear();
4822 self.pending_semantic_index_paths.lock().clear();
4823 *self.pending_semantic_corpus_refresh.lock() = false;
4824 }
4825
4826 pub(crate) fn take_pending_reconciliation_state(&self) -> PendingReconciliationState {
4834 PendingReconciliationState {
4835 search: std::mem::take(&mut *self.pending_search_index_paths.lock()),
4836 callgraph: std::mem::take(&mut *self.pending_callgraph_store_paths.lock()),
4837 tier2: std::mem::take(&mut *self.pending_tier2_paths.lock()),
4838 semantic: std::mem::take(&mut *self.pending_semantic_index_paths.lock()),
4839 corpus_refresh: std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock()),
4840 }
4841 }
4842
4843 pub(crate) fn restore_pending_reconciliation_state(&self, state: PendingReconciliationState) {
4844 self.pending_search_index_paths.lock().extend(state.search);
4845 self.pending_callgraph_store_paths
4846 .lock()
4847 .extend(state.callgraph);
4848 self.pending_tier2_paths.lock().extend(state.tier2);
4849 self.pending_semantic_index_paths
4850 .lock()
4851 .extend(state.semantic);
4852 if state.corpus_refresh {
4853 *self.pending_semantic_corpus_refresh.lock() = true;
4854 }
4855 }
4856
4857 pub(crate) fn cancel_unbound_artifact_work(&self) {
4871 let search_refresh_cancelled = self
4877 .search_index_rx
4878 .read()
4879 .unwrap_or_else(std::sync::PoisonError::into_inner)
4880 .is_some();
4881 self.retire_search_index_rx();
4882 if search_refresh_cancelled {
4883 let mut resident = self
4884 .search_index
4885 .write()
4886 .unwrap_or_else(std::sync::PoisonError::into_inner);
4887 if resident.as_ref().is_some_and(|index| !index.ready) {
4888 *resident = None;
4889 }
4890 }
4891 self.retire_callgraph_store_rx();
4892 let semantic_cancelled = self.semantic_index_rx.lock().is_some();
4893 self.retire_semantic_index_rx();
4894 let semantic_refresh_cancelled = self.semantic_refresh_event_rx.lock().is_some();
4895 self.clear_semantic_refresh_worker();
4896 self.reset_semantic_cold_seed_gate_for_configure();
4897 let _ = self.inspect_manager.discard_completions();
4898 let _ = self.take_new_reuse_completions();
4899 if semantic_cancelled || semantic_refresh_cancelled {
4900 let has_index = self
4901 .semantic_index
4902 .read()
4903 .unwrap_or_else(std::sync::PoisonError::into_inner)
4904 .is_some();
4905 {
4909 let mut status = self
4910 .semantic_index_status
4911 .write()
4912 .unwrap_or_else(std::sync::PoisonError::into_inner);
4913 let refreshing = status.take_refreshing_files();
4914 if !refreshing.is_empty() {
4915 self.pending_semantic_index_paths.lock().extend(refreshing);
4916 }
4917 if status.corpus_refresh_in_flight() {
4918 *self.pending_semantic_corpus_refresh.lock() = true;
4919 }
4920 *status = if has_index {
4921 SemanticIndexStatus::ready()
4922 } else {
4923 SemanticIndexStatus::Disabled
4924 };
4925 }
4926 }
4927 }
4928
4929 pub(crate) fn invalidate_artifacts_after_watcher_gap(&self) {
4933 self.next_search_persist_epoch();
4934 self.next_semantic_persist_epoch();
4935 self.next_callgraph_persist_epoch();
4936
4937 self.search_index
4938 .write()
4939 .unwrap_or_else(std::sync::PoisonError::into_inner)
4940 .take();
4941 self.semantic_index
4942 .write()
4943 .unwrap_or_else(std::sync::PoisonError::into_inner)
4944 .take();
4945 self.callgraph_store
4946 .write()
4947 .unwrap_or_else(std::sync::PoisonError::into_inner)
4948 .take();
4949 *self
4955 .semantic_index_status
4956 .write()
4957 .unwrap_or_else(std::sync::PoisonError::into_inner) = if self.config().semantic_search {
4958 SemanticIndexStatus::ready()
4959 } else {
4960 SemanticIndexStatus::Disabled
4961 };
4962 if self.callgraph_writer() {
4966 self.mark_callgraph_store_force_rebuild();
4967 }
4968
4969 if let Some(root) = self
4970 .canonical_cache_root_opt()
4971 .or_else(|| self.config().project_root.clone())
4972 {
4973 crate::cache_freshness::invalidate_verify_memo_strict(&root);
4974 }
4975 self.borrowed_index_cache.lock().clear();
4976 self.inspect_manager.evict_idle_caches();
4977 self.reset_symbol_cache();
4978 self.clear_tsconfig_membership_cache();
4979 }
4980
4981 fn drain_search_index_events_for_graceful_shutdown(&self) {
4982 crate::runtime_drain::drain_watcher_events(self);
4983 crate::runtime_drain::drain_search_index_events(self);
4984 }
4985
4986 fn search_index_build_in_progress(&self) -> bool {
4987 self.search_index_rx()
4988 .read()
4989 .unwrap_or_else(std::sync::PoisonError::into_inner)
4990 .is_some()
4991 }
4992
4993 fn wait_for_search_index_build_to_settle_on_graceful_shutdown(&self) {
4997 crate::runtime_drain::note_search_rebuild_shutdown_wait_for_test();
4998 let deadline = Instant::now() + GRACEFUL_SHUTDOWN_SEARCH_BUILD_WAIT;
4999 while self.search_index_build_in_progress() && Instant::now() < deadline {
5000 let remaining = deadline.saturating_duration_since(Instant::now());
5001 std::thread::sleep(remaining.min(GRACEFUL_SHUTDOWN_SEARCH_BUILD_POLL));
5002 self.drain_search_index_events_for_graceful_shutdown();
5003 }
5004 }
5005
5006 #[doc(hidden)]
5013 pub fn flush_search_index_on_graceful_shutdown(&self) -> bool {
5014 if self.shared_artifacts_read_only() {
5015 return false;
5016 }
5017
5018 self.drain_search_index_events_for_graceful_shutdown();
5019 if self.search_index_build_in_progress() {
5020 self.wait_for_search_index_build_to_settle_on_graceful_shutdown();
5021 self.drain_search_index_events_for_graceful_shutdown();
5022 }
5023
5024 if self.search_index_build_in_progress() {
5025 return false;
5026 }
5027
5028 let Some(canonical_root) = self.canonical_cache_root_opt() else {
5029 return false;
5030 };
5031 let config = self.config();
5032 let project_key = self.memoized_artifact_cache_key(&canonical_root);
5033 let cache_dir = crate::search_index::resolve_cache_dir_with_key(
5034 &project_key,
5035 config.storage_dir.as_deref(),
5036 );
5037
5038 {
5039 let search_index = self
5040 .search_index()
5041 .read()
5042 .unwrap_or_else(std::sync::PoisonError::into_inner);
5043 let Some(index) = search_index.as_ref() else {
5044 return false;
5045 };
5046 if !index.ready || !index.has_pending_disk_changes() {
5047 return false;
5048 }
5049 }
5050
5051 let _cache_lock = match crate::search_index::CacheLock::try_acquire_for_shutdown(
5052 &cache_dir,
5053 &canonical_root,
5054 ) {
5055 Ok(lock) => lock,
5056 Err(error) => {
5057 crate::slog_warn!(
5058 "search index: skipped shutdown flush because cache lock was unavailable: {}",
5059 error
5060 );
5061 return false;
5062 }
5063 };
5064
5065 let mut search_index = self
5066 .search_index()
5067 .write()
5068 .unwrap_or_else(std::sync::PoisonError::into_inner);
5069 let Some(index) = search_index.as_mut() else {
5070 return false;
5071 };
5072 if !index.ready || !index.has_pending_disk_changes() {
5073 return false;
5074 }
5075
5076 let git_head = index.stored_git_head().map(str::to_owned);
5077 index.write_to_disk(&cache_dir, git_head.as_deref())
5078 }
5079
5080 pub fn inspect_manager(&self) -> Arc<InspectManager> {
5081 Arc::clone(&self.inspect_manager)
5082 }
5083
5084 pub(crate) fn set_standing_artifact_exempt(&self, exempt: bool) {
5087 self.standing_artifact_exempt
5088 .store(exempt, Ordering::Release);
5089 }
5090
5091 pub(crate) fn cold_build_limiter(&self) -> Arc<crate::cold_build_limiter::ColdBuildLimiter> {
5092 Arc::clone(
5093 &self
5094 .cold_build_limiter
5095 .read()
5096 .unwrap_or_else(std::sync::PoisonError::into_inner),
5097 )
5098 }
5099
5100 #[doc(hidden)]
5103 pub fn isolate_cold_build_limiter_for_test(&self, limit: usize) {
5104 let limiter = crate::cold_build_limiter::isolated_limiter(limit);
5105 self.inspect_manager
5106 .set_cold_build_limiter(Arc::clone(&limiter));
5107 *self
5108 .cold_build_limiter
5109 .write()
5110 .unwrap_or_else(std::sync::PoisonError::into_inner) = limiter;
5111 }
5112
5113 pub fn add_pending_tier2_paths<I>(&self, paths: I)
5114 where
5115 I: IntoIterator<Item = PathBuf>,
5116 {
5117 self.pending_tier2_paths.lock().extend(paths);
5118 }
5119
5120 pub fn pending_tier2_paths(&self) -> Vec<PathBuf> {
5121 self.pending_tier2_paths.lock().iter().cloned().collect()
5122 }
5123
5124 pub fn remove_pending_tier2_paths<I>(&self, paths: I)
5125 where
5126 I: IntoIterator<Item = PathBuf>,
5127 {
5128 let mut pending = self.pending_tier2_paths.lock();
5129 for path in paths {
5130 pending.remove(&path);
5131 }
5132 }
5133
5134 pub fn has_new_reuse_completions(&self) -> bool {
5142 self.inspect_manager.reuse_completion_count()
5143 != self.last_seen_reuse_completions.load(Ordering::SeqCst)
5144 }
5145
5146 pub fn take_new_reuse_completions(&self) -> bool {
5147 let current = self.inspect_manager.reuse_completion_count();
5148 let previous = self
5149 .last_seen_reuse_completions
5150 .swap(current, Ordering::SeqCst);
5151 current != previous
5152 }
5153
5154 pub fn reset_tier2_refresh_scheduler(&self) {
5155 self.reset_tier2_refresh_scheduler_at(Instant::now());
5156 }
5157
5158 #[doc(hidden)]
5159 pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
5160 self.tier2_refresh_scheduler
5161 .lock()
5162 .reset_after_configure(now);
5163 }
5164
5165 pub fn request_tier2_refresh_pull(&self) -> bool {
5166 let can_schedule = self.inspect_writer()
5167 && self.heavy_root_work_allowed()
5168 && self.inspect_manager.automatic_tier2_refresh_allowed();
5169 self.tier2_refresh_scheduler
5170 .lock()
5171 .request_pull(can_schedule)
5172 }
5173
5174 pub fn tick_tier2_refresh_scheduler(
5175 &self,
5176 changed_path_count: usize,
5177 ) -> Option<Tier2TriggerReason> {
5178 self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
5179 }
5180
5181 #[doc(hidden)]
5182 pub fn tick_tier2_refresh_scheduler_at(
5183 &self,
5184 now: Instant,
5185 changed_path_count: usize,
5186 ) -> Option<Tier2TriggerReason> {
5187 let manager = self.inspect_manager();
5188 let can_write = self.inspect_writer()
5189 && self.heavy_root_work_allowed()
5190 && manager.automatic_tier2_refresh_allowed();
5191 let in_flight = manager.tier2_any_in_flight();
5192 let semantic_cold_seed_active = self.semantic_cold_seed_active();
5193 let decision = self.tier2_refresh_scheduler.lock().tick_with_semantic_gate(
5194 now,
5195 changed_path_count,
5196 can_write,
5197 in_flight,
5198 semantic_cold_seed_active,
5199 );
5200
5201 if let Some(reason) = decision {
5202 self.start_tier2_refresh(reason, manager);
5203 }
5204
5205 decision
5206 }
5207
5208 pub fn note_tier2_refresh_started(&self) {
5209 self.note_tier2_refresh_started_at(Instant::now());
5210 }
5211
5212 #[doc(hidden)]
5213 pub fn note_tier2_refresh_started_at(&self, now: Instant) {
5214 self.tier2_refresh_scheduler
5215 .lock()
5216 .note_external_scan_started(now);
5217 }
5218
5219 pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
5220 self.tier2_refresh_scheduler
5221 .lock()
5222 .last_trigger_reason()
5223 .map(Tier2TriggerReason::as_str)
5224 }
5225
5226 #[doc(hidden)]
5227 pub fn tier2_pull_demand_pending(&self) -> bool {
5228 self.tier2_refresh_scheduler.lock().pull_demand_pending()
5229 }
5230
5231 fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
5232 let generation = self.configure_generation();
5233 if !self.inspect_writer()
5234 || !self.heavy_root_work_allowed()
5235 || !manager.automatic_tier2_refresh_allowed()
5236 || !self.config().inspect.enabled
5237 {
5238 return;
5239 }
5240 let _ = self.run_if_subc_bound_generation(generation, || {
5241 self.start_tier2_refresh_admitted(reason, manager);
5242 });
5243 }
5244
5245 fn start_tier2_refresh_admitted(
5246 &self,
5247 reason: Tier2TriggerReason,
5248 manager: Arc<InspectManager>,
5249 ) {
5250 let Some(snapshot) = self.tier2_refresh_snapshot() else {
5251 return;
5252 };
5253 let categories = Self::automatic_tier2_refresh_categories(&snapshot);
5254 let submission =
5255 manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
5256 if !submission.deferred_categories.is_empty() {
5257 self.tier2_refresh_scheduler.lock().note_dispatch_deferred();
5258 crate::slog_info!(
5259 "tier2 refresh deferred by cold build limit: categories={:?}",
5260 submission
5261 .deferred_categories
5262 .iter()
5263 .map(|category| category.as_str())
5264 .collect::<Vec<_>>()
5265 );
5266 }
5267 if submission.has_new_work() {
5268 crate::slog_info!(
5269 "tier2 refresh scheduled: reason={}, categories={:?}",
5270 reason.as_str(),
5271 submission
5272 .newly_queued_categories
5273 .iter()
5274 .map(|category| category.as_str())
5275 .collect::<Vec<_>>()
5276 );
5277 }
5278 for error in submission.errors {
5279 crate::slog_warn!(
5280 "tier2 refresh schedule failed for {}: {}",
5281 error.category,
5282 error.message
5283 );
5284 }
5285 }
5286
5287 fn automatic_tier2_refresh_categories(snapshot: &InspectSnapshot) -> Vec<InspectCategory> {
5288 let callgraph_store_enabled = snapshot.config.callgraph_store;
5289 InspectCategory::active()
5290 .iter()
5291 .copied()
5292 .filter(|category| category.is_tier2())
5293 .filter(|category| {
5294 if *category == InspectCategory::DeadCode && !callgraph_store_enabled {
5295 return false;
5299 }
5300 true
5301 })
5302 .collect()
5303 }
5304
5305 #[doc(hidden)]
5306 pub fn automatic_tier2_refresh_categories_for_test(&self) -> Vec<InspectCategory> {
5307 self.tier2_refresh_snapshot()
5308 .map(|snapshot| Self::automatic_tier2_refresh_categories(&snapshot))
5309 .unwrap_or_default()
5310 }
5311
5312 fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
5313 self.harness_opt()?;
5314 let config = self.config();
5315 let project_root = config
5316 .project_root
5317 .clone()
5318 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
5319 let project_root = crate::inspect::job::canonicalize_normalized(&project_root);
5323 Some(InspectSnapshot::new_with_capabilities(
5324 project_root,
5325 self.inspect_dir(),
5326 config,
5327 self.symbol_cache(),
5328 self.inspect_writer(),
5329 self.callgraph_writer(),
5330 ))
5331 }
5332
5333 pub fn symbol_cache(&self) -> SharedSymbolCache {
5335 Arc::clone(&self.symbol_cache)
5336 }
5337
5338 pub fn reset_symbol_cache(&self) -> u64 {
5340 self.symbol_cache
5341 .write()
5342 .map(|mut cache| cache.reset())
5343 .unwrap_or(0)
5344 }
5345
5346 pub fn semantic_index(&self) -> &RwLock<Option<SemanticIndex>> {
5348 &self.semantic_index
5349 }
5350
5351 pub fn semantic_index_rx(
5353 &self,
5354 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
5355 &self.semantic_index_rx
5356 }
5357
5358 pub(crate) fn install_semantic_index_rx(
5359 &self,
5360 receiver: crossbeam_channel::Receiver<SemanticIndexEvent>,
5361 generation: u64,
5362 ) -> u64 {
5363 let mut slot = self.semantic_index_rx.lock();
5364 self.note_semantic_index_rx_generation(generation);
5365 let epoch = self.next_semantic_index_rx_epoch();
5366 *slot = Some(receiver);
5367 epoch
5368 }
5369
5370 pub(crate) fn semantic_index_rx_terminal_guard(&self, epoch: u64) -> ReceiverTerminalGuard {
5371 ReceiverTerminalGuard::new(Arc::clone(&self.semantic_index_rx_terminal_epoch), epoch)
5372 }
5373
5374 pub(crate) fn with_current_semantic_index_rx<R>(
5377 &self,
5378 generation: u64,
5379 epoch: u64,
5380 action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<SemanticIndexEvent>>) -> R,
5381 ) -> Option<R> {
5382 self.run_if_subc_bound_generation(generation, || {
5383 let mut receiver = self.semantic_index_rx.lock();
5384 if receiver.is_none()
5385 || self.semantic_index_rx_generation() != generation
5386 || self.semantic_index_rx_epoch() != epoch
5387 {
5388 return None;
5389 }
5390 Some(action(&mut receiver))
5391 })
5392 .flatten()
5393 }
5394
5395 pub(crate) fn retire_semantic_index_rx(&self) {
5396 let mut receiver = self.semantic_index_rx.lock();
5397 *receiver = None;
5398 self.next_semantic_index_rx_epoch();
5399 }
5400
5401 pub(crate) fn retire_semantic_index_rx_if_epoch(&self, expected_epoch: u64) -> Option<bool> {
5405 let mut receiver = self.semantic_index_rx.lock();
5406 if self.semantic_index_rx_epoch() != expected_epoch {
5407 return None;
5408 }
5409 let retired = receiver.take().is_some();
5410 if retired {
5411 self.next_semantic_index_rx_epoch();
5412 }
5413 Some(retired)
5414 }
5415
5416 pub(crate) fn note_semantic_index_rx_generation(&self, generation: u64) {
5417 self.semantic_index_rx_generation
5418 .store(generation, Ordering::SeqCst);
5419 }
5420
5421 pub(crate) fn semantic_index_rx_generation(&self) -> u64 {
5422 self.semantic_index_rx_generation.load(Ordering::SeqCst)
5423 }
5424
5425 pub(crate) fn next_semantic_index_rx_epoch(&self) -> u64 {
5426 self.semantic_index_rx_epoch
5427 .fetch_add(1, Ordering::SeqCst)
5428 .wrapping_add(1)
5429 }
5430
5431 pub(crate) fn semantic_index_rx_epoch(&self) -> u64 {
5432 self.semantic_index_rx_epoch.load(Ordering::SeqCst)
5433 }
5434
5435 pub(crate) fn next_semantic_persist_epoch(&self) -> u64 {
5436 self.semantic_persist_epoch.next()
5437 }
5438
5439 pub(crate) fn semantic_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
5440 self.semantic_persist_epoch.clone()
5441 }
5442
5443 pub(crate) fn semantic_persist_lock(&self) -> Arc<parking_lot::Mutex<()>> {
5444 Arc::clone(&self.semantic_persist_lock)
5445 }
5446
5447 pub fn semantic_index_status(&self) -> &RwLock<SemanticIndexStatus> {
5448 &self.semantic_index_status
5449 }
5450
5451 pub(crate) fn artifact_reload_guard(&self) -> parking_lot::MutexGuard<'_, ()> {
5452 self.artifact_reload_lock.lock()
5453 }
5454
5455 pub fn reset_semantic_cold_seed_gate_for_configure(&self) -> u64 {
5458 self.semantic_cold_seed_active
5459 .store(false, Ordering::SeqCst);
5460 self.semantic_callgraph_warm_deferred
5461 .store(false, Ordering::SeqCst);
5462 self.semantic_cold_seed_generation
5463 .fetch_add(1, Ordering::SeqCst)
5464 .wrapping_add(1)
5465 }
5466
5467 pub fn semantic_cold_seed_active_flag(&self) -> Arc<AtomicBool> {
5468 Arc::clone(&self.semantic_cold_seed_active)
5469 }
5470
5471 pub fn semantic_cold_seed_generation_flag(&self) -> Arc<AtomicU64> {
5472 Arc::clone(&self.semantic_cold_seed_generation)
5473 }
5474
5475 pub fn semantic_cold_seed_generation(&self) -> u64 {
5476 self.semantic_cold_seed_generation.load(Ordering::SeqCst)
5477 }
5478
5479 pub fn semantic_cold_seed_active(&self) -> bool {
5480 self.semantic_cold_seed_active.load(Ordering::SeqCst)
5481 }
5482
5483 pub fn schedule_semantic_cold_seed_gate_for_configure(&self) {
5484 self.semantic_cold_seed_active.store(true, Ordering::SeqCst);
5485 }
5486
5487 pub fn defer_callgraph_store_warm_for_semantic_cold_seed(&self) {
5488 self.semantic_callgraph_warm_deferred
5489 .store(true, Ordering::SeqCst);
5490 }
5491
5492 fn semantic_callgraph_warm_deferred(&self) -> bool {
5493 self.semantic_callgraph_warm_deferred.load(Ordering::SeqCst)
5494 }
5495
5496 pub fn clear_semantic_cold_seed_gate_and_resume_deferred_work(&self) {
5500 self.resume_semantic_cold_seed_deferred_work(false);
5501 }
5502
5503 pub fn resume_deferred_work_after_semantic_cold_seed_gate_cleared(&self) {
5506 self.resume_semantic_cold_seed_deferred_work(true);
5507 }
5508
5509 pub(crate) fn take_semantic_cold_seed_resume(&self, force: bool) -> SemanticColdSeedResume {
5510 let was_active = self.semantic_cold_seed_active.swap(false, Ordering::SeqCst);
5511 let warm_callgraph = self
5512 .semantic_callgraph_warm_deferred
5513 .swap(false, Ordering::SeqCst);
5514 SemanticColdSeedResume {
5515 request_tier2: force || was_active || warm_callgraph,
5516 warm_callgraph,
5517 }
5518 }
5519
5520 pub(crate) fn apply_semantic_cold_seed_resume(&self, resume: SemanticColdSeedResume) {
5521 if resume.request_tier2 {
5522 let _ = self.request_tier2_refresh_pull();
5523 }
5524
5525 if !resume.warm_callgraph
5526 || !self.config().callgraph_store
5527 || !self.heavy_root_work_allowed()
5528 {
5529 return;
5530 }
5531
5532 match self.schedule_callgraph_store_warm() {
5533 CallgraphStoreAccess::Ready(_) => {
5534 crate::slog_debug!(
5535 "deferred callgraph store warm completed after semantic cold seed gate cleared"
5536 );
5537 }
5538 CallgraphStoreAccess::Building => {
5539 crate::slog_info!(
5540 "deferred callgraph store warm scheduled after semantic cold seed gate cleared"
5541 );
5542 }
5543 CallgraphStoreAccess::Suspended(suspension) => {
5544 crate::slog_warn!(
5545 "deferred callgraph store warm suspended for {} after {} deaths",
5546 suspension.domain.as_str(),
5547 suspension.death_count
5548 );
5549 }
5550 CallgraphStoreAccess::Unavailable => {
5551 crate::slog_info!(
5552 "deferred callgraph store warm unavailable after semantic cold seed gate cleared"
5553 );
5554 }
5555 CallgraphStoreAccess::Error(error) => {
5556 crate::slog_warn!(
5557 "deferred callgraph store warm failed after semantic cold seed gate cleared: {}",
5558 error
5559 );
5560 }
5561 }
5562 }
5563
5564 fn resume_semantic_cold_seed_deferred_work(&self, force: bool) {
5565 let resume = self.take_semantic_cold_seed_resume(force);
5566 self.apply_semantic_cold_seed_resume(resume);
5567 }
5568
5569 #[doc(hidden)]
5570 pub fn set_semantic_cold_seed_active_for_test(&self, active: bool) {
5571 self.semantic_cold_seed_active
5572 .store(active, Ordering::SeqCst);
5573 }
5574
5575 #[doc(hidden)]
5576 pub fn semantic_callgraph_warm_deferred_for_test(&self) -> bool {
5577 self.semantic_callgraph_warm_deferred()
5578 }
5579
5580 pub fn install_semantic_refresh_worker(
5581 &self,
5582 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
5583 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
5584 worker_slot: SemanticRefreshWorkerSlot,
5585 ) {
5586 self.install_semantic_refresh_worker_for_build_epoch(
5587 sender,
5588 event_rx,
5589 worker_slot,
5590 self.semantic_index_rx_epoch(),
5591 );
5592 }
5593
5594 pub(crate) fn install_semantic_refresh_worker_for_build_epoch(
5595 &self,
5596 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
5597 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
5598 worker_slot: SemanticRefreshWorkerSlot,
5599 build_epoch: u64,
5600 ) {
5601 self.clear_semantic_refresh_worker();
5602 {
5603 let mut receiver = self.semantic_refresh_event_rx.lock();
5604 let mut request = self.semantic_refresh_tx.lock();
5605 let mut worker = self.semantic_refresh_worker.lock();
5606 self.semantic_refresh_generation
5607 .store(self.configure_generation(), Ordering::SeqCst);
5608 self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
5609 self.semantic_refresh_build_epoch
5610 .store(build_epoch, Ordering::SeqCst);
5611 *receiver = Some(event_rx);
5612 *request = Some(sender);
5613 *worker = Some(worker_slot);
5614 }
5615 }
5616
5617 pub(crate) fn semantic_refresh_generation(&self) -> u64 {
5618 self.semantic_refresh_generation.load(Ordering::SeqCst)
5619 }
5620
5621 pub(crate) fn semantic_refresh_epoch(&self) -> u64 {
5622 self.semantic_refresh_epoch.load(Ordering::SeqCst)
5623 }
5624
5625 pub(crate) fn with_current_semantic_refresh_rx<R>(
5628 &self,
5629 generation: u64,
5630 epoch: u64,
5631 action: impl FnOnce() -> R,
5632 ) -> Option<R> {
5633 self.run_if_subc_bound_generation(generation, || {
5634 let receiver = self.semantic_refresh_event_rx.lock();
5635 if receiver.is_none()
5636 || self.semantic_refresh_generation() != generation
5637 || self.semantic_refresh_epoch() != epoch
5638 {
5639 return None;
5640 }
5641 Some(action())
5642 })
5643 .flatten()
5644 }
5645
5646 pub(crate) fn clear_semantic_refresh_worker_if_current(
5647 &self,
5648 generation: u64,
5649 epoch: u64,
5650 ) -> Option<u64> {
5651 let worker_slot = {
5652 let mut receiver = self.semantic_refresh_event_rx.lock();
5653 if receiver.is_none()
5654 || self.semantic_refresh_generation() != generation
5655 || self.semantic_refresh_epoch() != epoch
5656 {
5657 return None;
5658 }
5659 let disconnected_build_epoch = self.semantic_refresh_build_epoch.load(Ordering::SeqCst);
5660 self.semantic_refresh_build_epoch.store(0, Ordering::SeqCst);
5661 let mut request = self.semantic_refresh_tx.lock();
5662 let mut worker = self.semantic_refresh_worker.lock();
5663 *receiver = None;
5664 *request = None;
5665 self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
5666 self.invalidate_semantic_refresh_probe();
5667 (worker.take(), disconnected_build_epoch)
5668 };
5669 if let Some(worker_slot) = worker_slot.0 {
5670 if let Ok(mut handle) = worker_slot.lock() {
5671 drop(handle.take());
5672 }
5673 }
5674 Some(worker_slot.1)
5675 }
5676
5677 pub fn clear_semantic_refresh_worker(&self) {
5678 let worker_slot = {
5679 let mut receiver = self.semantic_refresh_event_rx.lock();
5680 let mut request = self.semantic_refresh_tx.lock();
5681 let mut worker = self.semantic_refresh_worker.lock();
5682 *receiver = None;
5683 *request = None;
5684 self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
5685 self.semantic_refresh_build_epoch.store(0, Ordering::SeqCst);
5686 self.invalidate_semantic_refresh_probe();
5687 worker.take()
5688 };
5689 if let Some(worker_slot) = worker_slot {
5690 if let Ok(mut handle) = worker_slot.lock() {
5691 drop(handle.take());
5692 }
5693 }
5694 }
5695
5696 pub fn semantic_refresh_sender(
5697 &self,
5698 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
5699 self.semantic_refresh_tx.lock().clone()
5700 }
5701
5702 pub(crate) fn semantic_refresh_retry_slots(
5703 &self,
5704 ) -> (
5705 Arc<parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>>,
5706 Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>,
5707 ) {
5708 (
5709 Arc::clone(&self.semantic_refresh_tx),
5710 Arc::clone(&self.pending_semantic_index_paths),
5711 )
5712 }
5713
5714 pub fn semantic_refresh_event_rx(
5715 &self,
5716 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
5717 &self.semantic_refresh_event_rx
5718 }
5719
5720 pub fn with_semantic_refresh_retry_attempts_mut<R>(
5721 &self,
5722 f: impl FnOnce(&mut BTreeMap<PathBuf, usize>) -> R,
5723 ) -> R {
5724 let mut attempts = self.semantic_refresh_retry_attempts.lock();
5725 f(&mut attempts)
5726 }
5727
5728 pub fn clear_semantic_refresh_retry_attempts(&self, paths: &[PathBuf]) {
5729 let mut attempts = self.semantic_refresh_retry_attempts.lock();
5730 for path in paths {
5731 attempts.remove(path);
5732 }
5733 }
5734
5735 pub fn clear_all_semantic_refresh_retry_attempts(&self) {
5736 self.semantic_refresh_retry_attempts.lock().clear();
5737 }
5738
5739 pub fn semantic_refresh_circuit_is_open(&self) -> bool {
5740 self.semantic_refresh_circuit.open.load(Ordering::SeqCst)
5741 }
5742
5743 pub fn record_semantic_refresh_transient_failure(&self, trip_threshold: usize) -> bool {
5744 let failures = self
5745 .semantic_refresh_circuit
5746 .consecutive_transient_failures
5747 .fetch_add(1, Ordering::SeqCst)
5748 .saturating_add(1);
5749 if failures >= trip_threshold
5750 && !self
5751 .semantic_refresh_circuit
5752 .open
5753 .swap(true, Ordering::SeqCst)
5754 {
5755 crate::slog_warn!(
5756 "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
5757 );
5758 }
5759 self.semantic_refresh_circuit_is_open()
5760 }
5761
5762 pub fn trip_semantic_refresh_circuit(&self, trip_threshold: usize) {
5763 self.semantic_refresh_circuit
5764 .consecutive_transient_failures
5765 .store(trip_threshold, Ordering::SeqCst);
5766 if !self
5767 .semantic_refresh_circuit
5768 .open
5769 .swap(true, Ordering::SeqCst)
5770 {
5771 crate::slog_warn!(
5772 "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
5773 );
5774 }
5775 }
5776
5777 pub fn reset_semantic_refresh_transient_failure_count(&self) {
5778 self.semantic_refresh_circuit
5779 .consecutive_transient_failures
5780 .store(0, Ordering::SeqCst);
5781 }
5782
5783 pub fn reset_semantic_refresh_circuit_after_success(&self) {
5784 self.reset_semantic_refresh_transient_failure_count();
5785 self.semantic_refresh_circuit
5786 .probe_ready
5787 .store(false, Ordering::SeqCst);
5788 if self
5789 .semantic_refresh_circuit
5790 .open
5791 .swap(false, Ordering::SeqCst)
5792 {
5793 crate::slog_info!("embedding backend recovered; resuming normal refresh retries");
5794 }
5795 }
5796
5797 pub fn semantic_refresh_transient_failure_count(&self) -> usize {
5798 self.semantic_refresh_circuit
5799 .consecutive_transient_failures
5800 .load(Ordering::SeqCst)
5801 }
5802
5803 pub fn semantic_refresh_probe_is_scheduled(&self) -> bool {
5804 self.semantic_refresh_circuit
5805 .probe_in_flight
5806 .load(Ordering::SeqCst)
5807 || self.semantic_refresh_probe_ready()
5808 }
5809
5810 pub fn semantic_refresh_probe_ready(&self) -> bool {
5811 self.semantic_refresh_circuit
5812 .probe_ready
5813 .load(Ordering::SeqCst)
5814 }
5815
5816 pub fn take_semantic_refresh_probe_ready(&self) -> bool {
5817 self.semantic_refresh_circuit
5818 .probe_ready
5819 .swap(false, Ordering::SeqCst)
5820 }
5821
5822 fn invalidate_semantic_refresh_probe(&self) {
5823 self.semantic_refresh_circuit
5824 .probe_token
5825 .fetch_add(1, Ordering::SeqCst);
5826 self.semantic_refresh_circuit
5827 .probe_ready
5828 .store(false, Ordering::SeqCst);
5829 self.semantic_refresh_circuit
5830 .probe_in_flight
5831 .store(false, Ordering::SeqCst);
5832 }
5833
5834 pub fn ensure_semantic_refresh_probe_scheduled(&self, delay: Duration) {
5835 let receiver = self.semantic_refresh_event_rx.lock();
5836 if receiver.is_none()
5837 || self
5838 .semantic_refresh_circuit
5839 .probe_ready
5840 .load(Ordering::SeqCst)
5841 || self
5842 .semantic_refresh_circuit
5843 .probe_in_flight
5844 .swap(true, Ordering::SeqCst)
5845 {
5846 return;
5847 }
5848 let probe_token = self
5849 .semantic_refresh_circuit
5850 .probe_token
5851 .fetch_add(1, Ordering::SeqCst)
5852 .wrapping_add(1);
5853 drop(receiver);
5854
5855 let circuit = Arc::clone(&self.semantic_refresh_circuit);
5856 let session_id = crate::log_ctx::current_session();
5857 std::thread::spawn(move || {
5858 crate::log_ctx::with_session(session_id, || {
5859 std::thread::sleep(delay);
5860 if circuit.probe_token.load(Ordering::SeqCst) == probe_token {
5861 circuit.probe_ready.store(true, Ordering::SeqCst);
5862 circuit.probe_in_flight.store(false, Ordering::SeqCst);
5863 }
5864 });
5865 });
5866 }
5867
5868 pub fn semantic_embedding_model(
5870 &self,
5871 ) -> &parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>> {
5872 &self.semantic_embedding_model
5873 }
5874
5875 pub fn watcher(&self) -> &parking_lot::Mutex<Option<RecommendedWatcher>> {
5877 &self.watcher
5878 }
5879
5880 pub fn watcher_rx(
5882 &self,
5883 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>> {
5884 &self.watcher_rx
5885 }
5886
5887 pub(crate) fn watcher_drain_slice(
5889 &self,
5890 ) -> &parking_lot::Mutex<Option<WatcherDrainSliceState>> {
5891 &self.watcher_drain_slice
5892 }
5893
5894 pub fn watcher_drain_pending_path_count(&self) -> usize {
5896 self.watcher_drain_slice.lock().as_ref().map_or(0, |state| {
5897 let active_paths = match &state.phase {
5898 WatcherDrainPhase::Collect => 0,
5899 WatcherDrainPhase::Apply { paths, .. } => paths.len(),
5900 };
5901 active_paths + state.pending_paths.len()
5902 })
5903 }
5904
5905 pub fn watcher_drain_path_slice_count(&self) -> usize {
5907 self.watcher_drain_slice
5908 .lock()
5909 .as_ref()
5910 .map_or(0, |state| state.path_slice_count)
5911 }
5912
5913 pub fn install_watcher_runtime(
5916 &self,
5917 rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
5918 runtime: WatcherThreadHandle,
5919 ) {
5920 let _runtime_guard = self.watcher_runtime_lock.lock();
5921 let replaced = self.watcher_thread.lock().replace(runtime);
5922 self.app.watcher_started();
5923 if let Some(runtime) = replaced {
5924 Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
5925 }
5926 *self.watcher_rx.lock() = Some(rx);
5927 *self.watcher_drain_slice.lock() = None;
5928 }
5929
5930 fn watcher_root_path(&self) -> PathBuf {
5931 self.canonical_cache_root_opt()
5932 .or_else(|| self.config().project_root.clone())
5933 .unwrap_or_else(|| PathBuf::from("<unconfigured>"))
5934 }
5935
5936 fn spawn_watcher_shutdown(app: Arc<App>, root: PathBuf, runtime: WatcherThreadHandle) {
5937 const JOIN_TIMEOUT: Duration = Duration::from_secs(2);
5938 runtime.request_shutdown();
5941 std::thread::spawn(
5942 move || match runtime.shutdown_and_join_timeout(JOIN_TIMEOUT) {
5943 WatcherJoinOutcome::Joined => {
5944 app.watcher_stopped();
5945 crate::slog_info!("watcher stopped: {}", root.display());
5946 }
5947 WatcherJoinOutcome::TimedOut(join) => {
5948 crate::slog_warn!(
5949 "watcher stop timed out after {} ms: {}",
5950 JOIN_TIMEOUT.as_millis(),
5951 root.display()
5952 );
5953 std::thread::spawn(move || {
5954 let _ = join.join();
5955 app.watcher_stopped();
5956 crate::slog_info!("watcher stopped: {}", root.display());
5957 });
5958 }
5959 },
5960 );
5961 }
5962
5963 fn take_watcher_runtime(&self) -> Option<WatcherThreadHandle> {
5964 let _runtime_guard = self.watcher_runtime_lock.lock();
5965 let runtime = self.watcher_thread.lock().take();
5966 *self.watcher_rx.lock() = None;
5967 *self.watcher_drain_slice.lock() = None;
5968 *self.watcher.lock() = None;
5969 runtime
5970 }
5971
5972 pub fn stop_watcher_runtime(&self) {
5976 if let Some(runtime) = self.take_watcher_runtime() {
5977 Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
5978 }
5979 }
5980
5981 pub fn stop_watcher_runtime_in_background(&self) {
5983 self.stop_watcher_runtime();
5984 }
5985
5986 pub(crate) fn take_finished_watcher_runtime(&self) -> bool {
5991 let runtime = {
5992 let _runtime_guard = self.watcher_runtime_lock.lock();
5993 let finished = self
5994 .watcher_thread
5995 .lock()
5996 .as_ref()
5997 .is_some_and(|runtime| runtime.is_finished());
5998 if !finished {
5999 return false;
6000 }
6001 let runtime = self.watcher_thread.lock().take();
6002 *self.watcher_rx.lock() = None;
6003 *self.watcher_drain_slice.lock() = None;
6004 *self.watcher.lock() = None;
6005 runtime
6006 };
6007 if let Some(runtime) = runtime {
6008 Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
6009 }
6010 true
6011 }
6012
6013 pub fn watcher_registry_count(&self) -> usize {
6016 self.app.watcher_count()
6017 }
6018
6019 pub(crate) fn watcher_runtime_active(&self) -> bool {
6020 let _runtime_guard = self.watcher_runtime_lock.lock();
6021 let thread_live = self
6026 .watcher_thread
6027 .lock()
6028 .as_ref()
6029 .is_some_and(|runtime| !runtime.is_finished());
6030 thread_live && self.watcher_rx.lock().is_some()
6031 }
6032
6033 pub fn artifact_eviction_blocked(&self) -> bool {
6037 if self.standing_artifact_exempt.load(Ordering::Acquire) {
6038 return true;
6039 }
6040 let semantic_refresh_in_flight = match &*self
6041 .semantic_index_status
6042 .read()
6043 .unwrap_or_else(std::sync::PoisonError::into_inner)
6044 {
6045 SemanticIndexStatus::Building { .. } => true,
6046 SemanticIndexStatus::Ready { refreshing, .. } => !refreshing.is_empty(),
6047 SemanticIndexStatus::Disabled | SemanticIndexStatus::Failed(_) => false,
6048 };
6049 if crate::runtime_drain::any_build_in_flight(self)
6050 || semantic_refresh_in_flight
6051 || self.inspect_manager.tier2_any_in_flight()
6052 || !self.bash_background.running_tasks().is_empty()
6053 || !self.pending_callgraph_store_paths.lock().is_empty()
6054 || !self.pending_search_index_paths.lock().is_empty()
6055 || !self.pending_tier2_paths.lock().is_empty()
6056 || !self.pending_semantic_index_paths.lock().is_empty()
6057 || *self.pending_semantic_corpus_refresh.lock()
6058 {
6059 return true;
6060 }
6061
6062 let search_has_pending_disk_changes = self
6063 .search_index
6064 .read()
6065 .unwrap_or_else(std::sync::PoisonError::into_inner)
6066 .as_ref()
6067 .is_some_and(SearchIndex::has_pending_disk_changes);
6068 search_has_pending_disk_changes
6069 }
6070
6071 pub fn evict_idle_artifacts(&self) -> bool {
6076 if self.artifact_eviction_blocked() {
6077 return false;
6078 }
6079
6080 self.callgraph_store
6081 .write()
6082 .unwrap_or_else(std::sync::PoisonError::into_inner)
6083 .take();
6084 self.search_index
6085 .write()
6086 .unwrap_or_else(std::sync::PoisonError::into_inner)
6087 .take();
6088 self.semantic_index
6089 .write()
6090 .unwrap_or_else(std::sync::PoisonError::into_inner)
6091 .take();
6092 self.borrowed_index_cache.lock().clear();
6093 self.inspect_manager.evict_idle_caches();
6094 self.reset_symbol_cache();
6095 self.clear_tsconfig_membership_cache();
6096 true
6097 }
6098
6099 #[doc(hidden)]
6102 pub fn force_idle_teardown_for_test(self: &Arc<Self>) -> bool {
6103 if std::env::var("AFT_TEST_ALLOW_FORCE_IDLE_REAP").as_deref() != Ok("1") {
6104 return false;
6105 }
6106 if !self.evict_idle_artifacts() {
6107 return false;
6108 }
6109 self.stop_watcher_runtime_in_background();
6110 self.invalidate_artifacts_after_watcher_gap();
6111 true
6112 }
6113
6114 pub(crate) fn release_idle_reopenable_resources_in_background(self: &Arc<Self>) {
6118 let ctx = Arc::clone(self);
6119 std::thread::spawn(move || {
6120 if !ctx.subc_unbound_quiesced() {
6121 return;
6122 }
6123 {
6124 let mut lsp = ctx.lsp_manager.lock();
6125 if !ctx.subc_unbound_quiesced() {
6126 return;
6127 }
6128 lsp.shutdown_all();
6129 }
6130 let _ = ctx.subc_lifecycle.run_if_unbound(|| {
6131 ctx.bash_background.clear_db_pool();
6132 ctx.backup.lock().clear_db_pool();
6133 });
6134 });
6135 }
6136
6137 pub(crate) fn teardown_deleted_root(&self) {
6141 self.bash_background.detach();
6142 self.bash_background.clear_db_pool();
6143 self.backup.lock().clear_db_pool();
6144 self.lsp_manager.lock().shutdown_all();
6145 }
6146
6147 pub fn lsp(&self) -> parking_lot::MutexGuard<'_, LspManager> {
6149 self.lsp_manager.lock()
6150 }
6151
6152 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
6155 let config = self.config();
6156 if let Some(mut lsp) = self.lsp_manager.try_lock() {
6157 if let Err(e) = lsp.notify_file_changed_if_running(file_path, content, &config) {
6158 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
6159 }
6160 }
6161 }
6162
6163 pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
6169 if let Some(mut lsp) = self.lsp_manager.try_lock() {
6170 lsp.clear_diagnostics_for_file(file_path)
6171 } else {
6172 false
6173 }
6174 }
6175
6176 pub fn lsp_mark_diagnostics_stale_for_file(&self, file_path: &Path) -> StaleDiagnosticsMark {
6180 if let Some(mut lsp) = self.lsp_manager.try_lock() {
6181 lsp.mark_diagnostics_stale_for_file(file_path)
6182 } else {
6183 StaleDiagnosticsMark::default()
6184 }
6185 }
6186
6187 pub fn lsp_resync_changed_file_for_diagnostics(&self, file_path: &Path) -> bool {
6195 if !file_path.is_file() {
6196 return false;
6197 }
6198
6199 let content = match std::fs::read_to_string(file_path) {
6200 Ok(content) => content,
6201 Err(err) => {
6202 crate::slog_warn!(
6203 "skipping LSP resync for {} after external edit: {}",
6204 file_path.display(),
6205 err
6206 );
6207 return false;
6208 }
6209 };
6210
6211 let config = self.config();
6212 if let Some(mut lsp) = self.lsp_manager.try_lock() {
6213 if let Err(err) = lsp.notify_file_changed(file_path, &content, &config) {
6214 crate::slog_warn!(
6215 "LSP resync failed for {} after external edit: {}",
6216 file_path.display(),
6217 err
6218 );
6219 return false;
6220 }
6221 true
6222 } else {
6223 false
6224 }
6225 }
6226
6227 pub fn lsp_notify_and_collect_diagnostics(
6236 &self,
6237 file_path: &Path,
6238 content: &str,
6239 timeout: std::time::Duration,
6240 ) -> crate::lsp::manager::PostEditWaitOutcome {
6241 let config = self.config();
6242 let Some(mut lsp) = self.lsp_manager.try_lock() else {
6243 return crate::lsp::manager::PostEditWaitOutcome::default();
6244 };
6245
6246 lsp.drain_events();
6249
6250 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
6254
6255 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
6259 {
6260 Ok(v) => v,
6261 Err(e) => {
6262 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
6263 return crate::lsp::manager::PostEditWaitOutcome::default();
6264 }
6265 };
6266
6267 if expected_versions.is_empty() {
6270 return crate::lsp::manager::PostEditWaitOutcome::default();
6271 }
6272
6273 let mut wait = lsp.start_post_edit_diagnostics_wait(
6277 file_path,
6278 &expected_versions,
6279 &pre_snapshot,
6280 timeout,
6281 );
6282 let mut complete = lsp.poll_post_edit_diagnostics_wait(&mut wait, None);
6283 drop(lsp);
6284
6285 while !complete && !wait.deadline_reached() {
6286 let event = wait.next_event();
6289 let mut lsp = self.lsp_manager.lock();
6290 complete = lsp.poll_post_edit_diagnostics_wait(&mut wait, event);
6291 }
6292
6293 self.lsp_manager
6294 .lock()
6295 .finish_post_edit_diagnostics_wait(wait)
6296 }
6297
6298 fn custom_lsp_root_markers(&self) -> Vec<String> {
6301 self.config()
6302 .lsp_servers
6303 .iter()
6304 .flat_map(|s| s.root_markers.iter().cloned())
6305 .collect()
6306 }
6307
6308 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
6309 let custom_markers = self.custom_lsp_root_markers();
6310 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
6311 .iter()
6312 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
6313 .cloned()
6314 .map(|path| {
6315 let change_type = if path.exists() {
6316 FileChangeType::CHANGED
6317 } else {
6318 FileChangeType::DELETED
6319 };
6320 (path, change_type)
6321 })
6322 .collect();
6323
6324 self.notify_watched_config_events(&config_paths);
6325 }
6326
6327 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
6328 let paths = params
6329 .get("multi_file_write_paths")
6330 .and_then(|value| value.as_array())?
6331 .iter()
6332 .filter_map(|value| value.as_str())
6333 .map(PathBuf::from)
6334 .collect::<Vec<_>>();
6335
6336 (!paths.is_empty()).then_some(paths)
6337 }
6338
6339 fn watched_file_events_from_params(
6351 params: &serde_json::Value,
6352 extra_markers: &[String],
6353 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
6354 let events = params
6355 .get("multi_file_write_paths")
6356 .and_then(|value| value.as_array())?
6357 .iter()
6358 .filter_map(|entry| {
6359 let path = entry
6361 .get("path")
6362 .and_then(|value| value.as_str())
6363 .map(PathBuf::from)?;
6364
6365 if !is_config_file_path_with_custom(&path, extra_markers) {
6366 return None;
6367 }
6368
6369 let change_type = entry
6370 .get("type")
6371 .and_then(|value| value.as_str())
6372 .and_then(Self::parse_file_change_type)
6373 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
6374
6375 Some((path, change_type))
6376 })
6377 .collect::<Vec<_>>();
6378
6379 (!events.is_empty()).then_some(events)
6380 }
6381
6382 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
6383 match value {
6384 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
6385 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
6386 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
6387 _ => None,
6388 }
6389 }
6390
6391 fn change_type_from_current_state(path: &Path) -> FileChangeType {
6392 if path.exists() {
6393 FileChangeType::CHANGED
6394 } else {
6395 FileChangeType::DELETED
6396 }
6397 }
6398
6399 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
6400 if config_paths.is_empty() {
6401 return;
6402 }
6403
6404 let config = self.config();
6405 if let Some(mut lsp) = self.lsp_manager.try_lock() {
6406 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
6407 crate::slog_warn!("watched-file sync error: {}", e);
6408 }
6409 }
6410 }
6411
6412 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
6413 let custom_markers = self.custom_lsp_root_markers();
6414 if !is_config_file_path_with_custom(file_path, &custom_markers) {
6415 return;
6416 }
6417
6418 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
6419 }
6420
6421 pub fn lsp_post_multi_file_write(
6426 &self,
6427 file_path: &Path,
6428 content: &str,
6429 file_paths: &[PathBuf],
6430 params: &serde_json::Value,
6431 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
6432 self.notify_watched_config_files(file_paths);
6433 self.add_pending_tier2_paths(file_paths.iter().cloned());
6434 let _ = self.mark_status_bar_tier2_stale();
6435
6436 let wants_diagnostics = params
6437 .get("diagnostics")
6438 .and_then(|v| v.as_bool())
6439 .unwrap_or(false);
6440
6441 if !wants_diagnostics {
6442 self.lsp_notify_file_changed(file_path, content);
6443 return None;
6444 }
6445
6446 let wait_ms = params
6447 .get("wait_ms")
6448 .and_then(|v| v.as_u64())
6449 .unwrap_or(3000)
6450 .min(10_000);
6451
6452 Some(self.lsp_notify_and_collect_diagnostics(
6453 file_path,
6454 content,
6455 std::time::Duration::from_millis(wait_ms),
6456 ))
6457 }
6458
6459 pub fn lsp_post_write(
6476 &self,
6477 file_path: &Path,
6478 content: &str,
6479 params: &serde_json::Value,
6480 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
6481 let wants_diagnostics = params
6482 .get("diagnostics")
6483 .and_then(|v| v.as_bool())
6484 .unwrap_or(false);
6485
6486 let custom_markers = self.custom_lsp_root_markers();
6487 if let Some(file_paths) = Self::multi_file_write_paths(params) {
6488 self.add_pending_tier2_paths(file_paths);
6489 } else {
6490 self.add_pending_tier2_paths([file_path.to_path_buf()]);
6491 }
6492 let _ = self.mark_status_bar_tier2_stale();
6493
6494 if !wants_diagnostics {
6495 if let Some(file_paths) = Self::multi_file_write_paths(params) {
6496 self.notify_watched_config_files(&file_paths);
6497 } else if let Some(config_events) =
6498 Self::watched_file_events_from_params(params, &custom_markers)
6499 {
6500 self.notify_watched_config_events(&config_events);
6501 }
6502 self.lsp_notify_file_changed(file_path, content);
6503 return None;
6504 }
6505
6506 let wait_ms = params
6507 .get("wait_ms")
6508 .and_then(|v| v.as_u64())
6509 .unwrap_or(3000)
6510 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
6513 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
6514 }
6515
6516 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
6517 {
6518 self.notify_watched_config_events(&config_events);
6519 }
6520
6521 Some(self.lsp_notify_and_collect_diagnostics(
6522 file_path,
6523 content,
6524 std::time::Duration::from_millis(wait_ms),
6525 ))
6526 }
6527
6528 fn resolved_path_restriction_root(&self, root: &Path) -> PathBuf {
6529 let mut memo = self.path_restriction_root_memo.lock();
6530 if let Some(cached) = memo.as_ref() {
6531 if cached.configured_root.as_os_str() == root.as_os_str()
6532 && cached.resolved_root.exists()
6533 {
6534 return cached.resolved_root.clone();
6535 }
6536 }
6537
6538 #[cfg(test)]
6544 self.path_restriction_root_canonicalizations
6545 .fetch_add(1, Ordering::SeqCst);
6546 let resolved_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
6547 *memo = Some(PathRestrictionRootMemo {
6548 configured_root: root.to_path_buf(),
6549 resolved_root: resolved_root.clone(),
6550 });
6551 resolved_root
6552 }
6553
6554 fn path_restriction_context(
6555 &self,
6556 req_id: &str,
6557 path: &Path,
6558 ) -> Result<Option<PathRestrictionContext>, crate::protocol::Response> {
6559 let config = self.config();
6560 let force_restrict = self.request_force_restrict(req_id);
6561 if !config.restrict_to_project_root && !force_restrict {
6562 return Ok(None);
6563 }
6564 let root = match &config.project_root {
6565 Some(root) => root.clone(),
6566 None if force_restrict => {
6567 return Err(crate::protocol::Response::error(
6568 req_id,
6569 "path_outside_root",
6570 "project root is required when path restriction is forced",
6571 ));
6572 }
6573 None => return Ok(None),
6574 };
6575 drop(config);
6576
6577 let raw_root = root.clone();
6578 let resolved_root = self.resolved_path_restriction_root(&root);
6579 let path_for_resolution = if path.is_relative() {
6580 raw_root.join(path)
6581 } else {
6582 path.to_path_buf()
6583 };
6584 Ok(Some(PathRestrictionContext {
6585 raw_root,
6586 resolved_root,
6587 path_for_resolution,
6588 }))
6589 }
6590
6591 pub fn resolve_relative_path(&self, path: &Path) -> PathBuf {
6604 if path.is_absolute() {
6605 return path.to_path_buf();
6606 }
6607 if let Some(root) = &self.config().project_root {
6608 return root.join(path);
6609 }
6610 std::env::current_dir()
6611 .unwrap_or_else(|_| PathBuf::from("."))
6612 .join(path)
6613 }
6614
6615 pub fn validate_path(
6624 &self,
6625 req_id: &str,
6626 path: &Path,
6627 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6628 self.validate_path_with_artifact_session(req_id, path, None)
6629 }
6630
6631 pub fn validate_write_location(
6638 &self,
6639 req_id: &str,
6640 path: &Path,
6641 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6642 let Some(PathRestrictionContext {
6643 raw_root,
6644 resolved_root,
6645 path_for_resolution,
6646 }) = self.path_restriction_context(req_id, path)?
6647 else {
6648 return Ok(path.to_path_buf());
6649 };
6650 let normalized = normalize_path(&path_for_resolution);
6651 let Some(file_name) = normalized.file_name() else {
6652 return self.validate_path(req_id, path);
6653 };
6654 let parent = normalized.parent().unwrap_or_else(|| Path::new(""));
6655 let resolved_parent = match std::fs::canonicalize(parent) {
6656 Ok(resolved) => resolved,
6657 Err(_) => {
6658 reject_escaping_symlink(req_id, path, parent, &resolved_root, &raw_root)?;
6659 resolve_with_existing_ancestors(parent)
6660 }
6661 };
6662 let resolved = normalize_path(&resolved_parent.join(file_name));
6663
6664 if !resolved.starts_with(&resolved_root) {
6665 return Err(path_error_response(req_id, path, &resolved_root));
6666 }
6667
6668 Ok(resolved)
6669 }
6670
6671 pub fn validate_read_path(
6677 &self,
6678 req_id: &str,
6679 session_id: &str,
6680 path: &Path,
6681 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6682 self.validate_path_with_artifact_session(req_id, path, Some(session_id))
6683 }
6684
6685 fn validate_path_with_artifact_session(
6686 &self,
6687 req_id: &str,
6688 path: &Path,
6689 artifact_session_id: Option<&str>,
6690 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6691 let Some(PathRestrictionContext {
6692 raw_root,
6693 resolved_root,
6694 path_for_resolution,
6695 }) = self.path_restriction_context(req_id, path)?
6696 else {
6697 return Ok(path.to_path_buf());
6700 };
6701
6702 let resolved = match std::fs::canonicalize(&path_for_resolution) {
6707 Ok(resolved) => resolved,
6708 Err(_) => {
6709 let normalized = normalize_path(&path_for_resolution);
6710 reject_escaping_symlink(
6711 req_id,
6712 &path_for_resolution,
6713 &normalized,
6714 &resolved_root,
6715 &raw_root,
6716 )?;
6717 resolve_with_existing_ancestors(&normalized)
6718 }
6719 };
6720
6721 if !resolved.starts_with(&resolved_root) {
6722 let is_owned_bash_artifact = artifact_session_id.is_some_and(|session_id| {
6723 self.bash_background
6724 .is_session_owned_artifact_path(session_id, &resolved)
6725 });
6726 if !is_owned_bash_artifact {
6727 return Err(path_error_response(req_id, path, &resolved_root));
6728 }
6729 }
6730
6731 Ok(resolved)
6732 }
6733
6734 pub fn lsp_server_count(&self) -> usize {
6736 self.lsp_manager
6737 .try_lock()
6738 .map(|lsp| lsp.server_count())
6739 .unwrap_or(0)
6740 }
6741
6742 pub fn symbol_cache_stats(&self) -> serde_json::Value {
6744 let entries = self
6745 .symbol_cache
6746 .read()
6747 .map(|cache| cache.len())
6748 .unwrap_or(0);
6749 serde_json::json!({
6750 "local_entries": entries,
6751 "warm_entries": 0,
6752 })
6753 }
6754
6755 fn memory_estimates(&self) -> [crate::memory::MemoryEstimate; 9] {
6756 let semantic = match self.semantic_index.try_read() {
6757 Ok(index) => index
6758 .as_ref()
6759 .map(SemanticIndex::estimated_memory)
6760 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("entries", 0)),
6761 Err(TryLockError::Poisoned(error)) => error
6762 .into_inner()
6763 .as_ref()
6764 .map(SemanticIndex::estimated_memory)
6765 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("entries", 0)),
6766 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
6767 };
6768 let trigram = match self.search_index.try_read() {
6769 Ok(index) => index
6770 .as_ref()
6771 .map(SearchIndex::estimated_memory)
6772 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("files", 0)),
6773 Err(TryLockError::Poisoned(error)) => error
6774 .into_inner()
6775 .as_ref()
6776 .map(SearchIndex::estimated_memory)
6777 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("files", 0)),
6778 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
6779 };
6780 let symbols = match self.symbol_cache.try_read() {
6781 Ok(cache) => cache.estimated_memory(),
6782 Err(TryLockError::Poisoned(error)) => error.into_inner().estimated_memory(),
6783 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
6784 };
6785 let callgraph = match self.callgraph_store.try_read() {
6786 Ok(store) => store
6787 .as_ref()
6788 .map(|store| store.estimated_memory())
6789 .unwrap_or_else(|| {
6790 crate::memory::MemoryEstimate::estimated(0).count("open_generation_handles", 0)
6791 }),
6792 Err(TryLockError::Poisoned(error)) => error
6793 .into_inner()
6794 .as_ref()
6795 .map(|store| store.estimated_memory())
6796 .unwrap_or_else(|| {
6797 crate::memory::MemoryEstimate::estimated(0).count("open_generation_handles", 0)
6798 }),
6799 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
6800 };
6801 let callgraph_projection = self.inspect_manager.callgraph_projection_estimated_memory();
6802 let inspect = self.inspect_manager.estimated_memory();
6803 let bash = self.bash_background.estimated_memory();
6804 let lsp = self
6805 .lsp_manager
6806 .try_lock()
6807 .map(|lsp| lsp.estimated_memory())
6808 .unwrap_or_else(crate::memory::MemoryEstimate::busy);
6809 let parser_pool = crate::memory::MemoryEstimate::not_estimated()
6812 .count("pooled_parsers", 0)
6813 .gap("tree_sitter_parser_bytes");
6814 [
6815 semantic,
6816 trigram,
6817 symbols,
6818 callgraph,
6819 callgraph_projection,
6820 inspect,
6821 bash,
6822 lsp,
6823 parser_pool,
6824 ]
6825 }
6826
6827 pub fn memory_root_snapshot(&self) -> crate::memory::RootMemorySnapshot {
6831 let [semantic, trigram, symbols, callgraph, callgraph_projection, inspect, bash, lsp, parser_pool] =
6832 self.memory_estimates();
6833 crate::memory::RootMemorySnapshot::new(
6834 semantic,
6835 trigram,
6836 symbols,
6837 callgraph,
6838 callgraph_projection,
6839 inspect,
6840 bash,
6841 lsp,
6842 parser_pool,
6843 )
6844 }
6845
6846 pub(crate) fn memory_root_rollup(&self) -> crate::memory::RootMemoryRollup {
6849 let estimates = self.memory_estimates();
6850 crate::memory::RootMemoryRollup::from_estimates(&[
6851 &estimates[0],
6852 &estimates[1],
6853 &estimates[2],
6854 &estimates[3],
6855 &estimates[4],
6856 &estimates[5],
6857 &estimates[6],
6858 &estimates[7],
6859 &estimates[8],
6860 ])
6861 }
6862
6863 pub fn memory_snapshot(&self, current_root: Option<&Path>) -> crate::memory::MemorySnapshot {
6866 let mut roots = BTreeMap::new();
6867 let (roots_status, contexts) = match self.app.try_memory_contexts() {
6868 Some(contexts) => ("ready", contexts),
6869 None => ("busy", Vec::new()),
6870 };
6871 for (root, context) in contexts {
6872 roots.insert(root.display().to_string(), context.memory_root_snapshot());
6873 }
6874 let current_label = current_root
6878 .map(|root| {
6879 cortexkit_paths::ProjectRootId::from_path(root)
6880 .map(|id| id.as_path().display().to_string())
6881 .unwrap_or_else(|_| root.display().to_string())
6882 })
6883 .unwrap_or_else(|| "<unconfigured>".to_string());
6884 roots
6885 .entry(current_label)
6886 .or_insert_with(|| self.memory_root_snapshot());
6887 crate::memory::MemorySnapshot::new(roots_status, roots)
6888 }
6889}
6890
6891#[cfg(test)]
6892mod subc_lifecycle_admission_tests {
6893 use super::*;
6894
6895 #[test]
6896 fn route_teardown_does_not_supersede_disk_artifact_compatibility() {
6897 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6898 ctx.note_configure_warm_key("config-a".to_string());
6899 let content_generation = ctx.configure_content_generation();
6900 let lifecycle_generation = ctx.configure_generation();
6901 let search_epoch = ctx.next_search_persist_epoch();
6902 let semantic_epoch = ctx.next_semantic_persist_epoch();
6903 let search_persist_epoch = ctx.search_persist_epoch_flag();
6904 let semantic_persist_epoch = ctx.semantic_persist_epoch_flag();
6905
6906 ctx.mark_subc_unbound();
6907 assert!(ctx.configure_generation() > lifecycle_generation);
6908 assert_eq!(ctx.configure_content_generation(), content_generation);
6909 assert_eq!(search_persist_epoch.current(), search_epoch);
6910 assert_eq!(semantic_persist_epoch.current(), semantic_epoch);
6911
6912 ctx.mark_subc_bound();
6913 ctx.note_configure_warm_key("config-b".to_string());
6914 assert!(ctx.configure_content_generation() > content_generation);
6915 let replacement_search_epoch = ctx.next_search_persist_epoch();
6916 let replacement_semantic_epoch = ctx.next_semantic_persist_epoch();
6917 assert!(replacement_search_epoch > search_epoch);
6918 assert!(replacement_semantic_epoch > semantic_epoch);
6919 assert_eq!(search_persist_epoch.current(), replacement_search_epoch);
6920 assert_eq!(semantic_persist_epoch.current(), replacement_semantic_epoch);
6921 }
6922
6923 #[test]
6924 fn lifecycle_gate_serializes_unbind_with_worker_start_commit() {
6925 let admission = SubcLifecycleAdmission::default();
6926 let generation = Arc::new(AtomicU64::new(11));
6927 let expected = generation.load(Ordering::SeqCst);
6928 let starts = Arc::new(AtomicUsize::new(0));
6929 let (entered_tx, entered_rx) = std::sync::mpsc::channel();
6930 let (release_tx, release_rx) = std::sync::mpsc::channel();
6931
6932 let worker_admission = admission.clone();
6933 let worker_generation = Arc::clone(&generation);
6934 let worker_starts = Arc::clone(&starts);
6935 let worker = std::thread::spawn(move || {
6936 worker_admission.run_if_current(&worker_generation, expected, || {
6937 entered_tx.send(()).unwrap();
6938 release_rx.recv().unwrap();
6939 worker_starts.fetch_add(1, Ordering::SeqCst);
6940 })
6941 });
6942 entered_rx.recv().unwrap();
6943
6944 let unbind_admission = admission.clone();
6945 let unbind_generation = Arc::clone(&generation);
6946 let (unbound_tx, unbound_rx) = std::sync::mpsc::channel();
6947 let unbind = std::thread::spawn(move || {
6948 unbind_admission.mark_unbound(&unbind_generation);
6949 unbound_tx.send(()).unwrap();
6950 });
6951
6952 assert!(
6953 unbound_rx
6954 .recv_timeout(std::time::Duration::from_millis(50))
6955 .is_err(),
6956 "unbind must wait for an admitted worker-start commit"
6957 );
6958 release_tx.send(()).unwrap();
6959 assert!(worker.join().unwrap().is_some());
6960 unbound_rx
6961 .recv_timeout(std::time::Duration::from_secs(1))
6962 .unwrap();
6963 unbind.join().unwrap();
6964 assert_eq!(starts.load(Ordering::SeqCst), 1);
6965 assert!(
6966 admission
6967 .run_if_current(&generation, generation.load(Ordering::SeqCst), || {
6968 starts.fetch_add(1, Ordering::SeqCst);
6969 })
6970 .is_none(),
6971 "worker starts after unbind must be denied"
6972 );
6973 }
6974
6975 #[test]
6976 fn health_snapshot_returns_busy_before_locking_artifact_receivers() {
6977 let ctx = Arc::new(AppContext::new(
6978 default_language_provider_factory(),
6979 Config::default(),
6980 ));
6981 let lifecycle_guard = ctx.subc_lifecycle.unbound.lock();
6982 let (started_tx, started_rx) = std::sync::mpsc::channel();
6983 let (snapshot_tx, snapshot_rx) = std::sync::mpsc::channel();
6984 let worker_ctx = Arc::clone(&ctx);
6985 let worker = std::thread::spawn(move || {
6986 started_tx.send(()).unwrap();
6987 snapshot_tx
6988 .send(worker_ctx.try_health_snapshot(Path::new("health-root")))
6989 .unwrap();
6990 });
6991 started_rx
6992 .recv_timeout(Duration::from_secs(1))
6993 .expect("health snapshot worker should start");
6994
6995 let snapshot = snapshot_rx.recv_timeout(Duration::from_secs(2));
6996 let callgraph_receiver_available = ctx.callgraph_store_rx.try_lock().is_some();
6997 drop(lifecycle_guard);
6998 worker.join().unwrap();
6999
7000 assert!(
7001 matches!(
7002 snapshot,
7003 Ok(RootHealthSnapshot {
7004 state: RootHealthState::Busy,
7005 ..
7006 })
7007 ),
7008 "health snapshots must report busy instead of waiting for lifecycle admission"
7009 );
7010 assert!(
7011 callgraph_receiver_available,
7012 "health snapshots must not hold the callgraph receiver while lifecycle admission is busy"
7013 );
7014 }
7015
7016 #[test]
7017 fn borrow_only_root_with_partial_tier2_aggregates_reports_disabled() {
7018 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7019 ctx.set_artifact_owner(
7020 Some(crate::artifact_owner::ArtifactOwnerStatus {
7021 mode: crate::artifact_owner::ArtifactOwnerMode::ReadOnly,
7022 project_key: "borrowed".to_string(),
7023 manifest_path: "manifest.json".to_string(),
7024 owner_project_scope_key: "owner".to_string(),
7025 owner_checkout_path: "/owner".to_string(),
7026 note: None,
7027 }),
7028 None,
7029 );
7030 ctx.update_status_bar_tier2(Some(4), None, None, None, true);
7031
7032 let snapshot = ctx.try_health_snapshot(Path::new("borrow-only-root"));
7033
7034 assert_eq!(snapshot.tier2.expect("tier2 health").status, "disabled");
7035 }
7036
7037 #[test]
7038 fn worktree_guard_prevents_partial_tier2_from_reporting_building() {
7039 let root = tempfile::tempdir().unwrap();
7040 let ctx = AppContext::new(
7041 default_language_provider_factory(),
7042 Config {
7043 project_root: Some(root.path().to_path_buf()),
7044 ..Config::default()
7045 },
7046 );
7047 ctx.set_harness(crate::harness::Harness::Opencode);
7048 ctx.set_cache_writer_capabilities(true, true);
7049 ctx.update_status_bar_tier2(Some(4), None, None, None, true);
7050 assert_eq!(
7051 ctx.try_health_snapshot(Path::new("writer-root"))
7052 .tier2
7053 .expect("tier2 health")
7054 .status,
7055 "building"
7056 );
7057
7058 ctx.set_cache_role(true, None);
7059
7060 assert_eq!(
7061 ctx.try_health_snapshot(Path::new("worktree-root"))
7062 .tier2
7063 .expect("tier2 health")
7064 .status,
7065 "disabled"
7066 );
7067 let tier2_snapshot = ctx.tier2_refresh_snapshot().expect("tier2 snapshot");
7068 assert!(!tier2_snapshot.callgraph_writer);
7069 }
7070
7071 #[test]
7072 fn unbound_artifact_cancellation_clears_semantic_refresh_state() {
7073 let temp = tempfile::tempdir().unwrap();
7074 let ctx = AppContext::new(
7075 default_language_provider_factory(),
7076 Config {
7077 project_root: Some(temp.path().to_path_buf()),
7078 semantic_search: true,
7079 ..Config::default()
7080 },
7081 );
7082 *ctx.semantic_index()
7083 .write()
7084 .unwrap_or_else(std::sync::PoisonError::into_inner) =
7085 Some(SemanticIndex::new(temp.path().to_path_buf(), 3));
7086 let mut status = SemanticIndexStatus::ready();
7087 status.add_refreshing_file(temp.path().join("changed.rs"));
7088 *ctx.semantic_index_status()
7089 .write()
7090 .unwrap_or_else(std::sync::PoisonError::into_inner) = status;
7091 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
7092 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
7093 ctx.install_semantic_refresh_worker_for_build_epoch(
7094 request_tx,
7095 event_rx,
7096 Arc::new(Mutex::new(None)),
7097 ctx.semantic_index_rx_epoch(),
7098 );
7099
7100 ctx.cancel_unbound_artifact_work();
7101
7102 assert!(ctx.semantic_refresh_event_rx().lock().is_none());
7103 assert!(matches!(
7104 &*ctx
7105 .semantic_index_status()
7106 .read()
7107 .unwrap_or_else(std::sync::PoisonError::into_inner),
7108 SemanticIndexStatus::Ready { refreshing, .. } if refreshing.is_empty()
7109 ));
7110 }
7111
7112 #[test]
7113 fn terminal_empty_search_receiver_reports_completion_work() {
7114 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7115 let (sender, receiver) = crossbeam_channel::unbounded();
7116 let epoch = ctx.install_search_index_rx(receiver, ctx.configure_generation());
7117 let terminal_guard = ctx.search_index_rx_terminal_guard(epoch);
7118 drop(sender);
7119 drop(terminal_guard);
7120
7121 assert!(
7122 ctx.completion_drains_have_work(),
7123 "an empty disconnected one-shot receiver must wake the completion drain"
7124 );
7125 }
7126
7127 #[test]
7128 fn conditional_semantic_receiver_retire_preserves_replacement_epoch() {
7129 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7130 let (_old_sender, old_receiver) = crossbeam_channel::unbounded();
7131 let old_epoch = ctx.install_semantic_index_rx(old_receiver, ctx.configure_generation());
7132 let (_replacement_sender, replacement_receiver) = crossbeam_channel::unbounded();
7133 let replacement_epoch =
7134 ctx.install_semantic_index_rx(replacement_receiver, ctx.configure_generation());
7135
7136 assert!(replacement_epoch > old_epoch);
7137 assert_eq!(ctx.retire_semantic_index_rx_if_epoch(old_epoch), None);
7138 assert!(ctx.semantic_index_rx().lock().is_some());
7139 assert_eq!(ctx.semantic_index_rx_epoch(), replacement_epoch);
7140 }
7141
7142 #[test]
7143 fn stale_terminal_guard_cannot_hide_newer_finished_receiver() {
7144 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7145 let (old_sender, old_receiver) = crossbeam_channel::unbounded();
7146 let old_epoch = ctx.install_search_index_rx(old_receiver, ctx.configure_generation());
7147 let old_guard = ctx.search_index_rx_terminal_guard(old_epoch);
7148 let (current_sender, current_receiver) = crossbeam_channel::unbounded();
7149 let current_epoch =
7150 ctx.install_search_index_rx(current_receiver, ctx.configure_generation());
7151 let current_guard = ctx.search_index_rx_terminal_guard(current_epoch);
7152 drop(old_sender);
7153 drop(current_sender);
7154
7155 drop(current_guard);
7156 drop(old_guard);
7157
7158 assert!(current_epoch > old_epoch);
7159 assert_eq!(
7160 ctx.search_index_rx_terminal_epoch.load(Ordering::SeqCst),
7161 current_epoch,
7162 "a stale worker must not move the terminal watermark backward"
7163 );
7164 assert!(ctx.completion_drains_have_work());
7165 }
7166
7167 #[test]
7168 fn finished_semantic_refresh_worker_reports_completion_work() {
7169 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7170 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
7171 let (event_tx, event_rx) = crossbeam_channel::unbounded();
7172 let worker_slot = Arc::new(Mutex::new(Some(std::thread::spawn(|| {}))));
7173 ctx.install_semantic_refresh_worker_for_build_epoch(
7174 request_tx,
7175 event_rx,
7176 Arc::clone(&worker_slot),
7177 ctx.semantic_index_rx_epoch(),
7178 );
7179 drop(event_tx);
7180 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
7181 while !worker_slot
7182 .lock()
7183 .unwrap_or_else(std::sync::PoisonError::into_inner)
7184 .as_ref()
7185 .is_some_and(std::thread::JoinHandle::is_finished)
7186 {
7187 assert!(
7188 std::time::Instant::now() < deadline,
7189 "worker did not finish"
7190 );
7191 std::thread::yield_now();
7192 }
7193
7194 assert!(
7195 ctx.completion_drains_have_work(),
7196 "a finished refresh worker must wake the completion drain after its event queue empties"
7197 );
7198 }
7199
7200 #[test]
7201 fn unbound_lifecycle_rejects_all_deferred_worker_starts() {
7202 let admission = SubcLifecycleAdmission::default();
7203 let generation = Arc::new(AtomicU64::new(7));
7204 admission.mark_unbound(&generation);
7205 let expected = generation.load(Ordering::SeqCst);
7206 let starts = Arc::new(AtomicUsize::new(0));
7207
7208 let workers = (0..16)
7209 .map(|_| {
7210 let admission = admission.clone();
7211 let generation = Arc::clone(&generation);
7212 let starts = Arc::clone(&starts);
7213 std::thread::spawn(move || {
7214 admission.run_if_current(&generation, expected, || {
7215 starts.fetch_add(1, Ordering::SeqCst);
7216 })
7217 })
7218 })
7219 .collect::<Vec<_>>();
7220
7221 for worker in workers {
7222 assert!(worker.join().unwrap().is_none());
7223 }
7224 assert_eq!(starts.load(Ordering::SeqCst), 0);
7225 }
7226}
7227
7228#[cfg(test)]
7229mod force_restrict_tests {
7230 use super::*;
7231 use crate::language::StubProvider;
7232 use tempfile::TempDir;
7233
7234 fn test_context(project_root: Option<PathBuf>, restrict_to_project_root: bool) -> AppContext {
7235 AppContext::new(
7236 Box::new(StubProvider),
7237 Config {
7238 project_root,
7239 restrict_to_project_root,
7240 ..Config::default()
7241 },
7242 )
7243 }
7244
7245 #[test]
7246 fn standalone_validate_path_parity_without_force_restrict() {
7247 let root = TempDir::new().expect("root tempdir");
7248 let outside = TempDir::new().expect("outside tempdir");
7249 let outside_path = outside.path().join("outside.txt");
7250
7251 let unrestricted = test_context(Some(root.path().to_path_buf()), false);
7252 assert_eq!(
7253 unrestricted
7254 .validate_path("standalone-unrestricted", &outside_path)
7255 .expect("unrestricted standalone validates"),
7256 outside_path
7257 );
7258
7259 let restricted = test_context(Some(root.path().to_path_buf()), true);
7260 let err = restricted
7261 .validate_path("standalone-restricted", &outside_path)
7262 .expect_err("restricted standalone rejects outside root");
7263 assert_eq!(
7264 serde_json::to_value(err).unwrap()["code"],
7265 "path_outside_root"
7266 );
7267 }
7268
7269 #[test]
7270 fn path_restriction_root_memo_canonicalizes_once_for_1000_validations() {
7271 let root = TempDir::new().expect("root tempdir");
7272 let target = root.path().join("target.txt");
7273 std::fs::write(&target, "inside").expect("write target");
7274 let ctx = test_context(Some(root.path().to_path_buf()), true);
7275
7276 for request in 0..1_000 {
7277 let validated = ctx
7278 .validate_path(&format!("memo-{request}"), &target)
7279 .expect("in-root path validates");
7280 assert_eq!(validated, std::fs::canonicalize(&target).unwrap());
7281 }
7282
7283 assert_eq!(
7284 ctx.path_restriction_root_canonicalizations_for_test(),
7285 1,
7286 "the configured root should be canonicalized once instead of once per validation"
7287 );
7288 }
7289
7290 #[cfg(unix)]
7291 #[test]
7292 fn path_restriction_root_memo_recanonicalizes_after_cached_target_disappears() {
7293 let workspace = TempDir::new().expect("workspace tempdir");
7294 let first_target = workspace.path().join("first-target");
7295 let second_target = workspace.path().join("second-target");
7296 let configured_root = workspace.path().join("configured-root");
7297 std::fs::create_dir_all(&first_target).expect("create first target");
7298 std::fs::create_dir_all(&second_target).expect("create second target");
7299 std::os::unix::fs::symlink(&first_target, &configured_root)
7300 .expect("create configured-root symlink");
7301 std::fs::write(first_target.join("inside.txt"), "first").expect("write first target");
7302
7303 let ctx = test_context(Some(configured_root.clone()), true);
7304 assert_eq!(
7305 ctx.validate_path("first-target", Path::new("inside.txt"))
7306 .expect("first target validates"),
7307 std::fs::canonicalize(first_target.join("inside.txt")).unwrap()
7308 );
7309
7310 std::fs::remove_dir_all(&first_target).expect("remove first target");
7313 std::fs::remove_file(&configured_root).expect("remove old root symlink");
7314 std::os::unix::fs::symlink(&second_target, &configured_root)
7315 .expect("recreate configured-root symlink");
7316 std::fs::write(second_target.join("inside.txt"), "second").expect("write second target");
7317
7318 assert_eq!(
7319 ctx.validate_path("second-target", Path::new("inside.txt"))
7320 .expect("second target validates"),
7321 std::fs::canonicalize(second_target.join("inside.txt")).unwrap()
7322 );
7323 assert_eq!(ctx.path_restriction_root_canonicalizations_for_test(), 2);
7324 }
7325
7326 #[test]
7327 fn force_restrict_guard_refcounts_duplicate_request_ids() {
7328 let root = TempDir::new().expect("root tempdir");
7329 let outside = TempDir::new().expect("outside tempdir");
7330 let outside_path = outside.path().join("outside.txt");
7331 let ctx = test_context(Some(root.path().to_path_buf()), false);
7332
7333 assert!(ctx.validate_path("dup", &outside_path).is_ok());
7334 let guard1 = ctx.force_restrict_guard("dup");
7335 let guard2 = ctx.force_restrict_guard("dup");
7336 assert!(ctx.validate_path("dup", &outside_path).is_err());
7337 drop(guard1);
7338 assert!(
7339 ctx.validate_path("dup", &outside_path).is_err(),
7340 "duplicate guard must keep the request over-restricted"
7341 );
7342 drop(guard2);
7343 assert!(ctx.validate_path("dup", &outside_path).is_ok());
7344 }
7345
7346 #[test]
7347 fn with_force_restrict_cleans_up_after_normal_completion_and_panic() {
7348 let root = TempDir::new().expect("root tempdir");
7349 let outside = TempDir::new().expect("outside tempdir");
7350 let outside_path = outside.path().join("outside.txt");
7351 let ctx = test_context(Some(root.path().to_path_buf()), false);
7352
7353 ctx.with_force_restrict("normal", || {
7354 assert!(ctx.validate_path("normal", &outside_path).is_err());
7355 });
7356 assert!(!ctx.request_force_restrict("normal"));
7357 assert!(ctx.validate_path("normal", &outside_path).is_ok());
7358
7359 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7360 ctx.with_force_restrict("panic", || {
7361 assert!(ctx.validate_path("panic", &outside_path).is_err());
7362 panic!("intentional force-restrict cleanup panic");
7363 });
7364 }));
7365 assert!(panicked.is_err());
7366 assert!(!ctx.request_force_restrict("panic"));
7367 assert!(ctx.validate_path("panic", &outside_path).is_ok());
7368 }
7369
7370 #[cfg(unix)]
7371 #[test]
7372 fn validate_write_location_keeps_final_symlink_as_the_authorized_location() {
7373 let root = TempDir::new().expect("root tempdir");
7374 let outside = tempfile::NamedTempFile::new().expect("outside file");
7375 let link = root.path().join("file.txt");
7376 std::os::unix::fs::symlink(outside.path(), &link).expect("create final symlink");
7377 let ctx = test_context(Some(root.path().to_path_buf()), false);
7378 let _guard = ctx.force_restrict_guard("write-location-final-link");
7379
7380 let validated = ctx
7381 .validate_write_location("write-location-final-link", &link)
7382 .expect("the in-root link location is writable");
7383
7384 assert_eq!(
7385 validated,
7386 std::fs::canonicalize(root.path()).unwrap().join("file.txt")
7387 );
7388 }
7389
7390 #[cfg(unix)]
7391 #[test]
7392 fn validate_write_location_rejects_symlinked_parent_escape() {
7393 let root = TempDir::new().expect("root tempdir");
7394 let outside = TempDir::new().expect("outside tempdir");
7395 let linked_parent = root.path().join("linked-parent");
7396 std::os::unix::fs::symlink(outside.path(), &linked_parent).expect("create parent symlink");
7397 let candidate = linked_parent.join("file.txt");
7398 let ctx = test_context(Some(root.path().to_path_buf()), false);
7399 let _guard = ctx.force_restrict_guard("write-location-parent-link");
7400
7401 let error = ctx
7402 .validate_write_location("write-location-parent-link", &candidate)
7403 .expect_err("a symlinked parent must not escape the project root");
7404
7405 assert_eq!(
7406 serde_json::to_value(error).unwrap()["code"],
7407 "path_outside_root"
7408 );
7409 }
7410
7411 #[cfg(unix)]
7412 #[test]
7413 fn validate_write_location_rejects_outside_link_to_inside_file() {
7414 let root = TempDir::new().expect("root tempdir");
7415 let outside = TempDir::new().expect("outside tempdir");
7416 let inside = root.path().join("inside.txt");
7417 std::fs::write(&inside, "inside").unwrap();
7418 let outside_link = outside.path().join("outside-link.txt");
7419 std::os::unix::fs::symlink(&inside, &outside_link).expect("create outside symlink");
7420 let ctx = test_context(Some(root.path().to_path_buf()), false);
7421 let _guard = ctx.force_restrict_guard("write-location-outside-link");
7422
7423 let error = ctx
7424 .validate_write_location("write-location-outside-link", &outside_link)
7425 .expect_err("an out-of-root lexical location must remain blocked");
7426
7427 assert_eq!(
7428 serde_json::to_value(error).unwrap()["code"],
7429 "path_outside_root"
7430 );
7431 }
7432
7433 #[test]
7434 fn forced_restrict_without_project_root_fails_closed() {
7435 let ctx = test_context(None, false);
7436 let _guard = ctx.force_restrict_guard("missing-root");
7437 let err = ctx
7438 .validate_path("missing-root", Path::new("relative.txt"))
7439 .expect_err("forced restriction without a root must fail closed");
7440 assert_eq!(
7441 serde_json::to_value(err).unwrap()["code"],
7442 "path_outside_root"
7443 );
7444
7445 let write_err = ctx
7446 .validate_write_location("missing-root", Path::new("relative.txt"))
7447 .expect_err("write-location validation must also fail closed");
7448 assert_eq!(
7449 serde_json::to_value(write_err).unwrap()["code"],
7450 "path_outside_root"
7451 );
7452 }
7453}
7454
7455#[cfg(test)]
7456mod callgraph_store_for_ops_tests {
7457 use super::*;
7458 use crate::inspect::{InspectCategory, InspectSnapshot, JobOutcome, JobScope};
7459 use crate::parser::TreeSitterProvider;
7460 use crate::protocol::RawRequest;
7461 use serde_json::json;
7462 use std::path::Path;
7463 use std::sync::Barrier;
7464 use tempfile::TempDir;
7465
7466 fn callgraph_build_wait_ms(ms: u64) -> super::CallgraphBuildWaitMsGuard {
7467 super::override_callgraph_build_wait_ms_for_test(ms)
7468 }
7469
7470 fn force_async_callgraph_builds() -> super::CallgraphBuildWaitMsGuard {
7471 callgraph_build_wait_ms(0)
7472 }
7473
7474 fn cold_build_context() -> Arc<AppContext> {
7475 let project = TempDir::new().expect("project tempdir");
7476 let storage = TempDir::new().expect("storage tempdir");
7477 let source_dir = project.path().join("src");
7478 std::fs::create_dir_all(&source_dir).expect("source dir");
7479 std::fs::write(
7480 source_dir.join("lib.rs"),
7481 "pub fn caller() { callee(); }\npub fn callee() {}\n",
7482 )
7483 .expect("source file");
7484
7485 Arc::new(AppContext::new(
7486 Box::new(TreeSitterProvider::new()),
7487 Config {
7488 project_root: Some(project.keep()),
7489 storage_dir: Some(storage.keep()),
7490 callgraph_chunk_size: 1,
7491 ..Config::default()
7492 },
7493 ))
7494 }
7495
7496 fn with_fake_home_env<R>(home: &Path, f: impl FnOnce() -> R) -> R {
7497 let _guard = crate::test_env::process_env_lock();
7498 let prev_home = std::env::var_os("HOME");
7499 let prev_userprofile = std::env::var_os("USERPROFILE");
7500 unsafe {
7501 std::env::set_var("HOME", home);
7502 std::env::set_var("USERPROFILE", home);
7503 }
7504 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
7505 unsafe {
7506 match prev_home {
7507 Some(value) => std::env::set_var("HOME", value),
7508 None => std::env::remove_var("HOME"),
7509 }
7510 match prev_userprofile {
7511 Some(value) => std::env::set_var("USERPROFILE", value),
7512 None => std::env::remove_var("USERPROFILE"),
7513 }
7514 }
7515 match result {
7516 Ok(value) => value,
7517 Err(payload) => std::panic::resume_unwind(payload),
7518 }
7519 }
7520
7521 fn configure_request_with_params(params: serde_json::Value) -> RawRequest {
7522 RawRequest {
7523 id: "cfg".to_string(),
7524 command: "configure".to_string(),
7525 lsp_hints: None,
7526 session_id: None,
7527 params,
7528 }
7529 }
7530
7531 fn user_tier(doc: serde_json::Value) -> serde_json::Value {
7532 json!({
7533 "tier": "user",
7534 "source": "/u/aft.jsonc",
7535 "doc": doc.to_string(),
7536 })
7537 }
7538
7539 fn configure_context(project_root: &Path, storage_dir: &Path) -> AppContext {
7540 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7541 let response = crate::commands::configure::handle_configure(
7542 &configure_request_with_params(json!({
7543 "project_root": project_root,
7544 "harness": "opencode",
7545 "storage_dir": storage_dir,
7546 "config": [user_tier(json!({
7547 "callgraph_store": true,
7548 "search_index": true,
7549 "semantic_search": true,
7550 }))],
7551 })),
7552 &ctx,
7553 );
7554 assert!(response.success, "configure should succeed: {response:?}");
7555 ctx
7556 }
7557
7558 fn inspect_snapshot(ctx: &AppContext) -> InspectSnapshot {
7559 InspectSnapshot::new(
7560 ctx.canonical_cache_root(),
7561 ctx.inspect_dir(),
7562 ctx.config(),
7563 ctx.symbol_cache(),
7564 )
7565 }
7566
7567 fn empty_semantic_index_for_ctx(ctx: &AppContext) -> SemanticIndex {
7568 let project_root = ctx
7569 .config()
7570 .project_root
7571 .clone()
7572 .expect("test context has a project root");
7573 let files: Vec<PathBuf> = Vec::new();
7574 let mut embed = |_texts: Vec<String>| -> Result<Vec<Vec<f32>>, String> { Ok(Vec::new()) };
7575 SemanticIndex::build(&project_root, &files, &mut embed, 1)
7576 .expect("empty semantic index should build")
7577 }
7578
7579 #[test]
7580 fn home_root_gate_blocks_callgraph_store_entry_points() {
7581 let _wait_guard = force_async_callgraph_builds();
7582 let home = TempDir::new().expect("home tempdir");
7583 let storage = TempDir::new().expect("storage tempdir");
7584 let source_dir = home.path().join("src");
7585 std::fs::create_dir_all(&source_dir).expect("source dir");
7586 std::fs::write(
7587 source_dir.join("lib.rs"),
7588 "pub fn caller() { callee(); }\npub fn callee() {}\n",
7589 )
7590 .expect("source file");
7591
7592 with_fake_home_env(home.path(), || {
7593 let ctx = configure_context(home.path(), storage.path());
7594 assert!(
7595 !ctx.heavy_root_work_allowed(),
7596 "HOME root configure must close the heavy-root-work gate"
7597 );
7598 assert!(
7599 !ctx.config().callgraph_store,
7600 "HOME root configure must force-disable the callgraph store"
7601 );
7602 assert!(ctx.is_home_root());
7603 assert!(ctx
7604 .degraded_reasons()
7605 .iter()
7606 .any(|reason| reason == "home_root"));
7607 let status_request = RawRequest {
7608 id: "home-status".to_string(),
7609 command: "status".to_string(),
7610 lsp_hints: None,
7611 session_id: None,
7612 params: json!({}),
7613 };
7614 let status = crate::commands::status::handle_status(&status_request, &ctx);
7615 assert_eq!(status.data["features"]["callgraph_store"], false);
7616 crate::commands::configure::drain_deferred_configure_maintenance(&ctx);
7617 assert!(
7618 ctx.callgraph_store_rx().lock().is_none(),
7619 "HOME root maintenance must not schedule a callgraph build"
7620 );
7621 assert_eq!(
7622 ctx.try_health_snapshot(home.path())
7623 .callgraph_store
7624 .as_ref()
7625 .map(|component| component.status),
7626 Some("disabled"),
7627 "HOME root health must not advertise callgraph building"
7628 );
7629
7630 reset_callgraph_cold_build_spawn_count_for_test();
7631 assert!(matches!(
7632 ctx.callgraph_store_for_ops(),
7633 CallgraphStoreAccess::Unavailable
7634 ));
7635 assert!(
7636 ctx.ensure_callgraph_store()
7637 .expect("ensure_callgraph_store should not error")
7638 .is_none(),
7639 "shared gate must also block synchronous standalone callgraph builds"
7640 );
7641 assert_eq!(
7642 callgraph_cold_build_spawn_count_for_test(),
7643 0,
7644 "HOME root gate must not spawn a cold callgraph build"
7645 );
7646
7647 let navigation = RawRequest {
7648 id: "home-callers".to_string(),
7649 command: "callers".to_string(),
7650 lsp_hints: None,
7651 session_id: None,
7652 params: json!({
7653 "file": source_dir.join("lib.rs"),
7654 "symbol": "caller",
7655 }),
7656 };
7657 let response = crate::commands::callers::handle_callers(&navigation, &ctx);
7658 assert!(!response.success);
7659 assert_eq!(response.data["code"], "callgraph_disabled");
7660 assert_eq!(response.data["status"], "disabled");
7661 assert_eq!(response.data["reason"], "home_root");
7662 assert!(response.data["message"]
7663 .as_str()
7664 .is_some_and(|message| message.contains("disabled for home roots")));
7665 });
7666 }
7667
7668 #[test]
7669 fn home_root_gate_blocks_inspect_manager_submit_paths() {
7670 let home = TempDir::new().expect("home tempdir");
7671 let storage = TempDir::new().expect("storage tempdir");
7672 let source_dir = home.path().join("src");
7673 std::fs::create_dir_all(&source_dir).expect("source dir");
7674 std::fs::write(source_dir.join("lib.rs"), "pub fn one() {}\n").expect("source file");
7675
7676 with_fake_home_env(home.path(), || {
7677 let ctx = configure_context(home.path(), storage.path());
7678 let snapshot = inspect_snapshot(&ctx);
7679 let scope = JobScope::for_project(snapshot.project_root.clone());
7680 let manager = ctx.inspect_manager();
7681
7682 assert!(matches!(
7683 manager.submit_category(snapshot.clone(), InspectCategory::Metrics, scope.clone()),
7684 JobOutcome::Failed { .. }
7685 ));
7686
7687 let submission = manager.submit_tier2_run_with_reuse_serial_background(
7688 snapshot,
7689 vec![InspectCategory::DeadCode],
7690 );
7691 assert!(submission.queued_categories.is_empty());
7692 assert!(submission.newly_queued_categories.is_empty());
7693 assert!(submission.deferred_categories.is_empty());
7694 assert_eq!(submission.errors.len(), 1);
7695 assert!(
7696 !manager.tier2_any_in_flight(),
7697 "HOME root gate must reject Tier-2 submission before any job is queued"
7698 );
7699 });
7700 }
7701
7702 #[test]
7703 fn non_home_root_still_allows_callgraph_cold_builds() {
7704 let _env_guard = force_async_callgraph_builds();
7705 reset_callgraph_cold_build_spawn_count_for_test();
7706 let ctx = cold_build_context();
7707
7708 assert!(ctx.heavy_root_work_allowed());
7709 assert!(matches!(
7710 ctx.callgraph_store_for_ops(),
7711 CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
7712 ));
7713 assert_eq!(
7714 callgraph_cold_build_spawn_count_for_test(),
7715 1,
7716 "non-home roots must still be able to cold-build the callgraph store"
7717 );
7718
7719 let rx = ctx
7720 .callgraph_store_rx
7721 .lock()
7722 .as_ref()
7723 .cloned()
7724 .expect("non-home cold build should install an in-flight receiver");
7725 rx.recv_timeout(Duration::from_secs(30))
7726 .expect("background cold build should complete");
7727 *ctx.callgraph_store_rx.lock() = None;
7728 }
7729
7730 #[test]
7731 fn semantic_ready_event_resumes_deferred_callgraph_and_tier2() {
7732 let _env_guard = force_async_callgraph_builds();
7733 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
7734 let ctx = cold_build_context();
7735 let (tx, rx) = crossbeam_channel::unbounded();
7736 *ctx.semantic_index_rx().lock() = Some(rx);
7737 ctx.schedule_semantic_cold_seed_gate_for_configure();
7738
7739 assert!(matches!(
7740 ctx.callgraph_store_for_ops(),
7741 CallgraphStoreAccess::Building
7742 ));
7743 assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
7744 tx.send(SemanticIndexEvent::Ready(empty_semantic_index_for_ctx(
7745 &ctx,
7746 )))
7747 .expect("send ready event");
7748
7749 crate::runtime_drain::drain_semantic_index_events(&ctx);
7750
7751 assert!(
7752 !ctx.semantic_cold_seed_active(),
7753 "semantic Ready must clear the scheduled cold gate"
7754 );
7755 assert!(
7756 ctx.tier2_pull_demand_pending(),
7757 "semantic Ready must resume deferred Tier-2 work"
7758 );
7759 assert_eq!(
7760 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
7761 1,
7762 "semantic Ready must resume the deferred callgraph warm"
7763 );
7764 let rx = ctx
7765 .callgraph_store_rx
7766 .lock()
7767 .as_ref()
7768 .cloned()
7769 .expect("ready resume should install an in-flight callgraph receiver");
7770 rx.recv_timeout(Duration::from_secs(30))
7771 .expect("background cold build should complete");
7772 *ctx.callgraph_store_rx.lock() = None;
7773 }
7774
7775 #[test]
7776 fn semantic_gate_cleared_event_resumes_deferred_callgraph_and_tier2() {
7777 let _env_guard = force_async_callgraph_builds();
7778 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
7779 let ctx = cold_build_context();
7780 ctx.schedule_semantic_cold_seed_gate_for_configure();
7781
7782 assert!(matches!(
7783 ctx.callgraph_store_for_ops(),
7784 CallgraphStoreAccess::Building
7785 ));
7786 assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
7787 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
7788
7789 assert!(
7790 !ctx.semantic_cold_seed_active(),
7791 "cached-load or retry-wait clear must reopen the semantic cold gate"
7792 );
7793 assert!(
7794 ctx.tier2_pull_demand_pending(),
7795 "cached-load or retry-wait clear must resume deferred Tier-2 work"
7796 );
7797 assert_eq!(
7798 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
7799 1,
7800 "cached-load or retry-wait clear must resume deferred callgraph warm"
7801 );
7802 let rx = ctx
7803 .callgraph_store_rx
7804 .lock()
7805 .as_ref()
7806 .cloned()
7807 .expect("gate-clear resume should install an in-flight callgraph receiver");
7808 rx.recv_timeout(Duration::from_secs(30))
7809 .expect("background cold build should complete");
7810 *ctx.callgraph_store_rx.lock() = None;
7811 }
7812
7813 #[test]
7814 fn semantic_cold_seed_gate_defers_callgraph_cold_spawn_until_resume() {
7815 let _env_guard = force_async_callgraph_builds();
7816 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
7817 let ctx = cold_build_context();
7818
7819 ctx.set_semantic_cold_seed_active_for_test(true);
7820 assert!(
7821 matches!(
7822 ctx.callgraph_store_for_ops(),
7823 CallgraphStoreAccess::Building
7824 ),
7825 "callgraph ops should degrade as building while the semantic cold gate is active"
7826 );
7827 assert_eq!(
7828 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
7829 0,
7830 "semantic cold gate must not spawn a competing callgraph cold build"
7831 );
7832 assert!(ctx.semantic_callgraph_warm_deferred_for_test());
7833
7834 ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
7835 assert_eq!(
7836 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
7837 1,
7838 "clearing the semantic cold gate should resume the deferred callgraph warm"
7839 );
7840
7841 let rx = ctx
7842 .callgraph_store_rx
7843 .lock()
7844 .as_ref()
7845 .cloned()
7846 .expect("deferred warm should install an in-flight receiver");
7847 rx.recv_timeout(Duration::from_secs(30))
7848 .expect("background cold build should complete");
7849 *ctx.callgraph_store_rx.lock() = None;
7850 }
7851
7852 #[test]
7853 fn semantic_cold_seed_gate_clear_requests_tier2_pull() {
7854 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7855 ctx.schedule_semantic_cold_seed_gate_for_configure();
7856
7857 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
7858
7859 assert!(
7860 !ctx.semantic_cold_seed_active(),
7861 "retry-wait or cached-load events must reopen the semantic cold gate"
7862 );
7863 assert!(
7864 ctx.tier2_pull_demand_pending(),
7865 "clearing the semantic cold gate should kick a Tier-2 pull refresh"
7866 );
7867 }
7868
7869 #[test]
7870 fn semantic_failed_event_clears_scheduled_gate_and_requests_tier2_pull() {
7871 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7872 let (tx, rx) = crossbeam_channel::unbounded();
7873 *ctx.semantic_index_rx().lock() = Some(rx);
7874 ctx.schedule_semantic_cold_seed_gate_for_configure();
7875 tx.send(SemanticIndexEvent::Failed(
7876 "embedding backend failed".to_string(),
7877 ))
7878 .expect("send failed event");
7879
7880 crate::runtime_drain::drain_semantic_index_events(&ctx);
7881
7882 assert!(
7883 !ctx.semantic_cold_seed_active(),
7884 "semantic Failed must clear the scheduled cold gate"
7885 );
7886 assert!(
7887 ctx.tier2_pull_demand_pending(),
7888 "semantic Failed must resume deferred Tier-2 work"
7889 );
7890 }
7891
7892 #[test]
7893 fn semantic_disconnect_clears_scheduled_gate_and_requests_tier2_pull() {
7894 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7895 let (tx, rx) = crossbeam_channel::unbounded::<SemanticIndexEvent>();
7896 *ctx.semantic_index_rx().lock() = Some(rx);
7897 ctx.schedule_semantic_cold_seed_gate_for_configure();
7898 drop(tx);
7899
7900 crate::runtime_drain::drain_semantic_index_events(&ctx);
7901
7902 assert!(
7903 !ctx.semantic_cold_seed_active(),
7904 "semantic worker disconnect must clear the scheduled cold gate"
7905 );
7906 assert!(
7907 ctx.tier2_pull_demand_pending(),
7908 "semantic worker disconnect must resume deferred Tier-2 work"
7909 );
7910 }
7911
7912 #[test]
7913 fn semantic_cold_seed_gate_is_per_context_for_tier2_scheduler() {
7914 let ctx_a = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7915 let ctx_b = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7916 let base = Instant::now();
7917 ctx_a.reset_tier2_refresh_scheduler_at(base);
7918 ctx_b.reset_tier2_refresh_scheduler_at(base);
7919 ctx_a.set_semantic_cold_seed_active_for_test(true);
7920
7921 assert_eq!(
7922 ctx_a.tick_tier2_refresh_scheduler_at(
7923 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
7924 0,
7925 ),
7926 None,
7927 "root A should defer Tier-2 while its semantic cold seed is active"
7928 );
7929 assert_eq!(
7930 ctx_b.tick_tier2_refresh_scheduler_at(
7931 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
7932 0,
7933 ),
7934 Some(Tier2TriggerReason::ConfigureWarm),
7935 "root B must not inherit root A's semantic cold gate"
7936 );
7937 }
7938
7939 #[test]
7940 fn query_wait_joins_callgraph_build_scheduled_without_wait() {
7941 let _env_guard = callgraph_build_wait_ms(10_000);
7942 let project = TempDir::new().expect("project tempdir");
7943 let storage = TempDir::new().expect("storage tempdir");
7944 std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
7945 let project_root = std::fs::canonicalize(project.path()).expect("canonical project root");
7946 let project_key = crate::search_index::artifact_cache_key(&project_root);
7947 crate::root_cache::configure_artifact_access(&project_root, &project_key, false);
7948 let ctx = Arc::new(AppContext::new(
7949 Box::new(TreeSitterProvider::new()),
7950 Config {
7951 project_root: Some(project_root.clone()),
7952 storage_dir: Some(storage.path().to_path_buf()),
7953 callgraph_chunk_size: 1,
7954 ..Config::default()
7955 },
7956 ));
7957 let (reached, release) = install_callgraph_build_start_gate(project_root);
7958
7959 assert!(matches!(
7960 ctx.schedule_callgraph_store_warm(),
7961 CallgraphStoreAccess::Building
7962 ));
7963 reached
7964 .recv_timeout(Duration::from_secs(2))
7965 .expect("scheduled callgraph worker did not reach start barrier");
7966
7967 let (result_tx, result_rx) = std::sync::mpsc::channel();
7968 let query_ctx = Arc::clone(&ctx);
7969 let query = std::thread::spawn(move || {
7970 result_tx
7971 .send(query_ctx.callgraph_store_for_ops())
7972 .expect("send query result");
7973 });
7974 assert!(
7975 matches!(
7976 result_rx.recv_timeout(Duration::from_millis(100)),
7977 Err(std::sync::mpsc::RecvTimeoutError::Timeout)
7978 ),
7979 "query returned while the scheduled callgraph build was still in flight"
7980 );
7981
7982 release.send(()).expect("release callgraph worker");
7983 assert!(matches!(
7984 result_rx
7985 .recv_timeout(Duration::from_secs(10))
7986 .expect("query did not settle after the callgraph build completed"),
7987 CallgraphStoreAccess::Ready(_)
7988 ));
7989 query.join().expect("callgraph query thread");
7990 }
7991
7992 #[test]
7993 fn inline_wait_settled_event_clears_superseded_receiver() {
7994 let _env_guard = callgraph_build_wait_ms(2_000);
7995 let project = TempDir::new().expect("project tempdir");
7996 let storage = TempDir::new().expect("storage tempdir");
7997 std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
7998 let project_root = std::fs::canonicalize(project.path()).expect("canonical project root");
7999 let ctx = Arc::new(AppContext::new(
8000 Box::new(TreeSitterProvider::new()),
8001 Config {
8002 project_root: Some(project.path().to_path_buf()),
8003 storage_dir: Some(storage.path().to_path_buf()),
8004 callgraph_chunk_size: 1,
8005 ..Config::default()
8006 },
8007 ));
8008 let (reached, release) = install_callgraph_build_start_gate(project_root);
8009 let request_ctx = Arc::clone(&ctx);
8010 let request = std::thread::spawn(move || request_ctx.callgraph_store_for_ops());
8011 reached
8012 .recv_timeout(Duration::from_secs(2))
8013 .expect("callgraph worker did not reach start barrier");
8014
8015 ctx.next_callgraph_persist_epoch();
8016 release.send(()).unwrap();
8017 assert!(matches!(
8018 request.join().expect("callgraph request thread"),
8019 CallgraphStoreAccess::Building
8020 ));
8021 assert!(
8022 ctx.callgraph_store_rx().lock().is_none(),
8023 "inline Settled handling must retire the matching receiver"
8024 );
8025 assert!(
8026 ctx.callgraph_store()
8027 .read()
8028 .unwrap_or_else(std::sync::PoisonError::into_inner)
8029 .is_none(),
8030 "Settled must not reopen and install an older persisted store"
8031 );
8032 }
8033
8034 #[test]
8035 fn inline_ready_without_published_pointer_settles_and_preserves_pending_paths() {
8036 let _env_guard = callgraph_build_wait_ms(2_000);
8037 let project = TempDir::new().expect("project tempdir");
8038 let storage = TempDir::new().expect("storage tempdir");
8039 std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
8040 let ctx = AppContext::new(
8041 Box::new(TreeSitterProvider::new()),
8042 Config {
8043 project_root: Some(project.path().to_path_buf()),
8044 storage_dir: Some(storage.path().to_path_buf()),
8045 callgraph_chunk_size: 1,
8046 ..Config::default()
8047 },
8048 );
8049 let project_key = crate::search_index::artifact_cache_key(project.path());
8050 crate::root_cache::configure_artifact_access(project.path(), &project_key, false);
8051 let pending = project.path().join("pending.rs");
8052 ctx.add_pending_callgraph_store_paths([pending.clone()]);
8053 REMOVE_CALLGRAPH_POINTER_BEFORE_INLINE_REOPEN.store(true, Ordering::SeqCst);
8054 let _remove_pointer_guard = RemoveCallgraphPointerBeforeInlineReopenGuard;
8055
8056 assert!(matches!(
8057 ctx.callgraph_store_for_ops(),
8058 CallgraphStoreAccess::Building
8059 ));
8060 assert!(
8061 ctx.callgraph_store_rx().lock().is_none(),
8062 "inline Ready must settle after the published pointer disappears"
8063 );
8064 assert_eq!(
8065 ctx.take_pending_callgraph_store_paths(),
8066 vec![pending],
8067 "inline reopen failure must preserve pending watcher paths"
8068 );
8069 }
8070
8071 #[test]
8072 fn take_pending_callgraph_store_paths_drops_paths_outside_current_root() {
8073 let project = TempDir::new().expect("project tempdir");
8074 let foreign = TempDir::new().expect("foreign tempdir");
8075 let ctx = AppContext::new(
8076 Box::new(TreeSitterProvider::new()),
8077 Config {
8078 project_root: Some(project.path().to_path_buf()),
8079 ..Config::default()
8080 },
8081 );
8082 let inside = project.path().join("kept.rs");
8083 let outside = foreign.path().join("previous-root-file.rs");
8087 let dotdot_escape = project
8090 .path()
8091 .join("..")
8092 .join(
8093 foreign
8094 .path()
8095 .file_name()
8096 .expect("foreign tempdir has a name"),
8097 )
8098 .join("escaped.rs");
8099 ctx.add_pending_callgraph_store_paths([inside.clone(), outside, dotdot_escape]);
8100
8101 assert_eq!(
8102 ctx.take_pending_callgraph_store_paths(),
8103 vec![inside],
8104 "pending replay must drop foreign and dot-dot-escaping paths"
8105 );
8106 }
8107
8108 #[test]
8109 fn watcher_gap_invalidation_keeps_semantic_reloadable_and_skips_readonly_force_token() {
8110 let project = TempDir::new().expect("project tempdir");
8111 let ctx = AppContext::new(
8112 Box::new(TreeSitterProvider::new()),
8113 Config {
8114 project_root: Some(project.path().to_path_buf()),
8115 semantic_search: true,
8116 ..Config::default()
8117 },
8118 );
8119 ctx.set_canonical_cache_root(project.path().to_path_buf());
8120 ctx.set_cache_writer_capabilities(false, true);
8123 *ctx.semantic_index_status()
8124 .write()
8125 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
8126
8127 ctx.invalidate_artifacts_after_watcher_gap();
8128
8129 assert!(
8130 matches!(
8131 &*ctx
8132 .semantic_index_status()
8133 .read()
8134 .unwrap_or_else(std::sync::PoisonError::into_inner),
8135 SemanticIndexStatus::Ready { .. }
8136 ),
8137 "semantic-enabled root must stay reloadable (Disabled has no self-healing path)"
8138 );
8139 assert_eq!(
8140 ctx.pending_callgraph_store_force_token(),
8141 None,
8142 "read-only root must not be stuck behind an unfulfillable force token"
8143 );
8144 }
8145
8146 #[test]
8147 fn watcher_gap_invalidation_marks_force_rebuild_for_writer_roots() {
8148 let project = TempDir::new().expect("project tempdir");
8149 let ctx = AppContext::new(
8150 Box::new(TreeSitterProvider::new()),
8151 Config {
8152 project_root: Some(project.path().to_path_buf()),
8153 ..Config::default()
8154 },
8155 );
8156 ctx.set_canonical_cache_root(project.path().to_path_buf());
8157 ctx.set_cache_writer_capabilities(true, true);
8158
8159 ctx.invalidate_artifacts_after_watcher_gap();
8160
8161 assert!(
8162 ctx.pending_callgraph_store_force_token().is_some(),
8163 "writer roots must still reconcile the store after the unobserved interval"
8164 );
8165 assert!(
8166 matches!(
8167 &*ctx
8168 .semantic_index_status()
8169 .read()
8170 .unwrap_or_else(std::sync::PoisonError::into_inner),
8171 SemanticIndexStatus::Disabled
8172 ),
8173 "semantic-disabled config maps to Disabled status"
8174 );
8175 }
8176
8177 #[cfg(unix)]
8178 #[test]
8179 fn take_pending_callgraph_store_paths_drops_symlink_dotdot_escape() {
8180 let project = TempDir::new().expect("project tempdir");
8181 let foreign = TempDir::new().expect("foreign tempdir");
8182 std::fs::create_dir_all(foreign.path().join("dir")).expect("foreign dir");
8183 std::fs::write(foreign.path().join("secret.rs"), "pub fn s() {}\n").expect("secret");
8184 let ctx = AppContext::new(
8185 Box::new(TreeSitterProvider::new()),
8186 Config {
8187 project_root: Some(project.path().to_path_buf()),
8188 ..Config::default()
8189 },
8190 );
8191 std::os::unix::fs::symlink(foreign.path().join("dir"), project.path().join("link"))
8196 .expect("plant symlink");
8197 let escape = project.path().join("link").join("..").join("secret.rs");
8198 let dead_component_escape = project
8203 .path()
8204 .join("link")
8205 .join("dead")
8206 .join("..")
8207 .join("..")
8208 .join("deep-secret.rs");
8209 std::fs::write(foreign.path().join("reentry-secret.rs"), "pub fn r() {}\n")
8214 .expect("reentry secret");
8215 let reentry_escape = project
8216 .path()
8217 .join("dead")
8218 .join("..")
8219 .join("link")
8220 .join("..")
8221 .join("reentry-secret.rs");
8222 std::os::unix::fs::symlink(
8227 foreign.path().join("nonexistent-target"),
8228 project.path().join("dangling"),
8229 )
8230 .expect("plant dangling symlink");
8231 let dangling_reentry = project
8232 .path()
8233 .join("dangling")
8234 .join("..")
8235 .join("via-dangling.rs");
8236 std::fs::write(project.path().join("plain.rs"), "pub fn p() {}\n").expect("plain file");
8239 let through_file = project
8240 .path()
8241 .join("plain.rs")
8242 .join("..")
8243 .join("via-file.rs");
8244 let kept = project.path().join("kept.rs");
8245 ctx.add_pending_callgraph_store_paths([
8246 escape,
8247 dead_component_escape,
8248 reentry_escape,
8249 dangling_reentry,
8250 through_file,
8251 kept.clone(),
8252 ]);
8253
8254 assert_eq!(
8255 ctx.take_pending_callgraph_store_paths(),
8256 vec![kept],
8257 "symlink-plus-dotdot escapes must be dropped with filesystem-first semantics"
8258 );
8259 }
8260
8261 #[cfg(windows)]
8262 #[test]
8263 fn take_pending_callgraph_store_paths_drops_drive_relative_paths() {
8264 let cwd = std::env::current_dir().expect("drive cwd");
8271 let cwd_file = PathBuf::from(format!(
8272 "{}under-drive-cwd.rs",
8273 cwd.components()
8274 .next()
8275 .map(|prefix| prefix.as_os_str().to_string_lossy().into_owned())
8276 .expect("drive prefix")
8277 ));
8278 assert!(cwd_file.is_relative(), "C:foo must classify as relative");
8279 assert!(
8280 !pending_path_in_roots(&cwd_file, &[cwd.clone()]),
8281 "drive-relative spelling must be rejected even when the drive CWD is inside the root"
8282 );
8283 assert!(
8284 !pending_path_in_roots(Path::new(r"\root-relative.rs"), &[cwd]),
8285 "root-relative spelling must be rejected"
8286 );
8287
8288 let project = TempDir::new().expect("project tempdir");
8289 let ctx = AppContext::new(
8290 Box::new(TreeSitterProvider::new()),
8291 Config {
8292 project_root: Some(project.path().to_path_buf()),
8293 ..Config::default()
8294 },
8295 );
8296 let kept = project.path().join("kept.rs");
8297 ctx.add_pending_callgraph_store_paths([
8298 PathBuf::from("C:drive-relative.rs"),
8299 PathBuf::from(r"\root-relative.rs"),
8300 kept.clone(),
8301 ]);
8302
8303 assert_eq!(
8304 ctx.take_pending_callgraph_store_paths(),
8305 vec![kept],
8306 "drive-relative and root-relative spellings must be rejected"
8307 );
8308 }
8309
8310 #[test]
8311 fn take_pending_callgraph_store_paths_keeps_relative_and_deleted_paths() {
8312 let project = TempDir::new().expect("project tempdir");
8313 let ctx = AppContext::new(
8314 Box::new(TreeSitterProvider::new()),
8315 Config {
8316 project_root: Some(project.path().to_path_buf()),
8317 ..Config::default()
8318 },
8319 );
8320 let relative = PathBuf::from("src/relative.rs");
8323 let deleted = project.path().join("never-created.rs");
8324 ctx.add_pending_callgraph_store_paths([relative.clone(), deleted.clone()]);
8325
8326 let mut taken = ctx.take_pending_callgraph_store_paths();
8327 taken.sort();
8328 let mut expected = vec![relative, deleted];
8329 expected.sort();
8330 assert_eq!(
8331 taken, expected,
8332 "root-relative and deleted in-root paths must survive the filter"
8333 );
8334 }
8335
8336 #[test]
8337 fn writer_denied_callgraph_build_is_terminal_not_building() {
8338 let _env_guard = callgraph_build_wait_ms(30_000);
8339 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
8340
8341 let denied_ctx = cold_build_context();
8342 let denied_reason = match denied_ctx.callgraph_store_for_ops() {
8343 CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason)) => reason,
8344 CallgraphStoreAccess::Building => {
8345 panic!("writer-denied build must not remain in the retryable Building state")
8346 }
8347 _ => panic!("unregistered root must terminate with an unavailable reason"),
8348 };
8349 assert!(
8350 denied_reason.contains("could not acquire writer capability"),
8351 "terminal status must explain the writer-capability denial: {denied_reason}"
8352 );
8353 assert!(matches!(
8354 denied_ctx.callgraph_store_for_ops(),
8355 CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason))
8356 if reason.contains("could not acquire writer capability")
8357 ));
8358 assert_eq!(
8359 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8360 1,
8361 "polling a denied root must not spawn another doomed build"
8362 );
8363
8364 let writable_ctx = cold_build_context();
8367 let writable_root = writable_ctx
8368 .config()
8369 .project_root
8370 .clone()
8371 .expect("writable fixture root");
8372 let writable_key = crate::search_index::artifact_cache_key(&writable_root);
8373 crate::root_cache::configure_artifact_access(&writable_root, &writable_key, false);
8374 assert!(
8375 matches!(
8376 writable_ctx.callgraph_store_for_ops(),
8377 CallgraphStoreAccess::Ready(_)
8378 ),
8379 "removing the forced denial must change the terminal status"
8380 );
8381 }
8382
8383 #[test]
8384 fn concurrent_cold_callgraph_store_for_ops_spawns_one_build() {
8385 let _env_guard = force_async_callgraph_builds();
8386 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
8387
8388 let project = TempDir::new().expect("project tempdir");
8389 let storage = TempDir::new().expect("storage tempdir");
8390 let source_dir = project.path().join("src");
8391 std::fs::create_dir_all(&source_dir).expect("source dir");
8392 std::fs::write(
8393 source_dir.join("lib.rs"),
8394 "pub fn caller() { callee(); }\npub fn callee() {}\n",
8395 )
8396 .expect("source file");
8397
8398 let ctx = Arc::new(AppContext::new(
8399 Box::new(TreeSitterProvider::new()),
8400 Config {
8401 project_root: Some(project.path().to_path_buf()),
8402 storage_dir: Some(storage.path().to_path_buf()),
8403 callgraph_chunk_size: 1,
8404 ..Config::default()
8405 },
8406 ));
8407
8408 let barrier = Arc::new(Barrier::new(3));
8409 let handles = (0..2)
8410 .map(|_| {
8411 let ctx = Arc::clone(&ctx);
8412 let barrier = Arc::clone(&barrier);
8413 std::thread::spawn(move || {
8414 barrier.wait();
8415 matches!(
8416 ctx.callgraph_store_for_ops(),
8417 CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
8418 )
8419 })
8420 })
8421 .collect::<Vec<_>>();
8422
8423 barrier.wait();
8424 for handle in handles {
8425 assert!(
8426 handle.join().expect("callgraph caller thread"),
8427 "cold callgraph ops should report Building or observe the installed store"
8428 );
8429 }
8430
8431 assert_eq!(
8432 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8433 1,
8434 "concurrent cold callers must share one background build"
8435 );
8436
8437 let rx = ctx
8438 .callgraph_store_rx
8439 .lock()
8440 .as_ref()
8441 .cloned()
8442 .expect("in-flight receiver installed before spawn");
8443 rx.recv_timeout(Duration::from_secs(30))
8444 .expect("background cold build should complete");
8445 *ctx.callgraph_store_rx.lock() = None;
8446 }
8447
8448 #[test]
8449 fn watcher_gap_invalidation_gates_resident_artifacts_and_forces_strict_verify() {
8450 let root = TempDir::new().expect("project tempdir");
8451 let canonical_root = std::fs::canonicalize(root.path()).expect("canonical project root");
8452 let ctx = AppContext::new(
8453 Box::new(TreeSitterProvider::new()),
8454 Config {
8455 project_root: Some(canonical_root.clone()),
8456 ..Config::default()
8457 },
8458 );
8459 *ctx.search_index
8460 .write()
8461 .unwrap_or_else(std::sync::PoisonError::into_inner) =
8462 Some(SearchIndex::build(&canonical_root));
8463 *ctx.semantic_index
8464 .write()
8465 .unwrap_or_else(std::sync::PoisonError::into_inner) =
8466 Some(SemanticIndex::new(canonical_root.clone(), 3));
8467 *ctx.semantic_index_status
8468 .write()
8469 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
8470
8471 let artifact = canonical_root.join("verify-artifact.bin");
8472 std::fs::write(&artifact, b"same-size").expect("write verification artifact");
8473 let generation =
8474 crate::cache_freshness::artifact_generation(&artifact).expect("artifact generation");
8475 crate::cache_freshness::record_verify_completed(
8476 &canonical_root,
8477 crate::cache_freshness::VerifyArtifact::Search,
8478 Some(generation),
8479 );
8480 assert_eq!(
8481 crate::cache_freshness::warm_verify_plan(
8482 &canonical_root,
8483 crate::cache_freshness::VerifyArtifact::Search,
8484 Some(generation),
8485 ),
8486 crate::cache_freshness::WarmVerifyPlan::Skip
8487 );
8488
8489 ctx.invalidate_artifacts_after_watcher_gap();
8490
8491 assert!(ctx
8492 .search_index
8493 .read()
8494 .unwrap_or_else(std::sync::PoisonError::into_inner)
8495 .is_none());
8496 assert!(ctx
8497 .semantic_index
8498 .read()
8499 .unwrap_or_else(std::sync::PoisonError::into_inner)
8500 .is_none());
8501 assert!(ctx.pending_callgraph_store_force_token().is_some());
8502 assert_eq!(
8503 crate::cache_freshness::warm_verify_plan(
8504 &canonical_root,
8505 crate::cache_freshness::VerifyArtifact::Search,
8506 Some(generation),
8507 ),
8508 crate::cache_freshness::WarmVerifyPlan::Strict
8509 );
8510 }
8511
8512 #[test]
8513 fn cancelled_semantic_refresh_transfers_refreshing_files_to_pending() {
8514 let root = TempDir::new().expect("project tempdir");
8515 let ctx = AppContext::new(
8516 Box::new(TreeSitterProvider::new()),
8517 Config {
8518 project_root: Some(root.path().to_path_buf()),
8519 semantic_search: true,
8520 ..Config::default()
8521 },
8522 );
8523 *ctx.semantic_index
8524 .write()
8525 .unwrap_or_else(std::sync::PoisonError::into_inner) =
8526 Some(SemanticIndex::new(root.path().to_path_buf(), 3));
8527 let refreshing_path = root.path().join("src/lib.rs");
8528 {
8529 let mut status = ctx
8530 .semantic_index_status
8531 .write()
8532 .unwrap_or_else(std::sync::PoisonError::into_inner);
8533 *status = SemanticIndexStatus::ready();
8534 status.start_refreshing_file(refreshing_path.clone());
8535 }
8536 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
8537 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
8538 ctx.install_semantic_refresh_worker_for_build_epoch(
8539 request_tx,
8540 event_rx,
8541 Arc::new(Mutex::new(None)),
8542 ctx.semantic_index_rx_epoch(),
8543 );
8544
8545 ctx.cancel_unbound_artifact_work();
8546
8547 assert_eq!(
8550 ctx.pending_semantic_index_paths
8551 .lock()
8552 .iter()
8553 .cloned()
8554 .collect::<Vec<_>>(),
8555 vec![refreshing_path],
8556 "cancelled in-flight refresh files must transfer to the pending set"
8557 );
8558 assert!(matches!(
8559 &*ctx
8560 .semantic_index_status
8561 .read()
8562 .unwrap_or_else(std::sync::PoisonError::into_inner),
8563 SemanticIndexStatus::Ready { refreshing, .. } if refreshing.is_empty()
8564 ));
8565 }
8566
8567 #[test]
8568 fn unbind_before_corpus_started_preserves_corpus_intent() {
8569 let root = TempDir::new().expect("project tempdir");
8574 let ctx = AppContext::new(
8575 Box::new(TreeSitterProvider::new()),
8576 Config {
8577 project_root: Some(root.path().to_path_buf()),
8578 semantic_search: true,
8579 ..Config::default()
8580 },
8581 );
8582 *ctx.semantic_index
8583 .write()
8584 .unwrap_or_else(std::sync::PoisonError::into_inner) =
8585 Some(SemanticIndex::new(root.path().to_path_buf(), 3));
8586 *ctx.semantic_index_status
8587 .write()
8588 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
8589 stage: "refreshing_corpus".to_string(),
8590 files: None,
8591 entries_done: None,
8592 entries_total: None,
8593 };
8594 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
8595 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
8596 ctx.install_semantic_refresh_worker_for_build_epoch(
8597 request_tx,
8598 event_rx,
8599 Arc::new(Mutex::new(None)),
8600 ctx.semantic_index_rx_epoch(),
8601 );
8602
8603 ctx.cancel_unbound_artifact_work();
8604
8605 assert!(
8606 *ctx.pending_semantic_corpus_refresh.lock(),
8607 "corpus intent stamped before CorpusStarted must survive the cancellation"
8608 );
8609 }
8610
8611 #[test]
8612 fn cancelled_search_corpus_refresh_drops_nonready_resident_index() {
8613 let root = TempDir::new().expect("project tempdir");
8614 let ctx = AppContext::new(
8615 Box::new(TreeSitterProvider::new()),
8616 Config {
8617 project_root: Some(root.path().to_path_buf()),
8618 ..Config::default()
8619 },
8620 );
8621 let mut refreshing = SearchIndex::new();
8625 refreshing.ready = false;
8626 *ctx.search_index
8627 .write()
8628 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(refreshing);
8629 let (_tx, rx) = crossbeam_channel::unbounded();
8630 ctx.install_search_index_rx(rx, ctx.configure_generation());
8631
8632 ctx.cancel_unbound_artifact_work();
8633
8634 assert!(
8635 ctx.search_index
8636 .read()
8637 .unwrap_or_else(std::sync::PoisonError::into_inner)
8638 .is_none(),
8639 "a cancelled corpus refresh must drop the non-ready resident so rebind reloads it"
8640 );
8641 assert!(ctx
8642 .search_index_rx
8643 .read()
8644 .unwrap_or_else(std::sync::PoisonError::into_inner)
8645 .is_none());
8646 }
8647
8648 #[test]
8649 fn active_semantic_file_refresh_blocks_idle_eviction_until_completion() {
8650 let root = TempDir::new().expect("project tempdir");
8651 let ctx = AppContext::new(
8652 Box::new(TreeSitterProvider::new()),
8653 Config {
8654 project_root: Some(root.path().to_path_buf()),
8655 ..Config::default()
8656 },
8657 );
8658 *ctx.semantic_index
8659 .write()
8660 .unwrap_or_else(std::sync::PoisonError::into_inner) =
8661 Some(SemanticIndex::new(root.path().to_path_buf(), 3));
8662 let refreshing_path = root.path().join("src/lib.rs");
8663 {
8664 let mut status = ctx
8665 .semantic_index_status
8666 .write()
8667 .unwrap_or_else(std::sync::PoisonError::into_inner);
8668 *status = SemanticIndexStatus::ready();
8669 status.start_refreshing_file(refreshing_path.clone());
8670 }
8671
8672 assert!(ctx.artifact_eviction_blocked());
8673 assert!(!ctx.evict_idle_artifacts());
8674 assert!(ctx
8675 .semantic_index
8676 .read()
8677 .unwrap_or_else(std::sync::PoisonError::into_inner)
8678 .is_some());
8679
8680 ctx.semantic_index_status
8681 .write()
8682 .unwrap_or_else(std::sync::PoisonError::into_inner)
8683 .complete_refreshing_file(&refreshing_path);
8684 assert!(ctx.evict_idle_artifacts());
8685 assert!(ctx
8686 .semantic_index
8687 .read()
8688 .unwrap_or_else(std::sync::PoisonError::into_inner)
8689 .is_none());
8690 }
8691}
8692
8693#[cfg(test)]
8694mod status_emitter_tests {
8695 use super::*;
8696 use crate::parser::TreeSitterProvider;
8697
8698 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
8699 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
8700 let (tx, rx) = mpsc::channel();
8701 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
8702 let _ = tx.send(frame);
8703 }))));
8704 (ctx, rx)
8705 }
8706
8707 #[test]
8708 fn status_emitter_signal_triggers_push() {
8709 let (ctx, rx) = ctx_with_frame_rx();
8710 ctx.status_emitter().signal(ctx.build_status_snapshot());
8711 let frame = rx
8712 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
8713 .expect("status_changed push");
8714 assert!(matches!(frame, PushFrame::StatusChanged(_)));
8715 }
8716
8717 #[test]
8718 fn status_emitter_debounces_burst() {
8719 let (ctx, rx) = ctx_with_frame_rx();
8720 for _ in 0..10 {
8721 ctx.status_emitter().signal(ctx.build_status_snapshot());
8722 }
8723 let frame = rx
8724 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
8725 .expect("status_changed push");
8726 assert!(matches!(frame, PushFrame::StatusChanged(_)));
8727 assert!(rx.try_recv().is_err());
8728 }
8729
8730 #[test]
8731 fn status_emitter_separate_windows_separate_pushes() {
8732 let (ctx, rx) = ctx_with_frame_rx();
8733 ctx.status_emitter().signal(ctx.build_status_snapshot());
8734 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
8735 .expect("first push");
8736 ctx.status_emitter().signal(ctx.build_status_snapshot());
8737 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
8738 .expect("second push");
8739 }
8740
8741 #[test]
8742 fn status_emitter_no_signal_no_push() {
8743 let (_ctx, rx) = ctx_with_frame_rx();
8744 assert!(rx
8745 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
8746 .is_err());
8747 }
8748
8749 #[test]
8750 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
8751 let (ctx, rx) = ctx_with_frame_rx();
8752 drop(ctx);
8753 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
8754 }
8755
8756 #[test]
8757 fn progress_sender_slot_is_per_context_for_shared_app() {
8758 let app = App::default_shared();
8759 let ctx_a = AppContext::from_app(Arc::clone(&app), Config::default());
8760 let ctx_b = AppContext::from_app(app, Config::default());
8761 let (tx_a, rx_a) = mpsc::channel();
8762 let (tx_b, rx_b) = mpsc::channel();
8763
8764 ctx_a.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
8765 let _ = tx_a.send(frame);
8766 }))));
8767 ctx_b.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
8768 let _ = tx_b.send(frame);
8769 }))));
8770
8771 ctx_a.emit_progress(ProgressFrame {
8772 frame_type: "progress",
8773 request_id: "ctx-a".to_string(),
8774 kind: crate::protocol::ProgressKind::Stdout,
8775 chunk: "a".to_string(),
8776 });
8777 ctx_b.emit_progress(ProgressFrame {
8778 frame_type: "progress",
8779 request_id: "ctx-b".to_string(),
8780 kind: crate::protocol::ProgressKind::Stdout,
8781 chunk: "b".to_string(),
8782 });
8783
8784 match rx_a
8785 .recv_timeout(Duration::from_millis(50))
8786 .expect("ctx A progress frame")
8787 {
8788 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-a"),
8789 other => panic!("unexpected frame for ctx A: {other:?}"),
8790 }
8791 assert!(rx_a.try_recv().is_err());
8792
8793 match rx_b
8794 .recv_timeout(Duration::from_millis(50))
8795 .expect("ctx B progress frame")
8796 {
8797 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-b"),
8798 other => panic!("unexpected frame for ctx B: {other:?}"),
8799 }
8800 assert!(rx_b.try_recv().is_err());
8801 }
8802}
8803
8804#[cfg(test)]
8805mod health_warming_honesty_tests {
8806 use super::*;
8807 use crate::parser::TreeSitterProvider;
8808
8809 fn ctx_with_config(config: Config) -> AppContext {
8810 AppContext::new(Box::new(TreeSitterProvider::new()), config)
8811 }
8812
8813 fn health_search_status(ctx: &AppContext) -> &'static str {
8814 let root = std::path::Path::new("/tmp/health-warming-honesty-test");
8815 ctx.try_health_snapshot(root)
8816 .search_index
8817 .expect("search_index component present")
8818 .status
8819 }
8820
8821 fn health_tier2_status(ctx: &AppContext) -> &'static str {
8822 let root = std::path::Path::new("/tmp/health-warming-honesty-test");
8823 ctx.try_health_snapshot(root)
8824 .tier2
8825 .expect("tier2 component present")
8826 .status
8827 }
8828
8829 #[test]
8830 fn write_denied_search_index_reports_ready_not_building() {
8831 let config = Config {
8835 search_index: true,
8836 ..Config::default()
8837 };
8838 let ctx = ctx_with_config(config);
8839 let mut index = SearchIndex::new();
8840 index.build_denied = true;
8841 *ctx.search_index()
8842 .write()
8843 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
8844
8845 assert_eq!(
8846 health_search_status(&ctx),
8847 "ready",
8848 "a build-denied index is a terminal settled state and must not report building forever"
8849 );
8850 }
8851
8852 #[test]
8853 fn in_progress_search_index_still_reports_building() {
8854 let config = Config {
8858 search_index: true,
8859 ..Config::default()
8860 };
8861 let ctx = ctx_with_config(config);
8862 let index = SearchIndex::new(); *ctx.search_index()
8864 .write()
8865 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
8866
8867 assert_eq!(health_search_status(&ctx), "building");
8868 }
8869
8870 #[test]
8871 fn tier2_blocked_on_callgraph_reports_ready_not_building() {
8872 let ctx = ctx_with_config(Config::default()); ctx.update_status_bar_tier2(None, Some(3), Some(2), None, false);
8878 ctx.set_status_bar_tier2_dead_code_blocked_on_callgraph(true);
8879
8880 assert_eq!(
8881 health_tier2_status(&ctx),
8882 "ready",
8883 "tier2 complete except dead_code-blocked-on-callgraph must not stay building"
8884 );
8885 }
8886
8887 #[test]
8888 fn health_tier2_and_inspect_builder_state_read_the_same_registry() {
8889 let ctx = ctx_with_config(Config::default());
8892 ctx.update_status_bar_tier2(Some(1), Some(2), Some(3), None, false);
8893 ctx.inspect_manager()
8894 .set_tier2_in_flight_for_test(crate::inspect::InspectCategory::DeadCode, true);
8895
8896 assert_eq!(health_tier2_status(&ctx), "building");
8897 assert_eq!(
8898 ctx.inspect_manager()
8899 .tier2_builder_state(crate::inspect::InspectCategory::DeadCode),
8900 crate::inspect::InspectBuilderState::Building
8901 );
8902
8903 ctx.inspect_manager()
8904 .set_tier2_in_flight_for_test(crate::inspect::InspectCategory::DeadCode, false);
8905
8906 assert_eq!(health_tier2_status(&ctx), "ready");
8907 assert_eq!(
8908 ctx.inspect_manager()
8909 .tier2_builder_state(crate::inspect::InspectCategory::DeadCode),
8910 crate::inspect::InspectBuilderState::Absent
8911 );
8912
8913 ctx.inspect_manager().record_tier2_attempt_outcome_for_test(
8914 crate::inspect::InspectCategory::DeadCode,
8915 crate::inspect::JobOutcome::Fresh {
8916 payload: crate::inspect::scanners::dead_code::callgraph_unavailable_aggregate(0),
8917 },
8918 );
8919 assert_eq!(
8920 health_tier2_status(&ctx),
8921 "ready",
8922 "a finished callgraph_unavailable attempt must not keep health.tier2=building"
8923 );
8924 assert_eq!(
8925 ctx.inspect_manager()
8926 .tier2_builder_state(crate::inspect::InspectCategory::DeadCode),
8927 crate::inspect::InspectBuilderState::Absent
8928 );
8929 assert!(
8930 ctx.inspect_manager()
8931 .tier2_builder_state_detail(crate::inspect::InspectCategory::DeadCode)
8932 .starts_with("last attempt failed: callgraph_unavailable (attempt 1, first at "),
8933 "inspect refusals must carry the failed-attempt history the health surface no longer treats as busy"
8934 );
8935 }
8936
8937 #[test]
8938 fn tier2_missing_dead_code_without_callgraph_block_reports_building() {
8939 let ctx = ctx_with_config(Config::default());
8942 ctx.update_status_bar_tier2(None, Some(3), Some(2), None, false);
8943 ctx.set_status_bar_tier2_dead_code_blocked_on_callgraph(false);
8944
8945 assert_eq!(health_tier2_status(&ctx), "building");
8946 }
8947}
8948
8949#[cfg(test)]
8950mod status_bar_tests {
8951 use super::*;
8952 use crate::parser::TreeSitterProvider;
8953
8954 fn ctx() -> AppContext {
8955 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
8956 }
8957
8958 #[test]
8959 fn truthful_values_omit_unproven_categories_while_legacy_projection_stays_hidden() {
8960 let ctx = ctx();
8961 let values = ctx.status_bar_count_values();
8962 assert_eq!(values.errors, None);
8963 assert_eq!(values.warnings, None);
8964 assert_eq!(values.dead_code, None);
8965 assert_eq!(values.unused_exports, None);
8966 assert_eq!(values.duplicates, None);
8967 assert_eq!(values.todos, None);
8968 assert!(ctx.status_bar_counts().is_none());
8969
8970 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
8971 let values = ctx.status_bar_count_values();
8972 assert_eq!(values.dead_code, Some(5));
8973 assert_eq!(values.unused_exports, Some(3));
8974 assert_eq!(values.duplicates, Some(7));
8975 assert_eq!(values.todos, Some(2));
8976 assert_eq!(values.errors, None, "no analyzer report is not a clean E0");
8977 assert_eq!(
8978 values.warnings, None,
8979 "no analyzer report is not a clean W0"
8980 );
8981 assert!(!values.tier2_stale);
8982
8983 let legacy = ctx
8984 .status_bar_counts()
8985 .expect("legacy projection is populated");
8986 assert_eq!((legacy.errors, legacy.warnings), (0, 0));
8987 }
8988
8989 #[test]
8990 fn changing_root_clears_project_scoped_status_counts() {
8991 let temp = tempfile::tempdir().expect("tempdir");
8992 let first_root = temp.path().join("first");
8993 let second_root = temp.path().join("second");
8994 std::fs::create_dir_all(&first_root).expect("create first root");
8995 std::fs::create_dir_all(&second_root).expect("create second root");
8996 let ctx = ctx();
8997 ctx.set_canonical_cache_root(first_root);
8998 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
8999 assert!(ctx.status_bar_counts().is_some());
9000
9001 ctx.set_canonical_cache_root(second_root);
9002
9003 let values = ctx.status_bar_count_values();
9004 assert_eq!(values.dead_code, None);
9005 assert_eq!(values.unused_exports, None);
9006 assert_eq!(values.duplicates, None);
9007 assert!(
9008 ctx.status_bar_counts().is_none(),
9009 "counts from the previous root must not appear in a newly bound root"
9010 );
9011 }
9012
9013 #[test]
9014 fn partial_tier2_keeps_proven_categories_and_cache_hit_preserves_omissions() {
9015 let ctx = ctx();
9016 ctx.update_status_bar_tier2(Some(5), None, None, None, true);
9017
9018 let first = ctx.status_bar_count_values();
9019 assert_eq!(first.dead_code, Some(5));
9020 assert_eq!(first.unused_exports, None);
9021 assert_eq!(first.duplicates, None);
9022 assert_eq!(first.todos, None);
9023 assert!(first.tier2_stale);
9024 assert!(ctx.status_bar_counts().is_none());
9025
9026 let cached = ctx.status_bar_count_values();
9027 assert_eq!(cached, first, "a cache hit must preserve every omission");
9028 let cache = ctx
9029 .status_bar_cached
9030 .read()
9031 .unwrap_or_else(std::sync::PoisonError::into_inner);
9032 assert!(cache.valid);
9033 assert_eq!(cache.counts.as_ref(), Some(&first));
9034 drop(cache);
9035
9036 ctx.update_status_bar_tier2(None, Some(3), None, None, true);
9037 let partial = ctx.status_bar_count_values();
9038 assert_eq!(partial.dead_code, Some(5));
9039 assert_eq!(partial.unused_exports, Some(3));
9040 assert_eq!(partial.duplicates, None);
9041
9042 ctx.update_status_bar_tier2(None, None, Some(7), None, false);
9043 let complete = ctx.status_bar_count_values();
9044 assert_eq!(complete.dead_code, Some(5));
9045 assert_eq!(complete.unused_exports, Some(3));
9046 assert_eq!(complete.duplicates, Some(7));
9047 }
9048
9049 #[test]
9050 fn update_with_none_todos_preserves_last_known_todos() {
9051 let ctx = ctx();
9052 ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
9053 ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
9055 let counts = ctx.status_bar_count_values();
9056 assert_eq!(counts.todos, Some(9));
9057 assert_eq!(counts.dead_code, Some(2));
9058 }
9059
9060 #[test]
9061 fn update_with_none_count_preserves_last_known_count() {
9062 let ctx = ctx();
9063 ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
9064 ctx.update_status_bar_tier2(Some(11), None, None, None, false);
9067 let counts = ctx.status_bar_count_values();
9068 assert_eq!(counts.dead_code, Some(11));
9069 assert_eq!(counts.unused_exports, Some(20));
9070 assert_eq!(counts.duplicates, Some(30));
9071 }
9072
9073 #[test]
9074 fn mark_stale_sets_flag_after_any_proven_category() {
9075 let ctx = ctx();
9076 ctx.mark_status_bar_tier2_stale();
9077 assert!(!ctx.status_bar_count_values().tier2_stale);
9078
9079 ctx.update_status_bar_tier2(Some(4), None, None, None, false);
9080 ctx.mark_status_bar_tier2_stale();
9081 assert!(ctx.status_bar_count_values().tier2_stale);
9082
9083 ctx.update_status_bar_tier2(Some(4), None, None, None, false);
9085 assert!(!ctx.status_bar_count_values().tier2_stale);
9086 }
9087
9088 #[test]
9093 fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
9094 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
9095 use crate::lsp::registry::ServerKind;
9096 use crate::lsp::roots::ServerKey;
9097
9098 let ctx = ctx();
9099 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); let file = std::path::PathBuf::from("/proj/gone.ts");
9102 {
9103 let mut lsp = ctx.lsp();
9104 lsp.diagnostics_store_mut_for_test().publish(
9105 ServerKey {
9106 kind: ServerKind::TypeScript,
9107 root: std::path::PathBuf::from("/proj"),
9108 },
9109 file.clone(),
9110 vec![StoredDiagnostic {
9111 file: file.clone(),
9112 line: 1,
9113 column: 1,
9114 end_line: 1,
9115 end_column: 2,
9116 severity: DiagnosticSeverity::Error,
9117 message: "boom".into(),
9118 code: None,
9119 source: None,
9120 }],
9121 );
9122 }
9123
9124 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
9126
9127 let removed = ctx.lsp_clear_diagnostics_for_file(&file);
9129 assert!(removed);
9130 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
9131 }
9132
9133 #[test]
9134 fn status_bar_preserves_authoritative_counts_until_provisional_report_is_promoted() {
9135 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
9136 use crate::lsp::registry::ServerKind;
9137 use crate::lsp::roots::ServerKey;
9138
9139 let ctx = ctx();
9140 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
9141 let root = std::path::PathBuf::from("/proj");
9142 let file = root.join("src/main.rs");
9143 let key = ServerKey {
9144 kind: ServerKind::Rust,
9145 root,
9146 };
9147 let diagnostic = |severity, message: &str| StoredDiagnostic {
9148 file: file.clone(),
9149 line: 1,
9150 column: 1,
9151 end_line: 1,
9152 end_column: 2,
9153 severity,
9154 message: message.into(),
9155 code: None,
9156 source: None,
9157 };
9158
9159 {
9160 let mut lsp = ctx.lsp();
9161 lsp.diagnostics_store_mut_for_test().publish(
9162 key.clone(),
9163 file.clone(),
9164 vec![diagnostic(DiagnosticSeverity::Error, "settled error")],
9165 );
9166 }
9167 let counts = ctx.status_bar_counts().expect("populated");
9168 assert_eq!((counts.errors, counts.warnings), (1, 0));
9169
9170 {
9171 let mut lsp = ctx.lsp();
9172 lsp.diagnostics_store_mut_for_test()
9173 .publish_full_with_provisional(
9174 key.clone(),
9175 file.clone(),
9176 vec![diagnostic(
9177 DiagnosticSeverity::Warning,
9178 "latest warming warning",
9179 )],
9180 None,
9181 None,
9182 true,
9183 );
9184 }
9185 let counts = ctx.status_bar_counts().expect("populated");
9186 assert_eq!(
9187 (counts.errors, counts.warnings),
9188 (1, 0),
9189 "pre-quiescence diagnostics must not replace authoritative counts"
9190 );
9191
9192 {
9193 let mut lsp = ctx.lsp();
9194 assert!(lsp
9195 .diagnostics_store_mut_for_test()
9196 .promote_provisional_for_server(&key));
9197 }
9198 let counts = ctx.status_bar_counts().expect("populated");
9199 assert_eq!(
9200 (counts.errors, counts.warnings),
9201 (0, 1),
9202 "the latest report becomes authoritative at quiescence"
9203 );
9204 }
9205
9206 #[test]
9207 fn status_bar_filtered_counts_ignore_environmental_flap() {
9208 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
9209 use crate::lsp::registry::ServerKind;
9210 use crate::lsp::roots::ServerKey;
9211
9212 let ctx = ctx();
9213 let root = if cfg!(windows) {
9214 std::path::PathBuf::from(r"C:\proj")
9215 } else {
9216 std::path::PathBuf::from("/proj")
9217 };
9218 ctx.set_canonical_cache_root(root.clone());
9219 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
9220
9221 let file = root.join("aft.jsonc");
9222 let key = ServerKey {
9223 kind: ServerKind::TypeScript,
9224 root: root.clone(),
9225 };
9226 let env = StoredDiagnostic {
9227 file: file.clone(),
9228 line: 1,
9229 column: 1,
9230 end_line: 1,
9231 end_column: 2,
9232 severity: DiagnosticSeverity::Error,
9233 message: "Failed to load schema from https://example.com/schema.json".into(),
9234 code: None,
9235 source: Some("json".into()),
9236 };
9237
9238 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
9239
9240 {
9241 let mut lsp = ctx.lsp();
9242 lsp.diagnostics_store_mut_for_test()
9243 .publish(key.clone(), file.clone(), vec![env]);
9244 }
9245 assert_eq!(
9246 ctx.status_bar_counts().expect("populated").errors,
9247 0,
9248 "environmental publish must not change status-bar E"
9249 );
9250
9251 {
9252 let mut lsp = ctx.lsp();
9253 lsp.diagnostics_store_mut_for_test()
9254 .publish(key, file, vec![]);
9255 }
9256 assert_eq!(
9257 ctx.status_bar_counts().expect("populated").errors,
9258 0,
9259 "environmental clear must not change status-bar E"
9260 );
9261 }
9262}
9263
9264#[cfg(test)]
9265mod harness_path_tests {
9266 use super::*;
9267 use crate::harness::Harness;
9268 use crate::parser::TreeSitterProvider;
9269
9270 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
9271 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
9272 ctx.update_config(|config| {
9273 config.storage_dir = Some(storage_dir);
9274 });
9275 ctx.set_harness(harness);
9276 ctx
9277 }
9278
9279 #[test]
9280 fn harness_dir_resolves_correctly() {
9281 let storage = PathBuf::from("/tmp/cortexkit/aft");
9282 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
9283
9284 assert_eq!(ctx.harness_dir(), storage.join("pi"));
9285 }
9286
9287 #[test]
9288 fn bash_tasks_dir_uses_hash_session() {
9289 let storage = PathBuf::from("/tmp/cortexkit/aft");
9290 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
9291
9292 assert_eq!(
9293 ctx.bash_tasks_dir("ses_abc"),
9294 storage
9295 .join("opencode")
9296 .join("bash-tasks")
9297 .join(hash_session("ses_abc"))
9298 );
9299 }
9300
9301 #[test]
9302 fn backups_dir_includes_path_hash() {
9303 let storage = PathBuf::from("/tmp/cortexkit/aft");
9304 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
9305
9306 assert_eq!(
9307 ctx.backups_dir("ses_abc", "pathhash"),
9308 storage
9309 .join("pi")
9310 .join("backups")
9311 .join(hash_session("ses_abc"))
9312 .join("pathhash")
9313 );
9314 }
9315
9316 #[test]
9317 fn filters_dir_under_harness() {
9318 let storage = PathBuf::from("/tmp/cortexkit/aft");
9319 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
9320
9321 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
9322 }
9323
9324 #[test]
9325 fn trust_file_is_host_global() {
9326 let storage = PathBuf::from("/tmp/cortexkit/aft");
9327 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
9328
9329 assert_eq!(
9330 ctx.trust_file(),
9331 storage.join("trusted-filter-projects.json")
9332 );
9333 }
9334
9335 #[test]
9336 fn same_session_different_harness_resolve_different_paths() {
9337 let storage = PathBuf::from("/tmp/cortexkit/aft");
9338 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
9339 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
9340
9341 assert_ne!(
9342 opencode.bash_tasks_dir("ses_same"),
9343 pi.bash_tasks_dir("ses_same")
9344 );
9345 }
9346
9347 #[test]
9348 fn callgraph_and_inspect_dirs_are_root_keyed() {
9349 let temp = tempfile::tempdir().expect("tempdir");
9350 let storage = temp.path().join("storage");
9351 let root = temp.path().join("checkout");
9352 std::fs::create_dir_all(&root).expect("create root");
9353 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
9354 ctx.set_canonical_cache_root(root.clone());
9355
9356 assert_eq!(
9357 ctx.callgraph_store_dir(),
9358 storage
9359 .join("callgraph")
9360 .join(crate::search_index::artifact_cache_key(&root))
9361 );
9362 assert_eq!(
9363 ctx.inspect_dir(),
9364 storage
9365 .join("inspect")
9366 .join(crate::path_identity::project_scope_key(&root))
9367 );
9368 assert!(!ctx
9369 .callgraph_store_dir()
9370 .starts_with(storage.join("opencode")));
9371 assert!(!ctx.inspect_dir().starts_with(storage.join("opencode")));
9372 }
9373
9374 #[test]
9375 fn per_domain_capability_allows_inspect_writer_when_callgraph_read_only() {
9376 let storage = PathBuf::from("/tmp/cortexkit/aft");
9377 let ctx = ctx_with_storage_and_harness(storage, Harness::Opencode);
9378 ctx.set_cache_writer_capabilities(false, true);
9379
9380 assert!(ctx.shared_artifacts_read_only());
9381 assert!(!ctx.callgraph_writer());
9382 assert!(ctx.inspect_writer());
9383 }
9384}
9385
9386#[cfg(test)]
9387mod shared_db_tests {
9388 use super::*;
9389 use tempfile::tempdir;
9390
9391 #[test]
9392 fn app_contexts_share_one_database_connection() {
9393 let storage = tempdir().expect("storage tempdir");
9394 let root_one = tempdir().expect("first root tempdir");
9395 let root_two = tempdir().expect("second root tempdir");
9396 let app = App::default_shared();
9397 let ctx_one = AppContext::from_app(
9398 Arc::clone(&app),
9399 Config {
9400 project_root: Some(root_one.path().to_path_buf()),
9401 ..Config::default()
9402 },
9403 );
9404 let ctx_two = AppContext::from_app(
9405 Arc::clone(&app),
9406 Config {
9407 project_root: Some(root_two.path().to_path_buf()),
9408 ..Config::default()
9409 },
9410 );
9411 let path = storage.path().join("aft.db");
9412
9413 let first = app.open_db(&path).expect("open shared database");
9414 let second = app.open_db(&path).expect("reuse shared database");
9415
9416 assert!(Arc::ptr_eq(&first, &second));
9417 assert!(Arc::ptr_eq(
9418 &ctx_one.db().expect("first context database"),
9419 &ctx_two.db().expect("second context database")
9420 ));
9421 }
9422}
9423
9424#[cfg(test)]
9425mod gitignore_tests {
9426 use super::*;
9427 use std::fs;
9428 use std::path::Path;
9429 use tempfile::TempDir;
9430
9431 fn make_ctx_with_root(root: &Path) -> AppContext {
9432 let provider = Box::new(crate::parser::TreeSitterProvider::new());
9433 let config = Config {
9434 project_root: Some(root.to_path_buf()),
9435 ..Config::default()
9436 };
9437 AppContext::new(provider, config)
9438 }
9439
9440 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
9447 let Some(matcher) = ctx.gitignore() else {
9448 return false;
9449 };
9450 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
9451 if !canonical.starts_with(matcher.path()) {
9452 return false;
9453 }
9454 let is_dir = canonical.is_dir();
9455 matcher
9456 .matched_path_or_any_parents(&canonical, is_dir)
9457 .is_ignore()
9458 }
9459
9460 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
9473 let _guard = crate::test_env::process_env_lock();
9474 let tmp = TempDir::new().unwrap();
9475 let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
9476 let prev_home = std::env::var_os("HOME");
9477 let prev_userprofile = std::env::var_os("USERPROFILE");
9478 unsafe {
9481 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
9482 std::env::set_var("HOME", tmp.path());
9483 std::env::set_var("USERPROFILE", tmp.path());
9484 }
9485 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
9486 unsafe {
9487 match prev_xdg {
9488 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
9489 None => std::env::remove_var("XDG_CONFIG_HOME"),
9490 }
9491 match prev_home {
9492 Some(v) => std::env::set_var("HOME", v),
9493 None => std::env::remove_var("HOME"),
9494 }
9495 match prev_userprofile {
9496 Some(v) => std::env::set_var("USERPROFILE", v),
9497 None => std::env::remove_var("USERPROFILE"),
9498 }
9499 }
9500 match result {
9501 Ok(r) => r,
9502 Err(p) => std::panic::resume_unwind(p),
9503 }
9504 }
9505
9506 #[test]
9507 fn rebuild_gitignore_returns_none_without_project_root() {
9508 let provider = Box::new(crate::parser::TreeSitterProvider::new());
9509 let ctx = AppContext::new(provider, Config::default());
9510 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
9511 assert!(ctx.gitignore().is_none());
9512 }
9513
9514 #[test]
9515 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
9516 let tmp = TempDir::new().unwrap();
9517 let ctx = make_ctx_with_root(tmp.path());
9518 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
9519 assert!(ctx.gitignore().is_none());
9520 }
9521
9522 #[test]
9523 fn matcher_filters_files_in_ignored_dist_dir() {
9524 let tmp = TempDir::new().unwrap();
9525 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
9526 fs::create_dir_all(tmp.path().join("dist")).unwrap();
9527 fs::create_dir_all(tmp.path().join("src")).unwrap();
9528 let dist_file = tmp.path().join("dist").join("bundle.js");
9529 let src_file = tmp.path().join("src").join("app.ts");
9530 fs::write(&dist_file, "x").unwrap();
9531 fs::write(&src_file, "y").unwrap();
9532
9533 let ctx = make_ctx_with_root(tmp.path());
9534 ctx.rebuild_gitignore();
9535
9536 assert!(ctx.gitignore().is_some());
9537 assert!(
9538 is_ignored(&ctx, &dist_file),
9539 "dist/bundle.js should be ignored"
9540 );
9541 assert!(
9542 !is_ignored(&ctx, &src_file),
9543 "src/app.ts should NOT be ignored"
9544 );
9545 }
9546
9547 #[test]
9548 fn matcher_handles_node_modules_and_target() {
9549 let tmp = TempDir::new().unwrap();
9550 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
9551 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
9552 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
9553 let nm_file = tmp.path().join("node_modules/foo/index.js");
9554 let target_file = tmp.path().join("target/debug/aft");
9555 fs::write(&nm_file, "x").unwrap();
9556 fs::write(&target_file, "x").unwrap();
9557
9558 let ctx = make_ctx_with_root(tmp.path());
9559 ctx.rebuild_gitignore();
9560
9561 assert!(is_ignored(&ctx, &nm_file));
9562 assert!(is_ignored(&ctx, &target_file));
9563 }
9564
9565 #[test]
9566 fn matcher_honors_negation_pattern() {
9567 let tmp = TempDir::new().unwrap();
9569 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
9570 let random_log = tmp.path().join("random.log");
9571 let important_log = tmp.path().join("important.log");
9572 fs::write(&random_log, "x").unwrap();
9573 fs::write(&important_log, "y").unwrap();
9574
9575 let ctx = make_ctx_with_root(tmp.path());
9576 ctx.rebuild_gitignore();
9577
9578 assert!(is_ignored(&ctx, &random_log));
9579 assert!(
9580 !is_ignored(&ctx, &important_log),
9581 "negation pattern should un-ignore important.log"
9582 );
9583 }
9584
9585 #[test]
9586 fn rebuild_picks_up_gitignore_changes() {
9587 let tmp = TempDir::new().unwrap();
9588 let ignore_path = tmp.path().join(".gitignore");
9589 fs::write(&ignore_path, "foo.txt\n").unwrap();
9590 let foo = tmp.path().join("foo.txt");
9591 let bar = tmp.path().join("bar.txt");
9592 fs::write(&foo, "").unwrap();
9593 fs::write(&bar, "").unwrap();
9594
9595 let ctx = make_ctx_with_root(tmp.path());
9596 ctx.rebuild_gitignore();
9597 assert!(is_ignored(&ctx, &foo));
9598 assert!(!is_ignored(&ctx, &bar));
9599
9600 fs::write(&ignore_path, "bar.txt\n").unwrap();
9602 ctx.rebuild_gitignore();
9603 assert!(!is_ignored(&ctx, &foo));
9604 assert!(is_ignored(&ctx, &bar));
9605 }
9606
9607 #[test]
9608 fn gitignore_loads_info_exclude_when_present() {
9609 let tmp = TempDir::new().unwrap();
9610 let info_dir = tmp.path().join(".git/info");
9611 fs::create_dir_all(&info_dir).unwrap();
9612 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
9613 let secrets = tmp.path().join("secrets.txt");
9614 let public = tmp.path().join("public.txt");
9615 fs::write(&secrets, "token").unwrap();
9616 fs::write(&public, "ok").unwrap();
9617
9618 let ctx = make_ctx_with_root(tmp.path());
9619 ctx.rebuild_gitignore();
9620
9621 assert!(is_ignored(&ctx, &secrets));
9622 assert!(!is_ignored(&ctx, &public));
9623 }
9624
9625 #[test]
9626 fn matcher_picks_up_nested_gitignore() {
9627 let tmp = TempDir::new().unwrap();
9628 fs::write(tmp.path().join(".gitignore"), "").unwrap();
9630 let sub = tmp.path().join("packages/foo");
9631 fs::create_dir_all(&sub).unwrap();
9632 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
9633 let generated_file = sub.join("generated").join("out.js");
9634 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
9635 fs::write(&generated_file, "x").unwrap();
9636
9637 let ctx = make_ctx_with_root(tmp.path());
9638 ctx.rebuild_gitignore();
9639
9640 assert!(
9641 is_ignored(&ctx, &generated_file),
9642 "nested gitignore in packages/foo/.gitignore should ignore generated/"
9643 );
9644 }
9645}
9646
9647#[cfg(test)]
9648mod verify_memo_watcher_tests {
9649 use super::*;
9650
9651 #[test]
9652 fn pending_watcher_path_invalidates_root_verify_memo() {
9653 let root_dir = tempfile::tempdir().unwrap();
9654 let root = std::fs::canonicalize(root_dir.path()).unwrap();
9655 let artifact = root.join("cache.bin");
9656 std::fs::write(&artifact, b"generation").unwrap();
9657 let generation = crate::cache_freshness::artifact_generation(&artifact).unwrap();
9658 crate::cache_freshness::record_verify_completed(
9659 &root,
9660 crate::cache_freshness::VerifyArtifact::Search,
9661 Some(generation),
9662 );
9663 assert_eq!(
9664 crate::cache_freshness::warm_verify_plan(
9665 &root,
9666 crate::cache_freshness::VerifyArtifact::Search,
9667 Some(generation),
9668 ),
9669 crate::cache_freshness::WarmVerifyPlan::Skip
9670 );
9671
9672 let ctx = AppContext::from_app(
9673 App::default_shared(),
9674 Config {
9675 project_root: Some(root.clone()),
9676 ..Config::default()
9677 },
9678 );
9679 ctx.set_canonical_cache_root(root.clone());
9680 ctx.add_pending_search_index_paths([root.join("changed.rs")]);
9681 assert_eq!(
9682 crate::cache_freshness::warm_verify_plan(
9683 &root,
9684 crate::cache_freshness::VerifyArtifact::Search,
9685 Some(generation),
9686 ),
9687 crate::cache_freshness::WarmVerifyPlan::StatFirst
9688 );
9689 }
9690}
9691
9692#[cfg(test)]
9693mod watcher_runtime_state_tests {
9694 use super::*;
9695 use crate::language::StubProvider;
9696
9697 fn test_context() -> AppContext {
9698 AppContext::new(Box::new(StubProvider), Config::default())
9699 }
9700
9701 #[test]
9702 fn finished_watcher_thread_reports_inactive_and_is_reclaimed_with_invalidation() {
9703 let root = tempfile::tempdir().expect("project tempdir");
9704 let canonical_root = std::fs::canonicalize(root.path()).expect("canonical root");
9705 let ctx = AppContext::new(
9706 Box::new(StubProvider),
9707 Config {
9708 project_root: Some(canonical_root.clone()),
9709 ..Config::default()
9710 },
9711 );
9712 ctx.set_canonical_cache_root(canonical_root.clone());
9713 struct DisableWatcherGuard;
9717 impl Drop for DisableWatcherGuard {
9718 fn drop(&mut self) {
9719 unsafe { std::env::remove_var("AFT_TEST_DISABLE_FILE_WATCHER") };
9720 }
9721 }
9722 let _env_lock = crate::test_env::process_env_lock();
9723 unsafe { std::env::set_var("AFT_TEST_DISABLE_FILE_WATCHER", "1") };
9724 let _disable_watcher = DisableWatcherGuard;
9725 *ctx.search_index
9728 .write()
9729 .unwrap_or_else(std::sync::PoisonError::into_inner) =
9730 Some(crate::search_index::SearchIndex::new());
9731 let artifact = canonical_root.join("artifact.bin");
9732 std::fs::write(&artifact, b"artifact").expect("artifact");
9733 let generation = crate::cache_freshness::artifact_generation(&artifact);
9734 crate::cache_freshness::record_verify_completed(
9735 &canonical_root,
9736 crate::cache_freshness::VerifyArtifact::Search,
9737 generation,
9738 );
9739
9740 let (dispatch_tx, dispatch_rx) = crate::watcher_filter::watcher_dispatch_channel();
9741 let _dispatch_tx = dispatch_tx;
9742 let join = std::thread::spawn(|| {});
9745 ctx.install_watcher_runtime(
9746 dispatch_rx,
9747 WatcherThreadHandle::new(Arc::new(AtomicBool::new(false)), join),
9748 );
9749 let deadline = std::time::Instant::now() + Duration::from_secs(2);
9750 while ctx.watcher_runtime_active() {
9751 assert!(
9752 std::time::Instant::now() < deadline,
9753 "a finished watcher thread must report the runtime inactive"
9754 );
9755 std::thread::yield_now();
9756 }
9757
9758 crate::commands::configure::ensure_project_watcher(&ctx);
9761
9762 assert!(
9763 ctx.search_index
9764 .read()
9765 .unwrap_or_else(std::sync::PoisonError::into_inner)
9766 .is_none(),
9767 "corpse reclaim must drop resident artifacts (events since the failure are lost)"
9768 );
9769 assert_eq!(
9770 crate::cache_freshness::warm_verify_plan(
9771 &canonical_root,
9772 crate::cache_freshness::VerifyArtifact::Search,
9773 generation,
9774 ),
9775 crate::cache_freshness::WarmVerifyPlan::Strict,
9776 "corpse reclaim must force strict re-verification"
9777 );
9778 assert!(
9779 !ctx.take_finished_watcher_runtime(),
9780 "reclaim is one-shot; the corpse is gone after ensure_project_watcher"
9781 );
9782 }
9783
9784 #[test]
9785 fn watcher_runtime_requires_both_thread_and_dispatch_receiver() {
9786 let ctx = test_context();
9787 let (dispatch_tx, dispatch_rx) = crate::watcher_filter::watcher_dispatch_channel();
9788 let shutdown = Arc::new(AtomicBool::new(false));
9789 let thread_shutdown = Arc::clone(&shutdown);
9790 let join = std::thread::spawn(move || {
9791 while !thread_shutdown.load(Ordering::SeqCst) {
9792 std::thread::sleep(Duration::from_millis(1));
9793 }
9794 drop(dispatch_tx);
9795 });
9796 ctx.install_watcher_runtime(
9797 dispatch_rx,
9798 WatcherThreadHandle::new(Arc::clone(&shutdown), join),
9799 );
9800 assert!(ctx.watcher_runtime_active());
9801
9802 *ctx.watcher_rx.lock() = None;
9803 assert!(
9804 !ctx.watcher_runtime_active(),
9805 "a thread without its dispatch receiver is not a usable watcher runtime"
9806 );
9807 ctx.stop_watcher_runtime();
9808 }
9809}
9810
9811#[cfg(test)]
9812mod semantic_probe_tests {
9813 use super::*;
9814
9815 #[test]
9816 fn cleared_semantic_worker_invalidates_orphaned_probe_timer() {
9817 let root = tempfile::tempdir().unwrap();
9818 let ctx = AppContext::new(
9819 default_language_provider_factory(),
9820 Config {
9821 project_root: Some(root.path().to_path_buf()),
9822 ..Config::default()
9823 },
9824 );
9825 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
9826 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
9827 let worker_slot = Arc::new(Mutex::new(None));
9828 ctx.install_semantic_refresh_worker_for_build_epoch(
9829 request_tx,
9830 event_rx,
9831 worker_slot,
9832 ctx.semantic_index_rx_epoch(),
9833 );
9834
9835 ctx.ensure_semantic_refresh_probe_scheduled(Duration::from_millis(20));
9836 assert!(ctx.semantic_refresh_probe_is_scheduled());
9837 ctx.clear_semantic_refresh_worker();
9838 std::thread::sleep(Duration::from_millis(50));
9839
9840 assert!(!ctx.semantic_refresh_probe_ready());
9841 assert!(!ctx.semantic_refresh_probe_is_scheduled());
9842 assert!(!ctx.completion_drains_have_work());
9843 }
9844}