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 callgraph_writer: AtomicBool,
1577 inspect_writer: AtomicBool,
1578 artifact_owner_status: parking_lot::Mutex<Option<ArtifactOwnerStatus>>,
1579 artifact_owner_lease: parking_lot::Mutex<Option<ArtifactOwnerLeaseRegistration>>,
1580 degraded_reasons: parking_lot::Mutex<Vec<String>>,
1587 heavy_root_work_allowed: Arc<AtomicBool>,
1592 cold_build_limiter: RwLock<Arc<crate::cold_build_limiter::ColdBuildLimiter>>,
1593 callgraph_store: Arc<RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
1594 callgraph_store_force_requested: AtomicU64,
1595 callgraph_store_force_fulfilled: AtomicU64,
1596 callgraph_store_rx:
1597 parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>>,
1598 callgraph_store_rx_generation: AtomicU64,
1599 callgraph_store_rx_epoch: AtomicU64,
1600 callgraph_store_build_denied: parking_lot::Mutex<Option<(u64, String)>>,
1601 callgraph_store_build_suspension:
1602 parking_lot::Mutex<Option<(u64, crate::build_breaker::BuildSuspension)>>,
1603 health_build_suspensions: RwLock<Vec<SuspendedDomainHealthSnapshot>>,
1607 callgraph_persist_epoch: crate::root_cache::ArtifactPublishEpoch,
1608 callgraph_legacy_migration_summary_logged: Arc<AtomicBool>,
1609 pending_callgraph_store_paths: crate::callgraph_store::PendingCallGraphStorePaths,
1610 search_index: RwLock<Option<SearchIndex>>,
1611 search_index_rx: RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>>,
1612 search_index_rx_generation: AtomicU64,
1613 search_index_rx_epoch: AtomicU64,
1614 search_index_rx_terminal_epoch: Arc<AtomicU64>,
1615 search_index_disconnect_reschedule: parking_lot::Mutex<(u64, u32)>,
1621 search_persist_epoch: crate::root_cache::ArtifactPublishEpoch,
1622 pending_search_index_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
1623 symbol_cache: SharedSymbolCache,
1624 inspect_manager: Arc<InspectManager>,
1625 tier2_refresh_scheduler: parking_lot::Mutex<Tier2RefreshScheduler>,
1626 pending_tier2_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
1627 semantic_index: RwLock<Option<SemanticIndex>>,
1628 semantic_index_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
1629 semantic_index_rx_generation: AtomicU64,
1630 semantic_index_rx_epoch: AtomicU64,
1631 semantic_index_rx_terminal_epoch: Arc<AtomicU64>,
1632 semantic_persist_epoch: crate::root_cache::ArtifactPublishEpoch,
1633 semantic_persist_lock: Arc<parking_lot::Mutex<()>>,
1634 semantic_index_status: RwLock<SemanticIndexStatus>,
1635 artifact_reload_lock: parking_lot::Mutex<()>,
1638 semantic_cold_seed_active: Arc<AtomicBool>,
1642 semantic_cold_seed_generation: Arc<AtomicU64>,
1645 semantic_fingerprint_generation: Arc<AtomicU64>,
1646 semantic_callgraph_warm_deferred: AtomicBool,
1647 pending_semantic_index_paths: Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>,
1648 pending_semantic_corpus_refresh: parking_lot::Mutex<bool>,
1649 semantic_refresh_tx:
1650 Arc<parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>>,
1651 semantic_refresh_event_rx:
1652 parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
1653 semantic_refresh_generation: AtomicU64,
1654 semantic_refresh_epoch: AtomicU64,
1655 semantic_refresh_build_epoch: AtomicU64,
1656 semantic_refresh_worker: parking_lot::Mutex<Option<SemanticRefreshWorkerSlot>>,
1657 semantic_refresh_retry_attempts: parking_lot::Mutex<BTreeMap<PathBuf, usize>>,
1658 semantic_refresh_circuit: Arc<SemanticRefreshCircuit>,
1659 semantic_embedding_model: parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>>,
1660 watcher_runtime_lock: parking_lot::Mutex<()>,
1661 watcher: parking_lot::Mutex<Option<RecommendedWatcher>>,
1662 watcher_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>>,
1663 watcher_drain_slice: parking_lot::Mutex<Option<WatcherDrainSliceState>>,
1664 watcher_thread: parking_lot::Mutex<Option<WatcherThreadHandle>>,
1665 lsp_manager: parking_lot::Mutex<LspManager>,
1666 configure_generation: Arc<AtomicU64>,
1667 configure_content_generation: Arc<AtomicU64>,
1671 subc_lifecycle: SubcLifecycleAdmission,
1674 configure_warm_state: parking_lot::Mutex<ConfigureWarmState>,
1675 configure_phase_timing: parking_lot::Mutex<ConfigurePhaseTiming>,
1676 configured_session_roots: parking_lot::Mutex<BTreeSet<(PathBuf, String)>>,
1677 hashline_bindings: crate::hashline::integration::BindingRegistry,
1678 configure_maintenance_jobs: parking_lot::Mutex<VecDeque<ConfigureMaintenanceJob>>,
1679 artifact_cache_keys: parking_lot::Mutex<BTreeMap<PathBuf, String>>,
1680 artifact_cache_key_derivations: AtomicU64,
1681 borrowed_index_cache: parking_lot::Mutex<BorrowedIndexCache>,
1682 worktree_bridge_cache: parking_lot::Mutex<BTreeMap<PathBuf, WorktreeBridgeCacheEntry>>,
1685 #[cfg(test)]
1686 worktree_bridge_probe_spawns: AtomicU64,
1687 #[cfg(test)]
1688 force_worktree_bridge_reprobe: AtomicBool,
1689 last_seen_reuse_completions: AtomicU64,
1693 configure_warnings_tx: crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)>,
1694 configure_warnings_rx: crossbeam_channel::Receiver<(u64, ConfigureWarningsFrame)>,
1695 progress_sender: SharedProgressSender,
1698 status_emitter: StatusEmitter,
1699 fleet_status_client: RwLock<Option<crate::fleet_status::FleetStatusClient>>,
1702 status_bar_last_emitted: LegacyStatusBarEmission,
1705 status_bar_cached: RwLock<StatusBarCache>,
1708 alert_state: parking_lot::Mutex<AlertDeltaState>,
1711 compression_aggregates: Arc<crate::db::compression_events::CompressionAggregateCache>,
1712 bash_background: BgTaskRegistry,
1713 #[cfg(unix)]
1714 escalation_grants: parking_lot::Mutex<crate::sandbox_spawn::EscalationGrantStore>,
1715 filter_registry: crate::compress::SharedFilterRegistry,
1722 filter_registry_rebuild_count: AtomicU64,
1723 filter_registry_loaded: std::sync::atomic::AtomicBool,
1726 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
1731 gitignore: SharedGitignore,
1738 gitignore_generation: Arc<AtomicU64>,
1739 status_bar_tier2: RwLock<StatusBarTier2>,
1743 tsconfig_membership:
1750 parking_lot::Mutex<crate::lsp::tsconfig_membership::TsconfigMembershipCache>,
1751}
1752
1753pub struct ForceRestrictGuard<'a> {
1759 ctx: &'a AppContext,
1760 req_id: String,
1761}
1762
1763impl Drop for ForceRestrictGuard<'_> {
1764 fn drop(&mut self) {
1765 self.ctx.release_force_restrict(&self.req_id);
1766 }
1767}
1768
1769impl Drop for AppContext {
1770 fn drop(&mut self) {
1771 self.artifact_owner_lease.get_mut().take();
1772 if let Some(runtime) = self.watcher_thread.get_mut().take() {
1773 let root = self
1774 .canonical_cache_root
1775 .get_mut()
1776 .clone()
1777 .or_else(|| {
1778 self.config
1779 .get_mut()
1780 .unwrap_or_else(std::sync::PoisonError::into_inner)
1781 .project_root
1782 .clone()
1783 })
1784 .unwrap_or_else(|| PathBuf::from("<unconfigured>"));
1785 Self::spawn_watcher_shutdown(Arc::clone(&self.app), root, runtime);
1786 }
1787 }
1788}
1789
1790pub enum CallgraphStoreAccess {
1798 Ready(Arc<ReadonlyCallGraphStore>),
1800 Building,
1802 Suspended(crate::build_breaker::BuildSuspension),
1805 Unavailable,
1807 Error(CallGraphStoreError),
1809}
1810
1811#[derive(Clone, Copy)]
1812enum CallgraphBackgroundWork {
1813 Ensure,
1814 ForceRebuild(u64),
1815 LegacyMigration,
1816}
1817
1818#[cfg(test)]
1819struct CallgraphBuildStartGate {
1820 root: PathBuf,
1821 reached: crossbeam_channel::Sender<()>,
1822 release: crossbeam_channel::Receiver<()>,
1823}
1824
1825#[cfg(test)]
1826static CALLGRAPH_BUILD_START_GATE: std::sync::OnceLock<
1827 parking_lot::Mutex<Option<CallgraphBuildStartGate>>,
1828> = std::sync::OnceLock::new();
1829
1830#[cfg(test)]
1831fn install_callgraph_build_start_gate(
1832 root: PathBuf,
1833) -> (
1834 crossbeam_channel::Receiver<()>,
1835 crossbeam_channel::Sender<()>,
1836) {
1837 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
1838 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1839 *CALLGRAPH_BUILD_START_GATE
1840 .get_or_init(|| parking_lot::Mutex::new(None))
1841 .lock() = Some(CallgraphBuildStartGate {
1842 root,
1843 reached: reached_tx,
1844 release: release_rx,
1845 });
1846 (reached_rx, release_tx)
1847}
1848
1849#[cfg(test)]
1850pub(crate) fn install_callgraph_build_start_gate_for_test(
1851 root: PathBuf,
1852) -> (
1853 crossbeam_channel::Receiver<()>,
1854 crossbeam_channel::Sender<()>,
1855) {
1856 install_callgraph_build_start_gate(root)
1857}
1858
1859#[cfg(test)]
1860static CALLGRAPH_BUILD_WAIT_MS_LOCK: std::sync::OnceLock<std::sync::Mutex<()>> =
1861 std::sync::OnceLock::new();
1862
1863#[cfg(test)]
1864pub(crate) struct CallgraphBuildWaitMsGuard {
1865 _guard: std::sync::MutexGuard<'static, ()>,
1866 previous: Option<std::ffi::OsString>,
1867}
1868
1869#[cfg(test)]
1870impl Drop for CallgraphBuildWaitMsGuard {
1871 fn drop(&mut self) {
1872 unsafe {
1875 match &self.previous {
1876 Some(value) => std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", value),
1877 None => std::env::remove_var("AFT_CALLGRAPH_BUILD_WAIT_MS"),
1878 }
1879 }
1880 }
1881}
1882
1883#[cfg(test)]
1886pub(crate) fn override_callgraph_build_wait_ms_for_test(ms: u64) -> CallgraphBuildWaitMsGuard {
1887 let guard = CALLGRAPH_BUILD_WAIT_MS_LOCK
1888 .get_or_init(|| std::sync::Mutex::new(()))
1889 .lock()
1890 .unwrap_or_else(|error| error.into_inner());
1891 let previous = std::env::var_os("AFT_CALLGRAPH_BUILD_WAIT_MS");
1892 unsafe {
1894 std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", ms.to_string());
1895 }
1896 CallgraphBuildWaitMsGuard {
1897 _guard: guard,
1898 previous,
1899 }
1900}
1901
1902#[cfg(test)]
1903fn wait_on_callgraph_build_start_gate(root: &Path) {
1904 let mut slot = CALLGRAPH_BUILD_START_GATE
1905 .get_or_init(|| parking_lot::Mutex::new(None))
1906 .lock();
1907 if !slot.as_ref().is_some_and(|gate| gate.root == root) {
1908 return;
1909 }
1910 let gate = slot.take();
1911 drop(slot);
1912 if let Some(gate) = gate {
1913 let _ = gate.reached.send(());
1914 let _ = gate.release.recv_timeout(Duration::from_secs(5));
1915 }
1916}
1917
1918#[cfg(not(test))]
1919fn wait_on_callgraph_build_start_gate(_root: &Path) {}
1920
1921#[cfg(test)]
1922static REMOVE_CALLGRAPH_POINTER_BEFORE_INLINE_REOPEN: AtomicBool = AtomicBool::new(false);
1923
1924#[cfg(test)]
1925struct RemoveCallgraphPointerBeforeInlineReopenGuard;
1926
1927#[cfg(test)]
1928impl Drop for RemoveCallgraphPointerBeforeInlineReopenGuard {
1929 fn drop(&mut self) {
1930 REMOVE_CALLGRAPH_POINTER_BEFORE_INLINE_REOPEN.store(false, Ordering::SeqCst);
1931 }
1932}
1933
1934#[cfg(test)]
1935fn remove_callgraph_pointer_before_inline_reopen_for_test(
1936 callgraph_dir: &Path,
1937 store: &CallGraphStore,
1938) {
1939 if REMOVE_CALLGRAPH_POINTER_BEFORE_INLINE_REOPEN.swap(false, Ordering::SeqCst) {
1940 let pointer = callgraph_dir.join(format!("{}.current", store.project_key()));
1941 std::fs::remove_file(pointer).expect("remove callgraph pointer before inline reopen");
1942 }
1943}
1944
1945#[cfg(not(test))]
1946fn remove_callgraph_pointer_before_inline_reopen_for_test(
1947 _callgraph_dir: &Path,
1948 _store: &CallGraphStore,
1949) {
1950}
1951
1952fn callgraph_build_wait_window() -> Duration {
1957 std::env::var("AFT_CALLGRAPH_BUILD_WAIT_MS")
1958 .ok()
1959 .and_then(|raw| raw.parse::<u64>().ok())
1960 .map(Duration::from_millis)
1961 .unwrap_or(Duration::ZERO)
1962}
1963
1964static CALLGRAPH_COLD_BUILD_SPAWN_COUNT: AtomicUsize = AtomicUsize::new(0);
1965
1966#[doc(hidden)]
1967pub fn reset_callgraph_cold_build_spawn_count_for_test() {
1968 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
1969}
1970
1971#[doc(hidden)]
1972pub fn callgraph_cold_build_spawn_count_for_test() -> usize {
1973 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst)
1974}
1975
1976impl AppContext {
1977 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
1978 Self::with_app_and_provider(App::default_shared(), provider, config)
1979 }
1980
1981 pub fn from_app(app: Arc<App>, config: Config) -> Self {
1982 let provider = app.create_provider();
1983 Self::with_app_and_provider(app, provider, config)
1984 }
1985
1986 pub fn with_app_and_provider(
1987 app: Arc<App>,
1988 provider: Box<dyn LanguageProvider>,
1989 config: Config,
1990 ) -> Self {
1991 let bash_compress_enabled = config.experimental_bash_compress;
1992 let (configure_warnings_tx, configure_warnings_rx) = crossbeam_channel::unbounded();
1993 let progress_sender: SharedProgressSender = Arc::new(Mutex::new(None));
1994 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
1995 let heavy_root_work_allowed = Arc::new(AtomicBool::new(true));
1996 let semantic_cold_seed_active = Arc::new(AtomicBool::new(false));
1997 let symbol_cache = provider
1998 .as_any()
1999 .downcast_ref::<TreeSitterProvider>()
2000 .map(|provider| provider.symbol_cache())
2001 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
2002 let mut lsp_manager = LspManager::new();
2003 lsp_manager.set_child_registry(app.lsp_child_registry());
2004 lsp_manager.set_diagnostic_capacity(config.diagnostic_cache_size);
2007 let bash_background = BgTaskRegistry::new(Arc::clone(&progress_sender));
2008 let compression_aggregates = bash_background.compression_aggregate_cache();
2009 let context = AppContext {
2010 app: Arc::clone(&app),
2011 provider,
2012 backup: parking_lot::Mutex::new(BackupStore::new()),
2013 checkpoint: parking_lot::Mutex::new(CheckpointStore::new()),
2014 config: RwLock::new(Arc::new(config)),
2015 path_restriction_root_memo: parking_lot::Mutex::new(None),
2016 #[cfg(test)]
2017 path_restriction_root_canonicalizations: AtomicUsize::new(0),
2018 force_restrict_requests: parking_lot::Mutex::new(BTreeMap::new()),
2019 harness: parking_lot::Mutex::new(None),
2020 canonical_cache_root: parking_lot::Mutex::new(None),
2021 is_worktree_bridge: parking_lot::Mutex::new(false),
2022 git_common_dir: parking_lot::Mutex::new(None),
2023 shared_artifacts_read_only: AtomicBool::new(false),
2024 callgraph_writer: AtomicBool::new(true),
2025 inspect_writer: AtomicBool::new(true),
2026 artifact_owner_status: parking_lot::Mutex::new(None),
2027 artifact_owner_lease: parking_lot::Mutex::new(None),
2028 degraded_reasons: parking_lot::Mutex::new(Vec::new()),
2029 heavy_root_work_allowed: Arc::clone(&heavy_root_work_allowed),
2030 cold_build_limiter: RwLock::new(crate::cold_build_limiter::global_limiter()),
2031 callgraph_store: Arc::new(RwLock::new(None)),
2032 callgraph_store_force_requested: AtomicU64::new(0),
2033 callgraph_store_force_fulfilled: AtomicU64::new(0),
2034 callgraph_store_rx: parking_lot::Mutex::new(None),
2035 callgraph_store_rx_generation: AtomicU64::new(0),
2036 callgraph_store_rx_epoch: AtomicU64::new(0),
2037 callgraph_store_build_denied: parking_lot::Mutex::new(None),
2038 callgraph_store_build_suspension: parking_lot::Mutex::new(None),
2039 health_build_suspensions: RwLock::new(Vec::new()),
2040 callgraph_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(),
2041 callgraph_legacy_migration_summary_logged: Arc::new(AtomicBool::new(false)),
2042 pending_callgraph_store_paths: Arc::new(parking_lot::Mutex::new(BTreeSet::new())),
2043 search_index: RwLock::new(None),
2044 search_index_rx: RwLock::new(None),
2045 search_index_rx_generation: AtomicU64::new(0),
2046 search_index_rx_epoch: AtomicU64::new(0),
2047 search_index_rx_terminal_epoch: Arc::new(AtomicU64::new(0)),
2048 search_index_disconnect_reschedule: parking_lot::Mutex::new((0, 0)),
2049 search_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(),
2050 pending_search_index_paths: parking_lot::Mutex::new(BTreeSet::new()),
2051 symbol_cache,
2052 inspect_manager: Arc::new(InspectManager::with_root_work_gates(
2053 Arc::clone(&heavy_root_work_allowed),
2054 Arc::clone(&semantic_cold_seed_active),
2055 )),
2056 tier2_refresh_scheduler: parking_lot::Mutex::new(Tier2RefreshScheduler::new()),
2057 pending_tier2_paths: parking_lot::Mutex::new(BTreeSet::new()),
2058 semantic_index: RwLock::new(None),
2059 semantic_index_rx: parking_lot::Mutex::new(None),
2060 semantic_index_rx_generation: AtomicU64::new(0),
2061 semantic_index_rx_epoch: AtomicU64::new(0),
2062 semantic_index_rx_terminal_epoch: Arc::new(AtomicU64::new(0)),
2063 semantic_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(),
2064 semantic_persist_lock: Arc::new(parking_lot::Mutex::new(())),
2065 semantic_index_status: RwLock::new(SemanticIndexStatus::Disabled),
2066 artifact_reload_lock: parking_lot::Mutex::new(()),
2067 semantic_cold_seed_active,
2068 semantic_cold_seed_generation: Arc::new(AtomicU64::new(0)),
2069 semantic_fingerprint_generation: Arc::new(AtomicU64::new(0)),
2070 semantic_callgraph_warm_deferred: AtomicBool::new(false),
2071 pending_semantic_index_paths: Arc::new(parking_lot::Mutex::new(BTreeSet::new())),
2072 pending_semantic_corpus_refresh: parking_lot::Mutex::new(false),
2073 semantic_refresh_tx: Arc::new(parking_lot::Mutex::new(None)),
2074 semantic_refresh_event_rx: parking_lot::Mutex::new(None),
2075 semantic_refresh_generation: AtomicU64::new(0),
2076 semantic_refresh_epoch: AtomicU64::new(0),
2077 semantic_refresh_build_epoch: AtomicU64::new(0),
2078 semantic_refresh_worker: parking_lot::Mutex::new(None),
2079 semantic_refresh_retry_attempts: parking_lot::Mutex::new(BTreeMap::new()),
2080 semantic_refresh_circuit: Arc::new(SemanticRefreshCircuit::default()),
2081 semantic_embedding_model: parking_lot::Mutex::new(None),
2082 watcher_runtime_lock: parking_lot::Mutex::new(()),
2083 watcher: parking_lot::Mutex::new(None),
2084 watcher_rx: parking_lot::Mutex::new(None),
2085 watcher_drain_slice: parking_lot::Mutex::new(None),
2086 watcher_thread: parking_lot::Mutex::new(None),
2087 lsp_manager: parking_lot::Mutex::new(lsp_manager),
2088 configure_generation: Arc::new(AtomicU64::new(0)),
2089 configure_content_generation: Arc::new(AtomicU64::new(0)),
2090 subc_lifecycle: SubcLifecycleAdmission::default(),
2091 configure_warm_state: parking_lot::Mutex::new(ConfigureWarmState::default()),
2092 configure_phase_timing: parking_lot::Mutex::new(ConfigurePhaseTiming::default()),
2093 configured_session_roots: parking_lot::Mutex::new(BTreeSet::new()),
2094 hashline_bindings: crate::hashline::integration::BindingRegistry::new(),
2095 configure_maintenance_jobs: parking_lot::Mutex::new(VecDeque::new()),
2096 artifact_cache_keys: parking_lot::Mutex::new(BTreeMap::new()),
2097 artifact_cache_key_derivations: AtomicU64::new(0),
2098 borrowed_index_cache: parking_lot::Mutex::new(BorrowedIndexCache::default()),
2099 worktree_bridge_cache: parking_lot::Mutex::new(BTreeMap::new()),
2100 #[cfg(test)]
2101 worktree_bridge_probe_spawns: AtomicU64::new(0),
2102 #[cfg(test)]
2103 force_worktree_bridge_reprobe: AtomicBool::new(false),
2104 last_seen_reuse_completions: AtomicU64::new(0),
2105 configure_warnings_tx,
2106 configure_warnings_rx,
2107 progress_sender: Arc::clone(&progress_sender),
2108 status_emitter,
2109 fleet_status_client: RwLock::new(None),
2110 status_bar_last_emitted: LegacyStatusBarEmission::default(),
2111 status_bar_cached: RwLock::new(StatusBarCache::default()),
2112 alert_state: parking_lot::Mutex::new(AlertDeltaState::default()),
2113 compression_aggregates,
2114 bash_background,
2115 #[cfg(unix)]
2116 escalation_grants: parking_lot::Mutex::new(
2117 crate::sandbox_spawn::EscalationGrantStore::default(),
2118 ),
2119 filter_registry: Arc::new(std::sync::RwLock::new(
2120 crate::compress::toml_filter::FilterRegistry::default(),
2121 )),
2122 filter_registry_rebuild_count: AtomicU64::new(0),
2123 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
2124 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
2125 gitignore: Arc::new(std::sync::RwLock::new(None)),
2126 gitignore_generation: Arc::new(AtomicU64::new(0)),
2127 status_bar_tier2: RwLock::new(StatusBarTier2::default()),
2128 tsconfig_membership: parking_lot::Mutex::new(
2129 crate::lsp::tsconfig_membership::TsconfigMembershipCache::new(),
2130 ),
2131 };
2132 crate::logging::sync_storage_root(context.storage_dir());
2133 context
2134 }
2135
2136 pub fn status_bar_count_values(&self) -> StatusBarCountValues {
2140 let tier2 = self
2141 .status_bar_tier2
2142 .read()
2143 .unwrap_or_else(std::sync::PoisonError::into_inner)
2144 .clone();
2145 let tsconfig_generation = self.tsconfig_membership.lock().generation();
2146 let lsp = self.lsp_manager.lock();
2147 let diagnostics_generation = lsp.diagnostics_generation();
2148
2149 {
2150 let cached = self
2151 .status_bar_cached
2152 .read()
2153 .unwrap_or_else(std::sync::PoisonError::into_inner);
2154 if cached.valid
2155 && cached.diagnostics_generation == diagnostics_generation
2156 && cached.tier2_generation == tier2.generation
2157 && cached.tsconfig_generation == tsconfig_generation
2158 {
2159 return cached
2160 .counts
2161 .clone()
2162 .expect("a valid status-count cache carries truthful values");
2163 }
2164 }
2165
2166 let previous_authoritative = self
2167 .status_bar_cached
2168 .read()
2169 .unwrap_or_else(std::sync::PoisonError::into_inner)
2170 .counts
2171 .as_ref()
2172 .map(|counts| (counts.errors, counts.warnings));
2173 let ((current_errors, current_warnings), provisional) =
2174 match self.canonical_cache_root_opt() {
2175 Some(root) => {
2176 let root = crate::inspect::job::normalize_path(&root);
2180 let mut membership = self.tsconfig_membership.lock();
2181 lsp.filtered_error_warning_counts_with_provisional(|file| {
2182 file.starts_with(&root) && !membership.should_skip_diagnostics(file)
2183 })
2184 }
2185 None => lsp.warm_error_warning_counts_with_provisional(),
2186 };
2187 let (errors, warnings) = if provisional {
2188 previous_authoritative.unwrap_or((None, None))
2191 } else if lsp.has_any_diagnostic_reports() {
2192 (Some(current_errors), Some(current_warnings))
2193 } else {
2194 (None, None)
2195 };
2196 let counts = StatusBarCountValues {
2197 errors,
2198 warnings,
2199 dead_code: tier2.dead_code,
2200 unused_exports: tier2.unused_exports,
2201 duplicates: tier2.duplicates,
2202 todos: tier2.todos,
2203 tier2_stale: tier2.stale,
2204 };
2205
2206 *self
2207 .status_bar_cached
2208 .write()
2209 .unwrap_or_else(std::sync::PoisonError::into_inner) = StatusBarCache {
2210 valid: true,
2211 diagnostics_generation,
2212 tier2_generation: tier2.generation,
2213 tsconfig_generation,
2214 counts: Some(counts.clone()),
2215 };
2216 counts
2217 }
2218
2219 pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
2222 self.status_bar_count_values().legacy_projection()
2223 }
2224
2225 pub(crate) fn try_health_summary(&self) -> RootHealthSummary {
2226 let heavy_root_work_allowed = match self.try_heavy_root_work_allowed() {
2230 Some(allowed) => allowed,
2231 None => return RootHealthSummary::busy(),
2232 };
2233 let config = match self.config.try_read() {
2234 Ok(guard) => Arc::clone(&*guard),
2235 Err(_) => return RootHealthSummary::busy(),
2236 };
2237 let search_index = match self.search_index.try_read() {
2238 Ok(guard) => guard,
2239 Err(_) => return RootHealthSummary::busy(),
2240 };
2241 let search_index_rx = match self.search_index_rx.try_read() {
2242 Ok(guard) => guard,
2243 Err(_) => return RootHealthSummary::busy(),
2244 };
2245 let semantic_status = match self.semantic_index_status.try_read() {
2246 Ok(guard) => guard,
2247 Err(_) => return RootHealthSummary::busy(),
2248 };
2249 let callgraph_store = match self.callgraph_store.try_read() {
2250 Ok(guard) => guard,
2251 Err(_) => return RootHealthSummary::busy(),
2252 };
2253 let callgraph_store_rx = match self.callgraph_store_rx.try_lock() {
2254 Some(guard) => guard,
2255 None => return RootHealthSummary::busy(),
2256 };
2257 let tier2 = match self.status_bar_tier2.try_read() {
2258 Ok(guard) => guard,
2259 Err(_) => return RootHealthSummary::busy(),
2260 };
2261 let tier2_builder_busy = match self.inspect_manager.try_tier2_builder_busy() {
2266 Some(busy) => busy,
2267 None => return RootHealthSummary::busy(),
2268 };
2269 let bash = match self.bash_background.try_health_counts() {
2270 Some(counts) => counts,
2271 None => return RootHealthSummary::busy(),
2272 };
2273 let suspended_domains = match self.health_build_suspensions.try_read() {
2274 Ok(snapshot) => snapshot.clone(),
2275 Err(_) => return RootHealthSummary::busy(),
2276 };
2277
2278 let borrows_shared_artifacts = self.shared_artifacts_read_only.load(Ordering::SeqCst);
2283 let search_index_status = if search_index
2284 .as_ref()
2285 .is_some_and(|index| index.ready || index.build_denied)
2286 || (borrows_shared_artifacts && config.search_index)
2287 {
2288 "ready"
2289 } else if config.search_index
2290 || search_index.as_ref().is_some()
2291 || search_index_rx.as_ref().is_some()
2292 {
2293 "building"
2294 } else {
2295 "disabled"
2296 };
2297 let semantic_index_status = match &*semantic_status {
2298 SemanticIndexStatus::Ready { .. } => "ready",
2299 SemanticIndexStatus::Building { .. } => "building",
2300 SemanticIndexStatus::Disabled => "disabled",
2301 SemanticIndexStatus::Failed(_) => "degraded",
2302 };
2303 let callgraph_writer = self.callgraph_writer.load(Ordering::SeqCst);
2304 let callgraph_store_status = if !heavy_root_work_allowed {
2305 "disabled"
2306 } else if callgraph_store.as_ref().is_some() {
2307 "ready"
2308 } else if !callgraph_writer && config.callgraph_store {
2309 "ready"
2312 } else if callgraph_store_rx.is_some() || config.callgraph_store {
2313 "building"
2314 } else {
2315 "disabled"
2316 };
2317 let dead_code_blocked_on_callgraph = tier2.dead_code_blocked_on_callgraph;
2321 let tier2_complete = (tier2.dead_code.is_some() || dead_code_blocked_on_callgraph)
2322 && tier2.unused_exports.is_some()
2323 && tier2.duplicates.is_some()
2324 && !tier2.stale;
2325 let tier2_has_aggregates = tier2.dead_code.is_some()
2326 || tier2.unused_exports.is_some()
2327 || tier2.duplicates.is_some();
2328 let tier2_refresh_gated = borrows_shared_artifacts
2329 || !heavy_root_work_allowed
2330 || !self.inspect_writer.load(Ordering::SeqCst)
2331 || !self.inspect_manager.automatic_tier2_refresh_enabled();
2332 let tier2_status = if tier2_builder_busy {
2333 "building"
2337 } else if tier2_complete {
2338 "ready"
2339 } else if !config.inspect.enabled || !tier2_has_aggregates || tier2_refresh_gated {
2340 "disabled"
2343 } else {
2344 "building"
2345 };
2346
2347 RootHealthSummary {
2348 state: RootHealthState::Ready,
2349 search_index_status: Some(search_index_status),
2350 semantic_index_status: Some(semantic_index_status),
2351 callgraph_store_status: Some(callgraph_store_status),
2352 tier2_status: Some(tier2_status),
2353 bash: Some(bash),
2354 suspended_domains,
2355 }
2356 }
2357
2358 pub fn try_health_snapshot(&self, project_root: &Path) -> RootHealthSnapshot {
2359 self.try_health_summary().into_snapshot(project_root)
2360 }
2361
2362 pub fn should_emit_status_bar(&self, counts: &StatusBarCounts) -> bool {
2365 self.status_bar_last_emitted.should_emit(counts)
2366 }
2367
2368 pub fn accept_alert_observation_batch(
2372 &self,
2373 batch: &AcceptedObservationBatch,
2374 ) -> Result<Vec<AcceptedObservationResult>, ObservationError> {
2375 self.alert_state.lock().accept_batch(batch)
2376 }
2377
2378 pub fn clear_tsconfig_membership_cache(&self) {
2382 self.tsconfig_membership.lock().clear();
2383 }
2384
2385 #[cfg(test)]
2386 pub fn tsconfig_membership_clear_generation_for_test(&self) -> u64 {
2387 self.tsconfig_membership.lock().generation()
2388 }
2389
2390 pub fn mark_status_bar_tier2_stale(&self) -> bool {
2396 let mut tier2 = self
2397 .status_bar_tier2
2398 .write()
2399 .unwrap_or_else(std::sync::PoisonError::into_inner);
2400 if tier2.dead_code.is_some()
2402 || tier2.unused_exports.is_some()
2403 || tier2.duplicates.is_some()
2404 || tier2.todos.is_some()
2405 {
2406 let changed = !tier2.stale;
2407 tier2.stale = true;
2408 if changed {
2409 tier2.generation = tier2.generation.wrapping_add(1);
2410 }
2411 return changed;
2412 }
2413 false
2414 }
2415
2416 pub fn update_status_bar_tier2(
2422 &self,
2423 dead_code: Option<usize>,
2424 unused_exports: Option<usize>,
2425 duplicates: Option<usize>,
2426 todos: Option<usize>,
2427 stale: bool,
2428 ) {
2429 let mut tier2 = self
2430 .status_bar_tier2
2431 .write()
2432 .unwrap_or_else(std::sync::PoisonError::into_inner);
2433 let previous = (
2434 tier2.dead_code,
2435 tier2.unused_exports,
2436 tier2.duplicates,
2437 tier2.todos,
2438 tier2.stale,
2439 );
2440 if let Some(dead_code) = dead_code {
2441 tier2.dead_code = Some(dead_code);
2442 }
2443 if let Some(unused_exports) = unused_exports {
2444 tier2.unused_exports = Some(unused_exports);
2445 }
2446 if let Some(duplicates) = duplicates {
2447 tier2.duplicates = Some(duplicates);
2448 }
2449 if let Some(todos) = todos {
2450 tier2.todos = Some(todos);
2451 }
2452 tier2.stale = stale;
2453 let current = (
2454 tier2.dead_code,
2455 tier2.unused_exports,
2456 tier2.duplicates,
2457 tier2.todos,
2458 tier2.stale,
2459 );
2460 if current != previous {
2461 tier2.generation = tier2.generation.wrapping_add(1);
2462 }
2463 }
2464
2465 pub(crate) fn set_status_bar_tier2_dead_code_blocked_on_callgraph(&self, blocked: bool) {
2471 let mut tier2 = self
2472 .status_bar_tier2
2473 .write()
2474 .unwrap_or_else(std::sync::PoisonError::into_inner);
2475 tier2.dead_code_blocked_on_callgraph = blocked;
2476 }
2477
2478 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
2481 self.gitignore
2482 .read()
2483 .unwrap_or_else(|poisoned| poisoned.into_inner())
2484 .clone()
2485 }
2486
2487 pub fn shared_gitignore(&self) -> SharedGitignore {
2489 Arc::clone(&self.gitignore)
2490 }
2491
2492 pub fn gitignore_generation(&self) -> Arc<AtomicU64> {
2496 Arc::clone(&self.gitignore_generation)
2497 }
2498
2499 fn set_gitignore(&self, matcher: Option<Arc<ignore::gitignore::Gitignore>>) {
2500 *self
2501 .gitignore
2502 .write()
2503 .unwrap_or_else(|poisoned| poisoned.into_inner()) = matcher;
2504 self.gitignore_generation.fetch_add(1, Ordering::SeqCst);
2505 }
2506
2507 pub fn clear_gitignore(&self) {
2529 self.set_gitignore(None);
2530 }
2531
2532 pub fn rebuild_gitignore(&self) {
2533 use ignore::gitignore::GitignoreBuilder;
2534 use std::path::Path;
2535 let root_raw = match self.config().project_root.clone() {
2536 Some(r) => r,
2537 None => {
2538 self.set_gitignore(None);
2539 return;
2540 }
2541 };
2542 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
2550 let mut builder = GitignoreBuilder::new(&root);
2551 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
2556 if global_ignore.is_file() {
2557 if let Some(err) = builder.add(&global_ignore) {
2558 crate::slog_warn!(
2559 "global gitignore parse error in {}: {}",
2560 global_ignore.display(),
2561 err
2562 );
2563 }
2564 }
2565 }
2566 let root_ignore = Path::new(&root).join(".gitignore");
2568 if root_ignore.exists() {
2569 if let Some(err) = builder.add(&root_ignore) {
2570 crate::slog_warn!(
2571 "gitignore parse error in {}: {}",
2572 root_ignore.display(),
2573 err
2574 );
2575 }
2576 }
2577 let root_aftignore = Path::new(&root).join(".aftignore");
2582 if root_aftignore.exists() {
2583 if let Some(err) = builder.add(&root_aftignore) {
2584 crate::slog_warn!(
2585 "aftignore parse error in {}: {}",
2586 root_aftignore.display(),
2587 err
2588 );
2589 }
2590 }
2591 let info_exclude = self
2596 .git_common_dir
2597 .lock()
2598 .clone()
2599 .unwrap_or_else(|| Path::new(&root).join(".git"))
2600 .join("info")
2601 .join("exclude");
2602 if info_exclude.exists() {
2603 if let Some(err) = builder.add(&info_exclude) {
2604 crate::slog_warn!(
2605 "gitignore parse error in {}: {}",
2606 info_exclude.display(),
2607 err
2608 );
2609 }
2610 }
2611 let walker = ignore::WalkBuilder::new(&root)
2617 .standard_filters(true)
2618 .hidden(false)
2626 .filter_entry(|entry| {
2627 let name = entry.file_name().to_string_lossy();
2628 !matches!(
2629 name.as_ref(),
2630 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
2631 )
2632 })
2633 .build();
2634 for entry in walker.flatten() {
2635 let file_name = entry.file_name();
2636 let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
2637 let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
2638 if is_nested_gitignore || is_nested_aftignore {
2639 if let Some(err) = builder.add(entry.path()) {
2640 crate::slog_warn!(
2641 "nested ignore parse error in {}: {}",
2642 entry.path().display(),
2643 err
2644 );
2645 }
2646 }
2647 }
2648 match builder.build() {
2649 Ok(gi) => {
2650 let count = gi.num_ignores();
2651 if count > 0 {
2652 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
2653 self.set_gitignore(Some(Arc::new(gi)));
2654 } else {
2655 self.set_gitignore(None);
2656 }
2657 }
2658 Err(err) => {
2659 crate::slog_warn!("gitignore matcher build failed: {}", err);
2660 self.set_gitignore(None);
2661 }
2662 }
2663 }
2664
2665 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
2668 Arc::clone(&self.bash_compress_flag)
2669 }
2670
2671 pub fn sync_bash_compress_flag(&self) {
2675 let value = self.config().experimental_bash_compress;
2676 self.bash_compress_flag
2677 .store(value, std::sync::atomic::Ordering::Relaxed);
2678 }
2679
2680 pub fn set_bash_compress_enabled(&self, enabled: bool) {
2681 self.update_config(|config| {
2682 config.experimental_bash_compress = enabled;
2683 });
2684 self.bash_compress_flag
2685 .store(enabled, std::sync::atomic::Ordering::Relaxed);
2686 }
2687
2688 pub fn filter_registry(
2692 &self,
2693 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
2694 self.ensure_filter_registry_loaded();
2695 match self.filter_registry.read() {
2696 Ok(g) => g,
2697 Err(poisoned) => poisoned.into_inner(),
2698 }
2699 }
2700
2701 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
2705 self.ensure_filter_registry_loaded();
2706 Arc::clone(&self.filter_registry)
2707 }
2708
2709 pub fn reset_filter_registry(&self) {
2713 let new_registry = crate::compress::build_registry_for_context(self);
2714 self.filter_registry_rebuild_count
2715 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2716 match self.filter_registry.write() {
2717 Ok(mut slot) => *slot = new_registry,
2718 Err(poisoned) => *poisoned.into_inner() = new_registry,
2719 }
2720 self.filter_registry_loaded
2721 .store(true, std::sync::atomic::Ordering::Release);
2722 }
2723
2724 fn ensure_filter_registry_loaded(&self) {
2725 use std::sync::atomic::Ordering;
2726 if self.filter_registry_loaded.load(Ordering::Acquire) {
2727 return;
2728 }
2729 let new_registry = crate::compress::build_registry_for_context(self);
2732 self.filter_registry_rebuild_count
2733 .fetch_add(1, Ordering::SeqCst);
2734 if let Ok(mut slot) = self.filter_registry.write() {
2735 *slot = new_registry;
2736 self.filter_registry_loaded.store(true, Ordering::Release);
2737 }
2738 }
2739
2740 #[cfg(test)]
2741 pub fn filter_registry_rebuild_count_for_test(&self) -> u64 {
2742 self.filter_registry_rebuild_count.load(Ordering::SeqCst)
2743 }
2744
2745 pub fn app(&self) -> Arc<App> {
2746 Arc::clone(&self.app)
2747 }
2748
2749 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
2752 self.app.lsp_child_registry()
2753 }
2754
2755 pub fn stdout_writer(&self) -> SharedStdoutWriter {
2756 self.app.stdout_writer()
2757 }
2758
2759 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
2760 if let Ok(mut progress_sender) = self.progress_sender.lock() {
2761 *progress_sender = sender;
2762 }
2763 }
2764
2765 pub fn emit_progress(&self, frame: ProgressFrame) {
2766 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
2767 return;
2768 };
2769 if let Some(sender) = progress_sender.as_ref() {
2770 sender(PushFrame::Progress(frame));
2771 }
2772 }
2773
2774 pub fn status_emitter(&self) -> &StatusEmitter {
2775 &self.status_emitter
2776 }
2777
2778 pub(crate) fn install_fleet_status_client(
2779 &self,
2780 client: Option<crate::fleet_status::FleetStatusClient>,
2781 ) {
2782 *self
2783 .fleet_status_client
2784 .write()
2785 .unwrap_or_else(std::sync::PoisonError::into_inner) = client;
2786 }
2787
2788 pub(crate) fn fleet_status_client(&self) -> Option<crate::fleet_status::FleetStatusClient> {
2789 self.fleet_status_client
2790 .read()
2791 .unwrap_or_else(std::sync::PoisonError::into_inner)
2792 .clone()
2793 }
2794
2795 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
2803 self.progress_sender
2804 .lock()
2805 .ok()
2806 .and_then(|sender| sender.clone())
2807 }
2808
2809 pub fn advance_configure_generation(&self) -> u64 {
2810 self.subc_lifecycle
2811 .advance_generation(self.configure_generation.as_ref())
2812 }
2813
2814 pub(crate) fn mark_subc_bound(&self) {
2815 self.subc_lifecycle.mark_bound();
2816 }
2817
2818 pub(crate) fn mark_subc_unbound(&self) {
2819 self.subc_lifecycle
2820 .mark_unbound(self.configure_generation.as_ref());
2821 }
2822
2823 #[doc(hidden)]
2824 pub fn subc_unbound_quiesced(&self) -> bool {
2825 self.subc_lifecycle.is_unbound()
2826 }
2827
2828 pub(crate) fn subc_lifecycle_admission(&self) -> SubcLifecycleAdmission {
2829 self.subc_lifecycle.clone()
2830 }
2831
2832 pub(crate) fn run_if_subc_bound_generation<R>(
2833 &self,
2834 expected_generation: u64,
2835 action: impl FnOnce() -> R,
2836 ) -> Option<R> {
2837 self.subc_lifecycle.run_if_current(
2838 self.configure_generation.as_ref(),
2839 expected_generation,
2840 action,
2841 )
2842 }
2843
2844 pub fn note_configure_warm_key(&self, key: String) -> (u64, bool) {
2855 let mut state = self.configure_warm_state.lock();
2856 let equivalent = state.key.as_ref().is_some_and(|previous| *previous == key);
2857 let generation = if equivalent {
2858 self.configure_generation()
2859 } else {
2860 self.configure_content_generation
2861 .fetch_add(1, Ordering::SeqCst);
2862 self.advance_configure_generation()
2863 };
2864 state.generation = generation;
2865 state.key = Some(key);
2866 (generation, equivalent)
2867 }
2868
2869 pub(crate) fn configure_warm_key_matches(&self, key: &str) -> bool {
2870 self.configure_warm_state
2871 .lock()
2872 .key
2873 .as_deref()
2874 .is_some_and(|current| current == key)
2875 }
2876
2877 pub(crate) fn invalidate_configure_warm_state(&self) {
2878 self.configure_warm_state.lock().key = None;
2879 }
2880
2881 pub fn note_configure_session_binding(&self, root: PathBuf, session_id: String) -> bool {
2882 self.configured_session_roots
2883 .lock()
2884 .insert((root, session_id))
2885 }
2886
2887 pub(crate) fn has_configure_session_binding(&self, root: &Path, session_id: &str) -> bool {
2888 self.configured_session_roots
2889 .lock()
2890 .contains(&(root.to_path_buf(), session_id.to_string()))
2891 }
2892
2893 pub fn forget_configure_session_binding(&self, root: &Path, session_id: &str) {
2897 self.configured_session_roots
2898 .lock()
2899 .remove(&(root.to_path_buf(), session_id.to_string()));
2900 }
2901
2902 pub fn watcher_drain_has_work(&self) -> bool {
2908 let receiver_pending = self
2909 .watcher_rx
2910 .lock()
2911 .as_ref()
2912 .is_some_and(|rx| !rx.is_empty());
2913 receiver_pending
2914 || self
2915 .watcher_drain_slice
2916 .lock()
2917 .as_ref()
2918 .is_some_and(WatcherDrainSliceState::has_pending_work)
2919 }
2920
2921 pub fn lsp_drain_has_work(&self) -> bool {
2922 match self.lsp_manager.try_lock() {
2923 Some(lsp) => lsp.has_pending_events(),
2924 None => true,
2926 }
2927 }
2928
2929 pub fn completion_drains_have_work(&self) -> bool {
2930 let search_pending = self
2931 .search_index_rx
2932 .try_read()
2933 .map(|slot| {
2934 slot.as_ref().is_some_and(|receiver| {
2935 !receiver.is_empty()
2936 || self.search_index_rx_terminal_epoch.load(Ordering::SeqCst)
2937 == self.search_index_rx_epoch()
2938 })
2939 })
2940 .unwrap_or(true);
2941 if search_pending {
2942 return true;
2943 }
2944 if self
2945 .callgraph_store_rx
2946 .lock()
2947 .as_ref()
2948 .is_some_and(|rx| !rx.is_empty())
2949 {
2950 return true;
2951 }
2952 if self
2953 .semantic_index_rx
2954 .lock()
2955 .as_ref()
2956 .is_some_and(|receiver| {
2957 !receiver.is_empty()
2958 || self.semantic_index_rx_terminal_epoch.load(Ordering::SeqCst)
2959 == self.semantic_index_rx_epoch()
2960 })
2961 {
2962 return true;
2963 }
2964 if self
2965 .semantic_refresh_event_rx
2966 .lock()
2967 .as_ref()
2968 .is_some_and(|rx| !rx.is_empty())
2969 {
2970 return true;
2971 }
2972 if self.semantic_refresh_probe_ready() && self.semantic_refresh_event_rx.lock().is_some() {
2973 return true;
2974 }
2975 if self
2976 .semantic_refresh_worker
2977 .lock()
2978 .as_ref()
2979 .is_some_and(|worker_slot| match worker_slot.try_lock() {
2980 Ok(handle) => handle
2981 .as_ref()
2982 .is_some_and(std::thread::JoinHandle::is_finished),
2983 Err(std::sync::TryLockError::WouldBlock) => true,
2984 Err(std::sync::TryLockError::Poisoned(_)) => true,
2985 })
2986 {
2987 return true;
2988 }
2989 self.inspect_manager().has_pending_completions() || self.has_new_reuse_completions()
2990 }
2991
2992 pub fn configure_tail_has_work(&self) -> bool {
2993 !self.configure_maintenance_jobs.lock().is_empty() || !self.configure_warnings_rx.is_empty()
2994 }
2995
2996 pub(crate) fn configure_maintenance_has_capacity(&self) -> bool {
2997 self.configure_maintenance_jobs.lock().len() < crate::executor::MAINTENANCE_QUEUE_CAP
2998 }
2999
3000 pub(crate) fn enqueue_configure_maintenance(
3001 &self,
3002 job: ConfigureMaintenanceJob,
3003 ) -> Result<(), ConfigureMaintenanceJob> {
3004 let mut jobs = self.configure_maintenance_jobs.lock();
3005 if jobs.len() >= crate::executor::MAINTENANCE_QUEUE_CAP {
3006 return Err(job);
3007 }
3008 jobs.push_back(job);
3009 Ok(())
3010 }
3011
3012 pub(crate) fn drain_configure_maintenance(&self) -> Vec<ConfigureMaintenanceJob> {
3013 self.configure_maintenance_jobs.lock().drain(..).collect()
3014 }
3015
3016 #[cfg(test)]
3017 pub(crate) fn configure_maintenance_job_count_for_test(&self) -> usize {
3018 self.configure_maintenance_jobs.lock().len()
3019 }
3020
3021 pub fn cached_artifact_cache_key(&self, canonical_root: &Path) -> Option<String> {
3024 self.artifact_cache_keys.lock().get(canonical_root).cloned()
3025 }
3026
3027 pub(crate) fn cached_worktree_bridge(
3030 &self,
3031 canonical_root: &Path,
3032 ) -> Option<(bool, Option<PathBuf>)> {
3033 #[cfg(test)]
3034 if self.force_worktree_bridge_reprobe.load(Ordering::SeqCst) {
3035 return None;
3036 }
3037
3038 let signature = git_entry_signature(canonical_root);
3039 self.worktree_bridge_cache
3040 .lock()
3041 .get(canonical_root)
3042 .filter(|entry| entry.git_entry == signature)
3043 .map(|entry| (entry.is_worktree_bridge, entry.git_common_dir.clone()))
3044 }
3045
3046 pub(crate) fn cache_worktree_bridge(
3049 &self,
3050 canonical_root: &Path,
3051 is_worktree_bridge: bool,
3052 git_common_dir: PathBuf,
3053 ) {
3054 self.worktree_bridge_cache.lock().insert(
3055 canonical_root.to_path_buf(),
3056 WorktreeBridgeCacheEntry {
3057 git_entry: git_entry_signature(canonical_root),
3058 is_worktree_bridge,
3059 git_common_dir: Some(git_common_dir),
3060 },
3061 );
3062 }
3063
3064 #[cfg(test)]
3065 pub(crate) fn record_worktree_bridge_probe_spawn_for_test(&self) {
3066 self.worktree_bridge_probe_spawns
3067 .fetch_add(1, Ordering::SeqCst);
3068 }
3069
3070 #[cfg(test)]
3071 pub(crate) fn worktree_bridge_probe_spawns_for_test(&self) -> u64 {
3072 self.worktree_bridge_probe_spawns.load(Ordering::SeqCst)
3073 }
3074
3075 #[cfg(test)]
3076 pub(crate) fn force_worktree_bridge_reprobe_for_test(&self, enabled: bool) {
3077 self.force_worktree_bridge_reprobe
3078 .store(enabled, Ordering::SeqCst);
3079 }
3080
3081 pub fn memoized_artifact_cache_key(&self, canonical_root: &Path) -> String {
3082 let mut keys = self.artifact_cache_keys.lock();
3083 if let Some(key) = keys.get(canonical_root).cloned() {
3084 return key;
3085 }
3086 let key = crate::search_index::artifact_cache_key(canonical_root);
3087 self.artifact_cache_key_derivations
3088 .fetch_add(1, Ordering::SeqCst);
3089 keys.insert(canonical_root.to_path_buf(), key.clone());
3090 key
3091 }
3092
3093 pub fn memoized_artifact_cache_key_for_configure(
3094 &self,
3095 raw_root: &Path,
3096 canonical_root: &Path,
3097 storage_root: &Path,
3098 git_common_dir: Option<&Path>,
3099 ) -> Result<String, crate::search_index::ArtifactCacheKeyProbeError> {
3100 {
3101 let keys = self.artifact_cache_keys.lock();
3102 if let Some(key) = keys
3103 .get(canonical_root)
3104 .or_else(|| keys.get(raw_root))
3105 .cloned()
3106 {
3107 return Ok(key);
3108 }
3109 }
3110
3111 let key = crate::search_index::artifact_cache_key_with_memo(
3112 canonical_root,
3113 raw_root,
3114 storage_root,
3115 git_common_dir,
3116 )?;
3117 self.artifact_cache_key_derivations
3118 .fetch_add(1, Ordering::SeqCst);
3119 let mut keys = self.artifact_cache_keys.lock();
3120 keys.insert(canonical_root.to_path_buf(), key.clone());
3121 keys.insert(raw_root.to_path_buf(), key.clone());
3122 Ok(key)
3123 }
3124
3125 #[cfg(test)]
3126 pub fn artifact_cache_key_derivation_count_for_test(&self) -> u64 {
3127 self.artifact_cache_key_derivations.load(Ordering::SeqCst)
3128 }
3129
3130 pub(crate) fn resolve_external_git_root(
3131 &self,
3132 project_root: &Path,
3133 requested_path: &str,
3134 ) -> Result<PathBuf, crate::readonly_artifacts::GitRootResolutionError> {
3135 let raw_path = Path::new(requested_path);
3136 let canonical_requested = if raw_path.is_absolute() {
3137 std::fs::canonicalize(raw_path).ok()
3138 } else {
3139 None
3140 };
3141 if let Some(root) = canonical_requested
3142 .as_deref()
3143 .and_then(|root| self.borrowed_index_cache.lock().resolved_root(root))
3144 {
3145 return Ok(root);
3146 }
3147
3148 let root = crate::readonly_artifacts::resolve_git_root_from_user_path(
3149 project_root,
3150 requested_path,
3151 )?;
3152 if canonical_requested.as_deref() == Some(root.as_path()) {
3153 self.borrowed_index_cache
3154 .lock()
3155 .remember_resolved_root(root.clone());
3156 }
3157 Ok(root)
3158 }
3159
3160 pub(crate) fn open_borrowed_search_index(
3161 &self,
3162 external_root: &Path,
3163 storage_dir: Option<&Path>,
3164 ) -> crate::readonly_artifacts::ReadOnlyArtifact<Arc<SearchIndex>> {
3165 let canonical_root =
3166 std::fs::canonicalize(external_root).unwrap_or_else(|_| external_root.to_path_buf());
3167 let project_key = self.memoized_artifact_cache_key(&canonical_root);
3168 let Some(artifact) = crate::readonly_artifacts::search_index_artifact_generation_with_key(
3169 &project_key,
3170 storage_dir,
3171 ) else {
3172 return crate::readonly_artifacts::ReadOnlyArtifact::Absent;
3173 };
3174 let key = BorrowedIndexCacheKey {
3175 canonical_root: canonical_root.clone(),
3176 artifact,
3177 };
3178 let mut cache = self.borrowed_index_cache.lock();
3179 if let Some(index) = cache.search(&key) {
3180 return index;
3181 }
3182
3183 let opened = crate::readonly_artifacts::open_search_index_read_only_with_key(
3184 &canonical_root,
3185 storage_dir,
3186 &project_key,
3187 )
3188 .map(Arc::new);
3189 if !matches!(opened, crate::readonly_artifacts::ReadOnlyArtifact::Absent) {
3190 cache.insert(key, BorrowedIndexCacheValue::Search(opened.clone()));
3191 }
3192 opened
3193 }
3194
3195 pub(crate) fn open_borrowed_semantic_index(
3196 &self,
3197 external_root: &Path,
3198 storage_dir: Option<&Path>,
3199 ) -> crate::readonly_artifacts::ReadOnlyArtifact<Arc<SemanticIndex>> {
3200 let canonical_root =
3201 std::fs::canonicalize(external_root).unwrap_or_else(|_| external_root.to_path_buf());
3202 let project_key = self.memoized_artifact_cache_key(&canonical_root);
3203 let Some(artifact) = crate::readonly_artifacts::semantic_index_artifact_generation_with_key(
3204 &project_key,
3205 storage_dir,
3206 ) else {
3207 return crate::readonly_artifacts::ReadOnlyArtifact::Absent;
3208 };
3209 let key = BorrowedIndexCacheKey {
3210 canonical_root: canonical_root.clone(),
3211 artifact,
3212 };
3213 let mut cache = self.borrowed_index_cache.lock();
3214 if let Some(index) = cache.semantic(&key) {
3215 return index;
3216 }
3217
3218 let opened = crate::readonly_artifacts::open_semantic_index_read_only_with_key(
3219 &canonical_root,
3220 storage_dir,
3221 &project_key,
3222 )
3223 .map(Arc::new);
3224 if !matches!(opened, crate::readonly_artifacts::ReadOnlyArtifact::Absent) {
3225 cache.insert(key, BorrowedIndexCacheValue::Semantic(opened.clone()));
3226 }
3227 opened
3228 }
3229
3230 #[cfg(test)]
3231 pub(crate) fn borrowed_index_cache_len_for_test(&self) -> usize {
3232 self.borrowed_index_cache.lock().entries.len()
3233 }
3234
3235 pub fn configure_generation(&self) -> u64 {
3236 self.configure_generation.load(Ordering::SeqCst)
3237 }
3238
3239 pub fn configure_generation_flag(&self) -> Arc<AtomicU64> {
3240 Arc::clone(&self.configure_generation)
3241 }
3242
3243 pub(crate) fn configure_content_generation(&self) -> u64 {
3244 self.configure_content_generation.load(Ordering::SeqCst)
3245 }
3246
3247 pub(crate) fn configure_content_generation_flag(&self) -> Arc<AtomicU64> {
3248 Arc::clone(&self.configure_content_generation)
3249 }
3250
3251 pub(crate) fn begin_configure_ack_phase(&self, phase: &'static str) {
3252 let now = Instant::now();
3253 let mut timing = self.configure_phase_timing.lock();
3254 if phase == "canonicalize" {
3255 timing.completed.clear();
3256 } else if timing.phase != "idle" && timing.phase != "ack_ready" {
3257 let previous = timing.phase;
3258 let elapsed = now.saturating_duration_since(timing.started_at);
3259 timing.completed.push((previous, elapsed));
3260 }
3261 timing.phase = phase;
3262 timing.started_at = now;
3263 }
3264
3265 pub(crate) fn configure_ack_phase_snapshot(&self) -> String {
3266 let timing = self.configure_phase_timing.lock();
3267 let mut parts = timing
3268 .completed
3269 .iter()
3270 .map(|(phase, elapsed)| format!("{phase}={}ms", elapsed.as_millis()))
3271 .collect::<Vec<_>>();
3272 parts.push(format!(
3273 "{}={}ms",
3274 timing.phase,
3275 timing.started_at.elapsed().as_millis()
3276 ));
3277 parts.join(",")
3278 }
3279
3280 pub fn advance_semantic_fingerprint_generation(&self) -> u64 {
3281 self.semantic_fingerprint_generation
3282 .fetch_add(1, Ordering::SeqCst)
3283 .wrapping_add(1)
3284 }
3285
3286 pub fn semantic_fingerprint_generation(&self) -> u64 {
3287 self.semantic_fingerprint_generation.load(Ordering::SeqCst)
3288 }
3289
3290 pub fn semantic_fingerprint_generation_flag(&self) -> Arc<AtomicU64> {
3291 Arc::clone(&self.semantic_fingerprint_generation)
3292 }
3293
3294 pub fn configure_warnings_sender(
3295 &self,
3296 ) -> crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)> {
3297 self.configure_warnings_tx.clone()
3298 }
3299
3300 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
3301 let mut warnings = Vec::new();
3302 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
3303 warnings.push(warning);
3304 }
3305 warnings
3306 }
3307
3308 pub fn bash_background(&self) -> &BgTaskRegistry {
3309 &self.bash_background
3310 }
3311
3312 #[cfg(unix)]
3313 pub(crate) fn escalation_grants(
3314 &self,
3315 ) -> &parking_lot::Mutex<crate::sandbox_spawn::EscalationGrantStore> {
3316 &self.escalation_grants
3317 }
3318
3319 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
3320 self.bash_background.drain_completions()
3321 }
3322
3323 pub fn provider(&self) -> &dyn LanguageProvider {
3325 self.provider.as_ref()
3326 }
3327
3328 pub fn backup(&self) -> &parking_lot::Mutex<BackupStore> {
3330 &self.backup
3331 }
3332
3333 pub fn hashline_bindings(&self) -> &crate::hashline::integration::BindingRegistry {
3335 &self.hashline_bindings
3336 }
3337
3338 pub fn checkpoint(&self) -> &parking_lot::Mutex<CheckpointStore> {
3340 &self.checkpoint
3341 }
3342
3343 pub fn set_db(&self, conn: Arc<Mutex<Connection>>) {
3344 self.app.set_db(conn);
3345 self.compression_aggregates.clear();
3346 }
3347
3348 pub fn clear_db(&self) {
3349 self.app.clear_db();
3350 self.compression_aggregates.clear();
3351 }
3352
3353 pub fn db(&self) -> Option<Arc<Mutex<Connection>>> {
3354 self.app.db()
3355 }
3356
3357 pub(crate) fn compression_aggregate_cache(
3358 &self,
3359 ) -> &crate::db::compression_events::CompressionAggregateCache {
3360 self.compression_aggregates.as_ref()
3361 }
3362
3363 pub fn config(&self) -> Arc<Config> {
3365 let guard = match self.config.read() {
3366 Ok(guard) => guard,
3367 Err(poisoned) => poisoned.into_inner(),
3368 };
3369 Arc::clone(&*guard)
3370 }
3371
3372 pub fn set_config(&self, config: Config) {
3374 let next = Arc::new(config);
3375 let project_root_changed = {
3376 let mut guard = self
3377 .config
3378 .write()
3379 .unwrap_or_else(std::sync::PoisonError::into_inner);
3380 let changed = guard.project_root.as_ref().map(|root| root.as_os_str())
3383 != next.project_root.as_ref().map(|root| root.as_os_str());
3384 *guard = next;
3385 changed
3386 };
3387 if project_root_changed {
3388 self.path_restriction_root_memo.lock().take();
3389 }
3390 }
3391
3392 #[cfg(test)]
3393 pub(crate) fn path_restriction_root_memo_is_empty_for_test(&self) -> bool {
3394 self.path_restriction_root_memo.lock().is_none()
3395 }
3396
3397 #[cfg(test)]
3398 pub(crate) fn path_restriction_root_canonicalizations_for_test(&self) -> usize {
3399 self.path_restriction_root_canonicalizations
3400 .load(Ordering::SeqCst)
3401 }
3402
3403 pub fn update_config(&self, update: impl FnOnce(&mut Config)) {
3405 let mut next = self.config().as_ref().clone();
3406 update(&mut next);
3407 self.set_config(next);
3408 }
3409
3410 pub fn force_restrict_guard(&self, req_id: &str) -> ForceRestrictGuard<'_> {
3411 let mut requests = self.force_restrict_requests.lock();
3412 *requests.entry(req_id.to_string()).or_insert(0) += 1;
3413 ForceRestrictGuard {
3414 ctx: self,
3415 req_id: req_id.to_string(),
3416 }
3417 }
3418
3419 pub fn with_force_restrict<R>(&self, req_id: &str, f: impl FnOnce() -> R) -> R {
3420 let _guard = self.force_restrict_guard(req_id);
3421 f()
3422 }
3423
3424 pub fn request_force_restrict(&self, req_id: &str) -> bool {
3425 self.force_restrict_requests.lock().contains_key(req_id)
3426 }
3427
3428 fn release_force_restrict(&self, req_id: &str) {
3429 let mut requests = self.force_restrict_requests.lock();
3430 match requests.get_mut(req_id) {
3431 Some(count) if *count > 1 => *count -= 1,
3432 Some(_) => {
3433 requests.remove(req_id);
3434 }
3435 None => {}
3436 }
3437 }
3438
3439 pub fn set_harness(&self, harness: Harness) {
3440 self.bash_background.set_harness(harness.clone());
3441 *self.harness.lock() = Some(harness);
3442 }
3443
3444 pub fn harness_opt(&self) -> Option<Harness> {
3445 self.harness.lock().clone()
3446 }
3447
3448 pub fn harness(&self) -> Harness {
3449 self.harness_opt()
3450 .expect("harness set by configure before any tool call")
3451 }
3452
3453 pub fn storage_dir(&self) -> PathBuf {
3454 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
3455 }
3456
3457 pub fn harness_dir(&self) -> PathBuf {
3458 self.storage_dir().join(self.harness().storage_segment())
3459 }
3460
3461 pub(crate) fn refresh_build_suspensions_for_health(
3465 &self,
3466 project_root: &Path,
3467 project_key: Option<&str>,
3468 ) {
3469 let now_ms = SystemTime::now()
3470 .duration_since(UNIX_EPOCH)
3471 .unwrap_or_default()
3472 .as_millis()
3473 .min(u128::from(u64::MAX)) as u64;
3474 let suspended_domains = project_key
3475 .and_then(|key| {
3476 let path = self
3477 .storage_dir()
3478 .join("callgraph")
3479 .join(key)
3480 .join("build-breaker.sqlite");
3481 path.is_file().then_some(path)
3482 })
3483 .and_then(|path| crate::build_breaker::BuildDeathBreaker::open(path).ok())
3484 .and_then(|breaker| {
3485 breaker
3486 .active_suspensions_for_root_at(&project_root.display().to_string(), now_ms)
3487 .ok()
3488 })
3489 .unwrap_or_default()
3490 .into_iter()
3491 .map(|suspension| {
3492 let age_s = suspension.age_seconds_at(now_ms);
3493 SuspendedDomainHealthSnapshot {
3494 domain: suspension.domain.as_str().to_string(),
3495 reason: suspension.reason,
3496 death_count: suspension.death_count,
3497 age_s,
3498 }
3499 })
3500 .collect();
3501 if let Ok(mut snapshot) = self.health_build_suspensions.write() {
3502 *snapshot = suspended_domains;
3503 }
3504 }
3505
3506 pub fn inspect_dir(&self) -> PathBuf {
3507 if let Some(root) = self
3508 .canonical_cache_root_opt()
3509 .or_else(|| self.config().project_root.clone())
3510 {
3511 self.storage_dir()
3512 .join("inspect")
3513 .join(crate::path_identity::project_scope_key(&root))
3514 } else {
3515 self.storage_dir().join("inspect").join("unconfigured")
3516 }
3517 }
3518
3519 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
3520 self.harness_dir()
3521 .join("bash-tasks")
3522 .join(hash_session(session_id))
3523 }
3524
3525 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
3526 self.harness_dir()
3527 .join("backups")
3528 .join(hash_session(session_id))
3529 .join(path_hash)
3530 }
3531
3532 pub fn filters_dir(&self) -> PathBuf {
3533 self.harness_dir().join("filters")
3534 }
3535
3536 pub fn trust_file(&self) -> PathBuf {
3538 self.storage_dir().join("trusted-filter-projects.json")
3539 }
3540
3541 pub fn set_canonical_cache_root(&self, root: PathBuf) {
3542 debug_assert!(root.is_absolute());
3543 let root_changed = {
3544 let mut current = self.canonical_cache_root.lock();
3545 let changed = current.as_deref() != Some(root.as_path());
3546 *current = Some(root);
3547 changed
3548 };
3549 if root_changed {
3550 let mut tier2 = self
3551 .status_bar_tier2
3552 .write()
3553 .unwrap_or_else(std::sync::PoisonError::into_inner);
3554 let generation = tier2.generation.wrapping_add(1);
3555 *tier2 = StatusBarTier2 {
3556 generation,
3557 ..StatusBarTier2::default()
3558 };
3559 self.status_bar_last_emitted.clear();
3560 }
3561 }
3562
3563 pub fn canonical_cache_root(&self) -> PathBuf {
3564 self.canonical_cache_root
3565 .lock()
3566 .clone()
3567 .expect("canonical_cache_root accessed before handle_configure")
3568 }
3569
3570 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
3571 self.canonical_cache_root.lock().clone()
3572 }
3573
3574 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
3575 *self.is_worktree_bridge.lock() = is_worktree_bridge;
3576 *self.git_common_dir.lock() = git_common_dir;
3577 self.inspect_manager
3581 .set_automatic_tier2_refresh_allowed(!is_worktree_bridge);
3582 let artifact_read_only = self.shared_artifacts_read_only.load(Ordering::SeqCst);
3583 self.callgraph_writer
3584 .store(!is_worktree_bridge && !artifact_read_only, Ordering::SeqCst);
3585 }
3586
3587 pub fn set_artifact_owner(
3588 &self,
3589 status: Option<ArtifactOwnerStatus>,
3590 lease: Option<ArtifactOwnerLease>,
3591 ) {
3592 let read_only = status
3593 .as_ref()
3594 .is_some_and(|status| status.mode == ArtifactOwnerMode::ReadOnly);
3595 self.shared_artifacts_read_only
3596 .store(read_only, Ordering::SeqCst);
3597 self.callgraph_writer
3598 .store(!self.is_worktree_bridge() && !read_only, Ordering::SeqCst);
3599 self.inspect_writer.store(true, Ordering::SeqCst);
3600 *self.artifact_owner_status.lock() = status;
3601 *self.artifact_owner_lease.lock() = lease.map(crate::artifact_owner::register_heartbeat);
3602 }
3603
3604 pub fn set_cache_writer_capabilities(&self, callgraph_writer: bool, inspect_writer: bool) {
3605 self.callgraph_writer
3606 .store(callgraph_writer, Ordering::SeqCst);
3607 self.inspect_writer.store(inspect_writer, Ordering::SeqCst);
3608 }
3609
3610 pub fn callgraph_writer(&self) -> bool {
3611 self.callgraph_writer.load(Ordering::SeqCst)
3612 }
3613
3614 pub fn inspect_writer(&self) -> bool {
3615 self.inspect_writer.load(Ordering::SeqCst)
3616 }
3617
3618 pub fn shared_artifacts_read_only(&self) -> bool {
3619 !self.callgraph_writer()
3620 }
3621
3622 pub fn ram_overlay_active(&self) -> bool {
3628 self.shared_artifacts_read_only() && self.config().worktree.ram_overlay
3629 }
3630
3631 pub fn artifact_owner_status(&self) -> Option<ArtifactOwnerStatus> {
3632 self.artifact_owner_status.lock().clone()
3633 }
3634
3635 pub fn is_worktree_bridge(&self) -> bool {
3636 *self.is_worktree_bridge.lock()
3637 }
3638
3639 pub fn git_common_dir(&self) -> Option<PathBuf> {
3640 self.git_common_dir.lock().clone()
3641 }
3642
3643 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
3647 *self.degraded_reasons.lock() = reasons;
3648 }
3649
3650 pub fn set_heavy_root_work_allowed(&self, allowed: bool) {
3651 self.heavy_root_work_allowed
3652 .store(allowed, Ordering::SeqCst);
3653 }
3654
3655 pub fn heavy_root_work_allowed(&self) -> bool {
3656 self.heavy_root_work_allowed.load(Ordering::SeqCst) && !self.subc_lifecycle.is_unbound()
3657 }
3658
3659 fn try_heavy_root_work_allowed(&self) -> Option<bool> {
3660 if !self.heavy_root_work_allowed.load(Ordering::SeqCst) {
3661 return Some(false);
3662 }
3663 self.subc_lifecycle.try_is_bound()
3664 }
3665
3666 pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
3667 let reason = reason.into();
3668 let mut reasons = self.degraded_reasons.lock();
3669 if reasons.iter().any(|existing| existing == &reason) {
3670 return false;
3671 }
3672 reasons.push(reason);
3673 true
3674 }
3675
3676 pub fn degraded_reasons(&self) -> Vec<String> {
3680 self.degraded_reasons.lock().clone()
3681 }
3682
3683 pub fn is_degraded(&self) -> bool {
3685 !self.degraded_reasons.lock().is_empty()
3686 }
3687
3688 pub fn cache_role(&self) -> &'static str {
3689 if self.canonical_cache_root.lock().is_none() {
3690 "not_initialized"
3691 } else if self.is_worktree_bridge() {
3692 "worktree"
3693 } else if self.shared_artifacts_read_only.load(Ordering::SeqCst) {
3694 "read_only"
3695 } else {
3696 "main"
3697 }
3698 }
3699
3700 pub fn callgraph_store(&self) -> &RwLock<Option<Arc<ReadonlyCallGraphStore>>> {
3702 self.callgraph_store.as_ref()
3703 }
3704
3705 pub fn mark_callgraph_store_force_rebuild(&self) -> u64 {
3706 self.callgraph_store_force_requested
3707 .fetch_add(1, Ordering::SeqCst)
3708 .wrapping_add(1)
3709 }
3710
3711 pub(crate) fn pending_callgraph_store_force_token(&self) -> Option<u64> {
3712 let requested = self.callgraph_store_force_requested.load(Ordering::SeqCst);
3713 let fulfilled = self.callgraph_store_force_fulfilled.load(Ordering::SeqCst);
3714 (requested > fulfilled).then_some(requested)
3715 }
3716
3717 pub fn fulfill_callgraph_store_force_token(&self, token: u64) {
3718 self.callgraph_store_force_fulfilled
3719 .fetch_max(token, Ordering::SeqCst);
3720 }
3721
3722 #[doc(hidden)]
3723 pub fn record_callgraph_store_build_denied(&self, generation: u64, reason: String) {
3724 *self.callgraph_store_build_denied.lock() = Some((generation, reason));
3725 }
3726
3727 #[doc(hidden)]
3728 pub fn record_callgraph_store_build_suspension(
3729 &self,
3730 generation: u64,
3731 suspension: crate::build_breaker::BuildSuspension,
3732 ) {
3733 *self.callgraph_store_build_suspension.lock() = Some((generation, suspension));
3734 }
3735
3736 #[doc(hidden)]
3737 pub fn clear_callgraph_store_build_denied(&self) {
3738 *self.callgraph_store_build_denied.lock() = None;
3739 *self.callgraph_store_build_suspension.lock() = None;
3740 }
3741
3742 fn callgraph_store_build_suspension(&self) -> Option<crate::build_breaker::BuildSuspension> {
3743 let generation = self.configure_generation();
3744 let mut suspended = self.callgraph_store_build_suspension.lock();
3745 match suspended.as_ref() {
3746 Some((suspended_generation, value)) if *suspended_generation == generation => {
3747 Some(value.clone())
3748 }
3749 Some(_) => {
3750 *suspended = None;
3751 None
3752 }
3753 None => None,
3754 }
3755 }
3756
3757 fn callgraph_store_build_denial(&self) -> Option<String> {
3758 let generation = self.configure_generation();
3759 let mut denied = self.callgraph_store_build_denied.lock();
3760 match denied.as_ref() {
3761 Some((denied_generation, reason)) if *denied_generation == generation => {
3762 Some(reason.clone())
3763 }
3764 Some(_) => {
3765 *denied = None;
3766 None
3767 }
3768 None => None,
3769 }
3770 }
3771
3772 pub fn callgraph_store_dir(&self) -> PathBuf {
3773 if let Some(root) = self.callgraph_project_root() {
3774 self.storage_dir()
3775 .join("callgraph")
3776 .join(self.memoized_artifact_cache_key(&root))
3777 } else {
3778 self.storage_dir().join("callgraph").join("unconfigured")
3779 }
3780 }
3781
3782 pub fn ensure_callgraph_store(
3783 &self,
3784 ) -> Result<Option<Arc<ReadonlyCallGraphStore>>, CallGraphStoreError> {
3785 self.ensure_callgraph_store_with_flag(true)
3786 }
3787
3788 fn ensure_callgraph_store_with_flag(
3789 &self,
3790 respect_config_flag: bool,
3791 ) -> Result<Option<Arc<ReadonlyCallGraphStore>>, CallGraphStoreError> {
3792 if respect_config_flag && !self.config().callgraph_store {
3793 return Ok(None);
3794 }
3795 if !self.heavy_root_work_allowed() {
3796 return Ok(None);
3797 }
3798 self.revalidate_callgraph_store_generation();
3799 let force_token = self.pending_callgraph_store_force_token();
3800 if force_token.is_none() {
3801 if let Some(store) = {
3802 let guard = self
3803 .callgraph_store
3804 .read()
3805 .unwrap_or_else(std::sync::PoisonError::into_inner);
3806 guard.as_ref().map(Arc::clone)
3807 } {
3808 self.schedule_legacy_callgraph_migration_if_needed(
3809 store.as_ref(),
3810 store.project_root().to_path_buf(),
3811 self.callgraph_store_dir(),
3812 );
3813 return Ok(Some(store));
3814 }
3815 }
3816
3817 let Some(project_root) = self.callgraph_project_root() else {
3818 return Ok(None);
3819 };
3820 let callgraph_dir = self.callgraph_store_dir();
3821
3822 if force_token.is_none() {
3826 if let Some(store) =
3827 CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone())?
3828 {
3829 let store = Arc::new(store);
3830 {
3831 let mut guard = self
3832 .callgraph_store
3833 .write()
3834 .unwrap_or_else(std::sync::PoisonError::into_inner);
3835 *guard = Some(Arc::clone(&store));
3836 }
3837 self.schedule_legacy_callgraph_migration_if_needed(
3838 store.as_ref(),
3839 project_root,
3840 callgraph_dir,
3841 );
3842 return Ok(Some(store));
3843 }
3844 }
3845
3846 if !self.callgraph_writer() {
3847 return Ok(None);
3848 }
3849 let build_generation = self.configure_generation();
3850 let persist_epoch_flag = self.callgraph_persist_epoch_flag();
3851 let Some(persist_epoch) = self
3852 .run_if_subc_bound_generation(build_generation, || self.next_callgraph_persist_epoch())
3853 else {
3854 return Ok(None);
3855 };
3856 let (store, _stats) = crate::callgraph_store::with_publish_epoch(
3859 persist_epoch_flag.clone(),
3860 persist_epoch,
3861 || {
3862 if force_token.is_some() {
3863 CallGraphStore::force_cold_build_with_lease_chunked(
3864 callgraph_dir.clone(),
3865 project_root.clone(),
3866 &[],
3867 self.config().callgraph_chunk_size,
3868 )
3869 .map(|(store, _stats)| (store, ()))
3870 } else {
3871 CallGraphStore::ensure_built_with_lease_chunked(
3872 callgraph_dir.clone(),
3873 project_root.clone(),
3874 &[],
3875 self.config().callgraph_chunk_size,
3876 )
3877 .map(|(store, _stats)| (store, ()))
3878 }
3879 },
3880 )?;
3881 drop(store);
3882
3883 let Some(store) = CallGraphStore::open_readonly(callgraph_dir, project_root)? else {
3884 return Ok(None);
3885 };
3886 let store = Arc::new(store);
3887 self.run_if_subc_bound_generation(build_generation, || {
3888 if persist_epoch_flag.current() != persist_epoch {
3889 return None;
3890 }
3891 let mut guard = self
3892 .callgraph_store
3893 .write()
3894 .unwrap_or_else(std::sync::PoisonError::into_inner);
3895 *guard = Some(Arc::clone(&store));
3896 if let Some(force_token) = force_token {
3897 self.fulfill_callgraph_store_force_token(force_token);
3898 }
3899 Some(Arc::clone(&store))
3900 })
3901 .flatten()
3902 .map_or(Ok(None), |store| Ok(Some(store)))
3903 }
3904
3905 pub fn callgraph_project_root(&self) -> Option<PathBuf> {
3908 self.canonical_cache_root_opt().or_else(|| {
3909 self.config()
3910 .project_root
3911 .clone()
3912 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
3913 })
3914 }
3915
3916 pub fn revalidate_callgraph_store_generation(&self) {
3920 let (superseded, legacy_fallback) = {
3921 let guard = self
3922 .callgraph_store
3923 .read()
3924 .unwrap_or_else(std::sync::PoisonError::into_inner);
3925 guard
3926 .as_ref()
3927 .map(|store| (!store.is_current(), store.is_legacy_fallback()))
3928 .unwrap_or((false, false))
3929 };
3930 if !superseded {
3931 return;
3932 }
3933 if legacy_fallback && self.callgraph_store_rx.lock().is_some() {
3937 return;
3938 }
3939 let mut guard = self
3940 .callgraph_store
3941 .write()
3942 .unwrap_or_else(std::sync::PoisonError::into_inner);
3943 *guard = None;
3944 }
3945
3946 pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess {
3947 self.callgraph_store_for_ops_with_wait(callgraph_build_wait_window())
3948 }
3949
3950 pub(crate) fn schedule_callgraph_store_warm(&self) -> CallgraphStoreAccess {
3958 self.callgraph_store_for_ops_with_wait(Duration::ZERO)
3959 }
3960
3961 fn callgraph_store_for_ops_with_wait(&self, wait: Duration) -> CallgraphStoreAccess {
3962 if !self.heavy_root_work_allowed() {
3963 return CallgraphStoreAccess::Unavailable;
3964 }
3965 let operation_generation = self.configure_generation();
3966
3967 self.revalidate_callgraph_store_generation();
3971 let force_token = self.pending_callgraph_store_force_token();
3972 if force_token.is_none() {
3973 if let Some(store) = {
3974 let guard = self
3975 .callgraph_store
3976 .read()
3977 .unwrap_or_else(std::sync::PoisonError::into_inner);
3978 guard.as_ref().map(Arc::clone)
3979 } {
3980 self.clear_callgraph_store_build_denied();
3981 self.schedule_legacy_callgraph_migration_if_needed(
3982 store.as_ref(),
3983 store.project_root().to_path_buf(),
3984 self.callgraph_store_dir(),
3985 );
3986 return CallgraphStoreAccess::Ready(store);
3987 }
3988 }
3989
3990 if let Some(suspension) = self.callgraph_store_build_suspension() {
3991 return CallgraphStoreAccess::Suspended(suspension);
3992 }
3993 if let Some(reason) = self.callgraph_store_build_denial() {
3994 return CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason));
3995 }
3996
3997 let build_in_flight = self.callgraph_store_rx.lock().is_some();
4001
4002 let Some(project_root) = self.callgraph_project_root() else {
4003 return CallgraphStoreAccess::Unavailable;
4004 };
4005 let callgraph_dir = self.callgraph_store_dir();
4006
4007 if !build_in_flight {
4008 match CallGraphStore::cold_build_suspension(&callgraph_dir, &project_root) {
4009 Ok(Some(suspension)) => return CallgraphStoreAccess::Suspended(suspension),
4010 Ok(None) => {}
4011 Err(error) => return CallgraphStoreAccess::Error(error),
4012 }
4013 }
4014
4015 if !build_in_flight {
4016 if force_token.is_none() {
4017 match CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone()) {
4018 Ok(Some(store)) => {
4019 let store = Arc::new(store);
4020 let installed =
4021 self.run_if_subc_bound_generation(operation_generation, || {
4022 let mut guard = self
4023 .callgraph_store
4024 .write()
4025 .unwrap_or_else(std::sync::PoisonError::into_inner);
4026 *guard = Some(Arc::clone(&store));
4027 Arc::clone(&store)
4028 });
4029 let Some(store) = installed else {
4030 return CallgraphStoreAccess::Unavailable;
4031 };
4032 self.clear_callgraph_store_build_denied();
4033 self.schedule_legacy_callgraph_migration_if_needed(
4034 store.as_ref(),
4035 project_root.clone(),
4036 callgraph_dir.clone(),
4037 );
4038 return CallgraphStoreAccess::Ready(store);
4039 }
4040 Ok(None) => {
4041 if !self.callgraph_writer() {
4042 return CallgraphStoreAccess::Unavailable;
4043 }
4044 }
4045 Err(error) => {
4046 if !self.callgraph_writer() {
4047 return CallgraphStoreAccess::Unavailable;
4048 }
4049 crate::slog_warn!(
4050 "callgraph read-only open failed before writer promotion: {}",
4051 error
4052 );
4053 }
4054 }
4055 } else if !self.callgraph_writer() {
4056 return CallgraphStoreAccess::Unavailable;
4057 }
4058
4059 if self.semantic_cold_seed_active() {
4060 self.defer_callgraph_store_warm_for_semantic_cold_seed();
4061 return CallgraphStoreAccess::Building;
4062 }
4063
4064 let work = if let Some(force_token) = force_token {
4072 CallgraphBackgroundWork::ForceRebuild(force_token)
4073 } else {
4074 CallgraphBackgroundWork::Ensure
4075 };
4076 let _ = self.spawn_callgraph_store_cold_build(
4080 project_root.clone(),
4081 callgraph_dir.clone(),
4082 work,
4083 );
4084 }
4085
4086 if !wait.is_zero() {
4087 let (received, receiver_generation, receiver_epoch) = {
4088 let rx_ref = self.callgraph_store_rx.lock();
4089 let Some(rx) = rx_ref.as_ref() else {
4090 return CallgraphStoreAccess::Building;
4091 };
4092 (
4093 rx.recv_timeout(wait),
4094 self.callgraph_store_rx_generation(),
4095 self.callgraph_store_rx_epoch(),
4096 )
4097 };
4098 match received {
4099 Ok(CallGraphStoreBuildEvent::Ready {
4100 store,
4101 fulfilled_force_token,
4102 publication_epoch,
4103 }) => {
4104 if self.callgraph_persist_epoch_flag().current() != publication_epoch {
4105 drop(store);
4109 let _ = self.with_current_callgraph_store_rx(
4110 receiver_generation,
4111 receiver_epoch,
4112 |receiver| {
4113 *receiver = None;
4114 },
4115 );
4116 return CallgraphStoreAccess::Building;
4117 }
4118 remove_callgraph_pointer_before_inline_reopen_for_test(&callgraph_dir, &store);
4121 drop(store);
4122 let reopened =
4123 CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone());
4124 let mut pending = Vec::new();
4125 let outcome = self.with_current_callgraph_store_rx(
4126 receiver_generation,
4127 receiver_epoch,
4128 |receiver| {
4129 *receiver = None;
4130 match reopened {
4131 Ok(Some(store)) => {
4132 let ready = Arc::new(store);
4133 self.clear_callgraph_store_build_denied();
4134 *self
4135 .callgraph_store
4136 .write()
4137 .unwrap_or_else(std::sync::PoisonError::into_inner) =
4138 Some(Arc::clone(&ready));
4139 pending = self.take_pending_callgraph_store_paths();
4144 if let Some(force_token) = fulfilled_force_token {
4145 self.fulfill_callgraph_store_force_token(force_token);
4146 }
4147 CallgraphStoreAccess::Ready(ready)
4148 }
4149 Ok(None) => CallgraphStoreAccess::Building,
4150 Err(error) => CallgraphStoreAccess::Error(error),
4151 }
4152 },
4153 );
4154 let Some(outcome) = outcome else {
4155 return if self.subc_unbound_quiesced()
4156 || self.configure_generation() != receiver_generation
4157 {
4158 CallgraphStoreAccess::Unavailable
4159 } else {
4160 CallgraphStoreAccess::Building
4161 };
4162 };
4163 if !pending.is_empty() {
4164 let _ = self.enqueue_callgraph_store_refresh(pending);
4165 }
4166 if matches!(&outcome, CallgraphStoreAccess::Ready(_)) {
4167 let _ = self.request_tier2_refresh_pull();
4168 }
4169 return outcome;
4170 }
4171 Ok(CallGraphStoreBuildEvent::Suspended { suspension }) => {
4172 let suspended = self.with_current_callgraph_store_rx(
4173 receiver_generation,
4174 receiver_epoch,
4175 |receiver| {
4176 *receiver = None;
4177 self.record_callgraph_store_build_suspension(
4178 receiver_generation,
4179 suspension.clone(),
4180 );
4181 CallgraphStoreAccess::Suspended(suspension)
4182 },
4183 );
4184 return suspended.unwrap_or(CallgraphStoreAccess::Unavailable);
4185 }
4186 Ok(CallGraphStoreBuildEvent::Denied { reason }) => {
4187 let denied = self.with_current_callgraph_store_rx(
4188 receiver_generation,
4189 receiver_epoch,
4190 |receiver| {
4191 *receiver = None;
4192 self.record_callgraph_store_build_denied(
4193 receiver_generation,
4194 reason.clone(),
4195 );
4196 CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason))
4197 },
4198 );
4199 return denied.unwrap_or(CallgraphStoreAccess::Unavailable);
4200 }
4201 Ok(CallGraphStoreBuildEvent::Settled) => {
4202 let _ = self.with_current_callgraph_store_rx(
4203 receiver_generation,
4204 receiver_epoch,
4205 |receiver| *receiver = None,
4206 );
4207 return CallgraphStoreAccess::Building;
4208 }
4209 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
4210 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
4211 let _ = self.with_current_callgraph_store_rx(
4212 receiver_generation,
4213 receiver_epoch,
4214 |receiver| *receiver = None,
4215 );
4216 }
4217 }
4218 }
4219 CallgraphStoreAccess::Building
4220 }
4221
4222 fn schedule_legacy_callgraph_migration_if_needed(
4223 &self,
4224 store: &ReadonlyCallGraphStore,
4225 project_root: PathBuf,
4226 callgraph_dir: PathBuf,
4227 ) {
4228 if !store.is_legacy_fallback()
4229 || !self.callgraph_writer()
4230 || !self.heavy_root_work_allowed()
4231 {
4232 return;
4233 }
4234 if self.semantic_cold_seed_active() {
4235 self.defer_callgraph_store_warm_for_semantic_cold_seed();
4236 return;
4237 }
4238 let _ = self.spawn_callgraph_store_cold_build(
4239 project_root,
4240 callgraph_dir,
4241 CallgraphBackgroundWork::LegacyMigration,
4242 );
4243 }
4244
4245 fn configured_callgraph_keys(&self, current_root: &Path) -> BTreeSet<String> {
4246 let mut roots = self
4247 .configured_session_roots
4248 .lock()
4249 .iter()
4250 .map(|(root, _session)| root.clone())
4251 .collect::<BTreeSet<_>>();
4252 roots.insert(current_root.to_path_buf());
4253 roots
4254 .iter()
4255 .map(|root| self.memoized_artifact_cache_key(root))
4259 .collect()
4260 }
4261
4262 fn spawn_callgraph_store_cold_build(
4267 &self,
4268 project_root: PathBuf,
4269 callgraph_dir: PathBuf,
4270 work: CallgraphBackgroundWork,
4271 ) -> bool {
4272 if !self.heavy_root_work_allowed() || !self.callgraph_writer() {
4273 return false;
4274 }
4275 let generation = self.configure_generation();
4276 self.run_if_subc_bound_generation(generation, || {
4277 self.spawn_callgraph_store_cold_build_admitted(project_root, callgraph_dir, work)
4278 })
4279 .unwrap_or(false)
4280 }
4281
4282 fn spawn_callgraph_store_cold_build_admitted(
4284 &self,
4285 project_root: PathBuf,
4286 callgraph_dir: PathBuf,
4287 work: CallgraphBackgroundWork,
4288 ) -> bool {
4289 let session_id = crate::log_ctx::current_session();
4290 let chunk_size = self.config().callgraph_chunk_size;
4291 let build_generation = self.configure_generation();
4292 let generation_flag = self.configure_generation_flag();
4293 let configured_keys = self.configured_callgraph_keys(&project_root);
4294 let summary_logged = Arc::clone(&self.callgraph_legacy_migration_summary_logged);
4295
4296 let mut rx_guard = self.callgraph_store_rx.lock();
4297 if rx_guard.is_some() {
4298 return false;
4299 }
4300
4301 let limiter = self.cold_build_limiter();
4302 let request = crate::cold_build_limiter::ColdBuildAdmissionRequest::new(
4303 "callgraph-background",
4304 crate::cold_build_limiter::ColdBuildAdmissionClass::Maintenance,
4305 );
4306 let Some(permit) =
4307 crate::cold_build_limiter::try_acquire_classified_with_limiter(&limiter, &request)
4308 else {
4309 crate::slog_info!(
4310 "callgraph store background work deferred by cold build limit ({})",
4311 limiter.limit()
4312 );
4313 return false;
4314 };
4315
4316 let force_token = match work {
4317 CallgraphBackgroundWork::ForceRebuild(token) => Some(token),
4318 CallgraphBackgroundWork::Ensure | CallgraphBackgroundWork::LegacyMigration => None,
4319 };
4320 let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStoreBuildEvent>();
4321 self.note_callgraph_store_rx_generation(build_generation);
4322 self.next_callgraph_store_rx_epoch();
4323 *rx_guard = Some(rx);
4324 let persist_epoch = self.next_callgraph_persist_epoch();
4325 let persist_epoch_flag = self.callgraph_persist_epoch_flag();
4326
4327 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.fetch_add(1, Ordering::SeqCst);
4328
4329 std::thread::spawn(move || {
4330 let _permit = permit;
4331 let mut settlement = CallGraphStoreBuildSettlement::new(tx, force_token, persist_epoch);
4332 crate::log_ctx::with_session(session_id, || {
4333 wait_on_callgraph_build_start_gate(&project_root);
4334 if persist_epoch_flag.current() != persist_epoch {
4335 crate::slog_info!(
4336 "callgraph store background work skipped for superseded epoch {}",
4337 persist_epoch
4338 );
4339 return;
4340 }
4341 let built = crate::callgraph_store::with_publish_epoch(
4342 persist_epoch_flag,
4343 persist_epoch,
4344 || match work {
4345 CallgraphBackgroundWork::LegacyMigration => {
4346 CallGraphStore::migrate_legacy_with_lease(
4347 callgraph_dir.clone(),
4348 project_root.clone(),
4349 )
4350 }
4351 CallgraphBackgroundWork::ForceRebuild(_) => {
4352 let files = crate::callgraph::walk_project_files(&project_root)
4353 .collect::<Vec<_>>();
4354 CallGraphStore::force_cold_build_with_lease_chunked(
4355 callgraph_dir.clone(),
4356 project_root.clone(),
4357 &files,
4358 chunk_size,
4359 )
4360 .map(|(store, _)| Some(store))
4361 }
4362 CallgraphBackgroundWork::Ensure => {
4363 let files = crate::callgraph::walk_project_files(&project_root)
4364 .collect::<Vec<_>>();
4365 CallGraphStore::ensure_built_with_lease_chunked(
4366 callgraph_dir.clone(),
4367 project_root.clone(),
4368 &files,
4369 chunk_size,
4370 )
4371 .map(|(store, _)| Some(store))
4372 }
4373 },
4374 );
4375 match built {
4376 Ok(Some(store)) => {
4377 if store.is_legacy_migration() {
4378 match crate::callgraph_store::all_legacy_partitions_migrated_for_keys(
4379 &callgraph_dir,
4380 &configured_keys,
4381 ) {
4382 Ok(true)
4383 if summary_logged
4384 .compare_exchange(
4385 false,
4386 true,
4387 Ordering::SeqCst,
4388 Ordering::SeqCst,
4389 )
4390 .is_ok() =>
4391 {
4392 crate::slog_info!(
4393 "all legacy callgraph partitions migrated for configured roots"
4394 );
4395 }
4396 Ok(_) => {}
4397 Err(error) => crate::slog_warn!(
4398 "failed to inspect legacy callgraph migration completion: {}",
4399 error
4400 ),
4401 }
4402 }
4403 if generation_flag.load(Ordering::SeqCst) == build_generation {
4404 settlement.ready(store);
4405 } else {
4406 crate::slog_info!(
4407 "callgraph store warm build result discarded for stale generation {}",
4408 build_generation
4409 );
4410 }
4411 }
4412 Ok(None) => {}
4413 Err(crate::callgraph_store::CallGraphStoreError::Superseded) => {
4414 crate::slog_info!(
4415 "callgraph store disk publication skipped for superseded epoch {}",
4416 persist_epoch
4417 );
4418 }
4419 Err(crate::callgraph_store::CallGraphStoreError::Suspended(suspension)) => {
4420 crate::slog_warn!(
4421 "callgraph store background work suspended: {}",
4422 suspension.reason
4423 );
4424 settlement.suspended(suspension);
4425 }
4426 Err(crate::callgraph_store::CallGraphStoreError::Unavailable(reason))
4427 if reason.ends_with("could not acquire writer capability") =>
4428 {
4429 crate::slog_warn!(
4430 "callgraph store background work denied writer capability: {}",
4431 reason
4432 );
4433 settlement.denied(reason);
4434 }
4435 Err(error) => {
4436 crate::slog_warn!("callgraph store background work failed: {}", error);
4437 }
4438 }
4439 });
4440 });
4441 true
4442 }
4443
4444 pub fn callgraph_store_rx(
4447 &self,
4448 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>> {
4449 &self.callgraph_store_rx
4450 }
4451
4452 #[doc(hidden)]
4456 pub fn with_current_callgraph_store_rx<R>(
4457 &self,
4458 generation: u64,
4459 epoch: u64,
4460 action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>) -> R,
4461 ) -> Option<R> {
4462 self.run_if_subc_bound_generation(generation, || {
4463 let mut receiver = self.callgraph_store_rx.lock();
4464 if receiver.is_none()
4465 || self.callgraph_store_rx_generation() != generation
4466 || self.callgraph_store_rx_epoch() != epoch
4467 {
4468 return None;
4469 }
4470 Some(action(&mut receiver))
4471 })
4472 .flatten()
4473 }
4474
4475 pub(crate) fn retire_callgraph_store_rx(&self) {
4476 let mut receiver = self.callgraph_store_rx.lock();
4477 *receiver = None;
4478 self.next_callgraph_store_rx_epoch();
4479 }
4480
4481 pub(crate) fn note_callgraph_store_rx_generation(&self, generation: u64) {
4482 self.callgraph_store_rx_generation
4483 .store(generation, Ordering::SeqCst);
4484 }
4485
4486 #[doc(hidden)]
4487 pub fn callgraph_store_rx_generation(&self) -> u64 {
4488 self.callgraph_store_rx_generation.load(Ordering::SeqCst)
4489 }
4490
4491 pub(crate) fn next_callgraph_store_rx_epoch(&self) -> u64 {
4492 self.callgraph_store_rx_epoch
4493 .fetch_add(1, Ordering::SeqCst)
4494 .wrapping_add(1)
4495 }
4496
4497 #[doc(hidden)]
4498 pub fn callgraph_store_rx_epoch(&self) -> u64 {
4499 self.callgraph_store_rx_epoch.load(Ordering::SeqCst)
4500 }
4501
4502 pub(crate) fn next_callgraph_persist_epoch(&self) -> u64 {
4503 self.callgraph_persist_epoch.next()
4504 }
4505
4506 #[doc(hidden)]
4507 pub fn callgraph_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
4508 self.callgraph_persist_epoch.clone()
4509 }
4510
4511 pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
4514 where
4515 I: IntoIterator<Item = PathBuf>,
4516 {
4517 self.pending_callgraph_store_paths.lock().extend(paths);
4518 }
4519
4520 pub fn enqueue_callgraph_store_refresh<I>(&self, paths: I) -> bool
4521 where
4522 I: IntoIterator<Item = PathBuf>,
4523 {
4524 let generation = self.configure_generation();
4525 self.enqueue_callgraph_store_refresh_for_generation(paths, generation)
4526 }
4527
4528 pub(crate) fn enqueue_callgraph_store_refresh_for_generation<I>(
4529 &self,
4530 paths: I,
4531 generation: u64,
4532 ) -> bool
4533 where
4534 I: IntoIterator<Item = PathBuf>,
4535 {
4536 let paths = paths.into_iter().collect::<Vec<_>>();
4537 if paths.is_empty() {
4538 return true;
4539 }
4540 self.run_if_subc_bound_generation(generation, || {
4541 if !self.callgraph_writer() {
4542 self.add_pending_callgraph_store_paths(paths);
4543 return false;
4544 }
4545 let Some(project_root) = self.callgraph_project_root() else {
4546 self.add_pending_callgraph_store_paths(paths);
4547 return false;
4548 };
4549
4550 let ticket = crate::callgraph_store::CallgraphRefreshTicket::new(
4555 self.subc_lifecycle_admission(),
4556 self.configure_generation_flag(),
4557 generation,
4558 self.callgraph_persist_epoch_flag(),
4559 self.callgraph_persist_epoch_flag().current(),
4560 );
4561 crate::callgraph_store::enqueue_callgraph_store_refresh_fenced_with_state(
4562 self.callgraph_store_dir(),
4563 project_root,
4564 paths,
4565 Arc::clone(&self.pending_callgraph_store_paths),
4566 crate::callgraph_store::CallgraphRefreshState::new(
4567 Arc::clone(&self.callgraph_store),
4568 Arc::clone(&self.heavy_root_work_allowed),
4569 ),
4570 ticket,
4571 )
4572 })
4573 .unwrap_or(false)
4574 }
4575
4576 pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
4584 let roots: Vec<PathBuf> = [
4585 self.canonical_cache_root_opt(),
4586 self.config().project_root.clone(),
4587 ]
4588 .into_iter()
4589 .flatten()
4590 .collect();
4591 std::mem::take(&mut *self.pending_callgraph_store_paths.lock())
4592 .into_iter()
4593 .filter(|path| {
4594 let in_root = pending_path_in_roots(path, &roots);
4595 if !in_root {
4596 crate::slog_debug!(
4597 "dropping pending callgraph path outside current root: {}",
4598 path.display()
4599 );
4600 }
4601 in_root
4602 })
4603 .collect()
4604 }
4605
4606 pub fn search_index(&self) -> &RwLock<Option<SearchIndex>> {
4608 &self.search_index
4609 }
4610
4611 pub fn search_index_rx(&self) -> &RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>> {
4613 &self.search_index_rx
4614 }
4615
4616 pub(crate) fn install_search_index_rx(
4617 &self,
4618 receiver: crossbeam_channel::Receiver<SearchIndex>,
4619 generation: u64,
4620 ) -> u64 {
4621 let mut slot = self
4622 .search_index_rx
4623 .write()
4624 .unwrap_or_else(std::sync::PoisonError::into_inner);
4625 self.note_search_index_rx_generation(generation);
4626 let epoch = self.next_search_index_rx_epoch();
4627 *slot = Some(receiver);
4628 epoch
4629 }
4630
4631 pub(crate) fn search_index_rx_terminal_guard(&self, epoch: u64) -> ReceiverTerminalGuard {
4632 ReceiverTerminalGuard::new(Arc::clone(&self.search_index_rx_terminal_epoch), epoch)
4633 }
4634
4635 pub(crate) fn with_current_search_index_rx<R>(
4638 &self,
4639 generation: u64,
4640 epoch: u64,
4641 action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<SearchIndex>>) -> R,
4642 ) -> Option<R> {
4643 self.run_if_subc_bound_generation(generation, || {
4644 let mut receiver = self
4645 .search_index_rx
4646 .write()
4647 .unwrap_or_else(std::sync::PoisonError::into_inner);
4648 if receiver.is_none()
4649 || self.search_index_rx_generation() != generation
4650 || self.search_index_rx_epoch() != epoch
4651 {
4652 return None;
4653 }
4654 Some(action(&mut receiver))
4655 })
4656 .flatten()
4657 }
4658
4659 pub(crate) fn retire_search_index_rx(&self) {
4660 let mut receiver = self
4661 .search_index_rx
4662 .write()
4663 .unwrap_or_else(std::sync::PoisonError::into_inner);
4664 *receiver = None;
4665 self.next_search_index_rx_epoch();
4666 }
4667
4668 pub(crate) fn note_search_index_rx_generation(&self, generation: u64) {
4669 self.search_index_rx_generation
4670 .store(generation, Ordering::SeqCst);
4671 }
4672
4673 pub(crate) fn search_index_rx_generation(&self) -> u64 {
4674 self.search_index_rx_generation.load(Ordering::SeqCst)
4675 }
4676
4677 pub(crate) fn next_search_index_rx_epoch(&self) -> u64 {
4678 self.search_index_rx_epoch
4679 .fetch_add(1, Ordering::SeqCst)
4680 .wrapping_add(1)
4681 }
4682
4683 pub(crate) fn search_index_rx_epoch(&self) -> u64 {
4684 self.search_index_rx_epoch.load(Ordering::SeqCst)
4685 }
4686
4687 pub(crate) fn allow_search_index_disconnect_reschedule(&self) -> bool {
4694 const MAX_REPLACEMENTS_PER_GENERATION: u32 = 1;
4695 let generation = self.configure_generation();
4696 let mut state = self.search_index_disconnect_reschedule.lock();
4697 if state.0 != generation {
4698 *state = (generation, 0);
4699 }
4700 if state.1 >= MAX_REPLACEMENTS_PER_GENERATION {
4701 return false;
4702 }
4703 state.1 += 1;
4704 true
4705 }
4706
4707 pub(crate) fn next_search_persist_epoch(&self) -> u64 {
4708 self.search_persist_epoch.next()
4709 }
4710
4711 pub(crate) fn search_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
4712 self.search_persist_epoch.clone()
4713 }
4714
4715 pub fn add_pending_search_index_paths<I>(&self, paths: I)
4716 where
4717 I: IntoIterator<Item = PathBuf>,
4718 {
4719 let paths = paths.into_iter().collect::<Vec<_>>();
4720 if !paths.is_empty() {
4721 self.invalidate_warm_verify_memo();
4722 self.pending_search_index_paths.lock().extend(paths);
4723 }
4724 }
4725
4726 pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
4727 std::mem::take(&mut *self.pending_search_index_paths.lock())
4728 .into_iter()
4729 .collect()
4730 }
4731
4732 pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
4733 where
4734 I: IntoIterator<Item = PathBuf>,
4735 {
4736 let paths = paths.into_iter().collect::<Vec<_>>();
4737 if !paths.is_empty() {
4738 self.invalidate_warm_verify_memo();
4739 self.pending_semantic_index_paths.lock().extend(paths);
4740 }
4741 }
4742
4743 pub(crate) fn invalidate_warm_verify_memo(&self) {
4744 if let Some(root) = self.canonical_cache_root_opt() {
4745 crate::cache_freshness::invalidate_verify_memo(&root);
4746 }
4747 }
4748
4749 pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
4750 std::mem::take(&mut *self.pending_semantic_index_paths.lock())
4751 .into_iter()
4752 .collect()
4753 }
4754
4755 pub fn mark_pending_semantic_corpus_refresh(&self) {
4756 *self.pending_semantic_corpus_refresh.lock() = true;
4757 }
4758
4759 pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
4760 std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock())
4761 }
4762
4763 pub fn clear_pending_index_updates(&self) {
4764 self.pending_search_index_paths.lock().clear();
4765 self.pending_callgraph_store_paths.lock().clear();
4766 self.pending_tier2_paths.lock().clear();
4767 self.pending_semantic_index_paths.lock().clear();
4768 *self.pending_semantic_corpus_refresh.lock() = false;
4769 }
4770
4771 pub(crate) fn take_pending_reconciliation_state(&self) -> PendingReconciliationState {
4779 PendingReconciliationState {
4780 search: std::mem::take(&mut *self.pending_search_index_paths.lock()),
4781 callgraph: std::mem::take(&mut *self.pending_callgraph_store_paths.lock()),
4782 tier2: std::mem::take(&mut *self.pending_tier2_paths.lock()),
4783 semantic: std::mem::take(&mut *self.pending_semantic_index_paths.lock()),
4784 corpus_refresh: std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock()),
4785 }
4786 }
4787
4788 pub(crate) fn restore_pending_reconciliation_state(&self, state: PendingReconciliationState) {
4789 self.pending_search_index_paths.lock().extend(state.search);
4790 self.pending_callgraph_store_paths
4791 .lock()
4792 .extend(state.callgraph);
4793 self.pending_tier2_paths.lock().extend(state.tier2);
4794 self.pending_semantic_index_paths
4795 .lock()
4796 .extend(state.semantic);
4797 if state.corpus_refresh {
4798 *self.pending_semantic_corpus_refresh.lock() = true;
4799 }
4800 }
4801
4802 pub(crate) fn cancel_unbound_artifact_work(&self) {
4816 let search_refresh_cancelled = self
4822 .search_index_rx
4823 .read()
4824 .unwrap_or_else(std::sync::PoisonError::into_inner)
4825 .is_some();
4826 self.retire_search_index_rx();
4827 if search_refresh_cancelled {
4828 let mut resident = self
4829 .search_index
4830 .write()
4831 .unwrap_or_else(std::sync::PoisonError::into_inner);
4832 if resident.as_ref().is_some_and(|index| !index.ready) {
4833 *resident = None;
4834 }
4835 }
4836 self.retire_callgraph_store_rx();
4837 let semantic_cancelled = self.semantic_index_rx.lock().is_some();
4838 self.retire_semantic_index_rx();
4839 let semantic_refresh_cancelled = self.semantic_refresh_event_rx.lock().is_some();
4840 self.clear_semantic_refresh_worker();
4841 self.reset_semantic_cold_seed_gate_for_configure();
4842 let _ = self.inspect_manager.discard_completions();
4843 let _ = self.take_new_reuse_completions();
4844 if semantic_cancelled || semantic_refresh_cancelled {
4845 let has_index = self
4846 .semantic_index
4847 .read()
4848 .unwrap_or_else(std::sync::PoisonError::into_inner)
4849 .is_some();
4850 {
4854 let mut status = self
4855 .semantic_index_status
4856 .write()
4857 .unwrap_or_else(std::sync::PoisonError::into_inner);
4858 let refreshing = status.take_refreshing_files();
4859 if !refreshing.is_empty() {
4860 self.pending_semantic_index_paths.lock().extend(refreshing);
4861 }
4862 if status.corpus_refresh_in_flight() {
4863 *self.pending_semantic_corpus_refresh.lock() = true;
4864 }
4865 *status = if has_index {
4866 SemanticIndexStatus::ready()
4867 } else {
4868 SemanticIndexStatus::Disabled
4869 };
4870 }
4871 }
4872 }
4873
4874 pub(crate) fn invalidate_artifacts_after_watcher_gap(&self) {
4878 self.next_search_persist_epoch();
4879 self.next_semantic_persist_epoch();
4880 self.next_callgraph_persist_epoch();
4881
4882 self.search_index
4883 .write()
4884 .unwrap_or_else(std::sync::PoisonError::into_inner)
4885 .take();
4886 self.semantic_index
4887 .write()
4888 .unwrap_or_else(std::sync::PoisonError::into_inner)
4889 .take();
4890 self.callgraph_store
4891 .write()
4892 .unwrap_or_else(std::sync::PoisonError::into_inner)
4893 .take();
4894 *self
4900 .semantic_index_status
4901 .write()
4902 .unwrap_or_else(std::sync::PoisonError::into_inner) = if self.config().semantic_search {
4903 SemanticIndexStatus::ready()
4904 } else {
4905 SemanticIndexStatus::Disabled
4906 };
4907 if self.callgraph_writer() {
4911 self.mark_callgraph_store_force_rebuild();
4912 }
4913
4914 if let Some(root) = self
4915 .canonical_cache_root_opt()
4916 .or_else(|| self.config().project_root.clone())
4917 {
4918 crate::cache_freshness::invalidate_verify_memo_strict(&root);
4919 }
4920 self.borrowed_index_cache.lock().clear();
4921 self.inspect_manager.evict_idle_caches();
4922 self.reset_symbol_cache();
4923 self.clear_tsconfig_membership_cache();
4924 }
4925
4926 fn drain_search_index_events_for_graceful_shutdown(&self) {
4927 crate::runtime_drain::drain_watcher_events(self);
4928 crate::runtime_drain::drain_search_index_events(self);
4929 }
4930
4931 fn search_index_build_in_progress(&self) -> bool {
4932 self.search_index_rx()
4933 .read()
4934 .unwrap_or_else(std::sync::PoisonError::into_inner)
4935 .is_some()
4936 }
4937
4938 fn wait_for_search_index_build_to_settle_on_graceful_shutdown(&self) {
4942 crate::runtime_drain::note_search_rebuild_shutdown_wait_for_test();
4943 let deadline = Instant::now() + GRACEFUL_SHUTDOWN_SEARCH_BUILD_WAIT;
4944 while self.search_index_build_in_progress() && Instant::now() < deadline {
4945 let remaining = deadline.saturating_duration_since(Instant::now());
4946 std::thread::sleep(remaining.min(GRACEFUL_SHUTDOWN_SEARCH_BUILD_POLL));
4947 self.drain_search_index_events_for_graceful_shutdown();
4948 }
4949 }
4950
4951 #[doc(hidden)]
4958 pub fn flush_search_index_on_graceful_shutdown(&self) -> bool {
4959 if self.shared_artifacts_read_only() {
4960 return false;
4961 }
4962
4963 self.drain_search_index_events_for_graceful_shutdown();
4964 if self.search_index_build_in_progress() {
4965 self.wait_for_search_index_build_to_settle_on_graceful_shutdown();
4966 self.drain_search_index_events_for_graceful_shutdown();
4967 }
4968
4969 if self.search_index_build_in_progress() {
4970 return false;
4971 }
4972
4973 let Some(canonical_root) = self.canonical_cache_root_opt() else {
4974 return false;
4975 };
4976 let config = self.config();
4977 let project_key = self.memoized_artifact_cache_key(&canonical_root);
4978 let cache_dir = crate::search_index::resolve_cache_dir_with_key(
4979 &project_key,
4980 config.storage_dir.as_deref(),
4981 );
4982
4983 {
4984 let search_index = self
4985 .search_index()
4986 .read()
4987 .unwrap_or_else(std::sync::PoisonError::into_inner);
4988 let Some(index) = search_index.as_ref() else {
4989 return false;
4990 };
4991 if !index.ready || !index.has_pending_disk_changes() {
4992 return false;
4993 }
4994 }
4995
4996 let _cache_lock = match crate::search_index::CacheLock::try_acquire_for_shutdown(
4997 &cache_dir,
4998 &canonical_root,
4999 ) {
5000 Ok(lock) => lock,
5001 Err(error) => {
5002 crate::slog_warn!(
5003 "search index: skipped shutdown flush because cache lock was unavailable: {}",
5004 error
5005 );
5006 return false;
5007 }
5008 };
5009
5010 let mut search_index = self
5011 .search_index()
5012 .write()
5013 .unwrap_or_else(std::sync::PoisonError::into_inner);
5014 let Some(index) = search_index.as_mut() else {
5015 return false;
5016 };
5017 if !index.ready || !index.has_pending_disk_changes() {
5018 return false;
5019 }
5020
5021 let git_head = index.stored_git_head().map(str::to_owned);
5022 index.write_to_disk(&cache_dir, git_head.as_deref())
5023 }
5024
5025 pub fn inspect_manager(&self) -> Arc<InspectManager> {
5026 Arc::clone(&self.inspect_manager)
5027 }
5028
5029 pub(crate) fn cold_build_limiter(&self) -> Arc<crate::cold_build_limiter::ColdBuildLimiter> {
5030 Arc::clone(
5031 &self
5032 .cold_build_limiter
5033 .read()
5034 .unwrap_or_else(std::sync::PoisonError::into_inner),
5035 )
5036 }
5037
5038 #[doc(hidden)]
5041 pub fn isolate_cold_build_limiter_for_test(&self, limit: usize) {
5042 let limiter = crate::cold_build_limiter::isolated_limiter(limit);
5043 self.inspect_manager
5044 .set_cold_build_limiter(Arc::clone(&limiter));
5045 *self
5046 .cold_build_limiter
5047 .write()
5048 .unwrap_or_else(std::sync::PoisonError::into_inner) = limiter;
5049 }
5050
5051 pub fn add_pending_tier2_paths<I>(&self, paths: I)
5052 where
5053 I: IntoIterator<Item = PathBuf>,
5054 {
5055 self.pending_tier2_paths.lock().extend(paths);
5056 }
5057
5058 pub fn pending_tier2_paths(&self) -> Vec<PathBuf> {
5059 self.pending_tier2_paths.lock().iter().cloned().collect()
5060 }
5061
5062 pub fn remove_pending_tier2_paths<I>(&self, paths: I)
5063 where
5064 I: IntoIterator<Item = PathBuf>,
5065 {
5066 let mut pending = self.pending_tier2_paths.lock();
5067 for path in paths {
5068 pending.remove(&path);
5069 }
5070 }
5071
5072 pub fn has_new_reuse_completions(&self) -> bool {
5080 self.inspect_manager.reuse_completion_count()
5081 != self.last_seen_reuse_completions.load(Ordering::SeqCst)
5082 }
5083
5084 pub fn take_new_reuse_completions(&self) -> bool {
5085 let current = self.inspect_manager.reuse_completion_count();
5086 let previous = self
5087 .last_seen_reuse_completions
5088 .swap(current, Ordering::SeqCst);
5089 current != previous
5090 }
5091
5092 pub fn reset_tier2_refresh_scheduler(&self) {
5093 self.reset_tier2_refresh_scheduler_at(Instant::now());
5094 }
5095
5096 #[doc(hidden)]
5097 pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
5098 self.tier2_refresh_scheduler
5099 .lock()
5100 .reset_after_configure(now);
5101 }
5102
5103 pub fn request_tier2_refresh_pull(&self) -> bool {
5104 let can_schedule = self.inspect_writer()
5105 && self.heavy_root_work_allowed()
5106 && self.inspect_manager.automatic_tier2_refresh_allowed();
5107 self.tier2_refresh_scheduler
5108 .lock()
5109 .request_pull(can_schedule)
5110 }
5111
5112 pub fn tick_tier2_refresh_scheduler(
5113 &self,
5114 changed_path_count: usize,
5115 ) -> Option<Tier2TriggerReason> {
5116 self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
5117 }
5118
5119 #[doc(hidden)]
5120 pub fn tick_tier2_refresh_scheduler_at(
5121 &self,
5122 now: Instant,
5123 changed_path_count: usize,
5124 ) -> Option<Tier2TriggerReason> {
5125 let manager = self.inspect_manager();
5126 let can_write = self.inspect_writer()
5127 && self.heavy_root_work_allowed()
5128 && manager.automatic_tier2_refresh_allowed();
5129 let in_flight = manager.tier2_any_in_flight();
5130 let semantic_cold_seed_active = self.semantic_cold_seed_active();
5131 let decision = self.tier2_refresh_scheduler.lock().tick_with_semantic_gate(
5132 now,
5133 changed_path_count,
5134 can_write,
5135 in_flight,
5136 semantic_cold_seed_active,
5137 );
5138
5139 if let Some(reason) = decision {
5140 self.start_tier2_refresh(reason, manager);
5141 }
5142
5143 decision
5144 }
5145
5146 pub fn note_tier2_refresh_started(&self) {
5147 self.note_tier2_refresh_started_at(Instant::now());
5148 }
5149
5150 #[doc(hidden)]
5151 pub fn note_tier2_refresh_started_at(&self, now: Instant) {
5152 self.tier2_refresh_scheduler
5153 .lock()
5154 .note_external_scan_started(now);
5155 }
5156
5157 pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
5158 self.tier2_refresh_scheduler
5159 .lock()
5160 .last_trigger_reason()
5161 .map(Tier2TriggerReason::as_str)
5162 }
5163
5164 #[doc(hidden)]
5165 pub fn tier2_pull_demand_pending(&self) -> bool {
5166 self.tier2_refresh_scheduler.lock().pull_demand_pending()
5167 }
5168
5169 fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
5170 let generation = self.configure_generation();
5171 if !self.inspect_writer()
5172 || !self.heavy_root_work_allowed()
5173 || !manager.automatic_tier2_refresh_allowed()
5174 || !self.config().inspect.enabled
5175 {
5176 return;
5177 }
5178 let _ = self.run_if_subc_bound_generation(generation, || {
5179 self.start_tier2_refresh_admitted(reason, manager);
5180 });
5181 }
5182
5183 fn start_tier2_refresh_admitted(
5184 &self,
5185 reason: Tier2TriggerReason,
5186 manager: Arc<InspectManager>,
5187 ) {
5188 let Some(snapshot) = self.tier2_refresh_snapshot() else {
5189 return;
5190 };
5191 let categories = Self::automatic_tier2_refresh_categories(&snapshot);
5192 let submission =
5193 manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
5194 if !submission.deferred_categories.is_empty() {
5195 self.tier2_refresh_scheduler.lock().note_dispatch_deferred();
5196 crate::slog_info!(
5197 "tier2 refresh deferred by cold build limit: categories={:?}",
5198 submission
5199 .deferred_categories
5200 .iter()
5201 .map(|category| category.as_str())
5202 .collect::<Vec<_>>()
5203 );
5204 }
5205 if submission.has_new_work() {
5206 crate::slog_info!(
5207 "tier2 refresh scheduled: reason={}, categories={:?}",
5208 reason.as_str(),
5209 submission
5210 .newly_queued_categories
5211 .iter()
5212 .map(|category| category.as_str())
5213 .collect::<Vec<_>>()
5214 );
5215 }
5216 for error in submission.errors {
5217 crate::slog_warn!(
5218 "tier2 refresh schedule failed for {}: {}",
5219 error.category,
5220 error.message
5221 );
5222 }
5223 }
5224
5225 fn automatic_tier2_refresh_categories(snapshot: &InspectSnapshot) -> Vec<InspectCategory> {
5226 let callgraph_store_enabled = snapshot.config.callgraph_store;
5227 InspectCategory::active()
5228 .iter()
5229 .copied()
5230 .filter(|category| category.is_tier2())
5231 .filter(|category| {
5232 if *category == InspectCategory::DeadCode && !callgraph_store_enabled {
5233 return false;
5237 }
5238 true
5239 })
5240 .collect()
5241 }
5242
5243 #[doc(hidden)]
5244 pub fn automatic_tier2_refresh_categories_for_test(&self) -> Vec<InspectCategory> {
5245 self.tier2_refresh_snapshot()
5246 .map(|snapshot| Self::automatic_tier2_refresh_categories(&snapshot))
5247 .unwrap_or_default()
5248 }
5249
5250 fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
5251 self.harness_opt()?;
5252 let config = self.config();
5253 let project_root = config
5254 .project_root
5255 .clone()
5256 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
5257 let project_root = crate::inspect::job::canonicalize_normalized(&project_root);
5261 Some(InspectSnapshot::new_with_capabilities(
5262 project_root,
5263 self.inspect_dir(),
5264 config,
5265 self.symbol_cache(),
5266 self.inspect_writer(),
5267 self.callgraph_writer(),
5268 ))
5269 }
5270
5271 pub fn symbol_cache(&self) -> SharedSymbolCache {
5273 Arc::clone(&self.symbol_cache)
5274 }
5275
5276 pub fn reset_symbol_cache(&self) -> u64 {
5278 self.symbol_cache
5279 .write()
5280 .map(|mut cache| cache.reset())
5281 .unwrap_or(0)
5282 }
5283
5284 pub fn semantic_index(&self) -> &RwLock<Option<SemanticIndex>> {
5286 &self.semantic_index
5287 }
5288
5289 pub fn semantic_index_rx(
5291 &self,
5292 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
5293 &self.semantic_index_rx
5294 }
5295
5296 pub(crate) fn install_semantic_index_rx(
5297 &self,
5298 receiver: crossbeam_channel::Receiver<SemanticIndexEvent>,
5299 generation: u64,
5300 ) -> u64 {
5301 let mut slot = self.semantic_index_rx.lock();
5302 self.note_semantic_index_rx_generation(generation);
5303 let epoch = self.next_semantic_index_rx_epoch();
5304 *slot = Some(receiver);
5305 epoch
5306 }
5307
5308 pub(crate) fn semantic_index_rx_terminal_guard(&self, epoch: u64) -> ReceiverTerminalGuard {
5309 ReceiverTerminalGuard::new(Arc::clone(&self.semantic_index_rx_terminal_epoch), epoch)
5310 }
5311
5312 pub(crate) fn with_current_semantic_index_rx<R>(
5315 &self,
5316 generation: u64,
5317 epoch: u64,
5318 action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<SemanticIndexEvent>>) -> R,
5319 ) -> Option<R> {
5320 self.run_if_subc_bound_generation(generation, || {
5321 let mut receiver = self.semantic_index_rx.lock();
5322 if receiver.is_none()
5323 || self.semantic_index_rx_generation() != generation
5324 || self.semantic_index_rx_epoch() != epoch
5325 {
5326 return None;
5327 }
5328 Some(action(&mut receiver))
5329 })
5330 .flatten()
5331 }
5332
5333 pub(crate) fn retire_semantic_index_rx(&self) {
5334 let mut receiver = self.semantic_index_rx.lock();
5335 *receiver = None;
5336 self.next_semantic_index_rx_epoch();
5337 }
5338
5339 pub(crate) fn retire_semantic_index_rx_if_epoch(&self, expected_epoch: u64) -> Option<bool> {
5343 let mut receiver = self.semantic_index_rx.lock();
5344 if self.semantic_index_rx_epoch() != expected_epoch {
5345 return None;
5346 }
5347 let retired = receiver.take().is_some();
5348 if retired {
5349 self.next_semantic_index_rx_epoch();
5350 }
5351 Some(retired)
5352 }
5353
5354 pub(crate) fn note_semantic_index_rx_generation(&self, generation: u64) {
5355 self.semantic_index_rx_generation
5356 .store(generation, Ordering::SeqCst);
5357 }
5358
5359 pub(crate) fn semantic_index_rx_generation(&self) -> u64 {
5360 self.semantic_index_rx_generation.load(Ordering::SeqCst)
5361 }
5362
5363 pub(crate) fn next_semantic_index_rx_epoch(&self) -> u64 {
5364 self.semantic_index_rx_epoch
5365 .fetch_add(1, Ordering::SeqCst)
5366 .wrapping_add(1)
5367 }
5368
5369 pub(crate) fn semantic_index_rx_epoch(&self) -> u64 {
5370 self.semantic_index_rx_epoch.load(Ordering::SeqCst)
5371 }
5372
5373 pub(crate) fn next_semantic_persist_epoch(&self) -> u64 {
5374 self.semantic_persist_epoch.next()
5375 }
5376
5377 pub(crate) fn semantic_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
5378 self.semantic_persist_epoch.clone()
5379 }
5380
5381 pub(crate) fn semantic_persist_lock(&self) -> Arc<parking_lot::Mutex<()>> {
5382 Arc::clone(&self.semantic_persist_lock)
5383 }
5384
5385 pub fn semantic_index_status(&self) -> &RwLock<SemanticIndexStatus> {
5386 &self.semantic_index_status
5387 }
5388
5389 pub(crate) fn artifact_reload_guard(&self) -> parking_lot::MutexGuard<'_, ()> {
5390 self.artifact_reload_lock.lock()
5391 }
5392
5393 pub fn reset_semantic_cold_seed_gate_for_configure(&self) -> u64 {
5396 self.semantic_cold_seed_active
5397 .store(false, Ordering::SeqCst);
5398 self.semantic_callgraph_warm_deferred
5399 .store(false, Ordering::SeqCst);
5400 self.semantic_cold_seed_generation
5401 .fetch_add(1, Ordering::SeqCst)
5402 .wrapping_add(1)
5403 }
5404
5405 pub fn semantic_cold_seed_active_flag(&self) -> Arc<AtomicBool> {
5406 Arc::clone(&self.semantic_cold_seed_active)
5407 }
5408
5409 pub fn semantic_cold_seed_generation_flag(&self) -> Arc<AtomicU64> {
5410 Arc::clone(&self.semantic_cold_seed_generation)
5411 }
5412
5413 pub fn semantic_cold_seed_generation(&self) -> u64 {
5414 self.semantic_cold_seed_generation.load(Ordering::SeqCst)
5415 }
5416
5417 pub fn semantic_cold_seed_active(&self) -> bool {
5418 self.semantic_cold_seed_active.load(Ordering::SeqCst)
5419 }
5420
5421 pub fn schedule_semantic_cold_seed_gate_for_configure(&self) {
5422 self.semantic_cold_seed_active.store(true, Ordering::SeqCst);
5423 }
5424
5425 pub fn defer_callgraph_store_warm_for_semantic_cold_seed(&self) {
5426 self.semantic_callgraph_warm_deferred
5427 .store(true, Ordering::SeqCst);
5428 }
5429
5430 fn semantic_callgraph_warm_deferred(&self) -> bool {
5431 self.semantic_callgraph_warm_deferred.load(Ordering::SeqCst)
5432 }
5433
5434 pub fn clear_semantic_cold_seed_gate_and_resume_deferred_work(&self) {
5438 self.resume_semantic_cold_seed_deferred_work(false);
5439 }
5440
5441 pub fn resume_deferred_work_after_semantic_cold_seed_gate_cleared(&self) {
5444 self.resume_semantic_cold_seed_deferred_work(true);
5445 }
5446
5447 pub(crate) fn take_semantic_cold_seed_resume(&self, force: bool) -> SemanticColdSeedResume {
5448 let was_active = self.semantic_cold_seed_active.swap(false, Ordering::SeqCst);
5449 let warm_callgraph = self
5450 .semantic_callgraph_warm_deferred
5451 .swap(false, Ordering::SeqCst);
5452 SemanticColdSeedResume {
5453 request_tier2: force || was_active || warm_callgraph,
5454 warm_callgraph,
5455 }
5456 }
5457
5458 pub(crate) fn apply_semantic_cold_seed_resume(&self, resume: SemanticColdSeedResume) {
5459 if resume.request_tier2 {
5460 let _ = self.request_tier2_refresh_pull();
5461 }
5462
5463 if !resume.warm_callgraph
5464 || !self.config().callgraph_store
5465 || !self.heavy_root_work_allowed()
5466 {
5467 return;
5468 }
5469
5470 match self.schedule_callgraph_store_warm() {
5471 CallgraphStoreAccess::Ready(_) => {
5472 crate::slog_debug!(
5473 "deferred callgraph store warm completed after semantic cold seed gate cleared"
5474 );
5475 }
5476 CallgraphStoreAccess::Building => {
5477 crate::slog_info!(
5478 "deferred callgraph store warm scheduled after semantic cold seed gate cleared"
5479 );
5480 }
5481 CallgraphStoreAccess::Suspended(suspension) => {
5482 crate::slog_warn!(
5483 "deferred callgraph store warm suspended for {} after {} deaths",
5484 suspension.domain.as_str(),
5485 suspension.death_count
5486 );
5487 }
5488 CallgraphStoreAccess::Unavailable => {
5489 crate::slog_info!(
5490 "deferred callgraph store warm unavailable after semantic cold seed gate cleared"
5491 );
5492 }
5493 CallgraphStoreAccess::Error(error) => {
5494 crate::slog_warn!(
5495 "deferred callgraph store warm failed after semantic cold seed gate cleared: {}",
5496 error
5497 );
5498 }
5499 }
5500 }
5501
5502 fn resume_semantic_cold_seed_deferred_work(&self, force: bool) {
5503 let resume = self.take_semantic_cold_seed_resume(force);
5504 self.apply_semantic_cold_seed_resume(resume);
5505 }
5506
5507 #[doc(hidden)]
5508 pub fn set_semantic_cold_seed_active_for_test(&self, active: bool) {
5509 self.semantic_cold_seed_active
5510 .store(active, Ordering::SeqCst);
5511 }
5512
5513 #[doc(hidden)]
5514 pub fn semantic_callgraph_warm_deferred_for_test(&self) -> bool {
5515 self.semantic_callgraph_warm_deferred()
5516 }
5517
5518 pub fn install_semantic_refresh_worker(
5519 &self,
5520 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
5521 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
5522 worker_slot: SemanticRefreshWorkerSlot,
5523 ) {
5524 self.install_semantic_refresh_worker_for_build_epoch(
5525 sender,
5526 event_rx,
5527 worker_slot,
5528 self.semantic_index_rx_epoch(),
5529 );
5530 }
5531
5532 pub(crate) fn install_semantic_refresh_worker_for_build_epoch(
5533 &self,
5534 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
5535 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
5536 worker_slot: SemanticRefreshWorkerSlot,
5537 build_epoch: u64,
5538 ) {
5539 self.clear_semantic_refresh_worker();
5540 {
5541 let mut receiver = self.semantic_refresh_event_rx.lock();
5542 let mut request = self.semantic_refresh_tx.lock();
5543 let mut worker = self.semantic_refresh_worker.lock();
5544 self.semantic_refresh_generation
5545 .store(self.configure_generation(), Ordering::SeqCst);
5546 self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
5547 self.semantic_refresh_build_epoch
5548 .store(build_epoch, Ordering::SeqCst);
5549 *receiver = Some(event_rx);
5550 *request = Some(sender);
5551 *worker = Some(worker_slot);
5552 }
5553 }
5554
5555 pub(crate) fn semantic_refresh_generation(&self) -> u64 {
5556 self.semantic_refresh_generation.load(Ordering::SeqCst)
5557 }
5558
5559 pub(crate) fn semantic_refresh_epoch(&self) -> u64 {
5560 self.semantic_refresh_epoch.load(Ordering::SeqCst)
5561 }
5562
5563 pub(crate) fn with_current_semantic_refresh_rx<R>(
5566 &self,
5567 generation: u64,
5568 epoch: u64,
5569 action: impl FnOnce() -> R,
5570 ) -> Option<R> {
5571 self.run_if_subc_bound_generation(generation, || {
5572 let receiver = self.semantic_refresh_event_rx.lock();
5573 if receiver.is_none()
5574 || self.semantic_refresh_generation() != generation
5575 || self.semantic_refresh_epoch() != epoch
5576 {
5577 return None;
5578 }
5579 Some(action())
5580 })
5581 .flatten()
5582 }
5583
5584 pub(crate) fn clear_semantic_refresh_worker_if_current(
5585 &self,
5586 generation: u64,
5587 epoch: u64,
5588 ) -> Option<u64> {
5589 let worker_slot = {
5590 let mut receiver = self.semantic_refresh_event_rx.lock();
5591 if receiver.is_none()
5592 || self.semantic_refresh_generation() != generation
5593 || self.semantic_refresh_epoch() != epoch
5594 {
5595 return None;
5596 }
5597 let disconnected_build_epoch = self.semantic_refresh_build_epoch.load(Ordering::SeqCst);
5598 self.semantic_refresh_build_epoch.store(0, Ordering::SeqCst);
5599 let mut request = self.semantic_refresh_tx.lock();
5600 let mut worker = self.semantic_refresh_worker.lock();
5601 *receiver = None;
5602 *request = None;
5603 self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
5604 self.invalidate_semantic_refresh_probe();
5605 (worker.take(), disconnected_build_epoch)
5606 };
5607 if let Some(worker_slot) = worker_slot.0 {
5608 if let Ok(mut handle) = worker_slot.lock() {
5609 drop(handle.take());
5610 }
5611 }
5612 Some(worker_slot.1)
5613 }
5614
5615 pub fn clear_semantic_refresh_worker(&self) {
5616 let worker_slot = {
5617 let mut receiver = self.semantic_refresh_event_rx.lock();
5618 let mut request = self.semantic_refresh_tx.lock();
5619 let mut worker = self.semantic_refresh_worker.lock();
5620 *receiver = None;
5621 *request = None;
5622 self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
5623 self.semantic_refresh_build_epoch.store(0, Ordering::SeqCst);
5624 self.invalidate_semantic_refresh_probe();
5625 worker.take()
5626 };
5627 if let Some(worker_slot) = worker_slot {
5628 if let Ok(mut handle) = worker_slot.lock() {
5629 drop(handle.take());
5630 }
5631 }
5632 }
5633
5634 pub fn semantic_refresh_sender(
5635 &self,
5636 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
5637 self.semantic_refresh_tx.lock().clone()
5638 }
5639
5640 pub(crate) fn semantic_refresh_retry_slots(
5641 &self,
5642 ) -> (
5643 Arc<parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>>,
5644 Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>,
5645 ) {
5646 (
5647 Arc::clone(&self.semantic_refresh_tx),
5648 Arc::clone(&self.pending_semantic_index_paths),
5649 )
5650 }
5651
5652 pub fn semantic_refresh_event_rx(
5653 &self,
5654 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
5655 &self.semantic_refresh_event_rx
5656 }
5657
5658 pub fn with_semantic_refresh_retry_attempts_mut<R>(
5659 &self,
5660 f: impl FnOnce(&mut BTreeMap<PathBuf, usize>) -> R,
5661 ) -> R {
5662 let mut attempts = self.semantic_refresh_retry_attempts.lock();
5663 f(&mut attempts)
5664 }
5665
5666 pub fn clear_semantic_refresh_retry_attempts(&self, paths: &[PathBuf]) {
5667 let mut attempts = self.semantic_refresh_retry_attempts.lock();
5668 for path in paths {
5669 attempts.remove(path);
5670 }
5671 }
5672
5673 pub fn clear_all_semantic_refresh_retry_attempts(&self) {
5674 self.semantic_refresh_retry_attempts.lock().clear();
5675 }
5676
5677 pub fn semantic_refresh_circuit_is_open(&self) -> bool {
5678 self.semantic_refresh_circuit.open.load(Ordering::SeqCst)
5679 }
5680
5681 pub fn record_semantic_refresh_transient_failure(&self, trip_threshold: usize) -> bool {
5682 let failures = self
5683 .semantic_refresh_circuit
5684 .consecutive_transient_failures
5685 .fetch_add(1, Ordering::SeqCst)
5686 .saturating_add(1);
5687 if failures >= trip_threshold
5688 && !self
5689 .semantic_refresh_circuit
5690 .open
5691 .swap(true, Ordering::SeqCst)
5692 {
5693 crate::slog_warn!(
5694 "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
5695 );
5696 }
5697 self.semantic_refresh_circuit_is_open()
5698 }
5699
5700 pub fn trip_semantic_refresh_circuit(&self, trip_threshold: usize) {
5701 self.semantic_refresh_circuit
5702 .consecutive_transient_failures
5703 .store(trip_threshold, Ordering::SeqCst);
5704 if !self
5705 .semantic_refresh_circuit
5706 .open
5707 .swap(true, Ordering::SeqCst)
5708 {
5709 crate::slog_warn!(
5710 "embedding backend appears down; suspending active retries, will resume on next change or successful probe"
5711 );
5712 }
5713 }
5714
5715 pub fn reset_semantic_refresh_transient_failure_count(&self) {
5716 self.semantic_refresh_circuit
5717 .consecutive_transient_failures
5718 .store(0, Ordering::SeqCst);
5719 }
5720
5721 pub fn reset_semantic_refresh_circuit_after_success(&self) {
5722 self.reset_semantic_refresh_transient_failure_count();
5723 self.semantic_refresh_circuit
5724 .probe_ready
5725 .store(false, Ordering::SeqCst);
5726 if self
5727 .semantic_refresh_circuit
5728 .open
5729 .swap(false, Ordering::SeqCst)
5730 {
5731 crate::slog_info!("embedding backend recovered; resuming normal refresh retries");
5732 }
5733 }
5734
5735 pub fn semantic_refresh_transient_failure_count(&self) -> usize {
5736 self.semantic_refresh_circuit
5737 .consecutive_transient_failures
5738 .load(Ordering::SeqCst)
5739 }
5740
5741 pub fn semantic_refresh_probe_is_scheduled(&self) -> bool {
5742 self.semantic_refresh_circuit
5743 .probe_in_flight
5744 .load(Ordering::SeqCst)
5745 || self.semantic_refresh_probe_ready()
5746 }
5747
5748 pub fn semantic_refresh_probe_ready(&self) -> bool {
5749 self.semantic_refresh_circuit
5750 .probe_ready
5751 .load(Ordering::SeqCst)
5752 }
5753
5754 pub fn take_semantic_refresh_probe_ready(&self) -> bool {
5755 self.semantic_refresh_circuit
5756 .probe_ready
5757 .swap(false, Ordering::SeqCst)
5758 }
5759
5760 fn invalidate_semantic_refresh_probe(&self) {
5761 self.semantic_refresh_circuit
5762 .probe_token
5763 .fetch_add(1, Ordering::SeqCst);
5764 self.semantic_refresh_circuit
5765 .probe_ready
5766 .store(false, Ordering::SeqCst);
5767 self.semantic_refresh_circuit
5768 .probe_in_flight
5769 .store(false, Ordering::SeqCst);
5770 }
5771
5772 pub fn ensure_semantic_refresh_probe_scheduled(&self, delay: Duration) {
5773 let receiver = self.semantic_refresh_event_rx.lock();
5774 if receiver.is_none()
5775 || self
5776 .semantic_refresh_circuit
5777 .probe_ready
5778 .load(Ordering::SeqCst)
5779 || self
5780 .semantic_refresh_circuit
5781 .probe_in_flight
5782 .swap(true, Ordering::SeqCst)
5783 {
5784 return;
5785 }
5786 let probe_token = self
5787 .semantic_refresh_circuit
5788 .probe_token
5789 .fetch_add(1, Ordering::SeqCst)
5790 .wrapping_add(1);
5791 drop(receiver);
5792
5793 let circuit = Arc::clone(&self.semantic_refresh_circuit);
5794 let session_id = crate::log_ctx::current_session();
5795 std::thread::spawn(move || {
5796 crate::log_ctx::with_session(session_id, || {
5797 std::thread::sleep(delay);
5798 if circuit.probe_token.load(Ordering::SeqCst) == probe_token {
5799 circuit.probe_ready.store(true, Ordering::SeqCst);
5800 circuit.probe_in_flight.store(false, Ordering::SeqCst);
5801 }
5802 });
5803 });
5804 }
5805
5806 pub fn semantic_embedding_model(
5808 &self,
5809 ) -> &parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>> {
5810 &self.semantic_embedding_model
5811 }
5812
5813 pub fn watcher(&self) -> &parking_lot::Mutex<Option<RecommendedWatcher>> {
5815 &self.watcher
5816 }
5817
5818 pub fn watcher_rx(
5820 &self,
5821 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>> {
5822 &self.watcher_rx
5823 }
5824
5825 pub(crate) fn watcher_drain_slice(
5827 &self,
5828 ) -> &parking_lot::Mutex<Option<WatcherDrainSliceState>> {
5829 &self.watcher_drain_slice
5830 }
5831
5832 pub fn watcher_drain_pending_path_count(&self) -> usize {
5834 self.watcher_drain_slice.lock().as_ref().map_or(0, |state| {
5835 let active_paths = match &state.phase {
5836 WatcherDrainPhase::Collect => 0,
5837 WatcherDrainPhase::Apply { paths, .. } => paths.len(),
5838 };
5839 active_paths + state.pending_paths.len()
5840 })
5841 }
5842
5843 pub fn watcher_drain_path_slice_count(&self) -> usize {
5845 self.watcher_drain_slice
5846 .lock()
5847 .as_ref()
5848 .map_or(0, |state| state.path_slice_count)
5849 }
5850
5851 pub fn install_watcher_runtime(
5854 &self,
5855 rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
5856 runtime: WatcherThreadHandle,
5857 ) {
5858 let _runtime_guard = self.watcher_runtime_lock.lock();
5859 let replaced = self.watcher_thread.lock().replace(runtime);
5860 self.app.watcher_started();
5861 if let Some(runtime) = replaced {
5862 Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
5863 }
5864 *self.watcher_rx.lock() = Some(rx);
5865 *self.watcher_drain_slice.lock() = None;
5866 }
5867
5868 fn watcher_root_path(&self) -> PathBuf {
5869 self.canonical_cache_root_opt()
5870 .or_else(|| self.config().project_root.clone())
5871 .unwrap_or_else(|| PathBuf::from("<unconfigured>"))
5872 }
5873
5874 fn spawn_watcher_shutdown(app: Arc<App>, root: PathBuf, runtime: WatcherThreadHandle) {
5875 const JOIN_TIMEOUT: Duration = Duration::from_secs(2);
5876 runtime.request_shutdown();
5879 std::thread::spawn(
5880 move || match runtime.shutdown_and_join_timeout(JOIN_TIMEOUT) {
5881 WatcherJoinOutcome::Joined => {
5882 app.watcher_stopped();
5883 crate::slog_info!("watcher stopped: {}", root.display());
5884 }
5885 WatcherJoinOutcome::TimedOut(join) => {
5886 crate::slog_warn!(
5887 "watcher stop timed out after {} ms: {}",
5888 JOIN_TIMEOUT.as_millis(),
5889 root.display()
5890 );
5891 std::thread::spawn(move || {
5892 let _ = join.join();
5893 app.watcher_stopped();
5894 crate::slog_info!("watcher stopped: {}", root.display());
5895 });
5896 }
5897 },
5898 );
5899 }
5900
5901 fn take_watcher_runtime(&self) -> Option<WatcherThreadHandle> {
5902 let _runtime_guard = self.watcher_runtime_lock.lock();
5903 let runtime = self.watcher_thread.lock().take();
5904 *self.watcher_rx.lock() = None;
5905 *self.watcher_drain_slice.lock() = None;
5906 *self.watcher.lock() = None;
5907 runtime
5908 }
5909
5910 pub fn stop_watcher_runtime(&self) {
5914 if let Some(runtime) = self.take_watcher_runtime() {
5915 Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
5916 }
5917 }
5918
5919 pub fn stop_watcher_runtime_in_background(&self) {
5921 self.stop_watcher_runtime();
5922 }
5923
5924 pub(crate) fn take_finished_watcher_runtime(&self) -> bool {
5929 let runtime = {
5930 let _runtime_guard = self.watcher_runtime_lock.lock();
5931 let finished = self
5932 .watcher_thread
5933 .lock()
5934 .as_ref()
5935 .is_some_and(|runtime| runtime.is_finished());
5936 if !finished {
5937 return false;
5938 }
5939 let runtime = self.watcher_thread.lock().take();
5940 *self.watcher_rx.lock() = None;
5941 *self.watcher_drain_slice.lock() = None;
5942 *self.watcher.lock() = None;
5943 runtime
5944 };
5945 if let Some(runtime) = runtime {
5946 Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
5947 }
5948 true
5949 }
5950
5951 pub fn watcher_registry_count(&self) -> usize {
5954 self.app.watcher_count()
5955 }
5956
5957 pub(crate) fn watcher_runtime_active(&self) -> bool {
5958 let _runtime_guard = self.watcher_runtime_lock.lock();
5959 let thread_live = self
5964 .watcher_thread
5965 .lock()
5966 .as_ref()
5967 .is_some_and(|runtime| !runtime.is_finished());
5968 thread_live && self.watcher_rx.lock().is_some()
5969 }
5970
5971 pub fn artifact_eviction_blocked(&self) -> bool {
5975 let semantic_refresh_in_flight = match &*self
5976 .semantic_index_status
5977 .read()
5978 .unwrap_or_else(std::sync::PoisonError::into_inner)
5979 {
5980 SemanticIndexStatus::Building { .. } => true,
5981 SemanticIndexStatus::Ready { refreshing, .. } => !refreshing.is_empty(),
5982 SemanticIndexStatus::Disabled | SemanticIndexStatus::Failed(_) => false,
5983 };
5984 if crate::runtime_drain::any_build_in_flight(self)
5985 || semantic_refresh_in_flight
5986 || self.inspect_manager.tier2_any_in_flight()
5987 || !self.bash_background.running_tasks().is_empty()
5988 || !self.pending_callgraph_store_paths.lock().is_empty()
5989 || !self.pending_search_index_paths.lock().is_empty()
5990 || !self.pending_tier2_paths.lock().is_empty()
5991 || !self.pending_semantic_index_paths.lock().is_empty()
5992 || *self.pending_semantic_corpus_refresh.lock()
5993 {
5994 return true;
5995 }
5996
5997 let search_has_pending_disk_changes = self
5998 .search_index
5999 .read()
6000 .unwrap_or_else(std::sync::PoisonError::into_inner)
6001 .as_ref()
6002 .is_some_and(SearchIndex::has_pending_disk_changes);
6003 search_has_pending_disk_changes
6004 }
6005
6006 pub fn evict_idle_artifacts(&self) -> bool {
6011 if self.artifact_eviction_blocked() {
6012 return false;
6013 }
6014
6015 self.callgraph_store
6016 .write()
6017 .unwrap_or_else(std::sync::PoisonError::into_inner)
6018 .take();
6019 self.search_index
6020 .write()
6021 .unwrap_or_else(std::sync::PoisonError::into_inner)
6022 .take();
6023 self.semantic_index
6024 .write()
6025 .unwrap_or_else(std::sync::PoisonError::into_inner)
6026 .take();
6027 self.borrowed_index_cache.lock().clear();
6028 self.inspect_manager.evict_idle_caches();
6029 self.reset_symbol_cache();
6030 self.clear_tsconfig_membership_cache();
6031 true
6032 }
6033
6034 #[doc(hidden)]
6037 pub fn force_idle_teardown_for_test(self: &Arc<Self>) -> bool {
6038 if std::env::var("AFT_TEST_ALLOW_FORCE_IDLE_REAP").as_deref() != Ok("1") {
6039 return false;
6040 }
6041 if !self.evict_idle_artifacts() {
6042 return false;
6043 }
6044 self.stop_watcher_runtime_in_background();
6045 self.invalidate_artifacts_after_watcher_gap();
6046 true
6047 }
6048
6049 pub(crate) fn release_idle_reopenable_resources_in_background(self: &Arc<Self>) {
6053 let ctx = Arc::clone(self);
6054 std::thread::spawn(move || {
6055 if !ctx.subc_unbound_quiesced() {
6056 return;
6057 }
6058 {
6059 let mut lsp = ctx.lsp_manager.lock();
6060 if !ctx.subc_unbound_quiesced() {
6061 return;
6062 }
6063 lsp.shutdown_all();
6064 }
6065 let _ = ctx.subc_lifecycle.run_if_unbound(|| {
6066 ctx.bash_background.clear_db_pool();
6067 ctx.backup.lock().clear_db_pool();
6068 });
6069 });
6070 }
6071
6072 pub(crate) fn teardown_deleted_root(&self) {
6076 self.bash_background.detach();
6077 self.bash_background.clear_db_pool();
6078 self.backup.lock().clear_db_pool();
6079 self.lsp_manager.lock().shutdown_all();
6080 }
6081
6082 pub fn lsp(&self) -> parking_lot::MutexGuard<'_, LspManager> {
6084 self.lsp_manager.lock()
6085 }
6086
6087 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
6090 let config = self.config();
6091 if let Some(mut lsp) = self.lsp_manager.try_lock() {
6092 if let Err(e) = lsp.notify_file_changed_if_running(file_path, content, &config) {
6093 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
6094 }
6095 }
6096 }
6097
6098 pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
6104 if let Some(mut lsp) = self.lsp_manager.try_lock() {
6105 lsp.clear_diagnostics_for_file(file_path)
6106 } else {
6107 false
6108 }
6109 }
6110
6111 pub fn lsp_mark_diagnostics_stale_for_file(&self, file_path: &Path) -> StaleDiagnosticsMark {
6115 if let Some(mut lsp) = self.lsp_manager.try_lock() {
6116 lsp.mark_diagnostics_stale_for_file(file_path)
6117 } else {
6118 StaleDiagnosticsMark::default()
6119 }
6120 }
6121
6122 pub fn lsp_resync_changed_file_for_diagnostics(&self, file_path: &Path) -> bool {
6130 if !file_path.is_file() {
6131 return false;
6132 }
6133
6134 let content = match std::fs::read_to_string(file_path) {
6135 Ok(content) => content,
6136 Err(err) => {
6137 crate::slog_warn!(
6138 "skipping LSP resync for {} after external edit: {}",
6139 file_path.display(),
6140 err
6141 );
6142 return false;
6143 }
6144 };
6145
6146 let config = self.config();
6147 if let Some(mut lsp) = self.lsp_manager.try_lock() {
6148 if let Err(err) = lsp.notify_file_changed(file_path, &content, &config) {
6149 crate::slog_warn!(
6150 "LSP resync failed for {} after external edit: {}",
6151 file_path.display(),
6152 err
6153 );
6154 return false;
6155 }
6156 true
6157 } else {
6158 false
6159 }
6160 }
6161
6162 pub fn lsp_notify_and_collect_diagnostics(
6171 &self,
6172 file_path: &Path,
6173 content: &str,
6174 timeout: std::time::Duration,
6175 ) -> crate::lsp::manager::PostEditWaitOutcome {
6176 let config = self.config();
6177 let Some(mut lsp) = self.lsp_manager.try_lock() else {
6178 return crate::lsp::manager::PostEditWaitOutcome::default();
6179 };
6180
6181 lsp.drain_events();
6184
6185 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
6189
6190 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
6194 {
6195 Ok(v) => v,
6196 Err(e) => {
6197 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
6198 return crate::lsp::manager::PostEditWaitOutcome::default();
6199 }
6200 };
6201
6202 if expected_versions.is_empty() {
6205 return crate::lsp::manager::PostEditWaitOutcome::default();
6206 }
6207
6208 let mut wait = lsp.start_post_edit_diagnostics_wait(
6212 file_path,
6213 &expected_versions,
6214 &pre_snapshot,
6215 timeout,
6216 );
6217 let mut complete = lsp.poll_post_edit_diagnostics_wait(&mut wait, None);
6218 drop(lsp);
6219
6220 while !complete && !wait.deadline_reached() {
6221 let event = wait.next_event();
6224 let mut lsp = self.lsp_manager.lock();
6225 complete = lsp.poll_post_edit_diagnostics_wait(&mut wait, event);
6226 }
6227
6228 self.lsp_manager
6229 .lock()
6230 .finish_post_edit_diagnostics_wait(wait)
6231 }
6232
6233 fn custom_lsp_root_markers(&self) -> Vec<String> {
6236 self.config()
6237 .lsp_servers
6238 .iter()
6239 .flat_map(|s| s.root_markers.iter().cloned())
6240 .collect()
6241 }
6242
6243 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
6244 let custom_markers = self.custom_lsp_root_markers();
6245 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
6246 .iter()
6247 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
6248 .cloned()
6249 .map(|path| {
6250 let change_type = if path.exists() {
6251 FileChangeType::CHANGED
6252 } else {
6253 FileChangeType::DELETED
6254 };
6255 (path, change_type)
6256 })
6257 .collect();
6258
6259 self.notify_watched_config_events(&config_paths);
6260 }
6261
6262 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
6263 let paths = params
6264 .get("multi_file_write_paths")
6265 .and_then(|value| value.as_array())?
6266 .iter()
6267 .filter_map(|value| value.as_str())
6268 .map(PathBuf::from)
6269 .collect::<Vec<_>>();
6270
6271 (!paths.is_empty()).then_some(paths)
6272 }
6273
6274 fn watched_file_events_from_params(
6286 params: &serde_json::Value,
6287 extra_markers: &[String],
6288 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
6289 let events = params
6290 .get("multi_file_write_paths")
6291 .and_then(|value| value.as_array())?
6292 .iter()
6293 .filter_map(|entry| {
6294 let path = entry
6296 .get("path")
6297 .and_then(|value| value.as_str())
6298 .map(PathBuf::from)?;
6299
6300 if !is_config_file_path_with_custom(&path, extra_markers) {
6301 return None;
6302 }
6303
6304 let change_type = entry
6305 .get("type")
6306 .and_then(|value| value.as_str())
6307 .and_then(Self::parse_file_change_type)
6308 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
6309
6310 Some((path, change_type))
6311 })
6312 .collect::<Vec<_>>();
6313
6314 (!events.is_empty()).then_some(events)
6315 }
6316
6317 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
6318 match value {
6319 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
6320 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
6321 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
6322 _ => None,
6323 }
6324 }
6325
6326 fn change_type_from_current_state(path: &Path) -> FileChangeType {
6327 if path.exists() {
6328 FileChangeType::CHANGED
6329 } else {
6330 FileChangeType::DELETED
6331 }
6332 }
6333
6334 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
6335 if config_paths.is_empty() {
6336 return;
6337 }
6338
6339 let config = self.config();
6340 if let Some(mut lsp) = self.lsp_manager.try_lock() {
6341 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
6342 crate::slog_warn!("watched-file sync error: {}", e);
6343 }
6344 }
6345 }
6346
6347 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
6348 let custom_markers = self.custom_lsp_root_markers();
6349 if !is_config_file_path_with_custom(file_path, &custom_markers) {
6350 return;
6351 }
6352
6353 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
6354 }
6355
6356 pub fn lsp_post_multi_file_write(
6361 &self,
6362 file_path: &Path,
6363 content: &str,
6364 file_paths: &[PathBuf],
6365 params: &serde_json::Value,
6366 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
6367 self.notify_watched_config_files(file_paths);
6368 self.add_pending_tier2_paths(file_paths.iter().cloned());
6369 let _ = self.mark_status_bar_tier2_stale();
6370
6371 let wants_diagnostics = params
6372 .get("diagnostics")
6373 .and_then(|v| v.as_bool())
6374 .unwrap_or(false);
6375
6376 if !wants_diagnostics {
6377 self.lsp_notify_file_changed(file_path, content);
6378 return None;
6379 }
6380
6381 let wait_ms = params
6382 .get("wait_ms")
6383 .and_then(|v| v.as_u64())
6384 .unwrap_or(3000)
6385 .min(10_000);
6386
6387 Some(self.lsp_notify_and_collect_diagnostics(
6388 file_path,
6389 content,
6390 std::time::Duration::from_millis(wait_ms),
6391 ))
6392 }
6393
6394 pub fn lsp_post_write(
6411 &self,
6412 file_path: &Path,
6413 content: &str,
6414 params: &serde_json::Value,
6415 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
6416 let wants_diagnostics = params
6417 .get("diagnostics")
6418 .and_then(|v| v.as_bool())
6419 .unwrap_or(false);
6420
6421 let custom_markers = self.custom_lsp_root_markers();
6422 if let Some(file_paths) = Self::multi_file_write_paths(params) {
6423 self.add_pending_tier2_paths(file_paths);
6424 } else {
6425 self.add_pending_tier2_paths([file_path.to_path_buf()]);
6426 }
6427 let _ = self.mark_status_bar_tier2_stale();
6428
6429 if !wants_diagnostics {
6430 if let Some(file_paths) = Self::multi_file_write_paths(params) {
6431 self.notify_watched_config_files(&file_paths);
6432 } else if let Some(config_events) =
6433 Self::watched_file_events_from_params(params, &custom_markers)
6434 {
6435 self.notify_watched_config_events(&config_events);
6436 }
6437 self.lsp_notify_file_changed(file_path, content);
6438 return None;
6439 }
6440
6441 let wait_ms = params
6442 .get("wait_ms")
6443 .and_then(|v| v.as_u64())
6444 .unwrap_or(3000)
6445 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
6448 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
6449 }
6450
6451 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
6452 {
6453 self.notify_watched_config_events(&config_events);
6454 }
6455
6456 Some(self.lsp_notify_and_collect_diagnostics(
6457 file_path,
6458 content,
6459 std::time::Duration::from_millis(wait_ms),
6460 ))
6461 }
6462
6463 fn resolved_path_restriction_root(&self, root: &Path) -> PathBuf {
6464 let mut memo = self.path_restriction_root_memo.lock();
6465 if let Some(cached) = memo.as_ref() {
6466 if cached.configured_root.as_os_str() == root.as_os_str()
6467 && cached.resolved_root.exists()
6468 {
6469 return cached.resolved_root.clone();
6470 }
6471 }
6472
6473 #[cfg(test)]
6479 self.path_restriction_root_canonicalizations
6480 .fetch_add(1, Ordering::SeqCst);
6481 let resolved_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
6482 *memo = Some(PathRestrictionRootMemo {
6483 configured_root: root.to_path_buf(),
6484 resolved_root: resolved_root.clone(),
6485 });
6486 resolved_root
6487 }
6488
6489 fn path_restriction_context(
6490 &self,
6491 req_id: &str,
6492 path: &Path,
6493 ) -> Result<Option<PathRestrictionContext>, crate::protocol::Response> {
6494 let config = self.config();
6495 let force_restrict = self.request_force_restrict(req_id);
6496 if !config.restrict_to_project_root && !force_restrict {
6497 return Ok(None);
6498 }
6499 let root = match &config.project_root {
6500 Some(root) => root.clone(),
6501 None if force_restrict => {
6502 return Err(crate::protocol::Response::error(
6503 req_id,
6504 "path_outside_root",
6505 "project root is required when path restriction is forced",
6506 ));
6507 }
6508 None => return Ok(None),
6509 };
6510 drop(config);
6511
6512 let raw_root = root.clone();
6513 let resolved_root = self.resolved_path_restriction_root(&root);
6514 let path_for_resolution = if path.is_relative() {
6515 raw_root.join(path)
6516 } else {
6517 path.to_path_buf()
6518 };
6519 Ok(Some(PathRestrictionContext {
6520 raw_root,
6521 resolved_root,
6522 path_for_resolution,
6523 }))
6524 }
6525
6526 pub fn resolve_relative_path(&self, path: &Path) -> PathBuf {
6539 if path.is_absolute() {
6540 return path.to_path_buf();
6541 }
6542 if let Some(root) = &self.config().project_root {
6543 return root.join(path);
6544 }
6545 std::env::current_dir()
6546 .unwrap_or_else(|_| PathBuf::from("."))
6547 .join(path)
6548 }
6549
6550 pub fn validate_path(
6559 &self,
6560 req_id: &str,
6561 path: &Path,
6562 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6563 self.validate_path_with_artifact_session(req_id, path, None)
6564 }
6565
6566 pub fn validate_write_location(
6573 &self,
6574 req_id: &str,
6575 path: &Path,
6576 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6577 let Some(PathRestrictionContext {
6578 raw_root,
6579 resolved_root,
6580 path_for_resolution,
6581 }) = self.path_restriction_context(req_id, path)?
6582 else {
6583 return Ok(path.to_path_buf());
6584 };
6585 let normalized = normalize_path(&path_for_resolution);
6586 let Some(file_name) = normalized.file_name() else {
6587 return self.validate_path(req_id, path);
6588 };
6589 let parent = normalized.parent().unwrap_or_else(|| Path::new(""));
6590 let resolved_parent = match std::fs::canonicalize(parent) {
6591 Ok(resolved) => resolved,
6592 Err(_) => {
6593 reject_escaping_symlink(req_id, path, parent, &resolved_root, &raw_root)?;
6594 resolve_with_existing_ancestors(parent)
6595 }
6596 };
6597 let resolved = normalize_path(&resolved_parent.join(file_name));
6598
6599 if !resolved.starts_with(&resolved_root) {
6600 return Err(path_error_response(req_id, path, &resolved_root));
6601 }
6602
6603 Ok(resolved)
6604 }
6605
6606 pub fn validate_read_path(
6612 &self,
6613 req_id: &str,
6614 session_id: &str,
6615 path: &Path,
6616 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6617 self.validate_path_with_artifact_session(req_id, path, Some(session_id))
6618 }
6619
6620 fn validate_path_with_artifact_session(
6621 &self,
6622 req_id: &str,
6623 path: &Path,
6624 artifact_session_id: Option<&str>,
6625 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
6626 let Some(PathRestrictionContext {
6627 raw_root,
6628 resolved_root,
6629 path_for_resolution,
6630 }) = self.path_restriction_context(req_id, path)?
6631 else {
6632 return Ok(path.to_path_buf());
6635 };
6636
6637 let resolved = match std::fs::canonicalize(&path_for_resolution) {
6642 Ok(resolved) => resolved,
6643 Err(_) => {
6644 let normalized = normalize_path(&path_for_resolution);
6645 reject_escaping_symlink(
6646 req_id,
6647 &path_for_resolution,
6648 &normalized,
6649 &resolved_root,
6650 &raw_root,
6651 )?;
6652 resolve_with_existing_ancestors(&normalized)
6653 }
6654 };
6655
6656 if !resolved.starts_with(&resolved_root) {
6657 let is_owned_bash_artifact = artifact_session_id.is_some_and(|session_id| {
6658 self.bash_background
6659 .is_session_owned_artifact_path(session_id, &resolved)
6660 });
6661 if !is_owned_bash_artifact {
6662 return Err(path_error_response(req_id, path, &resolved_root));
6663 }
6664 }
6665
6666 Ok(resolved)
6667 }
6668
6669 pub fn lsp_server_count(&self) -> usize {
6671 self.lsp_manager
6672 .try_lock()
6673 .map(|lsp| lsp.server_count())
6674 .unwrap_or(0)
6675 }
6676
6677 pub fn symbol_cache_stats(&self) -> serde_json::Value {
6679 let entries = self
6680 .symbol_cache
6681 .read()
6682 .map(|cache| cache.len())
6683 .unwrap_or(0);
6684 serde_json::json!({
6685 "local_entries": entries,
6686 "warm_entries": 0,
6687 })
6688 }
6689
6690 fn memory_estimates(&self) -> [crate::memory::MemoryEstimate; 9] {
6691 let semantic = match self.semantic_index.try_read() {
6692 Ok(index) => index
6693 .as_ref()
6694 .map(SemanticIndex::estimated_memory)
6695 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("entries", 0)),
6696 Err(TryLockError::Poisoned(error)) => error
6697 .into_inner()
6698 .as_ref()
6699 .map(SemanticIndex::estimated_memory)
6700 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("entries", 0)),
6701 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
6702 };
6703 let trigram = match self.search_index.try_read() {
6704 Ok(index) => index
6705 .as_ref()
6706 .map(SearchIndex::estimated_memory)
6707 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("files", 0)),
6708 Err(TryLockError::Poisoned(error)) => error
6709 .into_inner()
6710 .as_ref()
6711 .map(SearchIndex::estimated_memory)
6712 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("files", 0)),
6713 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
6714 };
6715 let symbols = match self.symbol_cache.try_read() {
6716 Ok(cache) => cache.estimated_memory(),
6717 Err(TryLockError::Poisoned(error)) => error.into_inner().estimated_memory(),
6718 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
6719 };
6720 let callgraph = match self.callgraph_store.try_read() {
6721 Ok(store) => store
6722 .as_ref()
6723 .map(|store| store.estimated_memory())
6724 .unwrap_or_else(|| {
6725 crate::memory::MemoryEstimate::estimated(0).count("open_generation_handles", 0)
6726 }),
6727 Err(TryLockError::Poisoned(error)) => error
6728 .into_inner()
6729 .as_ref()
6730 .map(|store| store.estimated_memory())
6731 .unwrap_or_else(|| {
6732 crate::memory::MemoryEstimate::estimated(0).count("open_generation_handles", 0)
6733 }),
6734 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
6735 };
6736 let callgraph_projection = self.inspect_manager.callgraph_projection_estimated_memory();
6737 let inspect = self.inspect_manager.estimated_memory();
6738 let bash = self.bash_background.estimated_memory();
6739 let lsp = self
6740 .lsp_manager
6741 .try_lock()
6742 .map(|lsp| lsp.estimated_memory())
6743 .unwrap_or_else(crate::memory::MemoryEstimate::busy);
6744 let parser_pool = crate::memory::MemoryEstimate::not_estimated()
6747 .count("pooled_parsers", 0)
6748 .gap("tree_sitter_parser_bytes");
6749 [
6750 semantic,
6751 trigram,
6752 symbols,
6753 callgraph,
6754 callgraph_projection,
6755 inspect,
6756 bash,
6757 lsp,
6758 parser_pool,
6759 ]
6760 }
6761
6762 pub fn memory_root_snapshot(&self) -> crate::memory::RootMemorySnapshot {
6766 let [semantic, trigram, symbols, callgraph, callgraph_projection, inspect, bash, lsp, parser_pool] =
6767 self.memory_estimates();
6768 crate::memory::RootMemorySnapshot::new(
6769 semantic,
6770 trigram,
6771 symbols,
6772 callgraph,
6773 callgraph_projection,
6774 inspect,
6775 bash,
6776 lsp,
6777 parser_pool,
6778 )
6779 }
6780
6781 pub(crate) fn memory_root_rollup(&self) -> crate::memory::RootMemoryRollup {
6784 let estimates = self.memory_estimates();
6785 crate::memory::RootMemoryRollup::from_estimates(&[
6786 &estimates[0],
6787 &estimates[1],
6788 &estimates[2],
6789 &estimates[3],
6790 &estimates[4],
6791 &estimates[5],
6792 &estimates[6],
6793 &estimates[7],
6794 &estimates[8],
6795 ])
6796 }
6797
6798 pub fn memory_snapshot(&self, current_root: Option<&Path>) -> crate::memory::MemorySnapshot {
6801 let mut roots = BTreeMap::new();
6802 let (roots_status, contexts) = match self.app.try_memory_contexts() {
6803 Some(contexts) => ("ready", contexts),
6804 None => ("busy", Vec::new()),
6805 };
6806 for (root, context) in contexts {
6807 roots.insert(root.display().to_string(), context.memory_root_snapshot());
6808 }
6809 let current_label = current_root
6813 .map(|root| {
6814 cortexkit_paths::ProjectRootId::from_path(root)
6815 .map(|id| id.as_path().display().to_string())
6816 .unwrap_or_else(|_| root.display().to_string())
6817 })
6818 .unwrap_or_else(|| "<unconfigured>".to_string());
6819 roots
6820 .entry(current_label)
6821 .or_insert_with(|| self.memory_root_snapshot());
6822 crate::memory::MemorySnapshot::new(roots_status, roots)
6823 }
6824}
6825
6826#[cfg(test)]
6827mod subc_lifecycle_admission_tests {
6828 use super::*;
6829
6830 #[test]
6831 fn route_teardown_does_not_supersede_disk_artifact_compatibility() {
6832 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6833 ctx.note_configure_warm_key("config-a".to_string());
6834 let content_generation = ctx.configure_content_generation();
6835 let lifecycle_generation = ctx.configure_generation();
6836 let search_epoch = ctx.next_search_persist_epoch();
6837 let semantic_epoch = ctx.next_semantic_persist_epoch();
6838 let search_persist_epoch = ctx.search_persist_epoch_flag();
6839 let semantic_persist_epoch = ctx.semantic_persist_epoch_flag();
6840
6841 ctx.mark_subc_unbound();
6842 assert!(ctx.configure_generation() > lifecycle_generation);
6843 assert_eq!(ctx.configure_content_generation(), content_generation);
6844 assert_eq!(search_persist_epoch.current(), search_epoch);
6845 assert_eq!(semantic_persist_epoch.current(), semantic_epoch);
6846
6847 ctx.mark_subc_bound();
6848 ctx.note_configure_warm_key("config-b".to_string());
6849 assert!(ctx.configure_content_generation() > content_generation);
6850 let replacement_search_epoch = ctx.next_search_persist_epoch();
6851 let replacement_semantic_epoch = ctx.next_semantic_persist_epoch();
6852 assert!(replacement_search_epoch > search_epoch);
6853 assert!(replacement_semantic_epoch > semantic_epoch);
6854 assert_eq!(search_persist_epoch.current(), replacement_search_epoch);
6855 assert_eq!(semantic_persist_epoch.current(), replacement_semantic_epoch);
6856 }
6857
6858 #[test]
6859 fn lifecycle_gate_serializes_unbind_with_worker_start_commit() {
6860 let admission = SubcLifecycleAdmission::default();
6861 let generation = Arc::new(AtomicU64::new(11));
6862 let expected = generation.load(Ordering::SeqCst);
6863 let starts = Arc::new(AtomicUsize::new(0));
6864 let (entered_tx, entered_rx) = std::sync::mpsc::channel();
6865 let (release_tx, release_rx) = std::sync::mpsc::channel();
6866
6867 let worker_admission = admission.clone();
6868 let worker_generation = Arc::clone(&generation);
6869 let worker_starts = Arc::clone(&starts);
6870 let worker = std::thread::spawn(move || {
6871 worker_admission.run_if_current(&worker_generation, expected, || {
6872 entered_tx.send(()).unwrap();
6873 release_rx.recv().unwrap();
6874 worker_starts.fetch_add(1, Ordering::SeqCst);
6875 })
6876 });
6877 entered_rx.recv().unwrap();
6878
6879 let unbind_admission = admission.clone();
6880 let unbind_generation = Arc::clone(&generation);
6881 let (unbound_tx, unbound_rx) = std::sync::mpsc::channel();
6882 let unbind = std::thread::spawn(move || {
6883 unbind_admission.mark_unbound(&unbind_generation);
6884 unbound_tx.send(()).unwrap();
6885 });
6886
6887 assert!(
6888 unbound_rx
6889 .recv_timeout(std::time::Duration::from_millis(50))
6890 .is_err(),
6891 "unbind must wait for an admitted worker-start commit"
6892 );
6893 release_tx.send(()).unwrap();
6894 assert!(worker.join().unwrap().is_some());
6895 unbound_rx
6896 .recv_timeout(std::time::Duration::from_secs(1))
6897 .unwrap();
6898 unbind.join().unwrap();
6899 assert_eq!(starts.load(Ordering::SeqCst), 1);
6900 assert!(
6901 admission
6902 .run_if_current(&generation, generation.load(Ordering::SeqCst), || {
6903 starts.fetch_add(1, Ordering::SeqCst);
6904 })
6905 .is_none(),
6906 "worker starts after unbind must be denied"
6907 );
6908 }
6909
6910 #[test]
6911 fn health_snapshot_returns_busy_before_locking_artifact_receivers() {
6912 let ctx = Arc::new(AppContext::new(
6913 default_language_provider_factory(),
6914 Config::default(),
6915 ));
6916 let lifecycle_guard = ctx.subc_lifecycle.unbound.lock();
6917 let (started_tx, started_rx) = std::sync::mpsc::channel();
6918 let (snapshot_tx, snapshot_rx) = std::sync::mpsc::channel();
6919 let worker_ctx = Arc::clone(&ctx);
6920 let worker = std::thread::spawn(move || {
6921 started_tx.send(()).unwrap();
6922 snapshot_tx
6923 .send(worker_ctx.try_health_snapshot(Path::new("health-root")))
6924 .unwrap();
6925 });
6926 started_rx
6927 .recv_timeout(Duration::from_secs(1))
6928 .expect("health snapshot worker should start");
6929
6930 let snapshot = snapshot_rx.recv_timeout(Duration::from_secs(2));
6931 let callgraph_receiver_available = ctx.callgraph_store_rx.try_lock().is_some();
6932 drop(lifecycle_guard);
6933 worker.join().unwrap();
6934
6935 assert!(
6936 matches!(
6937 snapshot,
6938 Ok(RootHealthSnapshot {
6939 state: RootHealthState::Busy,
6940 ..
6941 })
6942 ),
6943 "health snapshots must report busy instead of waiting for lifecycle admission"
6944 );
6945 assert!(
6946 callgraph_receiver_available,
6947 "health snapshots must not hold the callgraph receiver while lifecycle admission is busy"
6948 );
6949 }
6950
6951 #[test]
6952 fn borrow_only_root_with_partial_tier2_aggregates_reports_disabled() {
6953 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
6954 ctx.set_artifact_owner(
6955 Some(crate::artifact_owner::ArtifactOwnerStatus {
6956 mode: crate::artifact_owner::ArtifactOwnerMode::ReadOnly,
6957 project_key: "borrowed".to_string(),
6958 manifest_path: "manifest.json".to_string(),
6959 owner_project_scope_key: "owner".to_string(),
6960 owner_checkout_path: "/owner".to_string(),
6961 note: None,
6962 }),
6963 None,
6964 );
6965 ctx.update_status_bar_tier2(Some(4), None, None, None, true);
6966
6967 let snapshot = ctx.try_health_snapshot(Path::new("borrow-only-root"));
6968
6969 assert_eq!(snapshot.tier2.expect("tier2 health").status, "disabled");
6970 }
6971
6972 #[test]
6973 fn worktree_guard_prevents_partial_tier2_from_reporting_building() {
6974 let root = tempfile::tempdir().unwrap();
6975 let ctx = AppContext::new(
6976 default_language_provider_factory(),
6977 Config {
6978 project_root: Some(root.path().to_path_buf()),
6979 ..Config::default()
6980 },
6981 );
6982 ctx.set_harness(crate::harness::Harness::Opencode);
6983 ctx.set_cache_writer_capabilities(true, true);
6984 ctx.update_status_bar_tier2(Some(4), None, None, None, true);
6985 assert_eq!(
6986 ctx.try_health_snapshot(Path::new("writer-root"))
6987 .tier2
6988 .expect("tier2 health")
6989 .status,
6990 "building"
6991 );
6992
6993 ctx.set_cache_role(true, None);
6994
6995 assert_eq!(
6996 ctx.try_health_snapshot(Path::new("worktree-root"))
6997 .tier2
6998 .expect("tier2 health")
6999 .status,
7000 "disabled"
7001 );
7002 let tier2_snapshot = ctx.tier2_refresh_snapshot().expect("tier2 snapshot");
7003 assert!(!tier2_snapshot.callgraph_writer);
7004 }
7005
7006 #[test]
7007 fn unbound_artifact_cancellation_clears_semantic_refresh_state() {
7008 let temp = tempfile::tempdir().unwrap();
7009 let ctx = AppContext::new(
7010 default_language_provider_factory(),
7011 Config {
7012 project_root: Some(temp.path().to_path_buf()),
7013 semantic_search: true,
7014 ..Config::default()
7015 },
7016 );
7017 *ctx.semantic_index()
7018 .write()
7019 .unwrap_or_else(std::sync::PoisonError::into_inner) =
7020 Some(SemanticIndex::new(temp.path().to_path_buf(), 3));
7021 let mut status = SemanticIndexStatus::ready();
7022 status.add_refreshing_file(temp.path().join("changed.rs"));
7023 *ctx.semantic_index_status()
7024 .write()
7025 .unwrap_or_else(std::sync::PoisonError::into_inner) = status;
7026 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
7027 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
7028 ctx.install_semantic_refresh_worker_for_build_epoch(
7029 request_tx,
7030 event_rx,
7031 Arc::new(Mutex::new(None)),
7032 ctx.semantic_index_rx_epoch(),
7033 );
7034
7035 ctx.cancel_unbound_artifact_work();
7036
7037 assert!(ctx.semantic_refresh_event_rx().lock().is_none());
7038 assert!(matches!(
7039 &*ctx
7040 .semantic_index_status()
7041 .read()
7042 .unwrap_or_else(std::sync::PoisonError::into_inner),
7043 SemanticIndexStatus::Ready { refreshing, .. } if refreshing.is_empty()
7044 ));
7045 }
7046
7047 #[test]
7048 fn terminal_empty_search_receiver_reports_completion_work() {
7049 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7050 let (sender, receiver) = crossbeam_channel::unbounded();
7051 let epoch = ctx.install_search_index_rx(receiver, ctx.configure_generation());
7052 let terminal_guard = ctx.search_index_rx_terminal_guard(epoch);
7053 drop(sender);
7054 drop(terminal_guard);
7055
7056 assert!(
7057 ctx.completion_drains_have_work(),
7058 "an empty disconnected one-shot receiver must wake the completion drain"
7059 );
7060 }
7061
7062 #[test]
7063 fn conditional_semantic_receiver_retire_preserves_replacement_epoch() {
7064 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7065 let (_old_sender, old_receiver) = crossbeam_channel::unbounded();
7066 let old_epoch = ctx.install_semantic_index_rx(old_receiver, ctx.configure_generation());
7067 let (_replacement_sender, replacement_receiver) = crossbeam_channel::unbounded();
7068 let replacement_epoch =
7069 ctx.install_semantic_index_rx(replacement_receiver, ctx.configure_generation());
7070
7071 assert!(replacement_epoch > old_epoch);
7072 assert_eq!(ctx.retire_semantic_index_rx_if_epoch(old_epoch), None);
7073 assert!(ctx.semantic_index_rx().lock().is_some());
7074 assert_eq!(ctx.semantic_index_rx_epoch(), replacement_epoch);
7075 }
7076
7077 #[test]
7078 fn stale_terminal_guard_cannot_hide_newer_finished_receiver() {
7079 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7080 let (old_sender, old_receiver) = crossbeam_channel::unbounded();
7081 let old_epoch = ctx.install_search_index_rx(old_receiver, ctx.configure_generation());
7082 let old_guard = ctx.search_index_rx_terminal_guard(old_epoch);
7083 let (current_sender, current_receiver) = crossbeam_channel::unbounded();
7084 let current_epoch =
7085 ctx.install_search_index_rx(current_receiver, ctx.configure_generation());
7086 let current_guard = ctx.search_index_rx_terminal_guard(current_epoch);
7087 drop(old_sender);
7088 drop(current_sender);
7089
7090 drop(current_guard);
7091 drop(old_guard);
7092
7093 assert!(current_epoch > old_epoch);
7094 assert_eq!(
7095 ctx.search_index_rx_terminal_epoch.load(Ordering::SeqCst),
7096 current_epoch,
7097 "a stale worker must not move the terminal watermark backward"
7098 );
7099 assert!(ctx.completion_drains_have_work());
7100 }
7101
7102 #[test]
7103 fn finished_semantic_refresh_worker_reports_completion_work() {
7104 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
7105 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
7106 let (event_tx, event_rx) = crossbeam_channel::unbounded();
7107 let worker_slot = Arc::new(Mutex::new(Some(std::thread::spawn(|| {}))));
7108 ctx.install_semantic_refresh_worker_for_build_epoch(
7109 request_tx,
7110 event_rx,
7111 Arc::clone(&worker_slot),
7112 ctx.semantic_index_rx_epoch(),
7113 );
7114 drop(event_tx);
7115 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
7116 while !worker_slot
7117 .lock()
7118 .unwrap_or_else(std::sync::PoisonError::into_inner)
7119 .as_ref()
7120 .is_some_and(std::thread::JoinHandle::is_finished)
7121 {
7122 assert!(
7123 std::time::Instant::now() < deadline,
7124 "worker did not finish"
7125 );
7126 std::thread::yield_now();
7127 }
7128
7129 assert!(
7130 ctx.completion_drains_have_work(),
7131 "a finished refresh worker must wake the completion drain after its event queue empties"
7132 );
7133 }
7134
7135 #[test]
7136 fn unbound_lifecycle_rejects_all_deferred_worker_starts() {
7137 let admission = SubcLifecycleAdmission::default();
7138 let generation = Arc::new(AtomicU64::new(7));
7139 admission.mark_unbound(&generation);
7140 let expected = generation.load(Ordering::SeqCst);
7141 let starts = Arc::new(AtomicUsize::new(0));
7142
7143 let workers = (0..16)
7144 .map(|_| {
7145 let admission = admission.clone();
7146 let generation = Arc::clone(&generation);
7147 let starts = Arc::clone(&starts);
7148 std::thread::spawn(move || {
7149 admission.run_if_current(&generation, expected, || {
7150 starts.fetch_add(1, Ordering::SeqCst);
7151 })
7152 })
7153 })
7154 .collect::<Vec<_>>();
7155
7156 for worker in workers {
7157 assert!(worker.join().unwrap().is_none());
7158 }
7159 assert_eq!(starts.load(Ordering::SeqCst), 0);
7160 }
7161}
7162
7163#[cfg(test)]
7164mod force_restrict_tests {
7165 use super::*;
7166 use crate::language::StubProvider;
7167 use tempfile::TempDir;
7168
7169 fn test_context(project_root: Option<PathBuf>, restrict_to_project_root: bool) -> AppContext {
7170 AppContext::new(
7171 Box::new(StubProvider),
7172 Config {
7173 project_root,
7174 restrict_to_project_root,
7175 ..Config::default()
7176 },
7177 )
7178 }
7179
7180 #[test]
7181 fn standalone_validate_path_parity_without_force_restrict() {
7182 let root = TempDir::new().expect("root tempdir");
7183 let outside = TempDir::new().expect("outside tempdir");
7184 let outside_path = outside.path().join("outside.txt");
7185
7186 let unrestricted = test_context(Some(root.path().to_path_buf()), false);
7187 assert_eq!(
7188 unrestricted
7189 .validate_path("standalone-unrestricted", &outside_path)
7190 .expect("unrestricted standalone validates"),
7191 outside_path
7192 );
7193
7194 let restricted = test_context(Some(root.path().to_path_buf()), true);
7195 let err = restricted
7196 .validate_path("standalone-restricted", &outside_path)
7197 .expect_err("restricted standalone rejects outside root");
7198 assert_eq!(
7199 serde_json::to_value(err).unwrap()["code"],
7200 "path_outside_root"
7201 );
7202 }
7203
7204 #[test]
7205 fn path_restriction_root_memo_canonicalizes_once_for_1000_validations() {
7206 let root = TempDir::new().expect("root tempdir");
7207 let target = root.path().join("target.txt");
7208 std::fs::write(&target, "inside").expect("write target");
7209 let ctx = test_context(Some(root.path().to_path_buf()), true);
7210
7211 for request in 0..1_000 {
7212 let validated = ctx
7213 .validate_path(&format!("memo-{request}"), &target)
7214 .expect("in-root path validates");
7215 assert_eq!(validated, std::fs::canonicalize(&target).unwrap());
7216 }
7217
7218 assert_eq!(
7219 ctx.path_restriction_root_canonicalizations_for_test(),
7220 1,
7221 "the configured root should be canonicalized once instead of once per validation"
7222 );
7223 }
7224
7225 #[cfg(unix)]
7226 #[test]
7227 fn path_restriction_root_memo_recanonicalizes_after_cached_target_disappears() {
7228 let workspace = TempDir::new().expect("workspace tempdir");
7229 let first_target = workspace.path().join("first-target");
7230 let second_target = workspace.path().join("second-target");
7231 let configured_root = workspace.path().join("configured-root");
7232 std::fs::create_dir_all(&first_target).expect("create first target");
7233 std::fs::create_dir_all(&second_target).expect("create second target");
7234 std::os::unix::fs::symlink(&first_target, &configured_root)
7235 .expect("create configured-root symlink");
7236 std::fs::write(first_target.join("inside.txt"), "first").expect("write first target");
7237
7238 let ctx = test_context(Some(configured_root.clone()), true);
7239 assert_eq!(
7240 ctx.validate_path("first-target", Path::new("inside.txt"))
7241 .expect("first target validates"),
7242 std::fs::canonicalize(first_target.join("inside.txt")).unwrap()
7243 );
7244
7245 std::fs::remove_dir_all(&first_target).expect("remove first target");
7248 std::fs::remove_file(&configured_root).expect("remove old root symlink");
7249 std::os::unix::fs::symlink(&second_target, &configured_root)
7250 .expect("recreate configured-root symlink");
7251 std::fs::write(second_target.join("inside.txt"), "second").expect("write second target");
7252
7253 assert_eq!(
7254 ctx.validate_path("second-target", Path::new("inside.txt"))
7255 .expect("second target validates"),
7256 std::fs::canonicalize(second_target.join("inside.txt")).unwrap()
7257 );
7258 assert_eq!(ctx.path_restriction_root_canonicalizations_for_test(), 2);
7259 }
7260
7261 #[test]
7262 fn force_restrict_guard_refcounts_duplicate_request_ids() {
7263 let root = TempDir::new().expect("root tempdir");
7264 let outside = TempDir::new().expect("outside tempdir");
7265 let outside_path = outside.path().join("outside.txt");
7266 let ctx = test_context(Some(root.path().to_path_buf()), false);
7267
7268 assert!(ctx.validate_path("dup", &outside_path).is_ok());
7269 let guard1 = ctx.force_restrict_guard("dup");
7270 let guard2 = ctx.force_restrict_guard("dup");
7271 assert!(ctx.validate_path("dup", &outside_path).is_err());
7272 drop(guard1);
7273 assert!(
7274 ctx.validate_path("dup", &outside_path).is_err(),
7275 "duplicate guard must keep the request over-restricted"
7276 );
7277 drop(guard2);
7278 assert!(ctx.validate_path("dup", &outside_path).is_ok());
7279 }
7280
7281 #[test]
7282 fn with_force_restrict_cleans_up_after_normal_completion_and_panic() {
7283 let root = TempDir::new().expect("root tempdir");
7284 let outside = TempDir::new().expect("outside tempdir");
7285 let outside_path = outside.path().join("outside.txt");
7286 let ctx = test_context(Some(root.path().to_path_buf()), false);
7287
7288 ctx.with_force_restrict("normal", || {
7289 assert!(ctx.validate_path("normal", &outside_path).is_err());
7290 });
7291 assert!(!ctx.request_force_restrict("normal"));
7292 assert!(ctx.validate_path("normal", &outside_path).is_ok());
7293
7294 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7295 ctx.with_force_restrict("panic", || {
7296 assert!(ctx.validate_path("panic", &outside_path).is_err());
7297 panic!("intentional force-restrict cleanup panic");
7298 });
7299 }));
7300 assert!(panicked.is_err());
7301 assert!(!ctx.request_force_restrict("panic"));
7302 assert!(ctx.validate_path("panic", &outside_path).is_ok());
7303 }
7304
7305 #[cfg(unix)]
7306 #[test]
7307 fn validate_write_location_keeps_final_symlink_as_the_authorized_location() {
7308 let root = TempDir::new().expect("root tempdir");
7309 let outside = tempfile::NamedTempFile::new().expect("outside file");
7310 let link = root.path().join("file.txt");
7311 std::os::unix::fs::symlink(outside.path(), &link).expect("create final symlink");
7312 let ctx = test_context(Some(root.path().to_path_buf()), false);
7313 let _guard = ctx.force_restrict_guard("write-location-final-link");
7314
7315 let validated = ctx
7316 .validate_write_location("write-location-final-link", &link)
7317 .expect("the in-root link location is writable");
7318
7319 assert_eq!(
7320 validated,
7321 std::fs::canonicalize(root.path()).unwrap().join("file.txt")
7322 );
7323 }
7324
7325 #[cfg(unix)]
7326 #[test]
7327 fn validate_write_location_rejects_symlinked_parent_escape() {
7328 let root = TempDir::new().expect("root tempdir");
7329 let outside = TempDir::new().expect("outside tempdir");
7330 let linked_parent = root.path().join("linked-parent");
7331 std::os::unix::fs::symlink(outside.path(), &linked_parent).expect("create parent symlink");
7332 let candidate = linked_parent.join("file.txt");
7333 let ctx = test_context(Some(root.path().to_path_buf()), false);
7334 let _guard = ctx.force_restrict_guard("write-location-parent-link");
7335
7336 let error = ctx
7337 .validate_write_location("write-location-parent-link", &candidate)
7338 .expect_err("a symlinked parent must not escape the project root");
7339
7340 assert_eq!(
7341 serde_json::to_value(error).unwrap()["code"],
7342 "path_outside_root"
7343 );
7344 }
7345
7346 #[cfg(unix)]
7347 #[test]
7348 fn validate_write_location_rejects_outside_link_to_inside_file() {
7349 let root = TempDir::new().expect("root tempdir");
7350 let outside = TempDir::new().expect("outside tempdir");
7351 let inside = root.path().join("inside.txt");
7352 std::fs::write(&inside, "inside").unwrap();
7353 let outside_link = outside.path().join("outside-link.txt");
7354 std::os::unix::fs::symlink(&inside, &outside_link).expect("create outside symlink");
7355 let ctx = test_context(Some(root.path().to_path_buf()), false);
7356 let _guard = ctx.force_restrict_guard("write-location-outside-link");
7357
7358 let error = ctx
7359 .validate_write_location("write-location-outside-link", &outside_link)
7360 .expect_err("an out-of-root lexical location must remain blocked");
7361
7362 assert_eq!(
7363 serde_json::to_value(error).unwrap()["code"],
7364 "path_outside_root"
7365 );
7366 }
7367
7368 #[test]
7369 fn forced_restrict_without_project_root_fails_closed() {
7370 let ctx = test_context(None, false);
7371 let _guard = ctx.force_restrict_guard("missing-root");
7372 let err = ctx
7373 .validate_path("missing-root", Path::new("relative.txt"))
7374 .expect_err("forced restriction without a root must fail closed");
7375 assert_eq!(
7376 serde_json::to_value(err).unwrap()["code"],
7377 "path_outside_root"
7378 );
7379
7380 let write_err = ctx
7381 .validate_write_location("missing-root", Path::new("relative.txt"))
7382 .expect_err("write-location validation must also fail closed");
7383 assert_eq!(
7384 serde_json::to_value(write_err).unwrap()["code"],
7385 "path_outside_root"
7386 );
7387 }
7388}
7389
7390#[cfg(test)]
7391mod callgraph_store_for_ops_tests {
7392 use super::*;
7393 use crate::inspect::{InspectCategory, InspectSnapshot, JobOutcome, JobScope};
7394 use crate::parser::TreeSitterProvider;
7395 use crate::protocol::RawRequest;
7396 use serde_json::json;
7397 use std::path::Path;
7398 use std::sync::Barrier;
7399 use tempfile::TempDir;
7400
7401 fn callgraph_build_wait_ms(ms: u64) -> super::CallgraphBuildWaitMsGuard {
7402 super::override_callgraph_build_wait_ms_for_test(ms)
7403 }
7404
7405 fn force_async_callgraph_builds() -> super::CallgraphBuildWaitMsGuard {
7406 callgraph_build_wait_ms(0)
7407 }
7408
7409 fn cold_build_context() -> Arc<AppContext> {
7410 let project = TempDir::new().expect("project tempdir");
7411 let storage = TempDir::new().expect("storage tempdir");
7412 let source_dir = project.path().join("src");
7413 std::fs::create_dir_all(&source_dir).expect("source dir");
7414 std::fs::write(
7415 source_dir.join("lib.rs"),
7416 "pub fn caller() { callee(); }\npub fn callee() {}\n",
7417 )
7418 .expect("source file");
7419
7420 Arc::new(AppContext::new(
7421 Box::new(TreeSitterProvider::new()),
7422 Config {
7423 project_root: Some(project.keep()),
7424 storage_dir: Some(storage.keep()),
7425 callgraph_chunk_size: 1,
7426 ..Config::default()
7427 },
7428 ))
7429 }
7430
7431 fn with_fake_home_env<R>(home: &Path, f: impl FnOnce() -> R) -> R {
7432 let _guard = crate::test_env::process_env_lock();
7433 let prev_home = std::env::var_os("HOME");
7434 let prev_userprofile = std::env::var_os("USERPROFILE");
7435 unsafe {
7436 std::env::set_var("HOME", home);
7437 std::env::set_var("USERPROFILE", home);
7438 }
7439 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
7440 unsafe {
7441 match prev_home {
7442 Some(value) => std::env::set_var("HOME", value),
7443 None => std::env::remove_var("HOME"),
7444 }
7445 match prev_userprofile {
7446 Some(value) => std::env::set_var("USERPROFILE", value),
7447 None => std::env::remove_var("USERPROFILE"),
7448 }
7449 }
7450 match result {
7451 Ok(value) => value,
7452 Err(payload) => std::panic::resume_unwind(payload),
7453 }
7454 }
7455
7456 fn configure_request_with_params(params: serde_json::Value) -> RawRequest {
7457 RawRequest {
7458 id: "cfg".to_string(),
7459 command: "configure".to_string(),
7460 lsp_hints: None,
7461 session_id: None,
7462 params,
7463 }
7464 }
7465
7466 fn user_tier(doc: serde_json::Value) -> serde_json::Value {
7467 json!({
7468 "tier": "user",
7469 "source": "/u/aft.jsonc",
7470 "doc": doc.to_string(),
7471 })
7472 }
7473
7474 fn configure_context(project_root: &Path, storage_dir: &Path) -> AppContext {
7475 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7476 let response = crate::commands::configure::handle_configure(
7477 &configure_request_with_params(json!({
7478 "project_root": project_root,
7479 "harness": "opencode",
7480 "storage_dir": storage_dir,
7481 "config": [user_tier(json!({
7482 "callgraph_store": true,
7483 "search_index": true,
7484 "semantic_search": true,
7485 }))],
7486 })),
7487 &ctx,
7488 );
7489 assert!(response.success, "configure should succeed: {response:?}");
7490 ctx
7491 }
7492
7493 fn inspect_snapshot(ctx: &AppContext) -> InspectSnapshot {
7494 InspectSnapshot::new(
7495 ctx.canonical_cache_root(),
7496 ctx.inspect_dir(),
7497 ctx.config(),
7498 ctx.symbol_cache(),
7499 )
7500 }
7501
7502 fn empty_semantic_index_for_ctx(ctx: &AppContext) -> SemanticIndex {
7503 let project_root = ctx
7504 .config()
7505 .project_root
7506 .clone()
7507 .expect("test context has a project root");
7508 let files: Vec<PathBuf> = Vec::new();
7509 let mut embed = |_texts: Vec<String>| -> Result<Vec<Vec<f32>>, String> { Ok(Vec::new()) };
7510 SemanticIndex::build(&project_root, &files, &mut embed, 1)
7511 .expect("empty semantic index should build")
7512 }
7513
7514 #[test]
7515 fn home_root_gate_blocks_callgraph_store_entry_points() {
7516 let _wait_guard = force_async_callgraph_builds();
7517 let home = TempDir::new().expect("home tempdir");
7518 let storage = TempDir::new().expect("storage tempdir");
7519 let source_dir = home.path().join("src");
7520 std::fs::create_dir_all(&source_dir).expect("source dir");
7521 std::fs::write(
7522 source_dir.join("lib.rs"),
7523 "pub fn caller() { callee(); }\npub fn callee() {}\n",
7524 )
7525 .expect("source file");
7526
7527 with_fake_home_env(home.path(), || {
7528 let ctx = configure_context(home.path(), storage.path());
7529 assert!(
7530 !ctx.heavy_root_work_allowed(),
7531 "HOME root configure must close the heavy-root-work gate"
7532 );
7533 assert_eq!(
7534 ctx.try_health_snapshot(home.path())
7535 .callgraph_store
7536 .as_ref()
7537 .map(|component| component.status),
7538 Some("disabled"),
7539 "HOME root health must not advertise callgraph building"
7540 );
7541
7542 reset_callgraph_cold_build_spawn_count_for_test();
7543 assert!(matches!(
7544 ctx.callgraph_store_for_ops(),
7545 CallgraphStoreAccess::Unavailable
7546 ));
7547 assert!(
7548 ctx.ensure_callgraph_store()
7549 .expect("ensure_callgraph_store should not error")
7550 .is_none(),
7551 "shared gate must also block synchronous standalone callgraph builds"
7552 );
7553 assert_eq!(
7554 callgraph_cold_build_spawn_count_for_test(),
7555 0,
7556 "HOME root gate must not spawn a cold callgraph build"
7557 );
7558 });
7559 }
7560
7561 #[test]
7562 fn home_root_gate_blocks_inspect_manager_submit_paths() {
7563 let home = TempDir::new().expect("home tempdir");
7564 let storage = TempDir::new().expect("storage tempdir");
7565 let source_dir = home.path().join("src");
7566 std::fs::create_dir_all(&source_dir).expect("source dir");
7567 std::fs::write(source_dir.join("lib.rs"), "pub fn one() {}\n").expect("source file");
7568
7569 with_fake_home_env(home.path(), || {
7570 let ctx = configure_context(home.path(), storage.path());
7571 let snapshot = inspect_snapshot(&ctx);
7572 let scope = JobScope::for_project(snapshot.project_root.clone());
7573 let manager = ctx.inspect_manager();
7574
7575 assert!(matches!(
7576 manager.submit_category(snapshot.clone(), InspectCategory::Metrics, scope.clone()),
7577 JobOutcome::Failed { .. }
7578 ));
7579
7580 let submission = manager.submit_tier2_run_with_reuse_serial_background(
7581 snapshot,
7582 vec![InspectCategory::DeadCode],
7583 );
7584 assert!(submission.queued_categories.is_empty());
7585 assert!(submission.newly_queued_categories.is_empty());
7586 assert!(submission.deferred_categories.is_empty());
7587 assert_eq!(submission.errors.len(), 1);
7588 assert!(
7589 !manager.tier2_any_in_flight(),
7590 "HOME root gate must reject Tier-2 submission before any job is queued"
7591 );
7592 });
7593 }
7594
7595 #[test]
7596 fn non_home_root_still_allows_callgraph_cold_builds() {
7597 let _env_guard = force_async_callgraph_builds();
7598 reset_callgraph_cold_build_spawn_count_for_test();
7599 let ctx = cold_build_context();
7600
7601 assert!(ctx.heavy_root_work_allowed());
7602 assert!(matches!(
7603 ctx.callgraph_store_for_ops(),
7604 CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
7605 ));
7606 assert_eq!(
7607 callgraph_cold_build_spawn_count_for_test(),
7608 1,
7609 "non-home roots must still be able to cold-build the callgraph store"
7610 );
7611
7612 let rx = ctx
7613 .callgraph_store_rx
7614 .lock()
7615 .as_ref()
7616 .cloned()
7617 .expect("non-home cold build should install an in-flight receiver");
7618 rx.recv_timeout(Duration::from_secs(30))
7619 .expect("background cold build should complete");
7620 *ctx.callgraph_store_rx.lock() = None;
7621 }
7622
7623 #[test]
7624 fn semantic_ready_event_resumes_deferred_callgraph_and_tier2() {
7625 let _env_guard = force_async_callgraph_builds();
7626 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
7627 let ctx = cold_build_context();
7628 let (tx, rx) = crossbeam_channel::unbounded();
7629 *ctx.semantic_index_rx().lock() = Some(rx);
7630 ctx.schedule_semantic_cold_seed_gate_for_configure();
7631
7632 assert!(matches!(
7633 ctx.callgraph_store_for_ops(),
7634 CallgraphStoreAccess::Building
7635 ));
7636 assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
7637 tx.send(SemanticIndexEvent::Ready(empty_semantic_index_for_ctx(
7638 &ctx,
7639 )))
7640 .expect("send ready event");
7641
7642 crate::runtime_drain::drain_semantic_index_events(&ctx);
7643
7644 assert!(
7645 !ctx.semantic_cold_seed_active(),
7646 "semantic Ready must clear the scheduled cold gate"
7647 );
7648 assert!(
7649 ctx.tier2_pull_demand_pending(),
7650 "semantic Ready must resume deferred Tier-2 work"
7651 );
7652 assert_eq!(
7653 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
7654 1,
7655 "semantic Ready must resume the deferred callgraph warm"
7656 );
7657 let rx = ctx
7658 .callgraph_store_rx
7659 .lock()
7660 .as_ref()
7661 .cloned()
7662 .expect("ready resume should install an in-flight callgraph receiver");
7663 rx.recv_timeout(Duration::from_secs(30))
7664 .expect("background cold build should complete");
7665 *ctx.callgraph_store_rx.lock() = None;
7666 }
7667
7668 #[test]
7669 fn semantic_gate_cleared_event_resumes_deferred_callgraph_and_tier2() {
7670 let _env_guard = force_async_callgraph_builds();
7671 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
7672 let ctx = cold_build_context();
7673 ctx.schedule_semantic_cold_seed_gate_for_configure();
7674
7675 assert!(matches!(
7676 ctx.callgraph_store_for_ops(),
7677 CallgraphStoreAccess::Building
7678 ));
7679 assert_eq!(CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst), 0);
7680 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
7681
7682 assert!(
7683 !ctx.semantic_cold_seed_active(),
7684 "cached-load or retry-wait clear must reopen the semantic cold gate"
7685 );
7686 assert!(
7687 ctx.tier2_pull_demand_pending(),
7688 "cached-load or retry-wait clear must resume deferred Tier-2 work"
7689 );
7690 assert_eq!(
7691 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
7692 1,
7693 "cached-load or retry-wait clear must resume deferred callgraph warm"
7694 );
7695 let rx = ctx
7696 .callgraph_store_rx
7697 .lock()
7698 .as_ref()
7699 .cloned()
7700 .expect("gate-clear resume should install an in-flight callgraph receiver");
7701 rx.recv_timeout(Duration::from_secs(30))
7702 .expect("background cold build should complete");
7703 *ctx.callgraph_store_rx.lock() = None;
7704 }
7705
7706 #[test]
7707 fn semantic_cold_seed_gate_defers_callgraph_cold_spawn_until_resume() {
7708 let _env_guard = force_async_callgraph_builds();
7709 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
7710 let ctx = cold_build_context();
7711
7712 ctx.set_semantic_cold_seed_active_for_test(true);
7713 assert!(
7714 matches!(
7715 ctx.callgraph_store_for_ops(),
7716 CallgraphStoreAccess::Building
7717 ),
7718 "callgraph ops should degrade as building while the semantic cold gate is active"
7719 );
7720 assert_eq!(
7721 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
7722 0,
7723 "semantic cold gate must not spawn a competing callgraph cold build"
7724 );
7725 assert!(ctx.semantic_callgraph_warm_deferred_for_test());
7726
7727 ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
7728 assert_eq!(
7729 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
7730 1,
7731 "clearing the semantic cold gate should resume the deferred callgraph warm"
7732 );
7733
7734 let rx = ctx
7735 .callgraph_store_rx
7736 .lock()
7737 .as_ref()
7738 .cloned()
7739 .expect("deferred warm should install an in-flight receiver");
7740 rx.recv_timeout(Duration::from_secs(30))
7741 .expect("background cold build should complete");
7742 *ctx.callgraph_store_rx.lock() = None;
7743 }
7744
7745 #[test]
7746 fn semantic_cold_seed_gate_clear_requests_tier2_pull() {
7747 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7748 ctx.schedule_semantic_cold_seed_gate_for_configure();
7749
7750 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
7751
7752 assert!(
7753 !ctx.semantic_cold_seed_active(),
7754 "retry-wait or cached-load events must reopen the semantic cold gate"
7755 );
7756 assert!(
7757 ctx.tier2_pull_demand_pending(),
7758 "clearing the semantic cold gate should kick a Tier-2 pull refresh"
7759 );
7760 }
7761
7762 #[test]
7763 fn semantic_failed_event_clears_scheduled_gate_and_requests_tier2_pull() {
7764 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7765 let (tx, rx) = crossbeam_channel::unbounded();
7766 *ctx.semantic_index_rx().lock() = Some(rx);
7767 ctx.schedule_semantic_cold_seed_gate_for_configure();
7768 tx.send(SemanticIndexEvent::Failed(
7769 "embedding backend failed".to_string(),
7770 ))
7771 .expect("send failed event");
7772
7773 crate::runtime_drain::drain_semantic_index_events(&ctx);
7774
7775 assert!(
7776 !ctx.semantic_cold_seed_active(),
7777 "semantic Failed must clear the scheduled cold gate"
7778 );
7779 assert!(
7780 ctx.tier2_pull_demand_pending(),
7781 "semantic Failed must resume deferred Tier-2 work"
7782 );
7783 }
7784
7785 #[test]
7786 fn semantic_disconnect_clears_scheduled_gate_and_requests_tier2_pull() {
7787 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7788 let (tx, rx) = crossbeam_channel::unbounded::<SemanticIndexEvent>();
7789 *ctx.semantic_index_rx().lock() = Some(rx);
7790 ctx.schedule_semantic_cold_seed_gate_for_configure();
7791 drop(tx);
7792
7793 crate::runtime_drain::drain_semantic_index_events(&ctx);
7794
7795 assert!(
7796 !ctx.semantic_cold_seed_active(),
7797 "semantic worker disconnect must clear the scheduled cold gate"
7798 );
7799 assert!(
7800 ctx.tier2_pull_demand_pending(),
7801 "semantic worker disconnect must resume deferred Tier-2 work"
7802 );
7803 }
7804
7805 #[test]
7806 fn semantic_cold_seed_gate_is_per_context_for_tier2_scheduler() {
7807 let ctx_a = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7808 let ctx_b = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
7809 let base = Instant::now();
7810 ctx_a.reset_tier2_refresh_scheduler_at(base);
7811 ctx_b.reset_tier2_refresh_scheduler_at(base);
7812 ctx_a.set_semantic_cold_seed_active_for_test(true);
7813
7814 assert_eq!(
7815 ctx_a.tick_tier2_refresh_scheduler_at(
7816 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
7817 0,
7818 ),
7819 None,
7820 "root A should defer Tier-2 while its semantic cold seed is active"
7821 );
7822 assert_eq!(
7823 ctx_b.tick_tier2_refresh_scheduler_at(
7824 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
7825 0,
7826 ),
7827 Some(Tier2TriggerReason::ConfigureWarm),
7828 "root B must not inherit root A's semantic cold gate"
7829 );
7830 }
7831
7832 #[test]
7833 fn query_wait_joins_callgraph_build_scheduled_without_wait() {
7834 let _env_guard = callgraph_build_wait_ms(10_000);
7835 let project = TempDir::new().expect("project tempdir");
7836 let storage = TempDir::new().expect("storage tempdir");
7837 std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
7838 let project_root = std::fs::canonicalize(project.path()).expect("canonical project root");
7839 let project_key = crate::search_index::artifact_cache_key(&project_root);
7840 crate::root_cache::configure_artifact_access(&project_root, &project_key, false);
7841 let ctx = Arc::new(AppContext::new(
7842 Box::new(TreeSitterProvider::new()),
7843 Config {
7844 project_root: Some(project_root.clone()),
7845 storage_dir: Some(storage.path().to_path_buf()),
7846 callgraph_chunk_size: 1,
7847 ..Config::default()
7848 },
7849 ));
7850 let (reached, release) = install_callgraph_build_start_gate(project_root);
7851
7852 assert!(matches!(
7853 ctx.schedule_callgraph_store_warm(),
7854 CallgraphStoreAccess::Building
7855 ));
7856 reached
7857 .recv_timeout(Duration::from_secs(2))
7858 .expect("scheduled callgraph worker did not reach start barrier");
7859
7860 let (result_tx, result_rx) = std::sync::mpsc::channel();
7861 let query_ctx = Arc::clone(&ctx);
7862 let query = std::thread::spawn(move || {
7863 result_tx
7864 .send(query_ctx.callgraph_store_for_ops())
7865 .expect("send query result");
7866 });
7867 assert!(
7868 matches!(
7869 result_rx.recv_timeout(Duration::from_millis(100)),
7870 Err(std::sync::mpsc::RecvTimeoutError::Timeout)
7871 ),
7872 "query returned while the scheduled callgraph build was still in flight"
7873 );
7874
7875 release.send(()).expect("release callgraph worker");
7876 assert!(matches!(
7877 result_rx
7878 .recv_timeout(Duration::from_secs(10))
7879 .expect("query did not settle after the callgraph build completed"),
7880 CallgraphStoreAccess::Ready(_)
7881 ));
7882 query.join().expect("callgraph query thread");
7883 }
7884
7885 #[test]
7886 fn inline_wait_settled_event_clears_superseded_receiver() {
7887 let _env_guard = callgraph_build_wait_ms(2_000);
7888 let project = TempDir::new().expect("project tempdir");
7889 let storage = TempDir::new().expect("storage tempdir");
7890 std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
7891 let project_root = std::fs::canonicalize(project.path()).expect("canonical project root");
7892 let ctx = Arc::new(AppContext::new(
7893 Box::new(TreeSitterProvider::new()),
7894 Config {
7895 project_root: Some(project.path().to_path_buf()),
7896 storage_dir: Some(storage.path().to_path_buf()),
7897 callgraph_chunk_size: 1,
7898 ..Config::default()
7899 },
7900 ));
7901 let (reached, release) = install_callgraph_build_start_gate(project_root);
7902 let request_ctx = Arc::clone(&ctx);
7903 let request = std::thread::spawn(move || request_ctx.callgraph_store_for_ops());
7904 reached
7905 .recv_timeout(Duration::from_secs(2))
7906 .expect("callgraph worker did not reach start barrier");
7907
7908 ctx.next_callgraph_persist_epoch();
7909 release.send(()).unwrap();
7910 assert!(matches!(
7911 request.join().expect("callgraph request thread"),
7912 CallgraphStoreAccess::Building
7913 ));
7914 assert!(
7915 ctx.callgraph_store_rx().lock().is_none(),
7916 "inline Settled handling must retire the matching receiver"
7917 );
7918 assert!(
7919 ctx.callgraph_store()
7920 .read()
7921 .unwrap_or_else(std::sync::PoisonError::into_inner)
7922 .is_none(),
7923 "Settled must not reopen and install an older persisted store"
7924 );
7925 }
7926
7927 #[test]
7928 fn inline_ready_without_published_pointer_settles_and_preserves_pending_paths() {
7929 let _env_guard = callgraph_build_wait_ms(2_000);
7930 let project = TempDir::new().expect("project tempdir");
7931 let storage = TempDir::new().expect("storage tempdir");
7932 std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
7933 let ctx = AppContext::new(
7934 Box::new(TreeSitterProvider::new()),
7935 Config {
7936 project_root: Some(project.path().to_path_buf()),
7937 storage_dir: Some(storage.path().to_path_buf()),
7938 callgraph_chunk_size: 1,
7939 ..Config::default()
7940 },
7941 );
7942 let project_key = crate::search_index::artifact_cache_key(project.path());
7943 crate::root_cache::configure_artifact_access(project.path(), &project_key, false);
7944 let pending = project.path().join("pending.rs");
7945 ctx.add_pending_callgraph_store_paths([pending.clone()]);
7946 REMOVE_CALLGRAPH_POINTER_BEFORE_INLINE_REOPEN.store(true, Ordering::SeqCst);
7947 let _remove_pointer_guard = RemoveCallgraphPointerBeforeInlineReopenGuard;
7948
7949 assert!(matches!(
7950 ctx.callgraph_store_for_ops(),
7951 CallgraphStoreAccess::Building
7952 ));
7953 assert!(
7954 ctx.callgraph_store_rx().lock().is_none(),
7955 "inline Ready must settle after the published pointer disappears"
7956 );
7957 assert_eq!(
7958 ctx.take_pending_callgraph_store_paths(),
7959 vec![pending],
7960 "inline reopen failure must preserve pending watcher paths"
7961 );
7962 }
7963
7964 #[test]
7965 fn take_pending_callgraph_store_paths_drops_paths_outside_current_root() {
7966 let project = TempDir::new().expect("project tempdir");
7967 let foreign = TempDir::new().expect("foreign tempdir");
7968 let ctx = AppContext::new(
7969 Box::new(TreeSitterProvider::new()),
7970 Config {
7971 project_root: Some(project.path().to_path_buf()),
7972 ..Config::default()
7973 },
7974 );
7975 let inside = project.path().join("kept.rs");
7976 let outside = foreign.path().join("previous-root-file.rs");
7980 let dotdot_escape = project
7983 .path()
7984 .join("..")
7985 .join(
7986 foreign
7987 .path()
7988 .file_name()
7989 .expect("foreign tempdir has a name"),
7990 )
7991 .join("escaped.rs");
7992 ctx.add_pending_callgraph_store_paths([inside.clone(), outside, dotdot_escape]);
7993
7994 assert_eq!(
7995 ctx.take_pending_callgraph_store_paths(),
7996 vec![inside],
7997 "pending replay must drop foreign and dot-dot-escaping paths"
7998 );
7999 }
8000
8001 #[test]
8002 fn watcher_gap_invalidation_keeps_semantic_reloadable_and_skips_readonly_force_token() {
8003 let project = TempDir::new().expect("project tempdir");
8004 let ctx = AppContext::new(
8005 Box::new(TreeSitterProvider::new()),
8006 Config {
8007 project_root: Some(project.path().to_path_buf()),
8008 semantic_search: true,
8009 ..Config::default()
8010 },
8011 );
8012 ctx.set_canonical_cache_root(project.path().to_path_buf());
8013 ctx.set_cache_writer_capabilities(false, true);
8016 *ctx.semantic_index_status()
8017 .write()
8018 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
8019
8020 ctx.invalidate_artifacts_after_watcher_gap();
8021
8022 assert!(
8023 matches!(
8024 &*ctx
8025 .semantic_index_status()
8026 .read()
8027 .unwrap_or_else(std::sync::PoisonError::into_inner),
8028 SemanticIndexStatus::Ready { .. }
8029 ),
8030 "semantic-enabled root must stay reloadable (Disabled has no self-healing path)"
8031 );
8032 assert_eq!(
8033 ctx.pending_callgraph_store_force_token(),
8034 None,
8035 "read-only root must not be stuck behind an unfulfillable force token"
8036 );
8037 }
8038
8039 #[test]
8040 fn watcher_gap_invalidation_marks_force_rebuild_for_writer_roots() {
8041 let project = TempDir::new().expect("project tempdir");
8042 let ctx = AppContext::new(
8043 Box::new(TreeSitterProvider::new()),
8044 Config {
8045 project_root: Some(project.path().to_path_buf()),
8046 ..Config::default()
8047 },
8048 );
8049 ctx.set_canonical_cache_root(project.path().to_path_buf());
8050 ctx.set_cache_writer_capabilities(true, true);
8051
8052 ctx.invalidate_artifacts_after_watcher_gap();
8053
8054 assert!(
8055 ctx.pending_callgraph_store_force_token().is_some(),
8056 "writer roots must still reconcile the store after the unobserved interval"
8057 );
8058 assert!(
8059 matches!(
8060 &*ctx
8061 .semantic_index_status()
8062 .read()
8063 .unwrap_or_else(std::sync::PoisonError::into_inner),
8064 SemanticIndexStatus::Disabled
8065 ),
8066 "semantic-disabled config maps to Disabled status"
8067 );
8068 }
8069
8070 #[cfg(unix)]
8071 #[test]
8072 fn take_pending_callgraph_store_paths_drops_symlink_dotdot_escape() {
8073 let project = TempDir::new().expect("project tempdir");
8074 let foreign = TempDir::new().expect("foreign tempdir");
8075 std::fs::create_dir_all(foreign.path().join("dir")).expect("foreign dir");
8076 std::fs::write(foreign.path().join("secret.rs"), "pub fn s() {}\n").expect("secret");
8077 let ctx = AppContext::new(
8078 Box::new(TreeSitterProvider::new()),
8079 Config {
8080 project_root: Some(project.path().to_path_buf()),
8081 ..Config::default()
8082 },
8083 );
8084 std::os::unix::fs::symlink(foreign.path().join("dir"), project.path().join("link"))
8089 .expect("plant symlink");
8090 let escape = project.path().join("link").join("..").join("secret.rs");
8091 let dead_component_escape = project
8096 .path()
8097 .join("link")
8098 .join("dead")
8099 .join("..")
8100 .join("..")
8101 .join("deep-secret.rs");
8102 std::fs::write(foreign.path().join("reentry-secret.rs"), "pub fn r() {}\n")
8107 .expect("reentry secret");
8108 let reentry_escape = project
8109 .path()
8110 .join("dead")
8111 .join("..")
8112 .join("link")
8113 .join("..")
8114 .join("reentry-secret.rs");
8115 std::os::unix::fs::symlink(
8120 foreign.path().join("nonexistent-target"),
8121 project.path().join("dangling"),
8122 )
8123 .expect("plant dangling symlink");
8124 let dangling_reentry = project
8125 .path()
8126 .join("dangling")
8127 .join("..")
8128 .join("via-dangling.rs");
8129 std::fs::write(project.path().join("plain.rs"), "pub fn p() {}\n").expect("plain file");
8132 let through_file = project
8133 .path()
8134 .join("plain.rs")
8135 .join("..")
8136 .join("via-file.rs");
8137 let kept = project.path().join("kept.rs");
8138 ctx.add_pending_callgraph_store_paths([
8139 escape,
8140 dead_component_escape,
8141 reentry_escape,
8142 dangling_reentry,
8143 through_file,
8144 kept.clone(),
8145 ]);
8146
8147 assert_eq!(
8148 ctx.take_pending_callgraph_store_paths(),
8149 vec![kept],
8150 "symlink-plus-dotdot escapes must be dropped with filesystem-first semantics"
8151 );
8152 }
8153
8154 #[cfg(windows)]
8155 #[test]
8156 fn take_pending_callgraph_store_paths_drops_drive_relative_paths() {
8157 let cwd = std::env::current_dir().expect("drive cwd");
8164 let cwd_file = PathBuf::from(format!(
8165 "{}under-drive-cwd.rs",
8166 cwd.components()
8167 .next()
8168 .map(|prefix| prefix.as_os_str().to_string_lossy().into_owned())
8169 .expect("drive prefix")
8170 ));
8171 assert!(cwd_file.is_relative(), "C:foo must classify as relative");
8172 assert!(
8173 !pending_path_in_roots(&cwd_file, &[cwd.clone()]),
8174 "drive-relative spelling must be rejected even when the drive CWD is inside the root"
8175 );
8176 assert!(
8177 !pending_path_in_roots(Path::new(r"\root-relative.rs"), &[cwd]),
8178 "root-relative spelling must be rejected"
8179 );
8180
8181 let project = TempDir::new().expect("project tempdir");
8182 let ctx = AppContext::new(
8183 Box::new(TreeSitterProvider::new()),
8184 Config {
8185 project_root: Some(project.path().to_path_buf()),
8186 ..Config::default()
8187 },
8188 );
8189 let kept = project.path().join("kept.rs");
8190 ctx.add_pending_callgraph_store_paths([
8191 PathBuf::from("C:drive-relative.rs"),
8192 PathBuf::from(r"\root-relative.rs"),
8193 kept.clone(),
8194 ]);
8195
8196 assert_eq!(
8197 ctx.take_pending_callgraph_store_paths(),
8198 vec![kept],
8199 "drive-relative and root-relative spellings must be rejected"
8200 );
8201 }
8202
8203 #[test]
8204 fn take_pending_callgraph_store_paths_keeps_relative_and_deleted_paths() {
8205 let project = TempDir::new().expect("project tempdir");
8206 let ctx = AppContext::new(
8207 Box::new(TreeSitterProvider::new()),
8208 Config {
8209 project_root: Some(project.path().to_path_buf()),
8210 ..Config::default()
8211 },
8212 );
8213 let relative = PathBuf::from("src/relative.rs");
8216 let deleted = project.path().join("never-created.rs");
8217 ctx.add_pending_callgraph_store_paths([relative.clone(), deleted.clone()]);
8218
8219 let mut taken = ctx.take_pending_callgraph_store_paths();
8220 taken.sort();
8221 let mut expected = vec![relative, deleted];
8222 expected.sort();
8223 assert_eq!(
8224 taken, expected,
8225 "root-relative and deleted in-root paths must survive the filter"
8226 );
8227 }
8228
8229 #[test]
8230 fn writer_denied_callgraph_build_is_terminal_not_building() {
8231 let _env_guard = callgraph_build_wait_ms(30_000);
8232 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
8233
8234 let denied_ctx = cold_build_context();
8235 let denied_reason = match denied_ctx.callgraph_store_for_ops() {
8236 CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason)) => reason,
8237 CallgraphStoreAccess::Building => {
8238 panic!("writer-denied build must not remain in the retryable Building state")
8239 }
8240 _ => panic!("unregistered root must terminate with an unavailable reason"),
8241 };
8242 assert!(
8243 denied_reason.contains("could not acquire writer capability"),
8244 "terminal status must explain the writer-capability denial: {denied_reason}"
8245 );
8246 assert!(matches!(
8247 denied_ctx.callgraph_store_for_ops(),
8248 CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason))
8249 if reason.contains("could not acquire writer capability")
8250 ));
8251 assert_eq!(
8252 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8253 1,
8254 "polling a denied root must not spawn another doomed build"
8255 );
8256
8257 let writable_ctx = cold_build_context();
8260 let writable_root = writable_ctx
8261 .config()
8262 .project_root
8263 .clone()
8264 .expect("writable fixture root");
8265 let writable_key = crate::search_index::artifact_cache_key(&writable_root);
8266 crate::root_cache::configure_artifact_access(&writable_root, &writable_key, false);
8267 assert!(
8268 matches!(
8269 writable_ctx.callgraph_store_for_ops(),
8270 CallgraphStoreAccess::Ready(_)
8271 ),
8272 "removing the forced denial must change the terminal status"
8273 );
8274 }
8275
8276 #[test]
8277 fn concurrent_cold_callgraph_store_for_ops_spawns_one_build() {
8278 let _env_guard = force_async_callgraph_builds();
8279 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
8280
8281 let project = TempDir::new().expect("project tempdir");
8282 let storage = TempDir::new().expect("storage tempdir");
8283 let source_dir = project.path().join("src");
8284 std::fs::create_dir_all(&source_dir).expect("source dir");
8285 std::fs::write(
8286 source_dir.join("lib.rs"),
8287 "pub fn caller() { callee(); }\npub fn callee() {}\n",
8288 )
8289 .expect("source file");
8290
8291 let ctx = Arc::new(AppContext::new(
8292 Box::new(TreeSitterProvider::new()),
8293 Config {
8294 project_root: Some(project.path().to_path_buf()),
8295 storage_dir: Some(storage.path().to_path_buf()),
8296 callgraph_chunk_size: 1,
8297 ..Config::default()
8298 },
8299 ));
8300
8301 let barrier = Arc::new(Barrier::new(3));
8302 let handles = (0..2)
8303 .map(|_| {
8304 let ctx = Arc::clone(&ctx);
8305 let barrier = Arc::clone(&barrier);
8306 std::thread::spawn(move || {
8307 barrier.wait();
8308 matches!(
8309 ctx.callgraph_store_for_ops(),
8310 CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
8311 )
8312 })
8313 })
8314 .collect::<Vec<_>>();
8315
8316 barrier.wait();
8317 for handle in handles {
8318 assert!(
8319 handle.join().expect("callgraph caller thread"),
8320 "cold callgraph ops should report Building or observe the installed store"
8321 );
8322 }
8323
8324 assert_eq!(
8325 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8326 1,
8327 "concurrent cold callers must share one background build"
8328 );
8329
8330 let rx = ctx
8331 .callgraph_store_rx
8332 .lock()
8333 .as_ref()
8334 .cloned()
8335 .expect("in-flight receiver installed before spawn");
8336 rx.recv_timeout(Duration::from_secs(30))
8337 .expect("background cold build should complete");
8338 *ctx.callgraph_store_rx.lock() = None;
8339 }
8340
8341 #[test]
8342 fn watcher_gap_invalidation_gates_resident_artifacts_and_forces_strict_verify() {
8343 let root = TempDir::new().expect("project tempdir");
8344 let canonical_root = std::fs::canonicalize(root.path()).expect("canonical project root");
8345 let ctx = AppContext::new(
8346 Box::new(TreeSitterProvider::new()),
8347 Config {
8348 project_root: Some(canonical_root.clone()),
8349 ..Config::default()
8350 },
8351 );
8352 *ctx.search_index
8353 .write()
8354 .unwrap_or_else(std::sync::PoisonError::into_inner) =
8355 Some(SearchIndex::build(&canonical_root));
8356 *ctx.semantic_index
8357 .write()
8358 .unwrap_or_else(std::sync::PoisonError::into_inner) =
8359 Some(SemanticIndex::new(canonical_root.clone(), 3));
8360 *ctx.semantic_index_status
8361 .write()
8362 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
8363
8364 let artifact = canonical_root.join("verify-artifact.bin");
8365 std::fs::write(&artifact, b"same-size").expect("write verification artifact");
8366 let generation =
8367 crate::cache_freshness::artifact_generation(&artifact).expect("artifact generation");
8368 crate::cache_freshness::record_verify_completed(
8369 &canonical_root,
8370 crate::cache_freshness::VerifyArtifact::Search,
8371 Some(generation),
8372 );
8373 assert_eq!(
8374 crate::cache_freshness::warm_verify_plan(
8375 &canonical_root,
8376 crate::cache_freshness::VerifyArtifact::Search,
8377 Some(generation),
8378 ),
8379 crate::cache_freshness::WarmVerifyPlan::Skip
8380 );
8381
8382 ctx.invalidate_artifacts_after_watcher_gap();
8383
8384 assert!(ctx
8385 .search_index
8386 .read()
8387 .unwrap_or_else(std::sync::PoisonError::into_inner)
8388 .is_none());
8389 assert!(ctx
8390 .semantic_index
8391 .read()
8392 .unwrap_or_else(std::sync::PoisonError::into_inner)
8393 .is_none());
8394 assert!(ctx.pending_callgraph_store_force_token().is_some());
8395 assert_eq!(
8396 crate::cache_freshness::warm_verify_plan(
8397 &canonical_root,
8398 crate::cache_freshness::VerifyArtifact::Search,
8399 Some(generation),
8400 ),
8401 crate::cache_freshness::WarmVerifyPlan::Strict
8402 );
8403 }
8404
8405 #[test]
8406 fn cancelled_semantic_refresh_transfers_refreshing_files_to_pending() {
8407 let root = TempDir::new().expect("project tempdir");
8408 let ctx = AppContext::new(
8409 Box::new(TreeSitterProvider::new()),
8410 Config {
8411 project_root: Some(root.path().to_path_buf()),
8412 semantic_search: true,
8413 ..Config::default()
8414 },
8415 );
8416 *ctx.semantic_index
8417 .write()
8418 .unwrap_or_else(std::sync::PoisonError::into_inner) =
8419 Some(SemanticIndex::new(root.path().to_path_buf(), 3));
8420 let refreshing_path = root.path().join("src/lib.rs");
8421 {
8422 let mut status = ctx
8423 .semantic_index_status
8424 .write()
8425 .unwrap_or_else(std::sync::PoisonError::into_inner);
8426 *status = SemanticIndexStatus::ready();
8427 status.start_refreshing_file(refreshing_path.clone());
8428 }
8429 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
8430 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
8431 ctx.install_semantic_refresh_worker_for_build_epoch(
8432 request_tx,
8433 event_rx,
8434 Arc::new(Mutex::new(None)),
8435 ctx.semantic_index_rx_epoch(),
8436 );
8437
8438 ctx.cancel_unbound_artifact_work();
8439
8440 assert_eq!(
8443 ctx.pending_semantic_index_paths
8444 .lock()
8445 .iter()
8446 .cloned()
8447 .collect::<Vec<_>>(),
8448 vec![refreshing_path],
8449 "cancelled in-flight refresh files must transfer to the pending set"
8450 );
8451 assert!(matches!(
8452 &*ctx
8453 .semantic_index_status
8454 .read()
8455 .unwrap_or_else(std::sync::PoisonError::into_inner),
8456 SemanticIndexStatus::Ready { refreshing, .. } if refreshing.is_empty()
8457 ));
8458 }
8459
8460 #[test]
8461 fn unbind_before_corpus_started_preserves_corpus_intent() {
8462 let root = TempDir::new().expect("project tempdir");
8467 let ctx = AppContext::new(
8468 Box::new(TreeSitterProvider::new()),
8469 Config {
8470 project_root: Some(root.path().to_path_buf()),
8471 semantic_search: true,
8472 ..Config::default()
8473 },
8474 );
8475 *ctx.semantic_index
8476 .write()
8477 .unwrap_or_else(std::sync::PoisonError::into_inner) =
8478 Some(SemanticIndex::new(root.path().to_path_buf(), 3));
8479 *ctx.semantic_index_status
8480 .write()
8481 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
8482 stage: "refreshing_corpus".to_string(),
8483 files: None,
8484 entries_done: None,
8485 entries_total: None,
8486 };
8487 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
8488 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
8489 ctx.install_semantic_refresh_worker_for_build_epoch(
8490 request_tx,
8491 event_rx,
8492 Arc::new(Mutex::new(None)),
8493 ctx.semantic_index_rx_epoch(),
8494 );
8495
8496 ctx.cancel_unbound_artifact_work();
8497
8498 assert!(
8499 *ctx.pending_semantic_corpus_refresh.lock(),
8500 "corpus intent stamped before CorpusStarted must survive the cancellation"
8501 );
8502 }
8503
8504 #[test]
8505 fn cancelled_search_corpus_refresh_drops_nonready_resident_index() {
8506 let root = TempDir::new().expect("project tempdir");
8507 let ctx = AppContext::new(
8508 Box::new(TreeSitterProvider::new()),
8509 Config {
8510 project_root: Some(root.path().to_path_buf()),
8511 ..Config::default()
8512 },
8513 );
8514 let mut refreshing = SearchIndex::new();
8518 refreshing.ready = false;
8519 *ctx.search_index
8520 .write()
8521 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(refreshing);
8522 let (_tx, rx) = crossbeam_channel::unbounded();
8523 ctx.install_search_index_rx(rx, ctx.configure_generation());
8524
8525 ctx.cancel_unbound_artifact_work();
8526
8527 assert!(
8528 ctx.search_index
8529 .read()
8530 .unwrap_or_else(std::sync::PoisonError::into_inner)
8531 .is_none(),
8532 "a cancelled corpus refresh must drop the non-ready resident so rebind reloads it"
8533 );
8534 assert!(ctx
8535 .search_index_rx
8536 .read()
8537 .unwrap_or_else(std::sync::PoisonError::into_inner)
8538 .is_none());
8539 }
8540
8541 #[test]
8542 fn active_semantic_file_refresh_blocks_idle_eviction_until_completion() {
8543 let root = TempDir::new().expect("project tempdir");
8544 let ctx = AppContext::new(
8545 Box::new(TreeSitterProvider::new()),
8546 Config {
8547 project_root: Some(root.path().to_path_buf()),
8548 ..Config::default()
8549 },
8550 );
8551 *ctx.semantic_index
8552 .write()
8553 .unwrap_or_else(std::sync::PoisonError::into_inner) =
8554 Some(SemanticIndex::new(root.path().to_path_buf(), 3));
8555 let refreshing_path = root.path().join("src/lib.rs");
8556 {
8557 let mut status = ctx
8558 .semantic_index_status
8559 .write()
8560 .unwrap_or_else(std::sync::PoisonError::into_inner);
8561 *status = SemanticIndexStatus::ready();
8562 status.start_refreshing_file(refreshing_path.clone());
8563 }
8564
8565 assert!(ctx.artifact_eviction_blocked());
8566 assert!(!ctx.evict_idle_artifacts());
8567 assert!(ctx
8568 .semantic_index
8569 .read()
8570 .unwrap_or_else(std::sync::PoisonError::into_inner)
8571 .is_some());
8572
8573 ctx.semantic_index_status
8574 .write()
8575 .unwrap_or_else(std::sync::PoisonError::into_inner)
8576 .complete_refreshing_file(&refreshing_path);
8577 assert!(ctx.evict_idle_artifacts());
8578 assert!(ctx
8579 .semantic_index
8580 .read()
8581 .unwrap_or_else(std::sync::PoisonError::into_inner)
8582 .is_none());
8583 }
8584}
8585
8586#[cfg(test)]
8587mod status_emitter_tests {
8588 use super::*;
8589 use crate::parser::TreeSitterProvider;
8590
8591 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
8592 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
8593 let (tx, rx) = mpsc::channel();
8594 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
8595 let _ = tx.send(frame);
8596 }))));
8597 (ctx, rx)
8598 }
8599
8600 #[test]
8601 fn status_emitter_signal_triggers_push() {
8602 let (ctx, rx) = ctx_with_frame_rx();
8603 ctx.status_emitter().signal(ctx.build_status_snapshot());
8604 let frame = rx
8605 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
8606 .expect("status_changed push");
8607 assert!(matches!(frame, PushFrame::StatusChanged(_)));
8608 }
8609
8610 #[test]
8611 fn status_emitter_debounces_burst() {
8612 let (ctx, rx) = ctx_with_frame_rx();
8613 for _ in 0..10 {
8614 ctx.status_emitter().signal(ctx.build_status_snapshot());
8615 }
8616 let frame = rx
8617 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
8618 .expect("status_changed push");
8619 assert!(matches!(frame, PushFrame::StatusChanged(_)));
8620 assert!(rx.try_recv().is_err());
8621 }
8622
8623 #[test]
8624 fn status_emitter_separate_windows_separate_pushes() {
8625 let (ctx, rx) = ctx_with_frame_rx();
8626 ctx.status_emitter().signal(ctx.build_status_snapshot());
8627 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
8628 .expect("first push");
8629 ctx.status_emitter().signal(ctx.build_status_snapshot());
8630 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
8631 .expect("second push");
8632 }
8633
8634 #[test]
8635 fn status_emitter_no_signal_no_push() {
8636 let (_ctx, rx) = ctx_with_frame_rx();
8637 assert!(rx
8638 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
8639 .is_err());
8640 }
8641
8642 #[test]
8643 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
8644 let (ctx, rx) = ctx_with_frame_rx();
8645 drop(ctx);
8646 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
8647 }
8648
8649 #[test]
8650 fn progress_sender_slot_is_per_context_for_shared_app() {
8651 let app = App::default_shared();
8652 let ctx_a = AppContext::from_app(Arc::clone(&app), Config::default());
8653 let ctx_b = AppContext::from_app(app, Config::default());
8654 let (tx_a, rx_a) = mpsc::channel();
8655 let (tx_b, rx_b) = mpsc::channel();
8656
8657 ctx_a.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
8658 let _ = tx_a.send(frame);
8659 }))));
8660 ctx_b.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
8661 let _ = tx_b.send(frame);
8662 }))));
8663
8664 ctx_a.emit_progress(ProgressFrame {
8665 frame_type: "progress",
8666 request_id: "ctx-a".to_string(),
8667 kind: crate::protocol::ProgressKind::Stdout,
8668 chunk: "a".to_string(),
8669 });
8670 ctx_b.emit_progress(ProgressFrame {
8671 frame_type: "progress",
8672 request_id: "ctx-b".to_string(),
8673 kind: crate::protocol::ProgressKind::Stdout,
8674 chunk: "b".to_string(),
8675 });
8676
8677 match rx_a
8678 .recv_timeout(Duration::from_millis(50))
8679 .expect("ctx A progress frame")
8680 {
8681 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-a"),
8682 other => panic!("unexpected frame for ctx A: {other:?}"),
8683 }
8684 assert!(rx_a.try_recv().is_err());
8685
8686 match rx_b
8687 .recv_timeout(Duration::from_millis(50))
8688 .expect("ctx B progress frame")
8689 {
8690 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-b"),
8691 other => panic!("unexpected frame for ctx B: {other:?}"),
8692 }
8693 assert!(rx_b.try_recv().is_err());
8694 }
8695}
8696
8697#[cfg(test)]
8698mod health_warming_honesty_tests {
8699 use super::*;
8700 use crate::parser::TreeSitterProvider;
8701
8702 fn ctx_with_config(config: Config) -> AppContext {
8703 AppContext::new(Box::new(TreeSitterProvider::new()), config)
8704 }
8705
8706 fn health_search_status(ctx: &AppContext) -> &'static str {
8707 let root = std::path::Path::new("/tmp/health-warming-honesty-test");
8708 ctx.try_health_snapshot(root)
8709 .search_index
8710 .expect("search_index component present")
8711 .status
8712 }
8713
8714 fn health_tier2_status(ctx: &AppContext) -> &'static str {
8715 let root = std::path::Path::new("/tmp/health-warming-honesty-test");
8716 ctx.try_health_snapshot(root)
8717 .tier2
8718 .expect("tier2 component present")
8719 .status
8720 }
8721
8722 #[test]
8723 fn write_denied_search_index_reports_ready_not_building() {
8724 let config = Config {
8728 search_index: true,
8729 ..Config::default()
8730 };
8731 let ctx = ctx_with_config(config);
8732 let mut index = SearchIndex::new();
8733 index.build_denied = true;
8734 *ctx.search_index()
8735 .write()
8736 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
8737
8738 assert_eq!(
8739 health_search_status(&ctx),
8740 "ready",
8741 "a build-denied index is a terminal settled state and must not report building forever"
8742 );
8743 }
8744
8745 #[test]
8746 fn in_progress_search_index_still_reports_building() {
8747 let config = Config {
8751 search_index: true,
8752 ..Config::default()
8753 };
8754 let ctx = ctx_with_config(config);
8755 let index = SearchIndex::new(); *ctx.search_index()
8757 .write()
8758 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
8759
8760 assert_eq!(health_search_status(&ctx), "building");
8761 }
8762
8763 #[test]
8764 fn tier2_blocked_on_callgraph_reports_ready_not_building() {
8765 let ctx = ctx_with_config(Config::default()); ctx.update_status_bar_tier2(None, Some(3), Some(2), None, false);
8771 ctx.set_status_bar_tier2_dead_code_blocked_on_callgraph(true);
8772
8773 assert_eq!(
8774 health_tier2_status(&ctx),
8775 "ready",
8776 "tier2 complete except dead_code-blocked-on-callgraph must not stay building"
8777 );
8778 }
8779
8780 #[test]
8781 fn health_tier2_and_inspect_builder_state_read_the_same_registry() {
8782 let ctx = ctx_with_config(Config::default());
8785 ctx.update_status_bar_tier2(Some(1), Some(2), Some(3), None, false);
8786 ctx.inspect_manager()
8787 .set_tier2_in_flight_for_test(crate::inspect::InspectCategory::DeadCode, true);
8788
8789 assert_eq!(health_tier2_status(&ctx), "building");
8790 assert_eq!(
8791 ctx.inspect_manager()
8792 .tier2_builder_state(crate::inspect::InspectCategory::DeadCode),
8793 crate::inspect::InspectBuilderState::Building
8794 );
8795
8796 ctx.inspect_manager()
8797 .set_tier2_in_flight_for_test(crate::inspect::InspectCategory::DeadCode, false);
8798
8799 assert_eq!(health_tier2_status(&ctx), "ready");
8800 assert_eq!(
8801 ctx.inspect_manager()
8802 .tier2_builder_state(crate::inspect::InspectCategory::DeadCode),
8803 crate::inspect::InspectBuilderState::Absent
8804 );
8805
8806 ctx.inspect_manager().record_tier2_attempt_outcome_for_test(
8807 crate::inspect::InspectCategory::DeadCode,
8808 crate::inspect::JobOutcome::Fresh {
8809 payload: crate::inspect::scanners::dead_code::callgraph_unavailable_aggregate(0),
8810 },
8811 );
8812 assert_eq!(
8813 health_tier2_status(&ctx),
8814 "ready",
8815 "a finished callgraph_unavailable attempt must not keep health.tier2=building"
8816 );
8817 assert_eq!(
8818 ctx.inspect_manager()
8819 .tier2_builder_state(crate::inspect::InspectCategory::DeadCode),
8820 crate::inspect::InspectBuilderState::Absent
8821 );
8822 assert!(
8823 ctx.inspect_manager()
8824 .tier2_builder_state_detail(crate::inspect::InspectCategory::DeadCode)
8825 .starts_with("last attempt failed: callgraph_unavailable (attempt 1, first at "),
8826 "inspect refusals must carry the failed-attempt history the health surface no longer treats as busy"
8827 );
8828 }
8829
8830 #[test]
8831 fn tier2_missing_dead_code_without_callgraph_block_reports_building() {
8832 let ctx = ctx_with_config(Config::default());
8835 ctx.update_status_bar_tier2(None, Some(3), Some(2), None, false);
8836 ctx.set_status_bar_tier2_dead_code_blocked_on_callgraph(false);
8837
8838 assert_eq!(health_tier2_status(&ctx), "building");
8839 }
8840}
8841
8842#[cfg(test)]
8843mod status_bar_tests {
8844 use super::*;
8845 use crate::parser::TreeSitterProvider;
8846
8847 fn ctx() -> AppContext {
8848 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
8849 }
8850
8851 #[test]
8852 fn truthful_values_omit_unproven_categories_while_legacy_projection_stays_hidden() {
8853 let ctx = ctx();
8854 let values = ctx.status_bar_count_values();
8855 assert_eq!(values.errors, None);
8856 assert_eq!(values.warnings, None);
8857 assert_eq!(values.dead_code, None);
8858 assert_eq!(values.unused_exports, None);
8859 assert_eq!(values.duplicates, None);
8860 assert_eq!(values.todos, None);
8861 assert!(ctx.status_bar_counts().is_none());
8862
8863 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
8864 let values = ctx.status_bar_count_values();
8865 assert_eq!(values.dead_code, Some(5));
8866 assert_eq!(values.unused_exports, Some(3));
8867 assert_eq!(values.duplicates, Some(7));
8868 assert_eq!(values.todos, Some(2));
8869 assert_eq!(values.errors, None, "no analyzer report is not a clean E0");
8870 assert_eq!(
8871 values.warnings, None,
8872 "no analyzer report is not a clean W0"
8873 );
8874 assert!(!values.tier2_stale);
8875
8876 let legacy = ctx
8877 .status_bar_counts()
8878 .expect("legacy projection is populated");
8879 assert_eq!((legacy.errors, legacy.warnings), (0, 0));
8880 }
8881
8882 #[test]
8883 fn changing_root_clears_project_scoped_status_counts() {
8884 let temp = tempfile::tempdir().expect("tempdir");
8885 let first_root = temp.path().join("first");
8886 let second_root = temp.path().join("second");
8887 std::fs::create_dir_all(&first_root).expect("create first root");
8888 std::fs::create_dir_all(&second_root).expect("create second root");
8889 let ctx = ctx();
8890 ctx.set_canonical_cache_root(first_root);
8891 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
8892 assert!(ctx.status_bar_counts().is_some());
8893
8894 ctx.set_canonical_cache_root(second_root);
8895
8896 let values = ctx.status_bar_count_values();
8897 assert_eq!(values.dead_code, None);
8898 assert_eq!(values.unused_exports, None);
8899 assert_eq!(values.duplicates, None);
8900 assert!(
8901 ctx.status_bar_counts().is_none(),
8902 "counts from the previous root must not appear in a newly bound root"
8903 );
8904 }
8905
8906 #[test]
8907 fn partial_tier2_keeps_proven_categories_and_cache_hit_preserves_omissions() {
8908 let ctx = ctx();
8909 ctx.update_status_bar_tier2(Some(5), None, None, None, true);
8910
8911 let first = ctx.status_bar_count_values();
8912 assert_eq!(first.dead_code, Some(5));
8913 assert_eq!(first.unused_exports, None);
8914 assert_eq!(first.duplicates, None);
8915 assert_eq!(first.todos, None);
8916 assert!(first.tier2_stale);
8917 assert!(ctx.status_bar_counts().is_none());
8918
8919 let cached = ctx.status_bar_count_values();
8920 assert_eq!(cached, first, "a cache hit must preserve every omission");
8921 let cache = ctx
8922 .status_bar_cached
8923 .read()
8924 .unwrap_or_else(std::sync::PoisonError::into_inner);
8925 assert!(cache.valid);
8926 assert_eq!(cache.counts.as_ref(), Some(&first));
8927 drop(cache);
8928
8929 ctx.update_status_bar_tier2(None, Some(3), None, None, true);
8930 let partial = ctx.status_bar_count_values();
8931 assert_eq!(partial.dead_code, Some(5));
8932 assert_eq!(partial.unused_exports, Some(3));
8933 assert_eq!(partial.duplicates, None);
8934
8935 ctx.update_status_bar_tier2(None, None, Some(7), None, false);
8936 let complete = ctx.status_bar_count_values();
8937 assert_eq!(complete.dead_code, Some(5));
8938 assert_eq!(complete.unused_exports, Some(3));
8939 assert_eq!(complete.duplicates, Some(7));
8940 }
8941
8942 #[test]
8943 fn update_with_none_todos_preserves_last_known_todos() {
8944 let ctx = ctx();
8945 ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
8946 ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
8948 let counts = ctx.status_bar_count_values();
8949 assert_eq!(counts.todos, Some(9));
8950 assert_eq!(counts.dead_code, Some(2));
8951 }
8952
8953 #[test]
8954 fn update_with_none_count_preserves_last_known_count() {
8955 let ctx = ctx();
8956 ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
8957 ctx.update_status_bar_tier2(Some(11), None, None, None, false);
8960 let counts = ctx.status_bar_count_values();
8961 assert_eq!(counts.dead_code, Some(11));
8962 assert_eq!(counts.unused_exports, Some(20));
8963 assert_eq!(counts.duplicates, Some(30));
8964 }
8965
8966 #[test]
8967 fn mark_stale_sets_flag_after_any_proven_category() {
8968 let ctx = ctx();
8969 ctx.mark_status_bar_tier2_stale();
8970 assert!(!ctx.status_bar_count_values().tier2_stale);
8971
8972 ctx.update_status_bar_tier2(Some(4), None, None, None, false);
8973 ctx.mark_status_bar_tier2_stale();
8974 assert!(ctx.status_bar_count_values().tier2_stale);
8975
8976 ctx.update_status_bar_tier2(Some(4), None, None, None, false);
8978 assert!(!ctx.status_bar_count_values().tier2_stale);
8979 }
8980
8981 #[test]
8986 fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
8987 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
8988 use crate::lsp::registry::ServerKind;
8989 use crate::lsp::roots::ServerKey;
8990
8991 let ctx = ctx();
8992 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); let file = std::path::PathBuf::from("/proj/gone.ts");
8995 {
8996 let mut lsp = ctx.lsp();
8997 lsp.diagnostics_store_mut_for_test().publish(
8998 ServerKey {
8999 kind: ServerKind::TypeScript,
9000 root: std::path::PathBuf::from("/proj"),
9001 },
9002 file.clone(),
9003 vec![StoredDiagnostic {
9004 file: file.clone(),
9005 line: 1,
9006 column: 1,
9007 end_line: 1,
9008 end_column: 2,
9009 severity: DiagnosticSeverity::Error,
9010 message: "boom".into(),
9011 code: None,
9012 source: None,
9013 }],
9014 );
9015 }
9016
9017 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 1);
9019
9020 let removed = ctx.lsp_clear_diagnostics_for_file(&file);
9022 assert!(removed);
9023 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
9024 }
9025
9026 #[test]
9027 fn status_bar_preserves_authoritative_counts_until_provisional_report_is_promoted() {
9028 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
9029 use crate::lsp::registry::ServerKind;
9030 use crate::lsp::roots::ServerKey;
9031
9032 let ctx = ctx();
9033 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
9034 let root = std::path::PathBuf::from("/proj");
9035 let file = root.join("src/main.rs");
9036 let key = ServerKey {
9037 kind: ServerKind::Rust,
9038 root,
9039 };
9040 let diagnostic = |severity, message: &str| StoredDiagnostic {
9041 file: file.clone(),
9042 line: 1,
9043 column: 1,
9044 end_line: 1,
9045 end_column: 2,
9046 severity,
9047 message: message.into(),
9048 code: None,
9049 source: None,
9050 };
9051
9052 {
9053 let mut lsp = ctx.lsp();
9054 lsp.diagnostics_store_mut_for_test().publish(
9055 key.clone(),
9056 file.clone(),
9057 vec![diagnostic(DiagnosticSeverity::Error, "settled error")],
9058 );
9059 }
9060 let counts = ctx.status_bar_counts().expect("populated");
9061 assert_eq!((counts.errors, counts.warnings), (1, 0));
9062
9063 {
9064 let mut lsp = ctx.lsp();
9065 lsp.diagnostics_store_mut_for_test()
9066 .publish_full_with_provisional(
9067 key.clone(),
9068 file.clone(),
9069 vec![diagnostic(
9070 DiagnosticSeverity::Warning,
9071 "latest warming warning",
9072 )],
9073 None,
9074 None,
9075 true,
9076 );
9077 }
9078 let counts = ctx.status_bar_counts().expect("populated");
9079 assert_eq!(
9080 (counts.errors, counts.warnings),
9081 (1, 0),
9082 "pre-quiescence diagnostics must not replace authoritative counts"
9083 );
9084
9085 {
9086 let mut lsp = ctx.lsp();
9087 assert!(lsp
9088 .diagnostics_store_mut_for_test()
9089 .promote_provisional_for_server(&key));
9090 }
9091 let counts = ctx.status_bar_counts().expect("populated");
9092 assert_eq!(
9093 (counts.errors, counts.warnings),
9094 (0, 1),
9095 "the latest report becomes authoritative at quiescence"
9096 );
9097 }
9098
9099 #[test]
9100 fn status_bar_filtered_counts_ignore_environmental_flap() {
9101 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
9102 use crate::lsp::registry::ServerKind;
9103 use crate::lsp::roots::ServerKey;
9104
9105 let ctx = ctx();
9106 let root = if cfg!(windows) {
9107 std::path::PathBuf::from(r"C:\proj")
9108 } else {
9109 std::path::PathBuf::from("/proj")
9110 };
9111 ctx.set_canonical_cache_root(root.clone());
9112 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
9113
9114 let file = root.join("aft.jsonc");
9115 let key = ServerKey {
9116 kind: ServerKind::TypeScript,
9117 root: root.clone(),
9118 };
9119 let env = StoredDiagnostic {
9120 file: file.clone(),
9121 line: 1,
9122 column: 1,
9123 end_line: 1,
9124 end_column: 2,
9125 severity: DiagnosticSeverity::Error,
9126 message: "Failed to load schema from https://example.com/schema.json".into(),
9127 code: None,
9128 source: Some("json".into()),
9129 };
9130
9131 assert_eq!(ctx.status_bar_counts().expect("populated").errors, 0);
9132
9133 {
9134 let mut lsp = ctx.lsp();
9135 lsp.diagnostics_store_mut_for_test()
9136 .publish(key.clone(), file.clone(), vec![env]);
9137 }
9138 assert_eq!(
9139 ctx.status_bar_counts().expect("populated").errors,
9140 0,
9141 "environmental publish must not change status-bar E"
9142 );
9143
9144 {
9145 let mut lsp = ctx.lsp();
9146 lsp.diagnostics_store_mut_for_test()
9147 .publish(key, file, vec![]);
9148 }
9149 assert_eq!(
9150 ctx.status_bar_counts().expect("populated").errors,
9151 0,
9152 "environmental clear must not change status-bar E"
9153 );
9154 }
9155}
9156
9157#[cfg(test)]
9158mod harness_path_tests {
9159 use super::*;
9160 use crate::harness::Harness;
9161 use crate::parser::TreeSitterProvider;
9162
9163 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
9164 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
9165 ctx.update_config(|config| {
9166 config.storage_dir = Some(storage_dir);
9167 });
9168 ctx.set_harness(harness);
9169 ctx
9170 }
9171
9172 #[test]
9173 fn harness_dir_resolves_correctly() {
9174 let storage = PathBuf::from("/tmp/cortexkit/aft");
9175 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
9176
9177 assert_eq!(ctx.harness_dir(), storage.join("pi"));
9178 }
9179
9180 #[test]
9181 fn bash_tasks_dir_uses_hash_session() {
9182 let storage = PathBuf::from("/tmp/cortexkit/aft");
9183 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
9184
9185 assert_eq!(
9186 ctx.bash_tasks_dir("ses_abc"),
9187 storage
9188 .join("opencode")
9189 .join("bash-tasks")
9190 .join(hash_session("ses_abc"))
9191 );
9192 }
9193
9194 #[test]
9195 fn backups_dir_includes_path_hash() {
9196 let storage = PathBuf::from("/tmp/cortexkit/aft");
9197 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
9198
9199 assert_eq!(
9200 ctx.backups_dir("ses_abc", "pathhash"),
9201 storage
9202 .join("pi")
9203 .join("backups")
9204 .join(hash_session("ses_abc"))
9205 .join("pathhash")
9206 );
9207 }
9208
9209 #[test]
9210 fn filters_dir_under_harness() {
9211 let storage = PathBuf::from("/tmp/cortexkit/aft");
9212 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
9213
9214 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
9215 }
9216
9217 #[test]
9218 fn trust_file_is_host_global() {
9219 let storage = PathBuf::from("/tmp/cortexkit/aft");
9220 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
9221
9222 assert_eq!(
9223 ctx.trust_file(),
9224 storage.join("trusted-filter-projects.json")
9225 );
9226 }
9227
9228 #[test]
9229 fn same_session_different_harness_resolve_different_paths() {
9230 let storage = PathBuf::from("/tmp/cortexkit/aft");
9231 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
9232 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
9233
9234 assert_ne!(
9235 opencode.bash_tasks_dir("ses_same"),
9236 pi.bash_tasks_dir("ses_same")
9237 );
9238 }
9239
9240 #[test]
9241 fn callgraph_and_inspect_dirs_are_root_keyed() {
9242 let temp = tempfile::tempdir().expect("tempdir");
9243 let storage = temp.path().join("storage");
9244 let root = temp.path().join("checkout");
9245 std::fs::create_dir_all(&root).expect("create root");
9246 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
9247 ctx.set_canonical_cache_root(root.clone());
9248
9249 assert_eq!(
9250 ctx.callgraph_store_dir(),
9251 storage
9252 .join("callgraph")
9253 .join(crate::search_index::artifact_cache_key(&root))
9254 );
9255 assert_eq!(
9256 ctx.inspect_dir(),
9257 storage
9258 .join("inspect")
9259 .join(crate::path_identity::project_scope_key(&root))
9260 );
9261 assert!(!ctx
9262 .callgraph_store_dir()
9263 .starts_with(storage.join("opencode")));
9264 assert!(!ctx.inspect_dir().starts_with(storage.join("opencode")));
9265 }
9266
9267 #[test]
9268 fn per_domain_capability_allows_inspect_writer_when_callgraph_read_only() {
9269 let storage = PathBuf::from("/tmp/cortexkit/aft");
9270 let ctx = ctx_with_storage_and_harness(storage, Harness::Opencode);
9271 ctx.set_cache_writer_capabilities(false, true);
9272
9273 assert!(ctx.shared_artifacts_read_only());
9274 assert!(!ctx.callgraph_writer());
9275 assert!(ctx.inspect_writer());
9276 }
9277}
9278
9279#[cfg(test)]
9280mod shared_db_tests {
9281 use super::*;
9282 use tempfile::tempdir;
9283
9284 #[test]
9285 fn app_contexts_share_one_database_connection() {
9286 let storage = tempdir().expect("storage tempdir");
9287 let root_one = tempdir().expect("first root tempdir");
9288 let root_two = tempdir().expect("second root tempdir");
9289 let app = App::default_shared();
9290 let ctx_one = AppContext::from_app(
9291 Arc::clone(&app),
9292 Config {
9293 project_root: Some(root_one.path().to_path_buf()),
9294 ..Config::default()
9295 },
9296 );
9297 let ctx_two = AppContext::from_app(
9298 Arc::clone(&app),
9299 Config {
9300 project_root: Some(root_two.path().to_path_buf()),
9301 ..Config::default()
9302 },
9303 );
9304 let path = storage.path().join("aft.db");
9305
9306 let first = app.open_db(&path).expect("open shared database");
9307 let second = app.open_db(&path).expect("reuse shared database");
9308
9309 assert!(Arc::ptr_eq(&first, &second));
9310 assert!(Arc::ptr_eq(
9311 &ctx_one.db().expect("first context database"),
9312 &ctx_two.db().expect("second context database")
9313 ));
9314 }
9315}
9316
9317#[cfg(test)]
9318mod gitignore_tests {
9319 use super::*;
9320 use std::fs;
9321 use std::path::Path;
9322 use tempfile::TempDir;
9323
9324 fn make_ctx_with_root(root: &Path) -> AppContext {
9325 let provider = Box::new(crate::parser::TreeSitterProvider::new());
9326 let config = Config {
9327 project_root: Some(root.to_path_buf()),
9328 ..Config::default()
9329 };
9330 AppContext::new(provider, config)
9331 }
9332
9333 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
9340 let Some(matcher) = ctx.gitignore() else {
9341 return false;
9342 };
9343 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
9344 if !canonical.starts_with(matcher.path()) {
9345 return false;
9346 }
9347 let is_dir = canonical.is_dir();
9348 matcher
9349 .matched_path_or_any_parents(&canonical, is_dir)
9350 .is_ignore()
9351 }
9352
9353 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
9366 let _guard = crate::test_env::process_env_lock();
9367 let tmp = TempDir::new().unwrap();
9368 let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
9369 let prev_home = std::env::var_os("HOME");
9370 let prev_userprofile = std::env::var_os("USERPROFILE");
9371 unsafe {
9374 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
9375 std::env::set_var("HOME", tmp.path());
9376 std::env::set_var("USERPROFILE", tmp.path());
9377 }
9378 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
9379 unsafe {
9380 match prev_xdg {
9381 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
9382 None => std::env::remove_var("XDG_CONFIG_HOME"),
9383 }
9384 match prev_home {
9385 Some(v) => std::env::set_var("HOME", v),
9386 None => std::env::remove_var("HOME"),
9387 }
9388 match prev_userprofile {
9389 Some(v) => std::env::set_var("USERPROFILE", v),
9390 None => std::env::remove_var("USERPROFILE"),
9391 }
9392 }
9393 match result {
9394 Ok(r) => r,
9395 Err(p) => std::panic::resume_unwind(p),
9396 }
9397 }
9398
9399 #[test]
9400 fn rebuild_gitignore_returns_none_without_project_root() {
9401 let provider = Box::new(crate::parser::TreeSitterProvider::new());
9402 let ctx = AppContext::new(provider, Config::default());
9403 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
9404 assert!(ctx.gitignore().is_none());
9405 }
9406
9407 #[test]
9408 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
9409 let tmp = TempDir::new().unwrap();
9410 let ctx = make_ctx_with_root(tmp.path());
9411 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
9412 assert!(ctx.gitignore().is_none());
9413 }
9414
9415 #[test]
9416 fn matcher_filters_files_in_ignored_dist_dir() {
9417 let tmp = TempDir::new().unwrap();
9418 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
9419 fs::create_dir_all(tmp.path().join("dist")).unwrap();
9420 fs::create_dir_all(tmp.path().join("src")).unwrap();
9421 let dist_file = tmp.path().join("dist").join("bundle.js");
9422 let src_file = tmp.path().join("src").join("app.ts");
9423 fs::write(&dist_file, "x").unwrap();
9424 fs::write(&src_file, "y").unwrap();
9425
9426 let ctx = make_ctx_with_root(tmp.path());
9427 ctx.rebuild_gitignore();
9428
9429 assert!(ctx.gitignore().is_some());
9430 assert!(
9431 is_ignored(&ctx, &dist_file),
9432 "dist/bundle.js should be ignored"
9433 );
9434 assert!(
9435 !is_ignored(&ctx, &src_file),
9436 "src/app.ts should NOT be ignored"
9437 );
9438 }
9439
9440 #[test]
9441 fn matcher_handles_node_modules_and_target() {
9442 let tmp = TempDir::new().unwrap();
9443 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
9444 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
9445 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
9446 let nm_file = tmp.path().join("node_modules/foo/index.js");
9447 let target_file = tmp.path().join("target/debug/aft");
9448 fs::write(&nm_file, "x").unwrap();
9449 fs::write(&target_file, "x").unwrap();
9450
9451 let ctx = make_ctx_with_root(tmp.path());
9452 ctx.rebuild_gitignore();
9453
9454 assert!(is_ignored(&ctx, &nm_file));
9455 assert!(is_ignored(&ctx, &target_file));
9456 }
9457
9458 #[test]
9459 fn matcher_honors_negation_pattern() {
9460 let tmp = TempDir::new().unwrap();
9462 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
9463 let random_log = tmp.path().join("random.log");
9464 let important_log = tmp.path().join("important.log");
9465 fs::write(&random_log, "x").unwrap();
9466 fs::write(&important_log, "y").unwrap();
9467
9468 let ctx = make_ctx_with_root(tmp.path());
9469 ctx.rebuild_gitignore();
9470
9471 assert!(is_ignored(&ctx, &random_log));
9472 assert!(
9473 !is_ignored(&ctx, &important_log),
9474 "negation pattern should un-ignore important.log"
9475 );
9476 }
9477
9478 #[test]
9479 fn rebuild_picks_up_gitignore_changes() {
9480 let tmp = TempDir::new().unwrap();
9481 let ignore_path = tmp.path().join(".gitignore");
9482 fs::write(&ignore_path, "foo.txt\n").unwrap();
9483 let foo = tmp.path().join("foo.txt");
9484 let bar = tmp.path().join("bar.txt");
9485 fs::write(&foo, "").unwrap();
9486 fs::write(&bar, "").unwrap();
9487
9488 let ctx = make_ctx_with_root(tmp.path());
9489 ctx.rebuild_gitignore();
9490 assert!(is_ignored(&ctx, &foo));
9491 assert!(!is_ignored(&ctx, &bar));
9492
9493 fs::write(&ignore_path, "bar.txt\n").unwrap();
9495 ctx.rebuild_gitignore();
9496 assert!(!is_ignored(&ctx, &foo));
9497 assert!(is_ignored(&ctx, &bar));
9498 }
9499
9500 #[test]
9501 fn gitignore_loads_info_exclude_when_present() {
9502 let tmp = TempDir::new().unwrap();
9503 let info_dir = tmp.path().join(".git/info");
9504 fs::create_dir_all(&info_dir).unwrap();
9505 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
9506 let secrets = tmp.path().join("secrets.txt");
9507 let public = tmp.path().join("public.txt");
9508 fs::write(&secrets, "token").unwrap();
9509 fs::write(&public, "ok").unwrap();
9510
9511 let ctx = make_ctx_with_root(tmp.path());
9512 ctx.rebuild_gitignore();
9513
9514 assert!(is_ignored(&ctx, &secrets));
9515 assert!(!is_ignored(&ctx, &public));
9516 }
9517
9518 #[test]
9519 fn matcher_picks_up_nested_gitignore() {
9520 let tmp = TempDir::new().unwrap();
9521 fs::write(tmp.path().join(".gitignore"), "").unwrap();
9523 let sub = tmp.path().join("packages/foo");
9524 fs::create_dir_all(&sub).unwrap();
9525 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
9526 let generated_file = sub.join("generated").join("out.js");
9527 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
9528 fs::write(&generated_file, "x").unwrap();
9529
9530 let ctx = make_ctx_with_root(tmp.path());
9531 ctx.rebuild_gitignore();
9532
9533 assert!(
9534 is_ignored(&ctx, &generated_file),
9535 "nested gitignore in packages/foo/.gitignore should ignore generated/"
9536 );
9537 }
9538}
9539
9540#[cfg(test)]
9541mod verify_memo_watcher_tests {
9542 use super::*;
9543
9544 #[test]
9545 fn pending_watcher_path_invalidates_root_verify_memo() {
9546 let root_dir = tempfile::tempdir().unwrap();
9547 let root = std::fs::canonicalize(root_dir.path()).unwrap();
9548 let artifact = root.join("cache.bin");
9549 std::fs::write(&artifact, b"generation").unwrap();
9550 let generation = crate::cache_freshness::artifact_generation(&artifact).unwrap();
9551 crate::cache_freshness::record_verify_completed(
9552 &root,
9553 crate::cache_freshness::VerifyArtifact::Search,
9554 Some(generation),
9555 );
9556 assert_eq!(
9557 crate::cache_freshness::warm_verify_plan(
9558 &root,
9559 crate::cache_freshness::VerifyArtifact::Search,
9560 Some(generation),
9561 ),
9562 crate::cache_freshness::WarmVerifyPlan::Skip
9563 );
9564
9565 let ctx = AppContext::from_app(
9566 App::default_shared(),
9567 Config {
9568 project_root: Some(root.clone()),
9569 ..Config::default()
9570 },
9571 );
9572 ctx.set_canonical_cache_root(root.clone());
9573 ctx.add_pending_search_index_paths([root.join("changed.rs")]);
9574 assert_eq!(
9575 crate::cache_freshness::warm_verify_plan(
9576 &root,
9577 crate::cache_freshness::VerifyArtifact::Search,
9578 Some(generation),
9579 ),
9580 crate::cache_freshness::WarmVerifyPlan::StatFirst
9581 );
9582 }
9583}
9584
9585#[cfg(test)]
9586mod watcher_runtime_state_tests {
9587 use super::*;
9588 use crate::language::StubProvider;
9589
9590 fn test_context() -> AppContext {
9591 AppContext::new(Box::new(StubProvider), Config::default())
9592 }
9593
9594 #[test]
9595 fn finished_watcher_thread_reports_inactive_and_is_reclaimed_with_invalidation() {
9596 let root = tempfile::tempdir().expect("project tempdir");
9597 let canonical_root = std::fs::canonicalize(root.path()).expect("canonical root");
9598 let ctx = AppContext::new(
9599 Box::new(StubProvider),
9600 Config {
9601 project_root: Some(canonical_root.clone()),
9602 ..Config::default()
9603 },
9604 );
9605 ctx.set_canonical_cache_root(canonical_root.clone());
9606 struct DisableWatcherGuard;
9610 impl Drop for DisableWatcherGuard {
9611 fn drop(&mut self) {
9612 unsafe { std::env::remove_var("AFT_TEST_DISABLE_FILE_WATCHER") };
9613 }
9614 }
9615 let _env_lock = crate::test_env::process_env_lock();
9616 unsafe { std::env::set_var("AFT_TEST_DISABLE_FILE_WATCHER", "1") };
9617 let _disable_watcher = DisableWatcherGuard;
9618 *ctx.search_index
9621 .write()
9622 .unwrap_or_else(std::sync::PoisonError::into_inner) =
9623 Some(crate::search_index::SearchIndex::new());
9624 let artifact = canonical_root.join("artifact.bin");
9625 std::fs::write(&artifact, b"artifact").expect("artifact");
9626 let generation = crate::cache_freshness::artifact_generation(&artifact);
9627 crate::cache_freshness::record_verify_completed(
9628 &canonical_root,
9629 crate::cache_freshness::VerifyArtifact::Search,
9630 generation,
9631 );
9632
9633 let (dispatch_tx, dispatch_rx) = crate::watcher_filter::watcher_dispatch_channel();
9634 let _dispatch_tx = dispatch_tx;
9635 let join = std::thread::spawn(|| {});
9638 ctx.install_watcher_runtime(
9639 dispatch_rx,
9640 WatcherThreadHandle::new(Arc::new(AtomicBool::new(false)), join),
9641 );
9642 let deadline = std::time::Instant::now() + Duration::from_secs(2);
9643 while ctx.watcher_runtime_active() {
9644 assert!(
9645 std::time::Instant::now() < deadline,
9646 "a finished watcher thread must report the runtime inactive"
9647 );
9648 std::thread::yield_now();
9649 }
9650
9651 crate::commands::configure::ensure_project_watcher(&ctx);
9654
9655 assert!(
9656 ctx.search_index
9657 .read()
9658 .unwrap_or_else(std::sync::PoisonError::into_inner)
9659 .is_none(),
9660 "corpse reclaim must drop resident artifacts (events since the failure are lost)"
9661 );
9662 assert_eq!(
9663 crate::cache_freshness::warm_verify_plan(
9664 &canonical_root,
9665 crate::cache_freshness::VerifyArtifact::Search,
9666 generation,
9667 ),
9668 crate::cache_freshness::WarmVerifyPlan::Strict,
9669 "corpse reclaim must force strict re-verification"
9670 );
9671 assert!(
9672 !ctx.take_finished_watcher_runtime(),
9673 "reclaim is one-shot; the corpse is gone after ensure_project_watcher"
9674 );
9675 }
9676
9677 #[test]
9678 fn watcher_runtime_requires_both_thread_and_dispatch_receiver() {
9679 let ctx = test_context();
9680 let (dispatch_tx, dispatch_rx) = crate::watcher_filter::watcher_dispatch_channel();
9681 let shutdown = Arc::new(AtomicBool::new(false));
9682 let thread_shutdown = Arc::clone(&shutdown);
9683 let join = std::thread::spawn(move || {
9684 while !thread_shutdown.load(Ordering::SeqCst) {
9685 std::thread::sleep(Duration::from_millis(1));
9686 }
9687 drop(dispatch_tx);
9688 });
9689 ctx.install_watcher_runtime(
9690 dispatch_rx,
9691 WatcherThreadHandle::new(Arc::clone(&shutdown), join),
9692 );
9693 assert!(ctx.watcher_runtime_active());
9694
9695 *ctx.watcher_rx.lock() = None;
9696 assert!(
9697 !ctx.watcher_runtime_active(),
9698 "a thread without its dispatch receiver is not a usable watcher runtime"
9699 );
9700 ctx.stop_watcher_runtime();
9701 }
9702}
9703
9704#[cfg(test)]
9705mod semantic_probe_tests {
9706 use super::*;
9707
9708 #[test]
9709 fn cleared_semantic_worker_invalidates_orphaned_probe_timer() {
9710 let root = tempfile::tempdir().unwrap();
9711 let ctx = AppContext::new(
9712 default_language_provider_factory(),
9713 Config {
9714 project_root: Some(root.path().to_path_buf()),
9715 ..Config::default()
9716 },
9717 );
9718 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
9719 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
9720 let worker_slot = Arc::new(Mutex::new(None));
9721 ctx.install_semantic_refresh_worker_for_build_epoch(
9722 request_tx,
9723 event_rx,
9724 worker_slot,
9725 ctx.semantic_index_rx_epoch(),
9726 );
9727
9728 ctx.ensure_semantic_refresh_probe_scheduled(Duration::from_millis(20));
9729 assert!(ctx.semantic_refresh_probe_is_scheduled());
9730 ctx.clear_semantic_refresh_worker();
9731 std::thread::sleep(Duration::from_millis(50));
9732
9733 assert!(!ctx.semantic_refresh_probe_ready());
9734 assert!(!ctx.semantic_refresh_probe_is_scheduled());
9735 assert!(!ctx.completion_drains_have_work());
9736 }
9737}