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