1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2use std::io::{self, BufWriter};
3use std::path::{Component, Path, PathBuf};
4use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, AtomicU8, AtomicUsize, Ordering};
5use std::sync::{mpsc, Arc, Mutex, RwLock, TryLockError, Weak};
6use std::time::{Duration, Instant, SystemTime};
7
8use crate::db::TrackedConnection;
9use lsp_types::FileChangeType;
10use notify::RecommendedWatcher;
11use serde::{Deserialize, 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::views::Manifest;
37use crate::watcher_filter::WatcherJoinOutcome;
38use crate::watcher_filter::{SharedGitignore, WatcherDispatchEvent, WatcherThreadHandle};
39
40pub type ProgressSender = Arc<Box<dyn Fn(PushFrame) + Send + Sync>>;
41pub type SharedProgressSender = Arc<Mutex<Option<ProgressSender>>>;
42pub type SharedStdoutWriter = Arc<Mutex<BufWriter<io::Stdout>>>;
43const STATUS_DEBOUNCE_MS: u64 = 1_000;
44
45fn canonicalize_lenient(path: &Path) -> Option<PathBuf> {
72 use std::path::Component;
73 if let Ok(canonical) = std::fs::canonicalize(path) {
74 return Some(canonical);
75 }
76 let mut resolved = PathBuf::new();
77 let mut missing: Vec<std::ffi::OsString> = Vec::new();
78 for component in path.components() {
79 match component {
80 Component::Prefix(_) | Component::RootDir => {
81 resolved.push(component.as_os_str());
82 if let Ok(canonical_anchor) = std::fs::canonicalize(&resolved) {
86 resolved = canonical_anchor;
87 }
88 }
89 Component::CurDir => {}
90 Component::ParentDir => {
91 if missing.pop().is_none() {
92 if !resolved.as_os_str().is_empty() && !resolved.is_dir() {
93 return None;
95 }
96 resolved.pop();
97 }
98 }
99 Component::Normal(name) => {
100 if missing.is_empty() {
101 let candidate = resolved.join(name);
102 match std::fs::canonicalize(&candidate) {
103 Ok(canonical) => resolved = canonical,
104 Err(_) => match std::fs::symlink_metadata(&candidate) {
105 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
108 missing.push(name.to_owned())
109 }
110 _ => return None,
113 },
114 }
115 } else {
116 missing.push(name.to_owned());
117 }
118 }
119 }
120 }
121 for name in missing {
122 resolved.push(name);
123 }
124 Some(resolved)
125}
126
127fn pending_path_in_roots(path: &Path, roots: &[PathBuf]) -> bool {
137 if path.is_relative() {
138 let has_prefix_or_root = path.components().next().is_some_and(|component| {
143 matches!(
144 component,
145 std::path::Component::Prefix(_) | std::path::Component::RootDir
146 )
147 });
148 if has_prefix_or_root {
149 return false;
150 }
151 return roots.iter().any(|root| {
154 let joined = root.join(path);
155 match (canonicalize_lenient(&joined), canonicalize_lenient(root)) {
156 (Some(path), Some(root)) => path.starts_with(&root),
157 _ => false,
158 }
159 });
160 }
161 let Some(canonical_path) = canonicalize_lenient(path) else {
162 return false;
163 };
164 roots.iter().any(|root| {
165 canonicalize_lenient(root)
166 .is_some_and(|canonical_root| canonical_path.starts_with(&canonical_root))
167 })
168}
169
170#[derive(Clone, Default)]
174pub(crate) struct SubcLifecycleAdmission {
175 unbound: Arc<parking_lot::Mutex<bool>>,
176}
177
178impl SubcLifecycleAdmission {
179 fn mark_bound(&self) {
180 *self.unbound.lock() = false;
181 }
182
183 fn mark_unbound(&self, configure_generation: &AtomicU64) {
184 let mut unbound = self.unbound.lock();
185 if !*unbound {
186 *unbound = true;
187 configure_generation.fetch_add(1, Ordering::SeqCst);
188 }
189 }
190
191 pub(crate) fn is_current(&self, generation: &AtomicU64, expected: u64) -> bool {
192 let unbound = self.unbound.lock();
193 !*unbound && generation.load(Ordering::SeqCst) == expected
194 }
195
196 fn advance_generation(&self, generation: &AtomicU64) -> u64 {
197 let _unbound = self.unbound.lock();
198 generation.fetch_add(1, Ordering::SeqCst).wrapping_add(1)
199 }
200
201 pub(crate) fn run_if_current<R>(
202 &self,
203 generation: &AtomicU64,
204 expected: u64,
205 action: impl FnOnce() -> R,
206 ) -> Option<R> {
207 let unbound = self.unbound.lock();
208 if *unbound || generation.load(Ordering::SeqCst) != expected {
209 return None;
210 }
211 Some(action())
212 }
213
214 pub(crate) fn is_bound(&self) -> bool {
215 !*self.unbound.lock()
216 }
217
218 fn try_is_bound(&self) -> Option<bool> {
219 self.unbound.try_lock().map(|unbound| !*unbound)
220 }
221
222 fn is_unbound(&self) -> bool {
223 !self.is_bound()
224 }
225
226 fn run_if_unbound<R>(&self, action: impl FnOnce() -> R) -> Option<R> {
227 let unbound = self.unbound.lock();
228 if !*unbound {
229 return None;
230 }
231 Some(action())
232 }
233}
234
235const GRACEFUL_SHUTDOWN_SEARCH_BUILD_WAIT: Duration = Duration::from_secs(5);
236const GRACEFUL_SHUTDOWN_SEARCH_BUILD_POLL: Duration = Duration::from_millis(10);
237
238#[derive(Debug, Clone, Default, PartialEq, Eq)]
242pub struct StatusBarCounts {
243 pub errors: usize,
244 pub warnings: usize,
245 pub dead_code: usize,
246 pub unused_exports: usize,
247 pub duplicates: usize,
248 pub todos: usize,
249 pub tier2_stale: bool,
250}
251
252#[derive(Debug, Clone, Default, PartialEq, Eq)]
255pub struct StatusBarCountValues {
256 pub errors: Option<usize>,
257 pub warnings: Option<usize>,
258 pub dead_code: Option<usize>,
259 pub unused_exports: Option<usize>,
260 pub duplicates: Option<usize>,
261 pub todos: Option<usize>,
262 pub tier2_stale: bool,
263}
264
265impl StatusBarCountValues {
266 fn legacy_projection(&self) -> Option<StatusBarCounts> {
267 let [Some(errors), Some(warnings), Some(dead_code), Some(unused_exports), Some(duplicates), Some(todos)] = [
268 self.errors,
269 self.warnings,
270 self.dead_code,
271 self.unused_exports,
272 self.duplicates,
273 self.todos,
274 ] else {
275 return None;
276 };
277
278 Some(StatusBarCounts {
279 errors,
280 warnings,
281 dead_code,
282 unused_exports,
283 duplicates,
284 todos,
285 tier2_stale: self.tier2_stale,
286 })
287 }
288}
289
290#[derive(Debug, Clone, Default)]
299struct StatusBarTier2 {
300 dead_code: Option<usize>,
301 unused_exports: Option<usize>,
302 duplicates: Option<usize>,
303 todos: Option<usize>,
304 stale: bool,
305 generation: u64,
306 dead_code_blocked_on_callgraph: bool,
313}
314
315#[derive(Debug, Clone, Default)]
316struct StatusBarCache {
317 valid: bool,
318 diagnostics_generation: u64,
319 tier2_generation: u64,
320 tsconfig_generation: u64,
321 counts: Option<StatusBarCountValues>,
322}
323
324#[derive(Debug, Default)]
328struct LegacyStatusBarEmission(RwLock<Option<StatusBarCounts>>);
329
330impl LegacyStatusBarEmission {
331 fn should_emit(&self, counts: &StatusBarCounts) -> bool {
332 let mut last = self
333 .0
334 .write()
335 .unwrap_or_else(std::sync::PoisonError::into_inner);
336 if last.as_ref() == Some(counts) {
337 return false;
338 }
339 *last = Some(counts.clone());
340 true
341 }
342
343 fn clear(&self) {
344 *self
345 .0
346 .write()
347 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
348 }
349}
350
351#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
352#[serde(rename_all = "snake_case")]
353pub enum RootHealthState {
354 Ready,
355 Busy,
356}
357
358#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
359pub struct HealthComponentSnapshot {
360 pub status: &'static str,
361}
362
363#[derive(Debug, Clone, Default)]
367pub struct SemanticBuildProgress {
368 embedded_chunks: Arc<AtomicUsize>,
369 total_chunks: Arc<AtomicUsize>,
370 current_batch: Arc<AtomicUsize>,
371 total_batches: Arc<AtomicUsize>,
372}
373
374#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
375pub struct SemanticBuildProgressSnapshot {
376 pub embedded_chunks: usize,
377 pub total_chunks: usize,
378 pub current_batch: usize,
379 pub total_batches: usize,
380}
381
382impl SemanticBuildProgress {
383 pub fn report(&self, embedded_chunks: usize, total_chunks: usize, batch_size: usize) {
384 let batch_size = batch_size.max(1);
385 let total_batches = total_chunks.div_ceil(batch_size);
386 self.total_chunks.store(total_chunks, Ordering::Relaxed);
387 self.embedded_chunks
388 .store(embedded_chunks.min(total_chunks), Ordering::Relaxed);
389 self.current_batch.store(
390 embedded_chunks.min(total_chunks).div_ceil(batch_size),
391 Ordering::Relaxed,
392 );
393 self.total_batches.store(total_batches, Ordering::Relaxed);
394 }
395
396 pub fn snapshot(&self) -> SemanticBuildProgressSnapshot {
397 SemanticBuildProgressSnapshot {
398 embedded_chunks: self.embedded_chunks.load(Ordering::Relaxed),
399 total_chunks: self.total_chunks.load(Ordering::Relaxed),
400 current_batch: self.current_batch.load(Ordering::Relaxed),
401 total_batches: self.total_batches.load(Ordering::Relaxed),
402 }
403 }
404}
405
406#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
407pub struct SemanticHealthComponentSnapshot {
408 pub status: &'static str,
409 #[serde(skip_serializing_if = "Option::is_none")]
410 pub stage: Option<String>,
411 #[serde(skip_serializing_if = "Option::is_none")]
412 pub embedded_chunks: Option<usize>,
413 #[serde(skip_serializing_if = "Option::is_none")]
414 pub total_chunks: Option<usize>,
415 #[serde(skip_serializing_if = "Option::is_none")]
416 pub current_batch: Option<usize>,
417 #[serde(skip_serializing_if = "Option::is_none")]
418 pub total_batches: Option<usize>,
419}
420
421#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
422pub struct ViewHealthSnapshot {
423 pub generation: u64,
424 pub pinned: bool,
425 pub pending_paths: usize,
426 pub failed_paths: usize,
427}
428
429#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
430pub struct Tier2HealthSnapshot {
431 pub status: &'static str,
432}
433
434#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
435pub struct SuspendedDomainHealthSnapshot {
436 pub domain: String,
437 pub reason: String,
438 pub death_count: u64,
439 pub age_s: u64,
440}
441
442#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
443pub struct RootHealthSnapshot {
444 pub project_root: String,
445 pub actor_count: usize,
446 pub state: RootHealthState,
447 #[serde(skip_serializing_if = "Option::is_none")]
448 pub search_index: Option<HealthComponentSnapshot>,
449 #[serde(skip_serializing_if = "Option::is_none")]
450 pub semantic_index: Option<SemanticHealthComponentSnapshot>,
451 #[serde(skip_serializing_if = "Option::is_none")]
452 pub callgraph_store: Option<HealthComponentSnapshot>,
453 #[serde(skip_serializing_if = "Option::is_none")]
454 pub callgraph_repair_entries_60s: Option<u64>,
455 #[serde(skip_serializing_if = "Option::is_none")]
456 pub callgraph_commits_60s: Option<u64>,
457 #[serde(skip_serializing_if = "Option::is_none")]
458 pub callgraph_pages_or_bytes_written_60s: Option<u64>,
459 #[serde(skip_serializing_if = "Option::is_none")]
460 pub views: Option<ViewHealthSnapshot>,
461 #[serde(skip_serializing_if = "Option::is_none")]
462 pub tier2: Option<Tier2HealthSnapshot>,
463 #[serde(skip_serializing_if = "Option::is_none")]
464 pub bash: Option<BgTaskHealthCounts>,
465 #[serde(skip_serializing_if = "Vec::is_empty")]
466 pub suspended_domains: Vec<SuspendedDomainHealthSnapshot>,
467}
468
469#[derive(Debug, Clone, PartialEq, Eq)]
470pub(crate) struct RootHealthSummary {
471 state: RootHealthState,
472 search_index_status: Option<&'static str>,
473 semantic_index: Option<SemanticHealthComponentSnapshot>,
474 callgraph_store_status: Option<&'static str>,
475 views: Option<ViewHealthSnapshot>,
476 tier2_status: Option<&'static str>,
477 bash: Option<BgTaskHealthCounts>,
478 suspended_domains: Vec<SuspendedDomainHealthSnapshot>,
479}
480
481impl RootHealthSummary {
482 fn busy() -> Self {
483 Self {
484 state: RootHealthState::Busy,
485 search_index_status: None,
486 semantic_index: None,
487 callgraph_store_status: None,
488 views: None,
489 tier2_status: None,
490 bash: None,
491 suspended_domains: Vec::new(),
492 }
493 }
494
495 pub(crate) fn is_busy(&self) -> bool {
496 matches!(self.state, RootHealthState::Busy)
497 }
498
499 pub(crate) fn is_fully_ready(&self) -> bool {
500 let component_is_satisfied = |status: &str| matches!(status, "ready" | "disabled");
501 matches!(self.state, RootHealthState::Ready)
502 && self.search_index_status.is_some_and(component_is_satisfied)
503 && self
504 .semantic_index
505 .as_ref()
506 .is_some_and(|semantic| component_is_satisfied(semantic.status))
507 && self
508 .callgraph_store_status
509 .is_some_and(component_is_satisfied)
510 && self
511 .views
512 .as_ref()
513 .is_none_or(|view| view.pinned && view.pending_paths == 0 && view.failed_paths == 0)
514 && self.tier2_status.is_some_and(component_is_satisfied)
515 }
516
517 pub(crate) fn into_snapshot(self, project_root: &Path) -> RootHealthSnapshot {
518 if self.is_busy() {
519 return RootHealthSnapshot::busy(project_root);
520 }
521 let callgraph_write_metrics =
525 crate::search_index::artifact_cache_key_memoized_only(project_root)
526 .map(|key| crate::callgraph_store::callgraph_write_metrics_for_project(&key));
527 let (callgraph_commits_60s, callgraph_pages_or_bytes_written_60s) =
528 match callgraph_write_metrics {
529 Some(metrics)
530 if metrics.commits_60s > 0 || metrics.pages_or_bytes_written_60s > 0 =>
531 {
532 (
533 Some(metrics.commits_60s),
534 Some(metrics.pages_or_bytes_written_60s),
535 )
536 }
537 _ => (None, None),
538 };
539 RootHealthSnapshot {
540 project_root: project_root.display().to_string(),
541 actor_count: 1,
542 state: self.state,
543 search_index: self
544 .search_index_status
545 .map(|status| HealthComponentSnapshot { status }),
546 semantic_index: self.semantic_index,
547 callgraph_store: self
548 .callgraph_store_status
549 .map(|status| HealthComponentSnapshot { status }),
550 callgraph_repair_entries_60s: None,
551 callgraph_commits_60s,
552 callgraph_pages_or_bytes_written_60s,
553 views: self.views,
554 tier2: self
555 .tier2_status
556 .map(|status| Tier2HealthSnapshot { status }),
557 bash: self.bash,
558 suspended_domains: self.suspended_domains,
559 }
560 }
561}
562
563impl RootHealthSnapshot {
564 fn busy(project_root: &Path) -> Self {
565 Self {
566 project_root: project_root.display().to_string(),
567 actor_count: 1,
568 state: RootHealthState::Busy,
569 search_index: None,
570 semantic_index: None,
571 callgraph_store: None,
572 callgraph_repair_entries_60s: None,
573 callgraph_commits_60s: None,
574 callgraph_pages_or_bytes_written_60s: None,
575 views: None,
576 tier2: None,
577 bash: None,
578 suspended_domains: Vec::new(),
579 }
580 }
581
582 pub fn is_fully_ready(&self) -> bool {
583 let component_is_satisfied =
584 |status: &HealthComponentSnapshot| matches!(status.status, "ready" | "disabled");
585 let tier2_is_satisfied =
586 |tier2: &Tier2HealthSnapshot| matches!(tier2.status, "ready" | "disabled");
587
588 matches!(self.state, RootHealthState::Ready)
589 && self
590 .search_index
591 .as_ref()
592 .is_some_and(component_is_satisfied)
593 && self
594 .semantic_index
595 .as_ref()
596 .is_some_and(|semantic| matches!(semantic.status, "ready" | "disabled"))
597 && self
598 .callgraph_store
599 .as_ref()
600 .is_some_and(component_is_satisfied)
601 && self
602 .views
603 .as_ref()
604 .is_none_or(|view| view.pinned && view.pending_paths == 0 && view.failed_paths == 0)
605 && self.tier2.as_ref().is_some_and(tier2_is_satisfied)
606 }
607}
608
609pub struct StatusEmitter {
610 latest: Arc<Mutex<Option<StatusPayload>>>,
611 notify: mpsc::Sender<()>,
612}
613
614#[derive(Clone, Debug, Default)]
615struct ConfigureWarmState {
616 generation: u64,
617 key: Option<String>,
618}
619
620#[derive(Debug)]
621struct ConfigurePhaseTiming {
622 phase: &'static str,
623 started_at: Instant,
624 completed: Vec<(&'static str, Duration)>,
625}
626
627impl Default for ConfigurePhaseTiming {
628 fn default() -> Self {
629 Self {
630 phase: "idle",
631 started_at: Instant::now(),
632 completed: Vec::new(),
633 }
634 }
635}
636
637#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
638pub(crate) enum WatcherDrainApplyPhase {
639 #[default]
640 PendingTier2,
641 PendingIndexes,
642 SymbolCache,
643 Callgraph,
644 SearchIndex,
645 SemanticIndex,
646 LspDiagnostics,
647 Complete,
648}
649
650#[derive(Debug, Default)]
651pub(crate) enum WatcherDrainPhase {
652 #[default]
653 Collect,
654 Apply {
655 stage: WatcherDrainApplyPhase,
656 paths: VecDeque<PathBuf>,
657 remaining: usize,
658 oversized_inline_batch: bool,
659 },
660}
661
662#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
663pub(crate) struct WatcherOverflowPrefix {
664 pub(crate) prefix: String,
665 pub(crate) count: u64,
666}
667
668#[derive(Debug, Clone)]
669pub(crate) struct WatcherBackendExclusions {
670 pub(crate) matcher_generation: u64,
671 pub(crate) paths: Vec<PathBuf>,
672 pub(crate) queue_depth: Option<usize>,
673}
674
675impl Default for WatcherBackendExclusions {
676 fn default() -> Self {
677 Self {
678 matcher_generation: 0,
679 paths: Vec::new(),
680 queue_depth: None,
681 }
682 }
683}
684
685#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
686pub(crate) struct WatcherCountersSnapshot {
687 pub(crate) raw_events_total: u64,
688 pub(crate) raw_events_since_last_rescan: u64,
689 pub(crate) invalidating_events_total: u64,
690 pub(crate) invalidating_events_since_last_rescan: u64,
691 pub(crate) paths_after_gitignore_total: u64,
692 pub(crate) paths_after_gitignore_since_last_rescan: u64,
693 pub(crate) paths_dispatched_total: u64,
694 pub(crate) paths_dispatched_since_last_rescan: u64,
695 pub(crate) overflows_total: u64,
696 pub(crate) overflows_during_rescan: u64,
697 pub(crate) last_overflow_prefixes: Vec<WatcherOverflowPrefix>,
698 pub(crate) rescans_kernel_dropped_total: u64,
699 pub(crate) rescans_user_dropped_total: u64,
700 pub(crate) rescans_unknown_total: u64,
701 pub(crate) last_rescan_at_ms: Option<u64>,
702 pub(crate) last_rescan_cost_ms: Option<u64>,
703 pub(crate) last_rescan_rss_delta_bytes: Option<i64>,
704}
705
706const WATCHER_RESCAN_IDLE: u8 = 0;
710const WATCHER_RESCAN_RUNNING: u8 = 1;
711const WATCHER_RESCAN_RERUN: u8 = 2;
712
713#[derive(Debug, Default)]
714pub(crate) struct WatcherCounters {
715 raw_events_total: AtomicU64,
716 raw_events_since_last_rescan: AtomicU64,
717 invalidating_events_total: AtomicU64,
718 invalidating_events_since_last_rescan: AtomicU64,
719 paths_after_gitignore_total: AtomicU64,
720 paths_after_gitignore_since_last_rescan: AtomicU64,
721 paths_dispatched_total: AtomicU64,
722 paths_dispatched_since_last_rescan: AtomicU64,
723 overflows_total: AtomicU64,
724 overflows_during_rescan: AtomicU64,
725 last_overflow_prefixes: RwLock<Vec<WatcherOverflowPrefix>>,
726 observed_exclusion_prefixes: RwLock<Vec<WatcherOverflowPrefix>>,
727 backend_exclusions: RwLock<WatcherBackendExclusions>,
728 rescan_state: AtomicU8,
729 rescan_again_reason: AtomicU8,
730 rescans_kernel_dropped_total: AtomicU64,
731 rescans_user_dropped_total: AtomicU64,
732 rescans_unknown_total: AtomicU64,
733 last_rescan_at_ms: AtomicU64,
734 last_rescan_cost_ms: AtomicU64,
735 last_rescan_rss_delta_bytes: AtomicI64,
736 last_rescan_rss_delta_known: AtomicBool,
737}
738
739#[derive(Debug, Clone, Copy, PartialEq, Eq)]
740pub(crate) struct WatcherRescanInterval {
741 pub(crate) raw_events: u64,
742}
743
744impl WatcherCounters {
745 pub(crate) fn note_raw_event(&self) {
746 self.raw_events_total.fetch_add(1, Ordering::Relaxed);
747 self.raw_events_since_last_rescan
748 .fetch_add(1, Ordering::Relaxed);
749 }
750
751 pub(crate) fn note_invalidating_event(&self) {
752 self.invalidating_events_total
753 .fetch_add(1, Ordering::Relaxed);
754 self.invalidating_events_since_last_rescan
755 .fetch_add(1, Ordering::Relaxed);
756 }
757
758 pub(crate) fn note_paths_after_gitignore(&self, count: usize) {
759 let count = count as u64;
760 self.paths_after_gitignore_total
761 .fetch_add(count, Ordering::Relaxed);
762 self.paths_after_gitignore_since_last_rescan
763 .fetch_add(count, Ordering::Relaxed);
764 }
765
766 pub(crate) fn note_paths_dispatched(&self, count: usize) {
767 let count = count as u64;
768 self.paths_dispatched_total
769 .fetch_add(count, Ordering::Relaxed);
770 self.paths_dispatched_since_last_rescan
771 .fetch_add(count, Ordering::Relaxed);
772 }
773
774 pub(crate) fn note_overflow(
775 &self,
776 reason: crate::watcher_filter::RescanReason,
777 prefixes: Vec<WatcherOverflowPrefix>,
778 ) -> bool {
779 self.overflows_total.fetch_add(1, Ordering::Relaxed);
780 *self
781 .last_overflow_prefixes
782 .write()
783 .unwrap_or_else(std::sync::PoisonError::into_inner) = prefixes;
784
785 let reason = match reason {
786 crate::watcher_filter::RescanReason::KernelDropped => 1,
787 crate::watcher_filter::RescanReason::UserDropped => 2,
788 crate::watcher_filter::RescanReason::Unknown => 3,
789 };
790 loop {
791 let state = self.rescan_state.load(Ordering::Acquire);
792 if state == WATCHER_RESCAN_IDLE {
793 return false;
794 }
795 self.rescan_again_reason.store(reason, Ordering::Release);
796 if self
797 .rescan_state
798 .compare_exchange(
799 state,
800 WATCHER_RESCAN_RERUN,
801 Ordering::AcqRel,
802 Ordering::Acquire,
803 )
804 .is_ok()
805 {
806 self.overflows_during_rescan.fetch_add(1, Ordering::Relaxed);
807 return true;
808 }
809 }
810 }
811
812 pub(crate) fn start_rescan(&self) {
813 let _ = self.rescan_state.compare_exchange(
814 WATCHER_RESCAN_IDLE,
815 WATCHER_RESCAN_RUNNING,
816 Ordering::AcqRel,
817 Ordering::Acquire,
818 );
819 }
820
821 pub(crate) fn finish_rescan_walk(&self) -> Option<crate::watcher_filter::RescanReason> {
822 loop {
823 match self.rescan_state.load(Ordering::Acquire) {
824 WATCHER_RESCAN_RERUN => {
825 if self
826 .rescan_state
827 .compare_exchange(
828 WATCHER_RESCAN_RERUN,
829 WATCHER_RESCAN_RUNNING,
830 Ordering::AcqRel,
831 Ordering::Acquire,
832 )
833 .is_ok()
834 {
835 return Some(match self.rescan_again_reason.load(Ordering::Acquire) {
836 1 => crate::watcher_filter::RescanReason::KernelDropped,
837 2 => crate::watcher_filter::RescanReason::UserDropped,
838 _ => crate::watcher_filter::RescanReason::Unknown,
839 });
840 }
841 }
842 WATCHER_RESCAN_RUNNING => {
843 if self
844 .rescan_state
845 .compare_exchange(
846 WATCHER_RESCAN_RUNNING,
847 WATCHER_RESCAN_IDLE,
848 Ordering::AcqRel,
849 Ordering::Acquire,
850 )
851 .is_ok()
852 {
853 return None;
854 }
855 }
856 _ => return None,
857 }
858 }
859 }
860
861 #[cfg(test)]
862 pub(crate) fn rescan_in_progress(&self) -> bool {
863 self.rescan_state.load(Ordering::Acquire) != WATCHER_RESCAN_IDLE
864 }
865
866 pub(crate) fn set_observed_exclusion_prefixes(&self, prefixes: Vec<WatcherOverflowPrefix>) {
867 *self
868 .observed_exclusion_prefixes
869 .write()
870 .unwrap_or_else(std::sync::PoisonError::into_inner) = prefixes;
871 }
872
873 pub(crate) fn observed_exclusion_prefixes(&self) -> Vec<WatcherOverflowPrefix> {
874 self.observed_exclusion_prefixes
875 .read()
876 .unwrap_or_else(std::sync::PoisonError::into_inner)
877 .clone()
878 }
879
880 #[cfg_attr(windows, allow(dead_code))]
883 pub(crate) fn set_backend_exclusions(&self, matcher_generation: u64, paths: Vec<PathBuf>) {
884 *self
885 .backend_exclusions
886 .write()
887 .unwrap_or_else(std::sync::PoisonError::into_inner) = WatcherBackendExclusions {
888 matcher_generation,
889 paths,
890 queue_depth: None,
891 };
892 }
893
894 pub(crate) fn backend_exclusions(&self) -> WatcherBackendExclusions {
895 self.backend_exclusions
896 .read()
897 .unwrap_or_else(std::sync::PoisonError::into_inner)
898 .clone()
899 }
900
901 pub(crate) fn begin_rescan(
902 &self,
903 reason: crate::watcher_filter::RescanReason,
904 ) -> WatcherRescanInterval {
905 match reason {
906 crate::watcher_filter::RescanReason::KernelDropped => {
907 &self.rescans_kernel_dropped_total
908 }
909 crate::watcher_filter::RescanReason::UserDropped => &self.rescans_user_dropped_total,
910 crate::watcher_filter::RescanReason::Unknown => &self.rescans_unknown_total,
911 }
912 .fetch_add(1, Ordering::Relaxed);
913
914 let raw_events = self.raw_events_since_last_rescan.swap(0, Ordering::Relaxed);
915 self.invalidating_events_since_last_rescan
916 .swap(0, Ordering::Relaxed);
917 self.paths_after_gitignore_since_last_rescan
918 .swap(0, Ordering::Relaxed);
919 self.paths_dispatched_since_last_rescan
920 .swap(0, Ordering::Relaxed);
921 WatcherRescanInterval { raw_events }
922 }
923
924 pub(crate) fn finish_rescan(&self, cost_ms: u64, rss_delta_bytes: Option<i64>) {
925 self.last_rescan_cost_ms.store(cost_ms, Ordering::Relaxed);
926 if let Some(delta) = rss_delta_bytes {
927 self.last_rescan_rss_delta_bytes
928 .store(delta, Ordering::Relaxed);
929 self.last_rescan_rss_delta_known
930 .store(true, Ordering::Relaxed);
931 } else {
932 self.last_rescan_rss_delta_known
933 .store(false, Ordering::Relaxed);
934 }
935 let at_ms = SystemTime::now()
936 .duration_since(std::time::UNIX_EPOCH)
937 .unwrap_or_default()
938 .as_millis()
939 .min(u64::MAX as u128) as u64;
940 self.last_rescan_at_ms.store(at_ms, Ordering::Release);
941 }
942
943 pub(crate) fn snapshot(&self) -> WatcherCountersSnapshot {
944 let last_rescan_at_ms = self.last_rescan_at_ms.load(Ordering::Acquire);
945 WatcherCountersSnapshot {
946 raw_events_total: self.raw_events_total.load(Ordering::Relaxed),
947 raw_events_since_last_rescan: self.raw_events_since_last_rescan.load(Ordering::Relaxed),
948 invalidating_events_total: self.invalidating_events_total.load(Ordering::Relaxed),
949 invalidating_events_since_last_rescan: self
950 .invalidating_events_since_last_rescan
951 .load(Ordering::Relaxed),
952 paths_after_gitignore_total: self.paths_after_gitignore_total.load(Ordering::Relaxed),
953 paths_after_gitignore_since_last_rescan: self
954 .paths_after_gitignore_since_last_rescan
955 .load(Ordering::Relaxed),
956 paths_dispatched_total: self.paths_dispatched_total.load(Ordering::Relaxed),
957 paths_dispatched_since_last_rescan: self
958 .paths_dispatched_since_last_rescan
959 .load(Ordering::Relaxed),
960 overflows_total: self.overflows_total.load(Ordering::Relaxed),
961 overflows_during_rescan: self.overflows_during_rescan.load(Ordering::Relaxed),
962 last_overflow_prefixes: self
963 .last_overflow_prefixes
964 .read()
965 .unwrap_or_else(std::sync::PoisonError::into_inner)
966 .clone(),
967 rescans_kernel_dropped_total: self.rescans_kernel_dropped_total.load(Ordering::Relaxed),
968 rescans_user_dropped_total: self.rescans_user_dropped_total.load(Ordering::Relaxed),
969 rescans_unknown_total: self.rescans_unknown_total.load(Ordering::Relaxed),
970 last_rescan_at_ms: (last_rescan_at_ms != 0).then_some(last_rescan_at_ms),
971 last_rescan_cost_ms: (last_rescan_at_ms != 0)
972 .then(|| self.last_rescan_cost_ms.load(Ordering::Relaxed)),
973 last_rescan_rss_delta_bytes: (last_rescan_at_ms != 0
974 && self.last_rescan_rss_delta_known.load(Ordering::Relaxed))
975 .then(|| self.last_rescan_rss_delta_bytes.load(Ordering::Relaxed)),
976 }
977 }
978}
979
980static WATCHER_COUNTERS_BY_ROOT: std::sync::OnceLock<
981 Mutex<BTreeMap<PathBuf, Arc<WatcherCounters>>>,
982> = std::sync::OnceLock::new();
983
984pub(crate) fn watcher_counters_for_root(root: &Path) -> Arc<WatcherCounters> {
985 let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
986 let registry = WATCHER_COUNTERS_BY_ROOT.get_or_init(|| Mutex::new(BTreeMap::new()));
987 let mut registry = registry
988 .lock()
989 .unwrap_or_else(std::sync::PoisonError::into_inner);
990 if let Some(counters) = registry.get(&root) {
991 return Arc::clone(counters);
992 }
993 let counters = Arc::new(WatcherCounters::default());
994 registry.insert(root, Arc::clone(&counters));
995 counters
996}
997
998#[derive(Debug)]
999pub(crate) struct WatcherDrainSliceState {
1000 pub(crate) configure_generation: u64,
1001 pub(crate) configure_content_generation: u64,
1007 pub(crate) phase: WatcherDrainPhase,
1008 pub(crate) pending_paths: VecDeque<PathBuf>,
1009 pub(crate) ignore_changed: bool,
1010 pub(crate) rescan_required: bool,
1011 pub(crate) rescan_reason: crate::watcher_filter::RescanReason,
1012 pub(crate) status_changed: bool,
1013 pub(crate) scheduler_changed_path_count: usize,
1014 pub(crate) semantic_refresh_paths: Vec<PathBuf>,
1015 pub(crate) view_publication_paths: BTreeSet<PathBuf>,
1016 pub(crate) view_publication_due: Option<Instant>,
1017 pub(crate) path_slice_count: usize,
1018}
1019
1020pub(crate) struct PendingReconciliationState {
1024 search: BTreeSet<PathBuf>,
1025 callgraph: BTreeSet<PathBuf>,
1026 tier2: BTreeSet<PathBuf>,
1027 semantic: BTreeSet<PathBuf>,
1028 corpus_refresh: bool,
1029}
1030
1031impl WatcherDrainSliceState {
1032 pub(crate) fn new(configure_generation: u64, configure_content_generation: u64) -> Self {
1033 Self {
1034 configure_generation,
1035 configure_content_generation,
1036 phase: WatcherDrainPhase::Collect,
1037 pending_paths: VecDeque::new(),
1038 ignore_changed: false,
1039 rescan_required: false,
1040 rescan_reason: crate::watcher_filter::RescanReason::Unknown,
1041 status_changed: false,
1042 scheduler_changed_path_count: 0,
1043 semantic_refresh_paths: Vec::new(),
1044 view_publication_paths: BTreeSet::new(),
1045 view_publication_due: None,
1046 path_slice_count: 0,
1047 }
1048 }
1049
1050 pub(crate) fn has_pending_work(&self) -> bool {
1051 !matches!(self.phase, WatcherDrainPhase::Collect)
1052 || !self.pending_paths.is_empty()
1053 || self.ignore_changed
1054 || self.rescan_required
1055 }
1056}
1057
1058#[doc(hidden)]
1059pub enum CallGraphStoreBuildEvent {
1060 Ready {
1061 store: CallGraphStore,
1062 fulfilled_force_token: Option<u64>,
1063 publication_epoch: u64,
1064 },
1065 Denied {
1066 reason: String,
1067 },
1068 Suspended {
1069 suspension: crate::build_breaker::BuildSuspension,
1070 },
1071 Settled,
1072}
1073
1074struct CallGraphStoreBuildSettlement {
1075 tx: crossbeam_channel::Sender<CallGraphStoreBuildEvent>,
1076 sent: bool,
1077 force_token: Option<u64>,
1078 publication_epoch: u64,
1079}
1080
1081impl CallGraphStoreBuildSettlement {
1082 fn new(
1083 tx: crossbeam_channel::Sender<CallGraphStoreBuildEvent>,
1084 force_token: Option<u64>,
1085 publication_epoch: u64,
1086 ) -> Self {
1087 Self {
1088 tx,
1089 sent: false,
1090 force_token,
1091 publication_epoch,
1092 }
1093 }
1094
1095 fn ready(&mut self, store: CallGraphStore) {
1096 let _ = self.tx.send(CallGraphStoreBuildEvent::Ready {
1097 store,
1098 fulfilled_force_token: self.force_token,
1099 publication_epoch: self.publication_epoch,
1100 });
1101 self.sent = true;
1102 }
1103
1104 fn denied(&mut self, reason: String) {
1105 let _ = self.tx.send(CallGraphStoreBuildEvent::Denied { reason });
1106 self.sent = true;
1107 }
1108
1109 fn suspended(&mut self, suspension: crate::build_breaker::BuildSuspension) {
1110 let _ = self
1111 .tx
1112 .send(CallGraphStoreBuildEvent::Suspended { suspension });
1113 self.sent = true;
1114 }
1115}
1116
1117impl Drop for CallGraphStoreBuildSettlement {
1118 fn drop(&mut self) {
1119 if !self.sent {
1120 let _ = self.tx.send(CallGraphStoreBuildEvent::Settled);
1121 }
1122 }
1123}
1124
1125#[derive(Clone, Debug)]
1126pub(crate) struct ViewRuntimeSnapshot {
1127 pub(crate) query_pin: Option<Arc<crate::pins::QueryPin>>,
1128 pub(crate) storage: PathBuf,
1129 pub(crate) family: String,
1130 pub(crate) scope: String,
1131 pub(crate) view_dir: PathBuf,
1132 pub(crate) generation: Option<String>,
1133 pub(crate) manifest: Option<Manifest>,
1134 pub(crate) pending_paths: BTreeSet<Vec<u8>>,
1135}
1136
1137#[derive(Debug)]
1138struct ViewRuntimeState {
1139 snapshot: ViewRuntimeSnapshot,
1140 pin: Option<Arc<crate::pins::QueryPin>>,
1141}
1142
1143pub(crate) struct PreparedViewUpdate {
1144 snapshot: Option<ViewRuntimeSnapshot>,
1145 pin: Option<Arc<crate::pins::QueryPin>>,
1146 retired: Option<ViewRuntimeState>,
1147 assembly: crate::views::assembly::PreparedAssembly,
1148 pub(crate) content_generation: u64,
1149}
1150
1151#[derive(Clone, Debug)]
1152pub(crate) struct ConfigureMaintenanceJob {
1153 pub(crate) generation: u64,
1154 pub(crate) root_path: PathBuf,
1155 pub(crate) canonical_cache_root: PathBuf,
1156 pub(crate) harness: Harness,
1157 pub(crate) storage_root: PathBuf,
1158 pub(crate) harness_dir: PathBuf,
1159 pub(crate) session_id: String,
1160 pub(crate) home_match: bool,
1161 pub(crate) format_tool_cache_clear_needed: bool,
1162 pub(crate) run_bash_replay: bool,
1163 pub(crate) refresh_project_runtime: bool,
1164 pub(crate) sync_bash_compress_flag: bool,
1165 pub(crate) reset_filter_registry: bool,
1166 pub(crate) clear_failed_spawns: bool,
1167 pub(crate) warm_callgraph_store: bool,
1168 pub(crate) supersede_search_artifact_persistence: bool,
1172 pub(crate) supersede_callgraph_artifact_persistence: bool,
1175 pub(crate) supersede_semantic_artifact_persistence: bool,
1179 pub(crate) search_artifact_load_start: Option<crossbeam_channel::Sender<()>>,
1182 pub(crate) semantic_artifact_load_start: Option<crossbeam_channel::Sender<()>>,
1185}
1186
1187impl StatusEmitter {
1188 fn new(progress_sender: SharedProgressSender) -> Self {
1189 let (notify, rx) = mpsc::channel();
1190 let latest = Arc::new(Mutex::new(None));
1191 let latest_for_thread = Arc::clone(&latest);
1192 std::thread::spawn(move || {
1193 status_debounce_loop(rx, latest_for_thread, progress_sender);
1194 });
1195 Self { latest, notify }
1196 }
1197
1198 pub fn signal(&self, snapshot: StatusPayload) {
1199 if let Ok(mut latest) = self.latest.lock() {
1200 *latest = Some(snapshot);
1201 }
1202 let _ = self.notify.send(());
1203 }
1204}
1205
1206fn status_debounce_loop(
1207 rx: mpsc::Receiver<()>,
1208 latest: Arc<Mutex<Option<StatusPayload>>>,
1209 progress_sender: SharedProgressSender,
1210) {
1211 while rx.recv().is_ok() {
1212 let deadline = Instant::now() + Duration::from_millis(STATUS_DEBOUNCE_MS);
1213 while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
1214 match rx.recv_timeout(remaining) {
1215 Ok(()) => continue,
1216 Err(mpsc::RecvTimeoutError::Timeout) => break,
1217 Err(mpsc::RecvTimeoutError::Disconnected) => return,
1218 }
1219 }
1220
1221 let snapshot = latest.lock().ok().and_then(|mut latest| latest.take());
1222 let Some(snapshot) = snapshot else { continue };
1223 let sender = progress_sender
1224 .lock()
1225 .ok()
1226 .and_then(|sender| sender.clone());
1227 if let Some(sender) = sender {
1228 sender(PushFrame::StatusChanged(StatusChangedFrame::new(
1229 None, snapshot,
1230 )));
1231 }
1232 }
1233}
1234use crate::cache_freshness::FileFreshness;
1235use crate::search_index::SearchIndex;
1236use crate::semantic_index::{EmbeddingEntry, SemanticIndex};
1237
1238#[derive(Debug, Default, Clone)]
1242#[doc(hidden)]
1243pub struct SemanticRefreshAccounting {
1244 #[doc(hidden)]
1245 pub pending: usize,
1246 #[doc(hidden)]
1247 pub in_flight: usize,
1248}
1249
1250#[derive(Debug, Default)]
1251struct SemanticRefreshCircuit {
1252 consecutive_transient_failures: AtomicUsize,
1253 open: AtomicBool,
1254 probe_in_flight: AtomicBool,
1255 probe_ready: AtomicBool,
1256 probe_token: AtomicU64,
1257}
1258
1259#[derive(Clone, Copy, Debug, Default)]
1260pub(crate) struct SemanticColdSeedResume {
1261 request_tier2: bool,
1262}
1263
1264fn ensure_refreshing_path(refreshing: &mut Vec<PathBuf>, path: PathBuf) {
1265 if !refreshing.iter().any(|existing| existing == &path) {
1266 refreshing.push(path);
1267 refreshing.sort();
1268 }
1269}
1270
1271fn remove_refreshing_path(refreshing: &mut Vec<PathBuf>, path: &Path) {
1272 refreshing.retain(|existing| existing != path);
1273}
1274
1275#[derive(Debug, Clone)]
1276pub enum SemanticIndexStatus {
1277 Disabled,
1278 Building {
1279 stage: String,
1281 files: Option<usize>,
1282 entries_done: Option<usize>,
1283 entries_total: Option<usize>,
1284 },
1285 Ready {
1286 refreshing: Vec<PathBuf>,
1289 #[doc(hidden)]
1293 accounting: BTreeMap<PathBuf, SemanticRefreshAccounting>,
1294 },
1295 Failed(String),
1296}
1297
1298impl SemanticIndexStatus {
1299 pub fn ready() -> Self {
1300 Self::Ready {
1301 refreshing: Vec::new(),
1302 accounting: BTreeMap::new(),
1303 }
1304 }
1305
1306 pub fn add_refreshing_file(&mut self, path: PathBuf) {
1307 if let Self::Ready {
1308 refreshing,
1309 accounting,
1310 } = self
1311 {
1312 let state = accounting.entry(path.clone()).or_default();
1313 state.pending = state.pending.saturating_add(1);
1314 ensure_refreshing_path(refreshing, path);
1315 }
1316 }
1317
1318 pub fn start_refreshing_file(&mut self, path: PathBuf) {
1319 if let Self::Ready {
1320 refreshing,
1321 accounting,
1322 } = self
1323 {
1324 let state = accounting.entry(path.clone()).or_default();
1325 if state.pending == 0 {
1326 state.pending = 1;
1327 }
1328 if state.in_flight == 0 {
1329 state.in_flight = state.pending;
1330 }
1331 ensure_refreshing_path(refreshing, path);
1332 }
1333 }
1334
1335 pub fn cancel_refreshing_file(&mut self, path: &Path) {
1336 self.finish_refreshing_file(path, false);
1337 }
1338
1339 pub fn take_refreshing_files(&mut self) -> Vec<PathBuf> {
1343 if let Self::Ready {
1344 refreshing,
1345 accounting,
1346 } = self
1347 {
1348 accounting.clear();
1349 std::mem::take(refreshing)
1350 } else {
1351 Vec::new()
1352 }
1353 }
1354
1355 pub fn corpus_refresh_in_flight(&self) -> bool {
1357 matches!(self, Self::Building { stage, .. } if stage == "refreshing_corpus")
1358 }
1359
1360 pub fn complete_refreshing_file(&mut self, path: &Path) {
1361 self.finish_refreshing_file(path, true);
1362 }
1363
1364 pub fn remove_refreshing_file(&mut self, path: &Path) {
1365 self.complete_refreshing_file(path);
1366 }
1367
1368 fn finish_refreshing_file(&mut self, path: &Path, complete_in_flight: bool) {
1369 if let Self::Ready {
1370 refreshing,
1371 accounting,
1372 } = self
1373 {
1374 let mut keep_refreshing = false;
1375 if let Some(state) = accounting.get_mut(path) {
1376 let finished = if complete_in_flight {
1377 state.in_flight.max(1)
1378 } else {
1379 1
1380 };
1381 state.pending = state.pending.saturating_sub(finished);
1382 if complete_in_flight {
1383 state.in_flight = 0;
1384 } else {
1385 state.in_flight = state.in_flight.min(state.pending);
1386 }
1387 keep_refreshing = state.pending > 0;
1388 if !keep_refreshing {
1389 accounting.remove(path);
1390 }
1391 }
1392
1393 if !keep_refreshing {
1394 remove_refreshing_path(refreshing, path);
1395 }
1396 }
1397 }
1398
1399 pub fn refreshing_count(&self) -> usize {
1400 match self {
1401 Self::Ready { refreshing, .. } => refreshing.len(),
1402 _ => 0,
1403 }
1404 }
1405}
1406
1407pub enum SemanticIndexEvent {
1408 Progress {
1409 stage: String,
1410 files: Option<usize>,
1411 entries_done: Option<usize>,
1412 entries_total: Option<usize>,
1413 },
1414 ColdSeedGateCleared,
1419 Ready(SemanticIndex),
1420 Failed(String),
1421}
1422
1423#[derive(Debug, Clone)]
1424pub enum SemanticRefreshRequest {
1425 Files {
1426 paths: Vec<PathBuf>,
1427 },
1428 Corpus,
1432}
1433
1434#[derive(Debug)]
1435pub enum SemanticRefreshEvent {
1436 Started {
1437 paths: Vec<PathBuf>,
1438 },
1439 CorpusStarted {
1440 files: usize,
1441 },
1442 Completed {
1443 added_entries: Vec<EmbeddingEntry>,
1444 updated_metadata: Vec<(PathBuf, FileFreshness)>,
1445 completed_paths: Vec<PathBuf>,
1446 },
1447 CorpusCompleted {
1448 index: SemanticIndex,
1449 changed: usize,
1450 added: usize,
1451 deleted: usize,
1452 total_processed: usize,
1453 },
1454 Failed {
1455 paths: Vec<PathBuf>,
1456 error: String,
1457 },
1458 CorpusFailed {
1459 paths: Vec<PathBuf>,
1462 error: String,
1463 },
1464}
1465
1466pub(crate) struct ReceiverTerminalGuard {
1467 terminal_epoch: Arc<AtomicU64>,
1468 epoch: u64,
1469}
1470
1471impl ReceiverTerminalGuard {
1472 fn new(terminal_epoch: Arc<AtomicU64>, epoch: u64) -> Self {
1473 Self {
1474 terminal_epoch,
1475 epoch,
1476 }
1477 }
1478}
1479
1480impl Drop for ReceiverTerminalGuard {
1481 fn drop(&mut self) {
1482 self.terminal_epoch.fetch_max(self.epoch, Ordering::SeqCst);
1483 }
1484}
1485
1486pub type SemanticRefreshWorkerSlot = Arc<Mutex<Option<std::thread::JoinHandle<()>>>>;
1487
1488struct PathRestrictionContext {
1489 raw_root: PathBuf,
1490 resolved_root: PathBuf,
1491 path_for_resolution: PathBuf,
1492}
1493
1494struct PathRestrictionRootMemo {
1499 configured_root: PathBuf,
1500 resolved_root: PathBuf,
1501}
1502
1503fn normalize_path(path: &Path) -> PathBuf {
1507 let mut result = PathBuf::new();
1508 for component in path.components() {
1509 match component {
1510 Component::ParentDir => {
1511 if !result.pop() {
1513 result.push(component);
1514 }
1515 }
1516 Component::CurDir => {} _ => result.push(component),
1518 }
1519 }
1520 result
1521}
1522
1523fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
1524 let mut existing = path.to_path_buf();
1525 let mut tail_segments = Vec::new();
1526
1527 while !existing.exists() {
1528 if let Some(name) = existing.file_name() {
1529 tail_segments.push(name.to_owned());
1530 } else {
1531 break;
1532 }
1533
1534 existing = match existing.parent() {
1535 Some(parent) => parent.to_path_buf(),
1536 None => break,
1537 };
1538 }
1539
1540 let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
1541 for segment in tail_segments.into_iter().rev() {
1542 resolved.push(segment);
1543 }
1544
1545 resolved
1546}
1547
1548fn path_error_response(
1549 req_id: &str,
1550 path: &Path,
1551 resolved_root: &Path,
1552) -> crate::protocol::Response {
1553 crate::protocol::Response::error(
1554 req_id,
1555 "path_outside_root",
1556 format!(
1557 "path '{}' is outside the project root '{}'",
1558 path.display(),
1559 resolved_root.display()
1560 ),
1561 )
1562}
1563
1564fn reject_escaping_symlink(
1574 req_id: &str,
1575 original_path: &Path,
1576 candidate: &Path,
1577 resolved_root: &Path,
1578 raw_root: &Path,
1579) -> Result<(), crate::protocol::Response> {
1580 let mut current = PathBuf::new();
1581
1582 for component in candidate.components() {
1583 current.push(component);
1584
1585 let Ok(metadata) = std::fs::symlink_metadata(¤t) else {
1586 continue;
1587 };
1588
1589 if !metadata.file_type().is_symlink() {
1590 continue;
1591 }
1592
1593 let inside_root = current.starts_with(resolved_root) || current.starts_with(raw_root);
1602 if !inside_root {
1603 continue;
1604 }
1605
1606 iterative_follow_chain(req_id, original_path, ¤t, resolved_root)?;
1607 }
1608
1609 Ok(())
1610}
1611
1612fn iterative_follow_chain(
1615 req_id: &str,
1616 original_path: &Path,
1617 start: &Path,
1618 resolved_root: &Path,
1619) -> Result<(), crate::protocol::Response> {
1620 let mut link = start.to_path_buf();
1621 let mut depth = 0usize;
1622
1623 loop {
1624 if depth > 40 {
1625 return Err(path_error_response(req_id, original_path, resolved_root));
1626 }
1627
1628 let target = match std::fs::read_link(&link) {
1629 Ok(t) => t,
1630 Err(_) => {
1631 return Err(path_error_response(req_id, original_path, resolved_root));
1633 }
1634 };
1635
1636 let resolved_target = if target.is_absolute() {
1637 normalize_path(&target)
1638 } else {
1639 let parent = link.parent().unwrap_or_else(|| Path::new(""));
1640 normalize_path(&parent.join(&target))
1641 };
1642
1643 let canonical_target =
1647 std::fs::canonicalize(&resolved_target).unwrap_or_else(|_| resolved_target.clone());
1648
1649 if !canonical_target.starts_with(resolved_root)
1650 && !resolved_target.starts_with(resolved_root)
1651 {
1652 return Err(path_error_response(req_id, original_path, resolved_root));
1653 }
1654
1655 match std::fs::symlink_metadata(&resolved_target) {
1657 Ok(meta) if meta.file_type().is_symlink() => {
1658 link = resolved_target;
1659 depth += 1;
1660 }
1661 _ => break, }
1663 }
1664
1665 Ok(())
1666}
1667
1668pub type LanguageProviderFactory = fn() -> Box<dyn LanguageProvider>;
1669
1670pub fn default_language_provider_factory() -> Box<dyn LanguageProvider> {
1671 Box::new(TreeSitterProvider::new())
1672}
1673
1674fn database_path_key(path: &Path) -> PathBuf {
1675 if let Ok(canonical) = std::fs::canonicalize(path) {
1676 return canonical;
1677 }
1678 let Some(parent) = path.parent() else {
1679 return path.to_path_buf();
1680 };
1681 let canonical_parent = std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf());
1682 path.file_name()
1683 .map(|name| canonical_parent.join(name))
1684 .unwrap_or_else(|| canonical_parent.join(path))
1685}
1686
1687pub struct App {
1692 db: parking_lot::Mutex<Option<(PathBuf, Arc<Mutex<TrackedConnection>>)>>,
1696 lifecycle_census: crate::lifecycle_census::LifecycleCensusCache,
1697 active_watchers: AtomicUsize,
1698 active_actor_roots: AtomicUsize,
1699 open_routes: AtomicUsize,
1700 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry,
1701 stdout_writer: SharedStdoutWriter,
1702 provider_factory: LanguageProviderFactory,
1703 memory_contexts: parking_lot::Mutex<BTreeMap<PathBuf, Weak<AppContext>>>,
1706}
1707
1708impl App {
1709 pub fn new(provider_factory: LanguageProviderFactory) -> Self {
1710 Self {
1711 db: parking_lot::Mutex::new(None),
1712 lifecycle_census: crate::lifecycle_census::LifecycleCensusCache::default(),
1713 active_watchers: AtomicUsize::new(0),
1714 active_actor_roots: AtomicUsize::new(0),
1715 open_routes: AtomicUsize::new(0),
1716 lsp_child_registry: crate::lsp::child_registry::LspChildRegistry::new(),
1717 stdout_writer: Arc::new(Mutex::new(BufWriter::new(io::stdout()))),
1718 provider_factory,
1719 memory_contexts: parking_lot::Mutex::new(BTreeMap::new()),
1720 }
1721 }
1722
1723 pub fn shared(provider_factory: LanguageProviderFactory) -> Arc<Self> {
1725 Arc::new(Self::new(provider_factory))
1726 }
1727
1728 pub fn default_shared() -> Arc<Self> {
1729 Self::shared(default_language_provider_factory)
1730 }
1731
1732 pub fn create_provider(&self) -> Box<dyn LanguageProvider> {
1733 (self.provider_factory)()
1734 }
1735
1736 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
1737 self.lsp_child_registry.clone()
1738 }
1739
1740 pub(crate) fn publish_lifecycle_census(
1741 &self,
1742 snapshot: crate::lifecycle_census::LifecycleCensusSnapshot,
1743 ) {
1744 self.lifecycle_census.publish(snapshot);
1745 }
1746
1747 pub(crate) fn lifecycle_census_snapshot(
1748 &self,
1749 ) -> crate::lifecycle_census::LifecycleCensusSnapshot {
1750 self.lifecycle_census.snapshot()
1751 }
1752
1753 pub fn stdout_writer(&self) -> SharedStdoutWriter {
1754 Arc::clone(&self.stdout_writer)
1755 }
1756
1757 pub(crate) fn register_memory_context(&self, root: PathBuf, ctx: &Arc<AppContext>) {
1758 let mut contexts = self.memory_contexts.lock();
1759 contexts.retain(|_, context| context.strong_count() > 0);
1760 contexts.insert(root, Arc::downgrade(ctx));
1761 }
1762
1763 pub(crate) fn unregister_memory_context(&self, root: &Path, ctx: &Arc<AppContext>) {
1764 let mut contexts = self.memory_contexts.lock();
1765 let removes_current = contexts
1766 .get(root)
1767 .and_then(Weak::upgrade)
1768 .is_some_and(|registered| Arc::ptr_eq(®istered, ctx));
1769 if removes_current {
1770 contexts.remove(root);
1771 }
1772 }
1773
1774 pub(crate) fn try_memory_contexts(&self) -> Option<Vec<(PathBuf, Arc<AppContext>)>> {
1777 let contexts = self.memory_contexts.try_lock()?;
1778 Some(
1779 contexts
1780 .iter()
1781 .filter_map(|(root, context)| {
1782 context.upgrade().map(|context| (root.clone(), context))
1783 })
1784 .collect(),
1785 )
1786 }
1787
1788 pub(crate) fn adopt_resident_semantic_index(
1789 &self,
1790 artifact_cache_key: &str,
1791 borrower_root: &Path,
1792 semantic_config: &crate::config::SemanticBackendConfig,
1793 ) -> Option<SemanticIndex> {
1794 let contexts = {
1795 let mut contexts = self.memory_contexts.lock();
1796 contexts.retain(|_, context| context.strong_count() > 0);
1797 contexts
1798 .iter()
1799 .filter_map(|(root, context)| {
1800 context.upgrade().map(|context| (root.clone(), context))
1801 })
1802 .collect::<Vec<_>>()
1803 };
1804
1805 let normalized_borrower = crate::inspect::job::canonicalize_normalized(borrower_root);
1809 contexts
1810 .into_iter()
1811 .filter_map(|(registered_root, context)| {
1812 let normalized_registered =
1813 crate::inspect::job::canonicalize_normalized(®istered_root);
1814 if normalized_registered == normalized_borrower {
1815 return None;
1816 }
1817 let cache_root = context.canonical_cache_root_opt()?;
1818 if normalized_registered
1819 != crate::inspect::job::canonicalize_normalized(&cache_root)
1820 {
1821 return None;
1822 }
1823 Some((cache_root, context))
1824 })
1825 .find_map(|(cache_root, context)| {
1826 if context.cached_artifact_cache_key(&cache_root).as_deref()
1827 != Some(artifact_cache_key)
1828 || !matches!(
1829 &*context
1830 .semantic_index_status()
1831 .read()
1832 .unwrap_or_else(std::sync::PoisonError::into_inner),
1833 SemanticIndexStatus::Ready { refreshing, .. } if refreshing.is_empty()
1834 )
1835 {
1836 return None;
1837 }
1838 context
1839 .semantic_index()
1840 .write()
1841 .unwrap_or_else(std::sync::PoisonError::into_inner)
1842 .as_mut()?
1843 .adopt_frozen_base_for_root(borrower_root, semantic_config)
1844 })
1845 }
1846
1847 pub fn open_db(
1852 &self,
1853 path: &Path,
1854 ) -> Result<Arc<Mutex<TrackedConnection>>, crate::db::OpenError> {
1855 let key = database_path_key(path);
1856 let mut slot = self.db.lock();
1857 if let Some((existing_path, conn)) = slot.as_ref() {
1858 if existing_path == &key {
1859 return Ok(Arc::clone(conn));
1860 }
1861 }
1862
1863 let conn = Arc::new(Mutex::new(crate::db::open(path)?));
1864 *slot = Some((key, Arc::clone(&conn)));
1865 Ok(conn)
1866 }
1867
1868 pub fn set_db(&self, conn: Arc<Mutex<TrackedConnection>>) {
1869 *self.db.lock() = Some((PathBuf::new(), conn));
1870 }
1871
1872 pub fn clear_db(&self) {
1873 *self.db.lock() = None;
1874 }
1875
1876 pub fn clear_db_for_path(&self, path: &Path) {
1880 let key = database_path_key(path);
1881 let mut slot = self.db.lock();
1882 if slot.as_ref().is_some_and(|(existing_path, _)| {
1883 existing_path.as_os_str().is_empty() || existing_path == &key
1884 }) {
1885 *slot = None;
1886 }
1887 }
1888
1889 pub fn db(&self) -> Option<Arc<Mutex<TrackedConnection>>> {
1890 self.db.lock().as_ref().map(|(_, conn)| Arc::clone(conn))
1891 }
1892
1893 pub(crate) fn watcher_started(&self) {
1894 self.active_watchers.fetch_add(1, Ordering::SeqCst);
1895 }
1896
1897 pub(crate) fn watcher_stopped(&self) {
1898 self.active_watchers
1899 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
1900 Some(count.saturating_sub(1))
1901 })
1902 .ok();
1903 }
1904
1905 pub fn watcher_count(&self) -> usize {
1908 self.active_watchers.load(Ordering::SeqCst)
1909 }
1910
1911 pub(crate) fn actor_root_registered(&self) {
1912 self.active_actor_roots.fetch_add(1, Ordering::SeqCst);
1913 }
1914
1915 pub(crate) fn actor_root_unregistered(&self) {
1916 self.active_actor_roots
1917 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
1918 Some(count.saturating_sub(1))
1919 })
1920 .ok();
1921 }
1922
1923 pub fn actor_root_count(&self) -> usize {
1924 self.active_actor_roots.load(Ordering::SeqCst)
1925 }
1926
1927 pub(crate) fn set_open_route_count(&self, count: usize) {
1928 self.open_routes.store(count, Ordering::SeqCst);
1929 }
1930
1931 pub fn open_route_count(&self) -> usize {
1932 self.open_routes.load(Ordering::SeqCst)
1933 }
1934}
1935
1936impl Default for App {
1937 fn default() -> Self {
1938 Self::new(default_language_provider_factory)
1939 }
1940}
1941
1942const _: fn() = || {
1943 fn assert_send_sync<T: Send + Sync>() {}
1944 fn assert_send<T: Send>() {}
1945
1946 assert_send_sync::<App>();
1947 assert_send_sync::<AppContext>();
1948 assert_send::<crate::lsp::manager::LspManager>();
1949 assert_send::<crate::semantic_index::EmbeddingModel>();
1950};
1951
1952#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1953enum GitEntryKind {
1954 Missing,
1955 File,
1956 Directory,
1957 Other,
1958}
1959
1960#[derive(Clone, Debug, PartialEq, Eq)]
1961struct GitEntrySignature {
1962 kind: GitEntryKind,
1963 modified: Option<SystemTime>,
1964}
1965
1966#[derive(Clone, Debug)]
1967struct WorktreeBridgeCacheEntry {
1968 git_entry: GitEntrySignature,
1969 is_worktree_bridge: bool,
1970 git_common_dir: Option<PathBuf>,
1971}
1972
1973pub(crate) const BORROWED_INDEX_CACHE_CAPACITY: usize = 4;
1974
1975#[derive(Clone, Debug, PartialEq, Eq)]
1976struct BorrowedIndexCacheKey {
1977 canonical_root: PathBuf,
1978 artifact: crate::readonly_artifacts::BorrowedArtifactGeneration,
1979}
1980
1981#[derive(Clone, Debug)]
1982enum BorrowedIndexCacheValue {
1983 Search(crate::readonly_artifacts::ReadOnlyArtifact<Arc<SearchIndex>>),
1984 Semantic(crate::readonly_artifacts::ReadOnlyArtifact<Arc<SemanticIndex>>),
1985}
1986
1987#[derive(Debug, Default)]
1988struct BorrowedIndexCache {
1989 entries: VecDeque<(BorrowedIndexCacheKey, BorrowedIndexCacheValue)>,
1990 resolved_roots: VecDeque<(PathBuf, GitEntrySignature)>,
1991}
1992
1993impl BorrowedIndexCache {
1994 fn search(
1995 &mut self,
1996 key: &BorrowedIndexCacheKey,
1997 ) -> Option<crate::readonly_artifacts::ReadOnlyArtifact<Arc<SearchIndex>>> {
1998 let position = self.entries.iter().position(|(candidate, value)| {
1999 candidate == key && matches!(value, BorrowedIndexCacheValue::Search(_))
2000 })?;
2001 let entry = self.entries.remove(position)?;
2002 let BorrowedIndexCacheValue::Search(index) = &entry.1 else {
2003 return None;
2004 };
2005 let index = (*index).clone();
2006 self.entries.push_back(entry);
2007 Some(index)
2008 }
2009
2010 fn semantic(
2011 &mut self,
2012 key: &BorrowedIndexCacheKey,
2013 ) -> Option<crate::readonly_artifacts::ReadOnlyArtifact<Arc<SemanticIndex>>> {
2014 let position = self.entries.iter().position(|(candidate, value)| {
2015 candidate == key && matches!(value, BorrowedIndexCacheValue::Semantic(_))
2016 })?;
2017 let entry = self.entries.remove(position)?;
2018 let BorrowedIndexCacheValue::Semantic(index) = &entry.1 else {
2019 return None;
2020 };
2021 let index = (*index).clone();
2022 self.entries.push_back(entry);
2023 Some(index)
2024 }
2025
2026 fn insert(&mut self, key: BorrowedIndexCacheKey, value: BorrowedIndexCacheValue) {
2027 self.entries.retain(|(candidate, _)| {
2028 candidate.canonical_root != key.canonical_root
2029 || candidate.artifact.path != key.artifact.path
2030 });
2031 self.entries.push_back((key, value));
2032 while self.entries.len() > BORROWED_INDEX_CACHE_CAPACITY {
2033 self.entries.pop_front();
2034 }
2035 }
2036
2037 fn resolved_root(&mut self, requested_root: &Path) -> Option<PathBuf> {
2038 let position = self
2039 .resolved_roots
2040 .iter()
2041 .position(|(candidate, _)| candidate == requested_root)?;
2042 let entry = self.resolved_roots.remove(position)?;
2043 if entry.1 != git_entry_signature(requested_root) {
2044 return None;
2045 }
2046 let root = entry.0.clone();
2047 self.resolved_roots.push_back(entry);
2048 Some(root)
2049 }
2050
2051 fn remember_resolved_root(&mut self, root: PathBuf) {
2052 self.resolved_roots
2053 .retain(|(candidate, _)| candidate != &root);
2054 let signature = git_entry_signature(&root);
2055 self.resolved_roots.push_back((root, signature));
2056 while self.resolved_roots.len() > BORROWED_INDEX_CACHE_CAPACITY {
2057 self.resolved_roots.pop_front();
2058 }
2059 }
2060
2061 fn clear(&mut self) {
2062 self.entries.clear();
2063 self.resolved_roots.clear();
2064 }
2065}
2066
2067fn git_entry_signature(project_root: &Path) -> GitEntrySignature {
2068 match std::fs::symlink_metadata(project_root.join(".git")) {
2069 Ok(metadata) => GitEntrySignature {
2070 kind: if metadata.file_type().is_file() {
2071 GitEntryKind::File
2072 } else if metadata.file_type().is_dir() {
2073 GitEntryKind::Directory
2074 } else {
2075 GitEntryKind::Other
2076 },
2077 modified: metadata.modified().ok(),
2078 },
2079 Err(error) if error.kind() == io::ErrorKind::NotFound => GitEntrySignature {
2080 kind: GitEntryKind::Missing,
2081 modified: None,
2082 },
2083 Err(_) => GitEntrySignature {
2084 kind: GitEntryKind::Other,
2085 modified: None,
2086 },
2087 }
2088}
2089
2090struct WatcherRuntimeIdentity {
2091 root: PathBuf,
2092 gitignore_generation: u64,
2093 #[cfg(test)]
2094 thread_id: Option<std::thread::ThreadId>,
2095}
2096
2097pub struct AppContext {
2109 app: Arc<App>,
2110 provider: Box<dyn LanguageProvider>,
2111 backup: parking_lot::Mutex<BackupStore>,
2112 checkpoint: parking_lot::Mutex<CheckpointStore>,
2113 config: RwLock<Arc<Config>>,
2114 last_request_at: parking_lot::Mutex<Instant>,
2117 path_restriction_root_memo: parking_lot::Mutex<Option<PathRestrictionRootMemo>>,
2121 #[cfg(test)]
2122 path_restriction_root_canonicalizations: AtomicUsize,
2123 force_restrict_requests: parking_lot::Mutex<BTreeMap<String, usize>>,
2124 pub harness: parking_lot::Mutex<Option<Harness>>,
2125 canonical_cache_root: parking_lot::Mutex<Option<PathBuf>>,
2126 is_worktree_bridge: parking_lot::Mutex<bool>,
2127 git_common_dir: parking_lot::Mutex<Option<PathBuf>>,
2128 shared_artifacts_read_only: AtomicBool,
2129 daemonless_query_mode: AtomicBool,
2132 callgraph_writer: AtomicBool,
2133 inspect_writer: AtomicBool,
2134 artifact_owner_status: parking_lot::Mutex<Option<ArtifactOwnerStatus>>,
2135 artifact_owner_lease: parking_lot::Mutex<Option<ArtifactOwnerLeaseRegistration>>,
2136 degraded_reasons: parking_lot::Mutex<Vec<String>>,
2143 heavy_root_work_allowed: Arc<AtomicBool>,
2148 standing_artifact_exempt: AtomicBool,
2151 cold_build_limiter: RwLock<Arc<crate::cold_build_limiter::ColdBuildLimiter>>,
2152 view_runtime: RwLock<Option<ViewRuntimeState>>,
2153 callgraph_store: Arc<RwLock<Option<Arc<ReadonlyCallGraphStore>>>>,
2154 callgraph_store_force_requested: AtomicU64,
2155 callgraph_store_force_fulfilled: AtomicU64,
2156 callgraph_store_rx:
2157 parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>>,
2158 callgraph_store_rx_generation: AtomicU64,
2159 callgraph_store_rx_epoch: AtomicU64,
2160 callgraph_store_build_denied: parking_lot::Mutex<Option<(u64, String)>>,
2161 callgraph_store_build_suspension:
2162 parking_lot::Mutex<Option<(u64, crate::build_breaker::BuildSuspension)>>,
2163 health_build_suspensions: RwLock<Vec<SuspendedDomainHealthSnapshot>>,
2167 callgraph_persist_epoch: crate::root_cache::ArtifactPublishEpoch,
2168 callgraph_legacy_migration_summary_logged: Arc<AtomicBool>,
2169 pending_callgraph_store_paths: crate::callgraph_store::PendingCallGraphStorePaths,
2170 search_index: RwLock<Option<SearchIndex>>,
2171 search_exact_memo: Arc<crate::commands::semantic_search::memo::ExactMemoStore>,
2172 search_index_rx: RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>>,
2173 search_index_rx_generation: AtomicU64,
2174 search_index_rx_epoch: AtomicU64,
2175 search_index_rx_terminal_epoch: Arc<AtomicU64>,
2176 search_index_disconnect_reschedule: parking_lot::Mutex<(u64, u32, Option<Instant>)>,
2184 search_persist_epoch: crate::root_cache::ArtifactPublishEpoch,
2185 pending_search_index_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
2186 symbol_cache: SharedSymbolCache,
2187 inspect_manager: Arc<InspectManager>,
2188 tier2_refresh_scheduler: parking_lot::Mutex<Tier2RefreshScheduler>,
2189 pending_tier2_paths: parking_lot::Mutex<BTreeSet<PathBuf>>,
2190 semantic_index: RwLock<Option<SemanticIndex>>,
2191 semantic_index_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>>,
2192 semantic_index_rx_generation: AtomicU64,
2193 semantic_index_rx_epoch: AtomicU64,
2194 semantic_index_rx_terminal_epoch: Arc<AtomicU64>,
2195 semantic_persist_epoch: crate::root_cache::ArtifactPublishEpoch,
2196 semantic_persist_lock: Arc<parking_lot::Mutex<()>>,
2197 semantic_index_status: RwLock<SemanticIndexStatus>,
2198 semantic_build_progress: RwLock<Option<SemanticBuildProgress>>,
2201 semantic_build_epoch: Arc<AtomicU64>,
2204 artifact_reload_lock: parking_lot::Mutex<()>,
2207 semantic_cold_seed_active: Arc<AtomicBool>,
2211 semantic_cold_seed_generation: Arc<AtomicU64>,
2214 semantic_fingerprint_generation: Arc<AtomicU64>,
2215 pending_semantic_index_paths: Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>,
2216 pending_semantic_corpus_refresh: parking_lot::Mutex<bool>,
2217 semantic_refresh_tx:
2218 Arc<parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>>,
2219 semantic_refresh_event_rx:
2220 parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>>,
2221 semantic_refresh_generation: AtomicU64,
2222 semantic_refresh_epoch: AtomicU64,
2223 semantic_refresh_build_epoch: AtomicU64,
2224 semantic_refresh_worker: parking_lot::Mutex<Option<SemanticRefreshWorkerSlot>>,
2225 semantic_refresh_retry_attempts: parking_lot::Mutex<BTreeMap<PathBuf, usize>>,
2226 semantic_refresh_circuit: Arc<SemanticRefreshCircuit>,
2227 semantic_embedding_model: parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>>,
2228 watcher_runtime_lock: parking_lot::Mutex<()>,
2229 watcher: parking_lot::Mutex<Option<RecommendedWatcher>>,
2230 watcher_rx: parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>>,
2231 watcher_drain_slice: parking_lot::Mutex<Option<WatcherDrainSliceState>>,
2232 watcher_thread: parking_lot::Mutex<Option<WatcherThreadHandle>>,
2233 watcher_runtime_identity: parking_lot::Mutex<Option<WatcherRuntimeIdentity>>,
2234 watcher_counters: RwLock<Arc<WatcherCounters>>,
2235 lsp_manager: parking_lot::Mutex<LspManager>,
2236 configure_generation: Arc<AtomicU64>,
2237 configure_content_generation: Arc<AtomicU64>,
2241 subc_lifecycle: SubcLifecycleAdmission,
2244 configure_warm_state: parking_lot::Mutex<ConfigureWarmState>,
2245 callgraph_build_key: parking_lot::Mutex<Option<String>>,
2249 configure_phase_timing: parking_lot::Mutex<ConfigurePhaseTiming>,
2250 configured_session_roots: parking_lot::Mutex<BTreeSet<(PathBuf, String)>>,
2251 hashline_bindings: crate::hashline::integration::BindingRegistry,
2252 configure_maintenance_jobs: parking_lot::Mutex<VecDeque<ConfigureMaintenanceJob>>,
2253 artifact_cache_keys: parking_lot::Mutex<BTreeMap<PathBuf, String>>,
2254 artifact_cache_key_derivations: AtomicU64,
2255 borrowed_index_cache: parking_lot::Mutex<BorrowedIndexCache>,
2256 worktree_bridge_cache: parking_lot::Mutex<BTreeMap<PathBuf, WorktreeBridgeCacheEntry>>,
2259 #[cfg(test)]
2260 worktree_bridge_probe_spawns: AtomicU64,
2261 #[cfg(test)]
2262 force_worktree_bridge_reprobe: AtomicBool,
2263 last_seen_reuse_completions: AtomicU64,
2267 configure_warnings_tx: crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)>,
2268 configure_warnings_rx: crossbeam_channel::Receiver<(u64, ConfigureWarningsFrame)>,
2269 progress_sender: SharedProgressSender,
2272 status_emitter: StatusEmitter,
2273 fleet_status_client: RwLock<Option<crate::fleet_status::FleetStatusClient>>,
2276 status_bar_last_emitted: LegacyStatusBarEmission,
2279 status_bar_cached: RwLock<StatusBarCache>,
2282 alert_state: parking_lot::Mutex<AlertDeltaState>,
2285 repeat_breaker: crate::response_finalize::repeat_breaker::RepeatBreaker,
2286 compression_aggregates: Arc<crate::db::compression_events::CompressionAggregateCache>,
2287 bash_background: BgTaskRegistry,
2288 #[cfg(unix)]
2289 escalation_grants: parking_lot::Mutex<crate::sandbox_spawn::EscalationGrantStore>,
2290 filter_registry: crate::compress::SharedFilterRegistry,
2297 filter_registry_rebuild_count: AtomicU64,
2298 filter_registry_loaded: std::sync::atomic::AtomicBool,
2301 bash_compress_flag: Arc<std::sync::atomic::AtomicBool>,
2306 gitignore: SharedGitignore,
2313 gitignore_generation: Arc<AtomicU64>,
2314 status_bar_tier2: RwLock<StatusBarTier2>,
2318 tsconfig_membership:
2325 parking_lot::Mutex<crate::lsp::tsconfig_membership::TsconfigMembershipCache>,
2326}
2327
2328pub struct ForceRestrictGuard<'a> {
2334 ctx: &'a AppContext,
2335 req_id: String,
2336}
2337
2338impl Drop for ForceRestrictGuard<'_> {
2339 fn drop(&mut self) {
2340 self.ctx.release_force_restrict(&self.req_id);
2341 }
2342}
2343
2344impl Drop for AppContext {
2345 fn drop(&mut self) {
2346 self.artifact_owner_lease.get_mut().take();
2347 if let Some(runtime) = self.watcher_thread.get_mut().take() {
2348 let root = self
2349 .canonical_cache_root
2350 .get_mut()
2351 .clone()
2352 .or_else(|| {
2353 self.config
2354 .get_mut()
2355 .unwrap_or_else(std::sync::PoisonError::into_inner)
2356 .project_root
2357 .clone()
2358 })
2359 .unwrap_or_else(|| PathBuf::from("<unconfigured>"));
2360 Self::spawn_watcher_shutdown(Arc::clone(&self.app), root, runtime);
2361 }
2362 }
2363}
2364
2365pub enum CallgraphStoreAccess {
2373 Ready(Arc<ReadonlyCallGraphStore>),
2375 Building,
2377 Suspended(crate::build_breaker::BuildSuspension),
2380 Unavailable,
2382 Error(CallGraphStoreError),
2384}
2385
2386#[derive(Clone, Copy)]
2387enum CallgraphBackgroundWork {
2388 Ensure,
2389 ForceRebuild(u64),
2390 LegacyMigration,
2391}
2392
2393#[cfg(test)]
2394struct CallgraphBuildStartGate {
2395 root: PathBuf,
2396 reached: crossbeam_channel::Sender<()>,
2397 release: crossbeam_channel::Receiver<()>,
2398}
2399
2400#[cfg(test)]
2401static CALLGRAPH_BUILD_START_GATE: std::sync::OnceLock<
2402 parking_lot::Mutex<Option<CallgraphBuildStartGate>>,
2403> = std::sync::OnceLock::new();
2404
2405#[cfg(test)]
2406fn install_callgraph_build_start_gate(
2407 root: PathBuf,
2408) -> (
2409 crossbeam_channel::Receiver<()>,
2410 crossbeam_channel::Sender<()>,
2411) {
2412 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
2413 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
2414 *CALLGRAPH_BUILD_START_GATE
2415 .get_or_init(|| parking_lot::Mutex::new(None))
2416 .lock() = Some(CallgraphBuildStartGate {
2417 root,
2418 reached: reached_tx,
2419 release: release_rx,
2420 });
2421 (reached_rx, release_tx)
2422}
2423
2424#[cfg(test)]
2425pub(crate) fn install_callgraph_build_start_gate_for_test(
2426 root: PathBuf,
2427) -> (
2428 crossbeam_channel::Receiver<()>,
2429 crossbeam_channel::Sender<()>,
2430) {
2431 install_callgraph_build_start_gate(root)
2432}
2433
2434#[cfg(test)]
2435static CALLGRAPH_BUILD_WAIT_MS_LOCK: std::sync::OnceLock<std::sync::Mutex<()>> =
2436 std::sync::OnceLock::new();
2437
2438#[cfg(test)]
2439pub(crate) struct CallgraphBuildWaitMsGuard {
2440 _guard: std::sync::MutexGuard<'static, ()>,
2441 previous: Option<std::ffi::OsString>,
2442}
2443
2444#[cfg(test)]
2445impl Drop for CallgraphBuildWaitMsGuard {
2446 fn drop(&mut self) {
2447 unsafe {
2450 match &self.previous {
2451 Some(value) => std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", value),
2452 None => std::env::remove_var("AFT_CALLGRAPH_BUILD_WAIT_MS"),
2453 }
2454 }
2455 }
2456}
2457
2458#[cfg(test)]
2461pub(crate) fn override_callgraph_build_wait_ms_for_test(ms: u64) -> CallgraphBuildWaitMsGuard {
2462 let guard = crate::test_env::lock_test_mutex(
2463 CALLGRAPH_BUILD_WAIT_MS_LOCK.get_or_init(|| std::sync::Mutex::new(())),
2464 );
2465 let previous = std::env::var_os("AFT_CALLGRAPH_BUILD_WAIT_MS");
2466 unsafe {
2468 std::env::set_var("AFT_CALLGRAPH_BUILD_WAIT_MS", ms.to_string());
2469 }
2470 CallgraphBuildWaitMsGuard {
2471 _guard: guard,
2472 previous,
2473 }
2474}
2475
2476#[cfg(test)]
2477fn wait_on_callgraph_build_start_gate(root: &Path) {
2478 let mut slot = CALLGRAPH_BUILD_START_GATE
2479 .get_or_init(|| parking_lot::Mutex::new(None))
2480 .lock();
2481 if !slot.as_ref().is_some_and(|gate| gate.root == root) {
2482 return;
2483 }
2484 let gate = slot.take();
2485 drop(slot);
2486 if let Some(gate) = gate {
2487 let _ = gate.reached.send(());
2488 let _ = gate.release.recv();
2489 }
2490}
2491
2492#[cfg(not(test))]
2493fn wait_on_callgraph_build_start_gate(_root: &Path) {}
2494
2495#[cfg(test)]
2496static CALLGRAPH_POINTER_REMOVAL_ARMS: std::sync::OnceLock<parking_lot::Mutex<BTreeSet<PathBuf>>> =
2497 std::sync::OnceLock::new();
2498
2499#[cfg(test)]
2500struct RemoveCallgraphPointerBeforeInlineReopenGuard {
2501 pointer: PathBuf,
2502}
2503
2504#[cfg(test)]
2505impl Drop for RemoveCallgraphPointerBeforeInlineReopenGuard {
2506 fn drop(&mut self) {
2507 CALLGRAPH_POINTER_REMOVAL_ARMS
2508 .get_or_init(|| parking_lot::Mutex::new(BTreeSet::new()))
2509 .lock()
2510 .remove(&self.pointer);
2511 }
2512}
2513
2514#[cfg(test)]
2515fn install_callgraph_pointer_removal_arm(
2516 pointer: PathBuf,
2517) -> RemoveCallgraphPointerBeforeInlineReopenGuard {
2518 let inserted = CALLGRAPH_POINTER_REMOVAL_ARMS
2519 .get_or_init(|| parking_lot::Mutex::new(BTreeSet::new()))
2520 .lock()
2521 .insert(pointer.clone());
2522 assert!(inserted, "callgraph pointer removal arm already installed");
2523 RemoveCallgraphPointerBeforeInlineReopenGuard { pointer }
2524}
2525
2526#[cfg(test)]
2527fn remove_armed_callgraph_pointer_for_test(pointer: &Path) {
2528 let armed = CALLGRAPH_POINTER_REMOVAL_ARMS
2529 .get_or_init(|| parking_lot::Mutex::new(BTreeSet::new()))
2530 .lock()
2531 .remove(pointer);
2532 if armed {
2533 std::fs::remove_file(pointer).expect("remove callgraph pointer before inline reopen");
2534 }
2535}
2536
2537#[cfg(test)]
2538fn remove_callgraph_pointer_before_inline_reopen_for_test(
2539 callgraph_dir: &Path,
2540 store: &CallGraphStore,
2541) {
2542 let pointer = callgraph_dir.join(format!("{}.current", store.project_key()));
2543 remove_armed_callgraph_pointer_for_test(&pointer);
2544}
2545
2546#[cfg(not(test))]
2547fn remove_callgraph_pointer_before_inline_reopen_for_test(
2548 _callgraph_dir: &Path,
2549 _store: &CallGraphStore,
2550) {
2551}
2552
2553fn callgraph_build_wait_window() -> Duration {
2558 std::env::var("AFT_CALLGRAPH_BUILD_WAIT_MS")
2559 .ok()
2560 .and_then(|raw| raw.parse::<u64>().ok())
2561 .map(Duration::from_millis)
2562 .unwrap_or(Duration::ZERO)
2563}
2564
2565static CALLGRAPH_COLD_BUILD_SPAWN_COUNT: AtomicUsize = AtomicUsize::new(0);
2566
2567#[doc(hidden)]
2568pub fn reset_callgraph_cold_build_spawn_count_for_test() {
2569 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
2570}
2571
2572#[doc(hidden)]
2573pub fn callgraph_cold_build_spawn_count_for_test() -> usize {
2574 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst)
2575}
2576
2577impl AppContext {
2578 pub fn new(provider: Box<dyn LanguageProvider>, config: Config) -> Self {
2579 Self::with_app_and_provider(App::default_shared(), provider, config)
2580 }
2581
2582 pub fn from_app(app: Arc<App>, config: Config) -> Self {
2583 let provider = app.create_provider();
2584 Self::with_app_and_provider(app, provider, config)
2585 }
2586
2587 pub fn with_app_and_provider(
2588 app: Arc<App>,
2589 provider: Box<dyn LanguageProvider>,
2590 config: Config,
2591 ) -> Self {
2592 let bash_compress_enabled = config.experimental_bash_compress;
2593 let watcher_counters = config
2594 .project_root
2595 .as_deref()
2596 .map(watcher_counters_for_root)
2597 .unwrap_or_else(|| Arc::new(WatcherCounters::default()));
2598 let (configure_warnings_tx, configure_warnings_rx) = crossbeam_channel::unbounded();
2599 let progress_sender: SharedProgressSender = Arc::new(Mutex::new(None));
2600 let status_emitter = StatusEmitter::new(Arc::clone(&progress_sender));
2601 let heavy_root_work_allowed = Arc::new(AtomicBool::new(true));
2602 let semantic_cold_seed_active = Arc::new(AtomicBool::new(false));
2603 let symbol_cache = provider
2604 .as_any()
2605 .downcast_ref::<TreeSitterProvider>()
2606 .map(|provider| provider.symbol_cache())
2607 .unwrap_or_else(|| Arc::new(std::sync::RwLock::new(SymbolCache::new())));
2608 let mut lsp_manager = LspManager::new();
2609 lsp_manager.set_child_registry(app.lsp_child_registry());
2610 lsp_manager.set_search_paths(config.lsp_paths_extra.clone());
2611 lsp_manager.set_diagnostic_capacity(config.diagnostic_cache_size);
2614 let bash_background = BgTaskRegistry::new(Arc::clone(&progress_sender));
2615 let compression_aggregates = bash_background.compression_aggregate_cache();
2616 let context = AppContext {
2617 app: Arc::clone(&app),
2618 provider,
2619 backup: parking_lot::Mutex::new(BackupStore::new()),
2620 checkpoint: parking_lot::Mutex::new(CheckpointStore::new()),
2621 config: RwLock::new(Arc::new(config)),
2622 last_request_at: parking_lot::Mutex::new(Instant::now()),
2623 path_restriction_root_memo: parking_lot::Mutex::new(None),
2624 #[cfg(test)]
2625 path_restriction_root_canonicalizations: AtomicUsize::new(0),
2626 force_restrict_requests: parking_lot::Mutex::new(BTreeMap::new()),
2627 harness: parking_lot::Mutex::new(None),
2628 canonical_cache_root: parking_lot::Mutex::new(None),
2629 is_worktree_bridge: parking_lot::Mutex::new(false),
2630 git_common_dir: parking_lot::Mutex::new(None),
2631 shared_artifacts_read_only: AtomicBool::new(false),
2632 daemonless_query_mode: AtomicBool::new(false),
2633 callgraph_writer: AtomicBool::new(true),
2634 inspect_writer: AtomicBool::new(true),
2635 artifact_owner_status: parking_lot::Mutex::new(None),
2636 artifact_owner_lease: parking_lot::Mutex::new(None),
2637 degraded_reasons: parking_lot::Mutex::new(Vec::new()),
2638 heavy_root_work_allowed: Arc::clone(&heavy_root_work_allowed),
2639 standing_artifact_exempt: AtomicBool::new(false),
2640 cold_build_limiter: RwLock::new(crate::cold_build_limiter::global_limiter()),
2641 view_runtime: RwLock::new(None),
2642 callgraph_store: Arc::new(RwLock::new(None)),
2643 callgraph_store_force_requested: AtomicU64::new(0),
2644 callgraph_store_force_fulfilled: AtomicU64::new(0),
2645 callgraph_store_rx: parking_lot::Mutex::new(None),
2646 callgraph_store_rx_generation: AtomicU64::new(0),
2647 callgraph_store_rx_epoch: AtomicU64::new(0),
2648 callgraph_store_build_denied: parking_lot::Mutex::new(None),
2649 callgraph_store_build_suspension: parking_lot::Mutex::new(None),
2650 health_build_suspensions: RwLock::new(Vec::new()),
2651 callgraph_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(),
2652 callgraph_legacy_migration_summary_logged: Arc::new(AtomicBool::new(false)),
2653 pending_callgraph_store_paths: Arc::new(parking_lot::Mutex::new(BTreeSet::new())),
2654 search_index: RwLock::new(None),
2655 search_exact_memo: Arc::new(
2656 crate::commands::semantic_search::memo::ExactMemoStore::new(),
2657 ),
2658 search_index_rx: RwLock::new(None),
2659 search_index_rx_generation: AtomicU64::new(0),
2660 search_index_rx_epoch: AtomicU64::new(0),
2661 search_index_rx_terminal_epoch: Arc::new(AtomicU64::new(0)),
2662 search_index_disconnect_reschedule: parking_lot::Mutex::new((0, 0, None)),
2663 search_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(),
2664 pending_search_index_paths: parking_lot::Mutex::new(BTreeSet::new()),
2665 symbol_cache,
2666 inspect_manager: Arc::new(InspectManager::with_root_work_gates(
2667 Arc::clone(&heavy_root_work_allowed),
2668 Arc::clone(&semantic_cold_seed_active),
2669 )),
2670 tier2_refresh_scheduler: parking_lot::Mutex::new(Tier2RefreshScheduler::new()),
2671 pending_tier2_paths: parking_lot::Mutex::new(BTreeSet::new()),
2672 semantic_index: RwLock::new(None),
2673 semantic_index_rx: parking_lot::Mutex::new(None),
2674 semantic_index_rx_generation: AtomicU64::new(0),
2675 semantic_index_rx_epoch: AtomicU64::new(0),
2676 semantic_index_rx_terminal_epoch: Arc::new(AtomicU64::new(0)),
2677 semantic_persist_epoch: crate::root_cache::ArtifactPublishEpoch::default(),
2678 semantic_persist_lock: Arc::new(parking_lot::Mutex::new(())),
2679 semantic_index_status: RwLock::new(SemanticIndexStatus::Disabled),
2680 semantic_build_progress: RwLock::new(None),
2681 semantic_build_epoch: Arc::new(AtomicU64::new(0)),
2682 artifact_reload_lock: parking_lot::Mutex::new(()),
2683 semantic_cold_seed_active,
2684 semantic_cold_seed_generation: Arc::new(AtomicU64::new(0)),
2685 semantic_fingerprint_generation: Arc::new(AtomicU64::new(0)),
2686 pending_semantic_index_paths: Arc::new(parking_lot::Mutex::new(BTreeSet::new())),
2687 pending_semantic_corpus_refresh: parking_lot::Mutex::new(false),
2688 semantic_refresh_tx: Arc::new(parking_lot::Mutex::new(None)),
2689 semantic_refresh_event_rx: parking_lot::Mutex::new(None),
2690 semantic_refresh_generation: AtomicU64::new(0),
2691 semantic_refresh_epoch: AtomicU64::new(0),
2692 semantic_refresh_build_epoch: AtomicU64::new(0),
2693 semantic_refresh_worker: parking_lot::Mutex::new(None),
2694 semantic_refresh_retry_attempts: parking_lot::Mutex::new(BTreeMap::new()),
2695 semantic_refresh_circuit: Arc::new(SemanticRefreshCircuit::default()),
2696 semantic_embedding_model: parking_lot::Mutex::new(None),
2697 watcher_runtime_lock: parking_lot::Mutex::new(()),
2698 watcher: parking_lot::Mutex::new(None),
2699 watcher_rx: parking_lot::Mutex::new(None),
2700 watcher_drain_slice: parking_lot::Mutex::new(None),
2701 watcher_thread: parking_lot::Mutex::new(None),
2702 watcher_runtime_identity: parking_lot::Mutex::new(None),
2703 watcher_counters: RwLock::new(watcher_counters),
2704 lsp_manager: parking_lot::Mutex::new(lsp_manager),
2705 configure_generation: Arc::new(AtomicU64::new(0)),
2706 configure_content_generation: Arc::new(AtomicU64::new(0)),
2707 subc_lifecycle: SubcLifecycleAdmission::default(),
2708 configure_warm_state: parking_lot::Mutex::new(ConfigureWarmState::default()),
2709 callgraph_build_key: parking_lot::Mutex::new(None),
2710 configure_phase_timing: parking_lot::Mutex::new(ConfigurePhaseTiming::default()),
2711 configured_session_roots: parking_lot::Mutex::new(BTreeSet::new()),
2712 hashline_bindings: crate::hashline::integration::BindingRegistry::new(),
2713 configure_maintenance_jobs: parking_lot::Mutex::new(VecDeque::new()),
2714 artifact_cache_keys: parking_lot::Mutex::new(BTreeMap::new()),
2715 artifact_cache_key_derivations: AtomicU64::new(0),
2716 borrowed_index_cache: parking_lot::Mutex::new(BorrowedIndexCache::default()),
2717 worktree_bridge_cache: parking_lot::Mutex::new(BTreeMap::new()),
2718 #[cfg(test)]
2719 worktree_bridge_probe_spawns: AtomicU64::new(0),
2720 #[cfg(test)]
2721 force_worktree_bridge_reprobe: AtomicBool::new(false),
2722 last_seen_reuse_completions: AtomicU64::new(0),
2723 configure_warnings_tx,
2724 configure_warnings_rx,
2725 progress_sender: Arc::clone(&progress_sender),
2726 status_emitter,
2727 fleet_status_client: RwLock::new(None),
2728 status_bar_last_emitted: LegacyStatusBarEmission::default(),
2729 status_bar_cached: RwLock::new(StatusBarCache::default()),
2730 alert_state: parking_lot::Mutex::new(AlertDeltaState::default()),
2731 repeat_breaker: crate::response_finalize::repeat_breaker::RepeatBreaker::default(),
2732 compression_aggregates,
2733 bash_background,
2734 #[cfg(unix)]
2735 escalation_grants: parking_lot::Mutex::new(
2736 crate::sandbox_spawn::EscalationGrantStore::default(),
2737 ),
2738 filter_registry: Arc::new(std::sync::RwLock::new(
2739 crate::compress::toml_filter::FilterRegistry::default(),
2740 )),
2741 filter_registry_rebuild_count: AtomicU64::new(0),
2742 filter_registry_loaded: std::sync::atomic::AtomicBool::new(false),
2743 bash_compress_flag: Arc::new(std::sync::atomic::AtomicBool::new(bash_compress_enabled)),
2744 gitignore: Arc::new(std::sync::RwLock::new(None)),
2745 gitignore_generation: Arc::new(AtomicU64::new(0)),
2746 status_bar_tier2: RwLock::new(StatusBarTier2::default()),
2747 tsconfig_membership: parking_lot::Mutex::new(
2748 crate::lsp::tsconfig_membership::TsconfigMembershipCache::new(),
2749 ),
2750 };
2751 crate::logging::sync_storage_root(context.storage_dir());
2752 context
2753 }
2754
2755 pub fn status_bar_count_values(&self) -> StatusBarCountValues {
2759 let tier2 = self
2760 .status_bar_tier2
2761 .read()
2762 .unwrap_or_else(std::sync::PoisonError::into_inner)
2763 .clone();
2764 let tsconfig_generation = self.tsconfig_membership.lock().generation();
2765 let lsp = self.lsp_manager.lock();
2766 let diagnostics_generation = lsp.diagnostics_generation();
2767
2768 {
2769 let cached = self
2770 .status_bar_cached
2771 .read()
2772 .unwrap_or_else(std::sync::PoisonError::into_inner);
2773 if cached.valid
2774 && cached.diagnostics_generation == diagnostics_generation
2775 && cached.tier2_generation == tier2.generation
2776 && cached.tsconfig_generation == tsconfig_generation
2777 {
2778 return cached
2779 .counts
2780 .clone()
2781 .expect("a valid status-count cache carries truthful values");
2782 }
2783 }
2784
2785 let previous_authoritative = self
2786 .status_bar_cached
2787 .read()
2788 .unwrap_or_else(std::sync::PoisonError::into_inner)
2789 .counts
2790 .as_ref()
2791 .map(|counts| (counts.errors, counts.warnings));
2792 let ((current_errors, current_warnings), provisional) =
2793 match self.canonical_cache_root_opt() {
2794 Some(root) => {
2795 let root = crate::inspect::job::normalize_path(&root);
2799 let mut membership = self.tsconfig_membership.lock();
2800 lsp.filtered_error_warning_counts_with_provisional(|file| {
2801 file.starts_with(&root) && !membership.should_skip_diagnostics(file)
2802 })
2803 }
2804 None => lsp.warm_error_warning_counts_with_provisional(),
2805 };
2806 let (errors, warnings) = if provisional {
2807 previous_authoritative.unwrap_or((None, None))
2810 } else if lsp.has_any_diagnostic_reports() {
2811 (Some(current_errors), Some(current_warnings))
2812 } else {
2813 (None, None)
2814 };
2815 let counts = StatusBarCountValues {
2816 errors,
2817 warnings,
2818 dead_code: tier2.dead_code,
2819 unused_exports: tier2.unused_exports,
2820 duplicates: tier2.duplicates,
2821 todos: tier2.todos,
2822 tier2_stale: tier2.stale,
2823 };
2824
2825 *self
2826 .status_bar_cached
2827 .write()
2828 .unwrap_or_else(std::sync::PoisonError::into_inner) = StatusBarCache {
2829 valid: true,
2830 diagnostics_generation,
2831 tier2_generation: tier2.generation,
2832 tsconfig_generation,
2833 counts: Some(counts.clone()),
2834 };
2835 counts
2836 }
2837
2838 pub fn status_bar_counts(&self) -> Option<StatusBarCounts> {
2841 self.status_bar_count_values().legacy_projection()
2842 }
2843
2844 pub(crate) fn try_health_summary(&self) -> RootHealthSummary {
2845 let heavy_root_work_allowed = match self.try_heavy_root_work_allowed() {
2849 Some(allowed) => allowed,
2850 None => return RootHealthSummary::busy(),
2851 };
2852 let config = match self.config.try_read() {
2853 Ok(guard) => Arc::clone(&*guard),
2854 Err(_) => return RootHealthSummary::busy(),
2855 };
2856 let search_index = match self.search_index.try_read() {
2857 Ok(guard) => guard,
2858 Err(_) => return RootHealthSummary::busy(),
2859 };
2860 let search_index_rx = match self.search_index_rx.try_read() {
2861 Ok(guard) => guard,
2862 Err(_) => return RootHealthSummary::busy(),
2863 };
2864 let semantic_status = match self.semantic_index_status.try_read() {
2865 Ok(guard) => guard,
2866 Err(_) => return RootHealthSummary::busy(),
2867 };
2868 let semantic_build_progress = match self.semantic_build_progress.try_read() {
2869 Ok(guard) => guard.clone(),
2870 Err(_) => return RootHealthSummary::busy(),
2871 };
2872 let callgraph_store = match self.callgraph_store.try_read() {
2873 Ok(guard) => guard,
2874 Err(_) => return RootHealthSummary::busy(),
2875 };
2876 let _callgraph_store_rx = match self.callgraph_store_rx.try_lock() {
2881 Some(guard) => guard,
2882 None => return RootHealthSummary::busy(),
2883 };
2884 let tier2 = match self.status_bar_tier2.try_read() {
2885 Ok(guard) => guard,
2886 Err(_) => return RootHealthSummary::busy(),
2887 };
2888 let tier2_builder_busy = match self.inspect_manager.try_tier2_builder_busy() {
2893 Some(busy) => busy,
2894 None => return RootHealthSummary::busy(),
2895 };
2896 let bash = match self.bash_background.try_health_counts() {
2897 Some(counts) => counts,
2898 None => return RootHealthSummary::busy(),
2899 };
2900 let suspended_domains = match self.health_build_suspensions.try_read() {
2901 Ok(snapshot) => snapshot.clone(),
2902 Err(_) => return RootHealthSummary::busy(),
2903 };
2904
2905 let borrows_shared_artifacts = self.shared_artifacts_read_only.load(Ordering::SeqCst);
2910 let search_index_status = if search_index
2911 .as_ref()
2912 .is_some_and(|index| index.ready || index.build_denied)
2913 || (borrows_shared_artifacts && config.search_index)
2914 {
2915 "ready"
2916 } else if config.search_index
2917 || search_index.as_ref().is_some()
2918 || search_index_rx.as_ref().is_some()
2919 {
2920 "building"
2921 } else {
2922 "disabled"
2923 };
2924 let semantic_index = match &*semantic_status {
2925 SemanticIndexStatus::Ready { .. } => SemanticHealthComponentSnapshot {
2926 status: "ready",
2927 stage: None,
2928 embedded_chunks: None,
2929 total_chunks: None,
2930 current_batch: None,
2931 total_batches: None,
2932 },
2933 SemanticIndexStatus::Building { stage, .. } => {
2934 let progress = semantic_build_progress
2935 .as_ref()
2936 .map(SemanticBuildProgress::snapshot);
2937 SemanticHealthComponentSnapshot {
2938 status: "building",
2939 stage: Some(stage.clone()),
2940 embedded_chunks: progress.as_ref().map(|progress| progress.embedded_chunks),
2941 total_chunks: progress.as_ref().map(|progress| progress.total_chunks),
2942 current_batch: progress.as_ref().map(|progress| progress.current_batch),
2943 total_batches: progress.as_ref().map(|progress| progress.total_batches),
2944 }
2945 }
2946 SemanticIndexStatus::Disabled => SemanticHealthComponentSnapshot {
2947 status: "disabled",
2948 stage: None,
2949 embedded_chunks: None,
2950 total_chunks: None,
2951 current_batch: None,
2952 total_batches: None,
2953 },
2954 SemanticIndexStatus::Failed(_) => SemanticHealthComponentSnapshot {
2955 status: "degraded",
2956 stage: None,
2957 embedded_chunks: None,
2958 total_chunks: None,
2959 current_batch: None,
2960 total_batches: None,
2961 },
2962 };
2963 let callgraph_writer = self.callgraph_writer.load(Ordering::SeqCst);
2964 let callgraph_store_status = if !heavy_root_work_allowed {
2965 "disabled"
2966 } else if callgraph_store.as_ref().is_some() {
2967 "ready"
2968 } else if !callgraph_writer && config.callgraph_store {
2969 "ready"
2972 } else if config.callgraph_store {
2973 "building"
2976 } else {
2977 "disabled"
2982 };
2983 let dead_code_blocked_on_callgraph = tier2.dead_code_blocked_on_callgraph;
2987 let tier2_complete = (tier2.dead_code.is_some() || dead_code_blocked_on_callgraph)
2988 && tier2.unused_exports.is_some()
2989 && tier2.duplicates.is_some()
2990 && !tier2.stale;
2991 let tier2_has_aggregates = tier2.dead_code.is_some()
2992 || tier2.unused_exports.is_some()
2993 || tier2.duplicates.is_some();
2994 let tier2_refresh_gated = borrows_shared_artifacts
2995 || !heavy_root_work_allowed
2996 || !self.inspect_writer.load(Ordering::SeqCst)
2997 || !self.inspect_manager.automatic_tier2_refresh_enabled();
2998 let tier2_status = if tier2_builder_busy {
2999 "building"
3003 } else if tier2_complete {
3004 "ready"
3005 } else if !config.inspect.enabled || !tier2_has_aggregates || tier2_refresh_gated {
3006 "disabled"
3009 } else {
3010 "building"
3011 };
3012
3013 RootHealthSummary {
3014 state: RootHealthState::Ready,
3015 search_index_status: Some(search_index_status),
3016 semantic_index: Some(semantic_index),
3017 callgraph_store_status: Some(callgraph_store_status),
3018 views: if config.views.enabled {
3019 self.view_health_snapshot()
3020 } else {
3021 None
3022 },
3023 tier2_status: Some(tier2_status),
3024 bash: Some(bash),
3025 suspended_domains,
3026 }
3027 }
3028
3029 pub fn try_health_snapshot(&self, project_root: &Path) -> RootHealthSnapshot {
3030 self.try_health_summary().into_snapshot(project_root)
3031 }
3032
3033 pub fn should_emit_status_bar(&self, counts: &StatusBarCounts) -> bool {
3036 self.status_bar_last_emitted.should_emit(counts)
3037 }
3038
3039 pub fn accept_alert_observation_batch(
3043 &self,
3044 batch: &AcceptedObservationBatch,
3045 ) -> Result<Vec<AcceptedObservationResult>, ObservationError> {
3046 self.alert_state.lock().accept_batch(batch)
3047 }
3048
3049 pub fn clear_tsconfig_membership_cache(&self) {
3053 self.tsconfig_membership.lock().clear();
3054 }
3055
3056 #[cfg(test)]
3057 pub fn tsconfig_membership_clear_generation_for_test(&self) -> u64 {
3058 self.tsconfig_membership.lock().generation()
3059 }
3060
3061 pub fn mark_status_bar_tier2_stale(&self) -> bool {
3067 let mut tier2 = self
3068 .status_bar_tier2
3069 .write()
3070 .unwrap_or_else(std::sync::PoisonError::into_inner);
3071 if tier2.dead_code.is_some()
3073 || tier2.unused_exports.is_some()
3074 || tier2.duplicates.is_some()
3075 || tier2.todos.is_some()
3076 {
3077 let changed = !tier2.stale;
3078 tier2.stale = true;
3079 if changed {
3080 tier2.generation = tier2.generation.wrapping_add(1);
3081 }
3082 return changed;
3083 }
3084 false
3085 }
3086
3087 pub fn update_status_bar_tier2(
3093 &self,
3094 dead_code: Option<usize>,
3095 unused_exports: Option<usize>,
3096 duplicates: Option<usize>,
3097 todos: Option<usize>,
3098 stale: bool,
3099 ) {
3100 let mut tier2 = self
3101 .status_bar_tier2
3102 .write()
3103 .unwrap_or_else(std::sync::PoisonError::into_inner);
3104 let previous = (
3105 tier2.dead_code,
3106 tier2.unused_exports,
3107 tier2.duplicates,
3108 tier2.todos,
3109 tier2.stale,
3110 );
3111 if let Some(dead_code) = dead_code {
3112 tier2.dead_code = Some(dead_code);
3113 }
3114 if let Some(unused_exports) = unused_exports {
3115 tier2.unused_exports = Some(unused_exports);
3116 }
3117 if let Some(duplicates) = duplicates {
3118 tier2.duplicates = Some(duplicates);
3119 }
3120 if let Some(todos) = todos {
3121 tier2.todos = Some(todos);
3122 }
3123 tier2.stale = stale;
3124 let current = (
3125 tier2.dead_code,
3126 tier2.unused_exports,
3127 tier2.duplicates,
3128 tier2.todos,
3129 tier2.stale,
3130 );
3131 if current != previous {
3132 tier2.generation = tier2.generation.wrapping_add(1);
3133 }
3134 }
3135
3136 pub(crate) fn set_status_bar_tier2_dead_code_blocked_on_callgraph(&self, blocked: bool) {
3142 let mut tier2 = self
3143 .status_bar_tier2
3144 .write()
3145 .unwrap_or_else(std::sync::PoisonError::into_inner);
3146 tier2.dead_code_blocked_on_callgraph = blocked;
3147 }
3148
3149 pub fn gitignore(&self) -> Option<Arc<ignore::gitignore::Gitignore>> {
3152 self.gitignore
3153 .read()
3154 .unwrap_or_else(|poisoned| poisoned.into_inner())
3155 .clone()
3156 }
3157
3158 pub fn shared_gitignore(&self) -> SharedGitignore {
3160 Arc::clone(&self.gitignore)
3161 }
3162
3163 pub fn gitignore_generation(&self) -> Arc<AtomicU64> {
3167 Arc::clone(&self.gitignore_generation)
3168 }
3169
3170 fn set_gitignore(&self, matcher: Option<Arc<ignore::gitignore::Gitignore>>) {
3171 *self
3172 .gitignore
3173 .write()
3174 .unwrap_or_else(|poisoned| poisoned.into_inner()) = matcher;
3175 self.gitignore_generation.fetch_add(1, Ordering::SeqCst);
3176 }
3177
3178 pub fn clear_gitignore(&self) {
3200 self.set_gitignore(None);
3201 }
3202
3203 pub fn rebuild_gitignore(&self) {
3204 use ignore::gitignore::GitignoreBuilder;
3205 use std::path::Path;
3206 let root_raw = match self.config().project_root.clone() {
3207 Some(r) => r,
3208 None => {
3209 self.set_gitignore(None);
3210 return;
3211 }
3212 };
3213 let root = std::fs::canonicalize(&root_raw).unwrap_or(root_raw);
3221 let mut builder = GitignoreBuilder::new(&root);
3222 if let Some(global_ignore) = ignore::gitignore::gitconfig_excludes_path() {
3227 if global_ignore.is_file() {
3228 if let Some(err) = builder.add(&global_ignore) {
3229 crate::slog_warn!(
3230 "global gitignore parse error in {}: {}",
3231 global_ignore.display(),
3232 err
3233 );
3234 }
3235 }
3236 }
3237 let root_ignore = Path::new(&root).join(".gitignore");
3239 if root_ignore.exists() {
3240 if let Some(err) = builder.add(&root_ignore) {
3241 crate::slog_warn!(
3242 "gitignore parse error in {}: {}",
3243 root_ignore.display(),
3244 err
3245 );
3246 }
3247 }
3248 let root_aftignore = Path::new(&root).join(".aftignore");
3253 if root_aftignore.exists() {
3254 if let Some(err) = builder.add(&root_aftignore) {
3255 crate::slog_warn!(
3256 "aftignore parse error in {}: {}",
3257 root_aftignore.display(),
3258 err
3259 );
3260 }
3261 }
3262 let info_exclude = self
3267 .git_common_dir
3268 .lock()
3269 .clone()
3270 .unwrap_or_else(|| Path::new(&root).join(".git"))
3271 .join("info")
3272 .join("exclude");
3273 if info_exclude.exists() {
3274 if let Some(err) = builder.add(&info_exclude) {
3275 crate::slog_warn!(
3276 "gitignore parse error in {}: {}",
3277 info_exclude.display(),
3278 err
3279 );
3280 }
3281 }
3282 let walker = ignore::WalkBuilder::new(&root)
3289 .same_file_system(true)
3290 .standard_filters(true)
3291 .hidden(false)
3299 .filter_entry(|entry| {
3300 let name = entry.file_name().to_string_lossy();
3301 !matches!(
3302 name.as_ref(),
3303 "node_modules" | "target" | ".git" | ".opencode" | ".alfonso"
3304 )
3305 })
3306 .build();
3307 for entry in walker.flatten() {
3308 let file_name = entry.file_name();
3309 let is_nested_gitignore = file_name == ".gitignore" && entry.path() != root_ignore;
3310 let is_nested_aftignore = file_name == ".aftignore" && entry.path() != root_aftignore;
3311 if is_nested_gitignore || is_nested_aftignore {
3312 if let Some(err) = builder.add(entry.path()) {
3313 crate::slog_warn!(
3314 "nested ignore parse error in {}: {}",
3315 entry.path().display(),
3316 err
3317 );
3318 }
3319 }
3320 }
3321 match builder.build() {
3322 Ok(gi) => {
3323 let count = gi.num_ignores();
3324 if count > 0 {
3325 crate::slog_info!("gitignore matcher built: {} pattern(s)", count);
3326 self.set_gitignore(Some(Arc::new(gi)));
3327 } else {
3328 self.set_gitignore(None);
3329 }
3330 }
3331 Err(err) => {
3332 crate::slog_warn!("gitignore matcher build failed: {}", err);
3333 self.set_gitignore(None);
3334 }
3335 }
3336 }
3337
3338 pub fn bash_compress_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
3341 Arc::clone(&self.bash_compress_flag)
3342 }
3343
3344 pub fn sync_bash_compress_flag(&self) {
3348 let value = self.config().experimental_bash_compress;
3349 self.bash_compress_flag
3350 .store(value, std::sync::atomic::Ordering::Relaxed);
3351 }
3352
3353 pub fn set_bash_compress_enabled(&self, enabled: bool) {
3354 self.update_config(|config| {
3355 config.experimental_bash_compress = enabled;
3356 });
3357 self.bash_compress_flag
3358 .store(enabled, std::sync::atomic::Ordering::Relaxed);
3359 }
3360
3361 pub fn filter_registry(
3365 &self,
3366 ) -> std::sync::RwLockReadGuard<'_, crate::compress::toml_filter::FilterRegistry> {
3367 self.ensure_filter_registry_loaded();
3368 match self.filter_registry.read() {
3369 Ok(g) => g,
3370 Err(poisoned) => poisoned.into_inner(),
3371 }
3372 }
3373
3374 pub fn shared_filter_registry(&self) -> crate::compress::SharedFilterRegistry {
3378 self.ensure_filter_registry_loaded();
3379 Arc::clone(&self.filter_registry)
3380 }
3381
3382 pub fn reset_filter_registry(&self) {
3386 let new_registry = crate::compress::build_registry_for_context(self);
3387 self.filter_registry_rebuild_count
3388 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3389 match self.filter_registry.write() {
3390 Ok(mut slot) => *slot = new_registry,
3391 Err(poisoned) => *poisoned.into_inner() = new_registry,
3392 }
3393 self.filter_registry_loaded
3394 .store(true, std::sync::atomic::Ordering::Release);
3395 }
3396
3397 fn ensure_filter_registry_loaded(&self) {
3398 use std::sync::atomic::Ordering;
3399 if self.filter_registry_loaded.load(Ordering::Acquire) {
3400 return;
3401 }
3402 let new_registry = crate::compress::build_registry_for_context(self);
3405 self.filter_registry_rebuild_count
3406 .fetch_add(1, Ordering::SeqCst);
3407 if let Ok(mut slot) = self.filter_registry.write() {
3408 *slot = new_registry;
3409 self.filter_registry_loaded.store(true, Ordering::Release);
3410 }
3411 }
3412
3413 #[cfg(test)]
3414 pub fn filter_registry_rebuild_count_for_test(&self) -> u64 {
3415 self.filter_registry_rebuild_count.load(Ordering::SeqCst)
3416 }
3417
3418 pub fn app(&self) -> Arc<App> {
3419 Arc::clone(&self.app)
3420 }
3421
3422 pub fn lsp_child_registry(&self) -> crate::lsp::child_registry::LspChildRegistry {
3425 self.app.lsp_child_registry()
3426 }
3427
3428 pub fn stdout_writer(&self) -> SharedStdoutWriter {
3429 self.app.stdout_writer()
3430 }
3431
3432 pub fn set_progress_sender(&self, sender: Option<ProgressSender>) {
3433 if let Ok(mut progress_sender) = self.progress_sender.lock() {
3434 *progress_sender = sender;
3435 }
3436 }
3437
3438 pub fn emit_progress(&self, frame: ProgressFrame) {
3439 let Ok(progress_sender) = self.progress_sender.lock().map(|sender| sender.clone()) else {
3440 return;
3441 };
3442 if let Some(sender) = progress_sender.as_ref() {
3443 sender(PushFrame::Progress(frame));
3444 }
3445 }
3446
3447 pub fn status_emitter(&self) -> &StatusEmitter {
3448 &self.status_emitter
3449 }
3450
3451 pub(crate) fn install_fleet_status_client(
3452 &self,
3453 client: Option<crate::fleet_status::FleetStatusClient>,
3454 ) {
3455 *self
3456 .fleet_status_client
3457 .write()
3458 .unwrap_or_else(std::sync::PoisonError::into_inner) = client;
3459 }
3460
3461 pub(crate) fn fleet_status_client(&self) -> Option<crate::fleet_status::FleetStatusClient> {
3462 self.fleet_status_client
3463 .read()
3464 .unwrap_or_else(std::sync::PoisonError::into_inner)
3465 .clone()
3466 }
3467
3468 pub fn progress_sender_handle(&self) -> Option<ProgressSender> {
3476 self.progress_sender
3477 .lock()
3478 .ok()
3479 .and_then(|sender| sender.clone())
3480 }
3481
3482 pub fn advance_configure_generation(&self) -> u64 {
3483 self.subc_lifecycle
3484 .advance_generation(self.configure_generation.as_ref())
3485 }
3486
3487 pub(crate) fn mark_subc_bound(&self) {
3488 self.subc_lifecycle.mark_bound();
3489 }
3490
3491 pub(crate) fn mark_subc_unbound(&self) {
3492 self.subc_lifecycle
3493 .mark_unbound(self.configure_generation.as_ref());
3494 self.repeat_breaker.clear();
3495 }
3496
3497 #[doc(hidden)]
3498 pub fn subc_unbound_quiesced(&self) -> bool {
3499 self.subc_lifecycle.is_unbound()
3500 }
3501
3502 pub(crate) fn subc_lifecycle_admission(&self) -> SubcLifecycleAdmission {
3503 self.subc_lifecycle.clone()
3504 }
3505
3506 pub(crate) fn run_if_subc_bound_generation<R>(
3507 &self,
3508 expected_generation: u64,
3509 action: impl FnOnce() -> R,
3510 ) -> Option<R> {
3511 self.subc_lifecycle.run_if_current(
3512 self.configure_generation.as_ref(),
3513 expected_generation,
3514 action,
3515 )
3516 }
3517
3518 pub fn note_configure_warm_key(
3526 &self,
3527 key: String,
3528 semantic_build_inputs_changed: bool,
3529 ) -> (u64, bool) {
3530 let mut state = self.configure_warm_state.lock();
3531 let equivalent = state.key.as_ref().is_some_and(|previous| *previous == key);
3532 let generation = if equivalent {
3533 self.configure_generation()
3534 } else {
3535 self.configure_content_generation
3536 .fetch_add(1, Ordering::SeqCst);
3537 self.advance_configure_generation()
3538 };
3539 if !equivalent && semantic_build_inputs_changed {
3540 self.advance_semantic_build_epoch();
3541 }
3542 state.generation = generation;
3543 state.key = Some(key);
3544 (generation, equivalent)
3545 }
3546
3547 pub(crate) fn configure_warm_key_matches(&self, key: &str) -> bool {
3548 self.configure_warm_state
3549 .lock()
3550 .key
3551 .as_deref()
3552 .is_some_and(|current| current == key)
3553 }
3554
3555 pub(crate) fn note_callgraph_build_key(&self, key: String) -> bool {
3558 let mut current = self.callgraph_build_key.lock();
3559 let equivalent = current.as_deref() == Some(key.as_str());
3560 *current = Some(key);
3561 equivalent
3562 }
3563
3564 pub(crate) fn invalidate_configure_warm_state(&self) {
3565 self.configure_warm_state.lock().key = None;
3566 }
3567
3568 pub fn note_configure_session_binding(&self, root: PathBuf, session_id: String) -> bool {
3569 self.configured_session_roots
3570 .lock()
3571 .insert((root, session_id))
3572 }
3573
3574 pub(crate) fn has_configure_session_binding(&self, root: &Path, session_id: &str) -> bool {
3575 self.configured_session_roots
3576 .lock()
3577 .contains(&(root.to_path_buf(), session_id.to_string()))
3578 }
3579
3580 pub fn forget_configure_session_binding(&self, root: &Path, session_id: &str) {
3584 self.configured_session_roots
3585 .lock()
3586 .remove(&(root.to_path_buf(), session_id.to_string()));
3587 self.repeat_breaker.clear_session(session_id);
3588 }
3589
3590 pub fn repeat_breaker(&self) -> &crate::response_finalize::repeat_breaker::RepeatBreaker {
3591 &self.repeat_breaker
3592 }
3593
3594 pub fn watcher_drain_has_work(&self) -> bool {
3600 let receiver_pending = self
3601 .watcher_rx
3602 .lock()
3603 .as_ref()
3604 .is_some_and(|rx| !rx.is_empty());
3605 receiver_pending
3606 || self
3607 .watcher_drain_slice
3608 .lock()
3609 .as_ref()
3610 .is_some_and(WatcherDrainSliceState::has_pending_work)
3611 }
3612
3613 pub fn lsp_drain_has_work(&self) -> bool {
3614 match self.lsp_manager.try_lock() {
3615 Some(lsp) => lsp.has_pending_events(),
3616 None => true,
3618 }
3619 }
3620
3621 pub fn completion_drains_have_work(&self) -> bool {
3622 let search_pending = self
3623 .search_index_rx
3624 .try_read()
3625 .map(|slot| {
3626 slot.as_ref().is_some_and(|receiver| {
3627 !receiver.is_empty()
3628 || self.search_index_rx_terminal_epoch.load(Ordering::SeqCst)
3629 == self.search_index_rx_epoch()
3630 })
3631 })
3632 .unwrap_or(true);
3633 if search_pending {
3634 return true;
3635 }
3636 if self
3637 .callgraph_store_rx
3638 .lock()
3639 .as_ref()
3640 .is_some_and(|rx| !rx.is_empty())
3641 {
3642 return true;
3643 }
3644 if self
3645 .semantic_index_rx
3646 .lock()
3647 .as_ref()
3648 .is_some_and(|receiver| {
3649 !receiver.is_empty()
3650 || self.semantic_index_rx_terminal_epoch.load(Ordering::SeqCst)
3651 == self.semantic_index_rx_epoch()
3652 })
3653 {
3654 return true;
3655 }
3656 if self
3657 .semantic_refresh_event_rx
3658 .lock()
3659 .as_ref()
3660 .is_some_and(|rx| !rx.is_empty())
3661 {
3662 return true;
3663 }
3664 if self.semantic_refresh_probe_ready() && self.semantic_refresh_event_rx.lock().is_some() {
3665 return true;
3666 }
3667 if self
3668 .semantic_refresh_worker
3669 .lock()
3670 .as_ref()
3671 .is_some_and(|worker_slot| match worker_slot.try_lock() {
3672 Ok(handle) => handle
3673 .as_ref()
3674 .is_some_and(std::thread::JoinHandle::is_finished),
3675 Err(std::sync::TryLockError::WouldBlock) => true,
3676 Err(std::sync::TryLockError::Poisoned(_)) => true,
3677 })
3678 {
3679 return true;
3680 }
3681 self.inspect_manager().has_pending_completions() || self.has_new_reuse_completions()
3682 }
3683
3684 pub fn configure_tail_has_work(&self) -> bool {
3685 !self.configure_maintenance_jobs.lock().is_empty() || !self.configure_warnings_rx.is_empty()
3686 }
3687
3688 pub(crate) fn configure_maintenance_has_capacity(&self) -> bool {
3689 self.configure_maintenance_jobs.lock().len() < crate::executor::MAINTENANCE_QUEUE_CAP
3690 }
3691
3692 pub(crate) fn enqueue_configure_maintenance(
3693 &self,
3694 job: ConfigureMaintenanceJob,
3695 ) -> Result<(), ConfigureMaintenanceJob> {
3696 let mut jobs = self.configure_maintenance_jobs.lock();
3697 if jobs.len() >= crate::executor::MAINTENANCE_QUEUE_CAP {
3698 return Err(job);
3699 }
3700 jobs.push_back(job);
3701 Ok(())
3702 }
3703
3704 pub(crate) fn drain_configure_maintenance(&self) -> Vec<ConfigureMaintenanceJob> {
3705 self.configure_maintenance_jobs.lock().drain(..).collect()
3706 }
3707
3708 #[cfg(test)]
3709 pub(crate) fn configure_maintenance_job_count_for_test(&self) -> usize {
3710 self.configure_maintenance_jobs.lock().len()
3711 }
3712
3713 pub fn cached_artifact_cache_key(&self, canonical_root: &Path) -> Option<String> {
3716 self.artifact_cache_keys.lock().get(canonical_root).cloned()
3717 }
3718
3719 pub(crate) fn cached_worktree_bridge(
3722 &self,
3723 canonical_root: &Path,
3724 ) -> Option<(bool, Option<PathBuf>)> {
3725 #[cfg(test)]
3726 if self.force_worktree_bridge_reprobe.load(Ordering::SeqCst) {
3727 return None;
3728 }
3729
3730 let signature = git_entry_signature(canonical_root);
3731 self.worktree_bridge_cache
3732 .lock()
3733 .get(canonical_root)
3734 .filter(|entry| entry.git_entry == signature)
3735 .map(|entry| (entry.is_worktree_bridge, entry.git_common_dir.clone()))
3736 }
3737
3738 pub(crate) fn cache_worktree_bridge(
3741 &self,
3742 canonical_root: &Path,
3743 is_worktree_bridge: bool,
3744 git_common_dir: PathBuf,
3745 ) {
3746 self.worktree_bridge_cache.lock().insert(
3747 canonical_root.to_path_buf(),
3748 WorktreeBridgeCacheEntry {
3749 git_entry: git_entry_signature(canonical_root),
3750 is_worktree_bridge,
3751 git_common_dir: Some(git_common_dir),
3752 },
3753 );
3754 }
3755
3756 #[cfg(test)]
3757 pub(crate) fn record_worktree_bridge_probe_spawn_for_test(&self) {
3758 self.worktree_bridge_probe_spawns
3759 .fetch_add(1, Ordering::SeqCst);
3760 }
3761
3762 #[cfg(test)]
3763 pub(crate) fn worktree_bridge_probe_spawns_for_test(&self) -> u64 {
3764 self.worktree_bridge_probe_spawns.load(Ordering::SeqCst)
3765 }
3766
3767 #[cfg(test)]
3768 pub(crate) fn force_worktree_bridge_reprobe_for_test(&self, enabled: bool) {
3769 self.force_worktree_bridge_reprobe
3770 .store(enabled, Ordering::SeqCst);
3771 }
3772
3773 pub(crate) fn note_index_query(
3775 &self,
3776 plane: crate::logging::IndexPlane,
3777 tool: &str,
3778 service_ms: u64,
3779 status: &str,
3780 ) {
3781 let root = self
3782 .canonical_cache_root_opt()
3783 .or_else(|| self.config().project_root.clone());
3784 let Some(root) = root else {
3785 return;
3786 };
3787 crate::logging::note_index_query(plane, &root, tool, service_ms, status);
3788 }
3789
3790 pub fn memoized_artifact_cache_key(&self, canonical_root: &Path) -> String {
3791 let mut keys = self.artifact_cache_keys.lock();
3792 if let Some(key) = keys.get(canonical_root).cloned() {
3793 return key;
3794 }
3795 let key = crate::search_index::artifact_cache_key(canonical_root);
3796 self.artifact_cache_key_derivations
3797 .fetch_add(1, Ordering::SeqCst);
3798 keys.insert(canonical_root.to_path_buf(), key.clone());
3799 key
3800 }
3801
3802 pub fn memoized_artifact_cache_key_for_configure(
3803 &self,
3804 raw_root: &Path,
3805 canonical_root: &Path,
3806 storage_root: &Path,
3807 git_common_dir: Option<&Path>,
3808 ) -> Result<String, crate::search_index::ArtifactCacheKeyProbeError> {
3809 {
3810 let keys = self.artifact_cache_keys.lock();
3811 if let Some(key) = keys
3812 .get(canonical_root)
3813 .or_else(|| keys.get(raw_root))
3814 .cloned()
3815 {
3816 return Ok(key);
3817 }
3818 }
3819
3820 let key = crate::search_index::artifact_cache_key_with_memo(
3821 canonical_root,
3822 raw_root,
3823 storage_root,
3824 git_common_dir,
3825 )?;
3826 self.artifact_cache_key_derivations
3827 .fetch_add(1, Ordering::SeqCst);
3828 let mut keys = self.artifact_cache_keys.lock();
3829 keys.insert(canonical_root.to_path_buf(), key.clone());
3830 keys.insert(raw_root.to_path_buf(), key.clone());
3831 Ok(key)
3832 }
3833
3834 #[cfg(test)]
3835 pub fn artifact_cache_key_derivation_count_for_test(&self) -> u64 {
3836 self.artifact_cache_key_derivations.load(Ordering::SeqCst)
3837 }
3838
3839 pub(crate) fn resolve_external_git_root(
3840 &self,
3841 project_root: &Path,
3842 requested_path: &str,
3843 ) -> Result<PathBuf, crate::readonly_artifacts::GitRootResolutionError> {
3844 let raw_path = Path::new(requested_path);
3845 let canonical_requested = if raw_path.is_absolute() {
3846 std::fs::canonicalize(raw_path).ok()
3847 } else {
3848 None
3849 };
3850 if let Some(root) = canonical_requested
3851 .as_deref()
3852 .and_then(|root| self.borrowed_index_cache.lock().resolved_root(root))
3853 {
3854 return Ok(root);
3855 }
3856
3857 let root = crate::readonly_artifacts::resolve_git_root_from_user_path(
3858 project_root,
3859 requested_path,
3860 )?;
3861 if canonical_requested.as_deref() == Some(root.as_path()) {
3862 self.borrowed_index_cache
3863 .lock()
3864 .remember_resolved_root(root.clone());
3865 }
3866 Ok(root)
3867 }
3868
3869 pub(crate) fn open_borrowed_search_index(
3870 &self,
3871 external_root: &Path,
3872 storage_dir: Option<&Path>,
3873 ) -> crate::readonly_artifacts::ReadOnlyArtifact<Arc<SearchIndex>> {
3874 let canonical_root =
3875 std::fs::canonicalize(external_root).unwrap_or_else(|_| external_root.to_path_buf());
3876 let project_key = self.memoized_artifact_cache_key(&canonical_root);
3877 let Some(artifact) = crate::readonly_artifacts::search_index_artifact_generation_with_key(
3878 &project_key,
3879 storage_dir,
3880 ) else {
3881 return crate::readonly_artifacts::ReadOnlyArtifact::Absent;
3882 };
3883 let key = BorrowedIndexCacheKey {
3884 canonical_root: canonical_root.clone(),
3885 artifact,
3886 };
3887 {
3888 let mut cache = self.borrowed_index_cache.lock();
3889 if let Some(index) = cache.search(&key) {
3890 return index;
3891 }
3892 }
3893
3894 let opened = crate::readonly_artifacts::open_search_index_read_only_with_key(
3897 &canonical_root,
3898 storage_dir,
3899 &project_key,
3900 )
3901 .map(Arc::new);
3902 if !matches!(
3903 opened,
3904 crate::readonly_artifacts::ReadOnlyArtifact::Absent
3905 | crate::readonly_artifacts::ReadOnlyArtifact::Cancelled
3906 ) {
3907 self.borrowed_index_cache
3908 .lock()
3909 .insert(key, BorrowedIndexCacheValue::Search(opened.clone()));
3910 }
3911 opened
3912 }
3913
3914 pub(crate) fn open_borrowed_semantic_index(
3915 &self,
3916 external_root: &Path,
3917 storage_dir: Option<&Path>,
3918 ) -> crate::readonly_artifacts::ReadOnlyArtifact<Arc<SemanticIndex>> {
3919 let canonical_root =
3920 std::fs::canonicalize(external_root).unwrap_or_else(|_| external_root.to_path_buf());
3921 let project_key = self.memoized_artifact_cache_key(&canonical_root);
3922 let Some(artifact) = crate::readonly_artifacts::semantic_index_artifact_generation_with_key(
3923 &project_key,
3924 storage_dir,
3925 ) else {
3926 return crate::readonly_artifacts::ReadOnlyArtifact::Absent;
3927 };
3928 let key = BorrowedIndexCacheKey {
3929 canonical_root: canonical_root.clone(),
3930 artifact,
3931 };
3932 {
3933 let mut cache = self.borrowed_index_cache.lock();
3934 if let Some(index) = cache.semantic(&key) {
3935 return index;
3936 }
3937 }
3938
3939 let opened = crate::readonly_artifacts::open_semantic_index_read_only_with_key(
3942 &canonical_root,
3943 storage_dir,
3944 &project_key,
3945 )
3946 .map(Arc::new);
3947 if !matches!(
3948 opened,
3949 crate::readonly_artifacts::ReadOnlyArtifact::Absent
3950 | crate::readonly_artifacts::ReadOnlyArtifact::Cancelled
3951 ) {
3952 self.borrowed_index_cache
3953 .lock()
3954 .insert(key, BorrowedIndexCacheValue::Semantic(opened.clone()));
3955 }
3956 opened
3957 }
3958
3959 #[cfg(test)]
3960 pub(crate) fn borrowed_index_cache_len_for_test(&self) -> usize {
3961 self.borrowed_index_cache.lock().entries.len()
3962 }
3963
3964 pub fn configure_generation(&self) -> u64 {
3965 self.configure_generation.load(Ordering::SeqCst)
3966 }
3967
3968 pub fn configure_generation_flag(&self) -> Arc<AtomicU64> {
3969 Arc::clone(&self.configure_generation)
3970 }
3971
3972 pub(crate) fn configure_content_generation(&self) -> u64 {
3973 self.configure_content_generation.load(Ordering::SeqCst)
3974 }
3975
3976 pub(crate) fn configure_content_generation_flag(&self) -> Arc<AtomicU64> {
3977 Arc::clone(&self.configure_content_generation)
3978 }
3979
3980 pub(crate) fn begin_configure_ack_phase(&self, phase: &'static str) {
3981 let now = Instant::now();
3982 let mut timing = self.configure_phase_timing.lock();
3983 if phase == "config_resolve" {
3984 timing.completed.clear();
3985 } else if timing.phase != "idle" && timing.phase != "ack_ready" {
3986 let previous = timing.phase;
3987 let elapsed = now.saturating_duration_since(timing.started_at);
3988 timing.completed.push((previous, elapsed));
3989 }
3990 timing.phase = phase;
3991 timing.started_at = now;
3992 }
3993
3994 pub(crate) fn configure_ack_phase_snapshot(&self) -> String {
3995 let timing = self.configure_phase_timing.lock();
3996 let mut parts = timing
3997 .completed
3998 .iter()
3999 .map(|(phase, elapsed)| format!("{phase}={}ms", elapsed.as_millis()))
4000 .collect::<Vec<_>>();
4001 parts.push(format!(
4002 "{}={}ms",
4003 timing.phase,
4004 timing.started_at.elapsed().as_millis()
4005 ));
4006 parts.join(",")
4007 }
4008
4009 pub fn advance_semantic_fingerprint_generation(&self) -> u64 {
4010 self.semantic_fingerprint_generation
4011 .fetch_add(1, Ordering::SeqCst)
4012 .wrapping_add(1)
4013 }
4014
4015 pub fn semantic_fingerprint_generation(&self) -> u64 {
4016 self.semantic_fingerprint_generation.load(Ordering::SeqCst)
4017 }
4018
4019 pub fn semantic_fingerprint_generation_flag(&self) -> Arc<AtomicU64> {
4020 Arc::clone(&self.semantic_fingerprint_generation)
4021 }
4022
4023 pub(crate) fn advance_semantic_build_epoch(&self) -> u64 {
4027 self.semantic_build_epoch
4028 .fetch_add(1, Ordering::SeqCst)
4029 .wrapping_add(1)
4030 }
4031
4032 pub(crate) fn semantic_build_epoch(&self) -> u64 {
4033 self.semantic_build_epoch.load(Ordering::SeqCst)
4034 }
4035
4036 pub(crate) fn semantic_build_epoch_flag(&self) -> Arc<AtomicU64> {
4037 Arc::clone(&self.semantic_build_epoch)
4038 }
4039
4040 pub fn configure_warnings_sender(
4041 &self,
4042 ) -> crossbeam_channel::Sender<(u64, ConfigureWarningsFrame)> {
4043 self.configure_warnings_tx.clone()
4044 }
4045
4046 pub fn drain_configure_warnings(&self) -> Vec<(u64, ConfigureWarningsFrame)> {
4047 let mut warnings = Vec::new();
4048 while let Ok(warning) = self.configure_warnings_rx.try_recv() {
4049 warnings.push(warning);
4050 }
4051 warnings
4052 }
4053
4054 pub fn bash_background(&self) -> &BgTaskRegistry {
4055 &self.bash_background
4056 }
4057
4058 #[cfg(unix)]
4059 pub(crate) fn escalation_grants(
4060 &self,
4061 ) -> &parking_lot::Mutex<crate::sandbox_spawn::EscalationGrantStore> {
4062 &self.escalation_grants
4063 }
4064
4065 pub fn drain_bg_completions(&self) -> Vec<BgCompletion> {
4066 self.bash_background.drain_completions()
4067 }
4068
4069 pub fn provider(&self) -> &dyn LanguageProvider {
4071 self.provider.as_ref()
4072 }
4073
4074 pub fn backup(&self) -> &parking_lot::Mutex<BackupStore> {
4076 &self.backup
4077 }
4078
4079 pub fn hashline_bindings(&self) -> &crate::hashline::integration::BindingRegistry {
4081 &self.hashline_bindings
4082 }
4083
4084 pub fn checkpoint(&self) -> &parking_lot::Mutex<CheckpointStore> {
4086 &self.checkpoint
4087 }
4088
4089 pub fn set_db(&self, conn: Arc<Mutex<TrackedConnection>>) {
4090 self.app.set_db(conn);
4091 self.compression_aggregates.clear();
4092 }
4093
4094 pub fn clear_db(&self) {
4095 self.app.clear_db();
4096 self.compression_aggregates.clear();
4097 }
4098
4099 pub fn db(&self) -> Option<Arc<Mutex<TrackedConnection>>> {
4100 self.app.db()
4101 }
4102
4103 pub(crate) fn compression_aggregate_cache(
4104 &self,
4105 ) -> &crate::db::compression_events::CompressionAggregateCache {
4106 self.compression_aggregates.as_ref()
4107 }
4108
4109 pub fn note_request(&self) {
4110 *self.last_request_at.lock() = Instant::now();
4111 }
4112
4113 pub fn last_request_at(&self) -> Instant {
4114 *self.last_request_at.lock()
4115 }
4116
4117 #[cfg(test)]
4118 pub fn set_last_request_at_for_test(&self, at: Instant) {
4119 *self.last_request_at.lock() = at;
4120 }
4121
4122 pub fn tool_enabled(&self, tool: &str) -> bool {
4124 !self.config().disabled_tools.iter().any(|name| name == tool)
4125 }
4126
4127 pub fn config(&self) -> Arc<Config> {
4129 let guard = match self.config.read() {
4130 Ok(guard) => guard,
4131 Err(poisoned) => poisoned.into_inner(),
4132 };
4133 Arc::clone(&*guard)
4134 }
4135
4136 pub fn set_config(&self, config: Config) {
4138 let next = Arc::new(config);
4139 let next_watcher_counters = next
4140 .project_root
4141 .as_deref()
4142 .map(watcher_counters_for_root)
4143 .unwrap_or_else(|| Arc::new(WatcherCounters::default()));
4144 let project_root_changed = {
4145 let mut guard = self
4146 .config
4147 .write()
4148 .unwrap_or_else(std::sync::PoisonError::into_inner);
4149 let changed = guard.project_root.as_ref().map(|root| root.as_os_str())
4152 != next.project_root.as_ref().map(|root| root.as_os_str());
4153 *guard = next;
4154 changed
4155 };
4156 if project_root_changed {
4157 self.path_restriction_root_memo.lock().take();
4158 *self
4159 .watcher_counters
4160 .write()
4161 .unwrap_or_else(std::sync::PoisonError::into_inner) = next_watcher_counters;
4162 }
4163 }
4164
4165 #[cfg(test)]
4166 pub(crate) fn path_restriction_root_memo_is_empty_for_test(&self) -> bool {
4167 self.path_restriction_root_memo.lock().is_none()
4168 }
4169
4170 #[cfg(test)]
4171 pub(crate) fn path_restriction_root_canonicalizations_for_test(&self) -> usize {
4172 self.path_restriction_root_canonicalizations
4173 .load(Ordering::SeqCst)
4174 }
4175
4176 pub fn update_config(&self, update: impl FnOnce(&mut Config)) {
4178 let mut next = self.config().as_ref().clone();
4179 update(&mut next);
4180 self.set_config(next);
4181 }
4182
4183 pub fn force_restrict_guard(&self, req_id: &str) -> ForceRestrictGuard<'_> {
4184 let mut requests = self.force_restrict_requests.lock();
4185 *requests.entry(req_id.to_string()).or_insert(0) += 1;
4186 ForceRestrictGuard {
4187 ctx: self,
4188 req_id: req_id.to_string(),
4189 }
4190 }
4191
4192 pub fn with_force_restrict<R>(&self, req_id: &str, f: impl FnOnce() -> R) -> R {
4193 let _guard = self.force_restrict_guard(req_id);
4194 f()
4195 }
4196
4197 pub fn request_force_restrict(&self, req_id: &str) -> bool {
4198 self.force_restrict_requests.lock().contains_key(req_id)
4199 }
4200
4201 fn release_force_restrict(&self, req_id: &str) {
4202 let mut requests = self.force_restrict_requests.lock();
4203 match requests.get_mut(req_id) {
4204 Some(count) if *count > 1 => *count -= 1,
4205 Some(_) => {
4206 requests.remove(req_id);
4207 }
4208 None => {}
4209 }
4210 }
4211
4212 pub fn set_harness(&self, harness: Harness) {
4213 self.bash_background.set_harness(harness.clone());
4214 *self.harness.lock() = Some(harness);
4215 }
4216
4217 pub fn harness_opt(&self) -> Option<Harness> {
4218 self.harness.lock().clone()
4219 }
4220
4221 pub fn harness(&self) -> Harness {
4222 self.harness_opt()
4223 .expect("harness set by configure before any tool call")
4224 }
4225
4226 pub fn storage_dir(&self) -> PathBuf {
4227 crate::bash_background::storage_dir(self.config().storage_dir.as_deref())
4228 }
4229
4230 pub fn harness_dir(&self) -> PathBuf {
4231 self.storage_dir().join(self.harness().storage_segment())
4232 }
4233
4234 #[cfg(test)]
4235 pub(crate) fn refresh_build_suspensions_for_health_at(
4236 &self,
4237 project_root: &Path,
4238 project_key: Option<&str>,
4239 now_ms: u64,
4240 ) {
4241 let suspensions = self
4242 .build_breaker_path_for_health(project_key)
4243 .and_then(|path| crate::build_breaker::BuildDeathBreaker::open(path).ok())
4244 .and_then(|breaker| {
4245 breaker
4246 .active_suspensions_for_root_at(&project_root.display().to_string(), now_ms)
4247 .ok()
4248 })
4249 .unwrap_or_default();
4250 self.publish_build_suspensions_for_health(suspensions, now_ms);
4251 }
4252
4253 pub(crate) fn build_breaker_path_for_health(
4254 &self,
4255 project_key: Option<&str>,
4256 ) -> Option<PathBuf> {
4257 project_key.and_then(|key| {
4258 let path = self
4259 .storage_dir()
4260 .join("callgraph")
4261 .join(key)
4262 .join("build-breaker.sqlite");
4263 path.is_file().then_some(path)
4264 })
4265 }
4266
4267 pub(crate) fn publish_build_suspensions_for_health(
4268 &self,
4269 suspensions: Vec<crate::build_breaker::BuildSuspension>,
4270 now_ms: u64,
4271 ) {
4272 let suspended_domains = suspensions
4273 .into_iter()
4274 .map(|suspension| {
4275 let age_s = suspension.age_seconds_at(now_ms);
4276 SuspendedDomainHealthSnapshot {
4277 domain: suspension.domain.as_str().to_string(),
4278 reason: suspension.reason,
4279 death_count: suspension.death_count,
4280 age_s,
4281 }
4282 })
4283 .collect();
4284 if let Ok(mut snapshot) = self.health_build_suspensions.write() {
4285 *snapshot = suspended_domains;
4286 }
4287 }
4288
4289 pub fn inspect_dir(&self) -> PathBuf {
4290 if let Some(root) = self
4291 .canonical_cache_root_opt()
4292 .or_else(|| self.config().project_root.clone())
4293 {
4294 self.storage_dir()
4295 .join("inspect")
4296 .join(crate::path_identity::project_scope_key(&root))
4297 } else {
4298 self.storage_dir().join("inspect").join("unconfigured")
4299 }
4300 }
4301
4302 pub fn bash_tasks_dir(&self, session_id: &str) -> PathBuf {
4303 self.harness_dir()
4304 .join("bash-tasks")
4305 .join(hash_session(session_id))
4306 }
4307
4308 pub fn backups_dir(&self, session_id: &str, path_hash: &str) -> PathBuf {
4309 self.harness_dir()
4310 .join("backups")
4311 .join(hash_session(session_id))
4312 .join(path_hash)
4313 }
4314
4315 pub fn filters_dir(&self) -> PathBuf {
4316 self.harness_dir().join("filters")
4317 }
4318
4319 pub fn trust_file(&self) -> PathBuf {
4321 self.storage_dir().join("trusted-filter-projects.json")
4322 }
4323
4324 pub fn set_canonical_cache_root(&self, root: PathBuf) {
4325 debug_assert!(root.is_absolute());
4326 let root_changed = {
4327 let mut current = self.canonical_cache_root.lock();
4328 let changed = current.as_deref() != Some(root.as_path());
4329 *current = Some(root);
4330 changed
4331 };
4332 if root_changed {
4333 let mut tier2 = self
4334 .status_bar_tier2
4335 .write()
4336 .unwrap_or_else(std::sync::PoisonError::into_inner);
4337 let generation = tier2.generation.wrapping_add(1);
4338 *tier2 = StatusBarTier2 {
4339 generation,
4340 ..StatusBarTier2::default()
4341 };
4342 self.status_bar_last_emitted.clear();
4343 }
4344 }
4345
4346 pub fn canonical_cache_root(&self) -> PathBuf {
4347 self.canonical_cache_root
4348 .lock()
4349 .clone()
4350 .expect("canonical_cache_root accessed before handle_configure")
4351 }
4352
4353 pub fn canonical_cache_root_opt(&self) -> Option<PathBuf> {
4354 self.canonical_cache_root.lock().clone()
4355 }
4356
4357 pub fn set_cache_role(&self, is_worktree_bridge: bool, git_common_dir: Option<PathBuf>) {
4358 *self.is_worktree_bridge.lock() = is_worktree_bridge;
4359 *self.git_common_dir.lock() = git_common_dir;
4360 self.inspect_manager
4364 .set_automatic_tier2_refresh_allowed(!is_worktree_bridge);
4365 let artifact_read_only = self.shared_artifacts_read_only.load(Ordering::SeqCst);
4366 self.callgraph_writer
4367 .store(!is_worktree_bridge && !artifact_read_only, Ordering::SeqCst);
4368 }
4369
4370 pub fn set_artifact_owner(
4371 &self,
4372 status: Option<ArtifactOwnerStatus>,
4373 lease: Option<ArtifactOwnerLease>,
4374 ) {
4375 let read_only = status
4376 .as_ref()
4377 .is_some_and(|status| status.mode == ArtifactOwnerMode::ReadOnly);
4378 self.shared_artifacts_read_only
4379 .store(read_only, Ordering::SeqCst);
4380 self.callgraph_writer
4381 .store(!self.is_worktree_bridge() && !read_only, Ordering::SeqCst);
4382 self.inspect_writer.store(true, Ordering::SeqCst);
4383 *self.artifact_owner_status.lock() = status;
4384 *self.artifact_owner_lease.lock() = lease.map(crate::artifact_owner::register_heartbeat);
4385 }
4386
4387 pub fn set_cache_writer_capabilities(&self, callgraph_writer: bool, inspect_writer: bool) {
4388 self.callgraph_writer
4389 .store(callgraph_writer, Ordering::SeqCst);
4390 self.inspect_writer.store(inspect_writer, Ordering::SeqCst);
4391 }
4392
4393 pub fn callgraph_writer(&self) -> bool {
4394 self.callgraph_writer.load(Ordering::SeqCst)
4395 }
4396
4397 pub fn inspect_writer(&self) -> bool {
4398 self.inspect_writer.load(Ordering::SeqCst)
4399 }
4400
4401 pub fn shared_artifacts_read_only(&self) -> bool {
4402 !self.callgraph_writer()
4403 }
4404
4405 #[doc(hidden)]
4409 pub fn set_daemonless_query_mode(&self, enabled: bool) {
4410 self.daemonless_query_mode.store(enabled, Ordering::SeqCst);
4411 }
4412
4413 pub(crate) fn daemonless_query_mode(&self) -> bool {
4414 self.daemonless_query_mode.load(Ordering::SeqCst)
4415 }
4416
4417 pub fn ram_overlay_active(&self) -> bool {
4423 self.shared_artifacts_read_only() && self.config().worktree.ram_overlay
4424 }
4425
4426 pub fn artifact_owner_status(&self) -> Option<ArtifactOwnerStatus> {
4427 self.artifact_owner_status.lock().clone()
4428 }
4429
4430 pub fn is_worktree_bridge(&self) -> bool {
4431 *self.is_worktree_bridge.lock()
4432 }
4433
4434 pub fn git_common_dir(&self) -> Option<PathBuf> {
4435 self.git_common_dir.lock().clone()
4436 }
4437
4438 pub fn set_degraded_reasons(&self, reasons: Vec<String>) {
4442 *self.degraded_reasons.lock() = reasons;
4443 }
4444
4445 pub fn set_heavy_root_work_allowed(&self, allowed: bool) {
4446 self.heavy_root_work_allowed
4447 .store(allowed, Ordering::SeqCst);
4448 }
4449
4450 pub fn heavy_root_work_allowed(&self) -> bool {
4451 self.heavy_root_work_allowed.load(Ordering::SeqCst) && !self.subc_lifecycle.is_unbound()
4452 }
4453
4454 fn try_heavy_root_work_allowed(&self) -> Option<bool> {
4455 if !self.heavy_root_work_allowed.load(Ordering::SeqCst) {
4456 return Some(false);
4457 }
4458 self.subc_lifecycle.try_is_bound()
4459 }
4460
4461 pub fn add_degraded_reason(&self, reason: impl Into<String>) -> bool {
4462 let reason = reason.into();
4463 let mut reasons = self.degraded_reasons.lock();
4464 if reasons.iter().any(|existing| existing == &reason) {
4465 return false;
4466 }
4467 reasons.push(reason);
4468 true
4469 }
4470
4471 pub fn degraded_reasons(&self) -> Vec<String> {
4475 self.degraded_reasons.lock().clone()
4476 }
4477
4478 pub fn is_degraded(&self) -> bool {
4480 !self.degraded_reasons.lock().is_empty()
4481 }
4482
4483 pub fn is_home_root(&self) -> bool {
4487 self.degraded_reasons
4488 .lock()
4489 .iter()
4490 .any(|reason| reason == "home_root")
4491 }
4492
4493 pub fn cache_role(&self) -> &'static str {
4494 if self.canonical_cache_root.lock().is_none() {
4495 "not_initialized"
4496 } else if self.is_worktree_bridge() {
4497 "worktree"
4498 } else if self.shared_artifacts_read_only.load(Ordering::SeqCst) {
4499 "read_only"
4500 } else {
4501 "main"
4502 }
4503 }
4504
4505 pub(crate) fn install_view_runtime(
4507 &self,
4508 snapshot: ViewRuntimeSnapshot,
4509 pin: Option<crate::pins::QueryPin>,
4510 ) {
4511 *self
4512 .view_runtime
4513 .write()
4514 .unwrap_or_else(|error| error.into_inner()) = Some(ViewRuntimeState {
4515 snapshot,
4516 pin: pin.map(Arc::new),
4517 });
4518 }
4519
4520 pub(crate) fn clear_view_runtime(&self) {
4521 *self
4522 .view_runtime
4523 .write()
4524 .unwrap_or_else(|error| error.into_inner()) = None;
4525 }
4526
4527 pub(crate) fn view_health_snapshot(&self) -> Option<ViewHealthSnapshot> {
4528 if !self.config().views.enabled {
4529 return None;
4530 }
4531 let state = self
4532 .view_runtime
4533 .read()
4534 .unwrap_or_else(|error| error.into_inner());
4535 let state = state.as_ref()?;
4536 let status = crate::path_status::PathStatusStore::open(&state.snapshot.view_dir)
4537 .ok()
4538 .and_then(|store| store.summary().ok());
4539 Some(ViewHealthSnapshot {
4540 generation: state
4541 .snapshot
4542 .generation
4543 .as_deref()
4544 .and_then(|generation| generation.split('-').next())
4545 .and_then(|generation| generation.parse().ok())
4546 .unwrap_or(0),
4547 pinned: state.pin.is_some(),
4548 pending_paths: status
4549 .as_ref()
4550 .map_or(state.snapshot.pending_paths.len(), |status| {
4551 status.pending_count
4552 }),
4553 failed_paths: status.as_ref().map_or(0, |status| status.failed_count),
4554 })
4555 }
4556
4557 pub(crate) fn view_runtime_snapshot(&self) -> Option<ViewRuntimeSnapshot> {
4558 self.view_runtime
4559 .read()
4560 .unwrap_or_else(|error| error.into_inner())
4561 .as_ref()
4562 .map(|state| state.snapshot.clone())
4563 }
4564
4565 pub(crate) fn pinned_view_runtime(&self) -> Option<ViewRuntimeSnapshot> {
4566 self.view_runtime
4567 .read()
4568 .unwrap_or_else(|error| error.into_inner())
4569 .as_ref()
4570 .filter(|state| state.pin.is_some() && state.snapshot.generation.is_some())
4571 .map(|state| {
4572 let mut snapshot = state.snapshot.clone();
4573 snapshot.query_pin = state.pin.clone();
4574 snapshot
4575 })
4576 }
4577
4578 pub(crate) fn publish_view_paths(
4579 &self,
4580 changed_paths: BTreeSet<Vec<u8>>,
4581 allow_blob_put: bool,
4582 ) -> Result<crate::views::assembly::AssemblyReport, String> {
4583 let mut prepared =
4584 self.prepare_view_paths(changed_paths, allow_blob_put, &mut |_| Ok(()))?;
4585 self.commit_view_update(&mut prepared)
4586 }
4587
4588 pub(crate) fn prepare_view_paths(
4589 &self,
4590 changed_paths: BTreeSet<Vec<u8>>,
4591 allow_blob_put: bool,
4592 phase: &mut impl FnMut(&str) -> crate::views::Result<()>,
4593 ) -> Result<PreparedViewUpdate, String> {
4594 let content_generation = self.configure_content_generation();
4595 phase("manifest").map_err(|error| error.to_string())?;
4596 let snapshot = self
4597 .view_runtime_snapshot()
4598 .ok_or_else(|| "view runtime is not configured".to_string())?;
4599 let root = self
4600 .canonical_cache_root_opt()
4601 .ok_or_else(|| "view root is not configured".to_string())?;
4602 let head = crate::alias::head_tree_entries(&root).map_err(|error| error.to_string())?;
4603 let desired_head = crate::views::assembly::head_tree_fingerprint(&head);
4604 let semantic_search = self.config().semantic_search;
4605 let semantic_keys = if semantic_search && allow_blob_put {
4606 let index = self
4607 .semantic_index
4608 .read()
4609 .unwrap_or_else(|error| error.into_inner())
4610 .clone()
4611 .ok_or_else(|| {
4612 "semantic view publication is waiting for the semantic index".to_string()
4613 })?;
4614 let fingerprint = index
4615 .fingerprint()
4616 .map(crate::semantic_index::SemanticIndexFingerprint::as_string)
4617 .ok_or_else(|| "semantic index fingerprint is unavailable".to_string())?;
4618 let mut request = crate::migration::SemanticMigrationRequest::for_root(
4619 snapshot.storage.clone(),
4620 root.clone(),
4621 fingerprint,
4622 );
4623 request.family.clone_from(&snapshot.family);
4624 request.view.clone_from(&snapshot.scope);
4625 crate::migration::store_live_semantic_blobs(&request, &index)
4626 .map_err(|error| error.to_string())?
4627 } else {
4628 BTreeMap::new()
4629 };
4630 let assembly = crate::views::assembly::prepare_checkout(
4631 &crate::views::assembly::AssemblyRequest {
4632 storage: snapshot.storage.clone(),
4633 project_root: root,
4634 family: snapshot.family.clone(),
4635 scope: snapshot.scope.clone(),
4636 desired_head: desired_head.clone(),
4637 changed_paths,
4638 semantic_keys,
4639 require_semantic: semantic_search,
4640 allow_blob_put,
4641 },
4642 phase,
4643 )
4644 .map_err(|error| error.to_string())?;
4645 let report = assembly.report();
4646 let view = crate::views::ViewStore::open(&snapshot.storage, &snapshot.scope)
4647 .map_err(|error| error.to_string())?;
4648 let generation = report.generation.clone();
4649 let manifest = match (&report.manifest, generation.as_deref()) {
4650 (Some(manifest), _) => Some(manifest.clone()),
4651 (None, Some(generation)) => view.load_manifest(generation).ok(),
4652 (None, None) => None,
4653 };
4654 let pin = generation
4655 .as_deref()
4656 .map(|generation| crate::pins::QueryPin::acquire(view.view_dir(), generation))
4657 .transpose()
4658 .map_err(|error| error.to_string())?;
4659 Ok(PreparedViewUpdate {
4660 snapshot: Some(ViewRuntimeSnapshot {
4661 generation,
4662 manifest,
4663 pending_paths: report.pending_paths.clone(),
4664 ..snapshot
4665 }),
4666 pin: pin.map(Arc::new),
4667 retired: None,
4668 assembly,
4669 content_generation,
4670 })
4671 }
4672
4673 pub(crate) fn commit_view_update(
4676 &self,
4677 prepared: &mut PreparedViewUpdate,
4678 ) -> Result<crate::views::assembly::AssemblyReport, String> {
4679 if self.configure_content_generation() != prepared.content_generation
4680 || !self.config().views.enabled
4681 {
4682 return Err("view publication configuration was superseded".to_owned());
4683 }
4684 let report = prepared
4685 .assembly
4686 .commit()
4687 .map_err(|error| error.to_string())?;
4688 if let Some(snapshot) = prepared
4689 .snapshot
4690 .take()
4691 .filter(|snapshot| report.generation == snapshot.generation)
4692 {
4693 prepared.retired = self
4694 .view_runtime
4695 .write()
4696 .unwrap_or_else(|error| error.into_inner())
4697 .replace(ViewRuntimeState {
4698 snapshot,
4699 pin: prepared.pin.take(),
4700 });
4701 }
4702 Ok(report)
4703 }
4704
4705 pub fn callgraph_store(&self) -> &RwLock<Option<Arc<ReadonlyCallGraphStore>>> {
4707 self.callgraph_store.as_ref()
4708 }
4709
4710 pub fn mark_callgraph_store_force_rebuild(&self) -> u64 {
4711 self.callgraph_store_force_requested
4712 .fetch_add(1, Ordering::SeqCst)
4713 .wrapping_add(1)
4714 }
4715
4716 pub(crate) fn pending_callgraph_store_force_token(&self) -> Option<u64> {
4717 let requested = self.callgraph_store_force_requested.load(Ordering::SeqCst);
4718 let fulfilled = self.callgraph_store_force_fulfilled.load(Ordering::SeqCst);
4719 (requested > fulfilled).then_some(requested)
4720 }
4721
4722 #[doc(hidden)]
4723 pub fn pending_callgraph_store_force_token_for_test(&self) -> Option<u64> {
4724 self.pending_callgraph_store_force_token()
4725 }
4726
4727 pub fn fulfill_callgraph_store_force_token(&self, token: u64) {
4728 self.callgraph_store_force_fulfilled
4729 .fetch_max(token, Ordering::SeqCst);
4730 }
4731
4732 #[doc(hidden)]
4733 pub fn record_callgraph_store_build_denied(&self, generation: u64, reason: String) {
4734 *self.callgraph_store_build_denied.lock() = Some((generation, reason));
4735 }
4736
4737 #[doc(hidden)]
4738 pub fn record_callgraph_store_build_suspension(
4739 &self,
4740 generation: u64,
4741 suspension: crate::build_breaker::BuildSuspension,
4742 ) {
4743 *self.callgraph_store_build_suspension.lock() = Some((generation, suspension));
4744 }
4745
4746 #[doc(hidden)]
4747 pub fn clear_callgraph_store_build_denied(&self) {
4748 *self.callgraph_store_build_denied.lock() = None;
4749 *self.callgraph_store_build_suspension.lock() = None;
4750 }
4751
4752 fn callgraph_store_build_suspension(&self) -> Option<crate::build_breaker::BuildSuspension> {
4753 let generation = self.configure_generation();
4754 let mut suspended = self.callgraph_store_build_suspension.lock();
4755 match suspended.as_ref() {
4756 Some((suspended_generation, value)) if *suspended_generation == generation => {
4757 Some(value.clone())
4758 }
4759 Some(_) => {
4760 *suspended = None;
4761 None
4762 }
4763 None => None,
4764 }
4765 }
4766
4767 fn callgraph_store_build_denial(&self) -> Option<String> {
4768 let generation = self.configure_generation();
4769 let mut denied = self.callgraph_store_build_denied.lock();
4770 match denied.as_ref() {
4771 Some((denied_generation, reason)) if *denied_generation == generation => {
4772 Some(reason.clone())
4773 }
4774 Some(_) => {
4775 *denied = None;
4776 None
4777 }
4778 None => None,
4779 }
4780 }
4781
4782 pub fn callgraph_store_dir(&self) -> PathBuf {
4783 if let Some(root) = self.callgraph_project_root() {
4784 self.storage_dir()
4785 .join("callgraph")
4786 .join(self.memoized_artifact_cache_key(&root))
4787 } else {
4788 self.storage_dir().join("callgraph").join("unconfigured")
4789 }
4790 }
4791
4792 pub fn ensure_callgraph_store(
4793 &self,
4794 ) -> Result<Option<Arc<ReadonlyCallGraphStore>>, CallGraphStoreError> {
4795 self.ensure_callgraph_store_with_flag(true)
4796 }
4797
4798 fn ensure_callgraph_store_with_flag(
4799 &self,
4800 respect_config_flag: bool,
4801 ) -> Result<Option<Arc<ReadonlyCallGraphStore>>, CallGraphStoreError> {
4802 if respect_config_flag && !self.config().callgraph_store {
4803 return Ok(None);
4804 }
4805 if !self.heavy_root_work_allowed() {
4806 return Ok(None);
4807 }
4808 self.revalidate_callgraph_store_generation();
4809 let force_token = self.pending_callgraph_store_force_token();
4810 if force_token.is_none() {
4811 if let Some(store) = {
4812 let guard = self
4813 .callgraph_store
4814 .read()
4815 .unwrap_or_else(std::sync::PoisonError::into_inner);
4816 guard.as_ref().map(Arc::clone)
4817 } {
4818 self.schedule_legacy_callgraph_migration_if_needed(
4819 store.as_ref(),
4820 store.project_root().to_path_buf(),
4821 self.callgraph_store_dir(),
4822 );
4823 return Ok(Some(store));
4824 }
4825 }
4826
4827 let Some(project_root) = self.callgraph_project_root() else {
4828 return Ok(None);
4829 };
4830 let callgraph_dir = self.callgraph_store_dir();
4831
4832 if force_token.is_none() {
4836 if let Some(store) =
4837 CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone())?
4838 {
4839 let store = Arc::new(store);
4840 {
4841 let mut guard = self
4842 .callgraph_store
4843 .write()
4844 .unwrap_or_else(std::sync::PoisonError::into_inner);
4845 *guard = Some(Arc::clone(&store));
4846 }
4847 self.schedule_legacy_callgraph_migration_if_needed(
4848 store.as_ref(),
4849 project_root,
4850 callgraph_dir,
4851 );
4852 return Ok(Some(store));
4853 }
4854 }
4855
4856 if !self.callgraph_writer() {
4857 return Ok(None);
4858 }
4859 let build_generation = self.configure_generation();
4860 let persist_epoch_flag = self.callgraph_persist_epoch_flag();
4861 let Some(persist_epoch) = self
4862 .run_if_subc_bound_generation(build_generation, || self.next_callgraph_persist_epoch())
4863 else {
4864 return Ok(None);
4865 };
4866 let (store, _stats) = crate::callgraph_store::with_publish_epoch(
4869 persist_epoch_flag.clone(),
4870 persist_epoch,
4871 || {
4872 if force_token.is_some() {
4873 CallGraphStore::force_cold_build_with_lease_chunked(
4874 callgraph_dir.clone(),
4875 project_root.clone(),
4876 &[],
4877 self.config().callgraph_chunk_size,
4878 )
4879 .map(|(store, _stats)| (store, ()))
4880 } else {
4881 CallGraphStore::ensure_built_with_lease_chunked(
4882 callgraph_dir.clone(),
4883 project_root.clone(),
4884 &[],
4885 self.config().callgraph_chunk_size,
4886 )
4887 .map(|(store, _stats)| (store, ()))
4888 }
4889 },
4890 )?;
4891 drop(store);
4892
4893 let Some(store) = CallGraphStore::open_readonly(callgraph_dir, project_root)? else {
4894 return Ok(None);
4895 };
4896 let store = Arc::new(store);
4897 self.run_if_subc_bound_generation(build_generation, || {
4898 if persist_epoch_flag.current() != persist_epoch {
4899 return None;
4900 }
4901 let mut guard = self
4902 .callgraph_store
4903 .write()
4904 .unwrap_or_else(std::sync::PoisonError::into_inner);
4905 *guard = Some(Arc::clone(&store));
4906 if let Some(force_token) = force_token {
4907 self.fulfill_callgraph_store_force_token(force_token);
4908 }
4909 Some(Arc::clone(&store))
4910 })
4911 .flatten()
4912 .map_or(Ok(None), |store| Ok(Some(store)))
4913 }
4914
4915 pub fn callgraph_project_root(&self) -> Option<PathBuf> {
4918 self.canonical_cache_root_opt().or_else(|| {
4919 self.config()
4920 .project_root
4921 .clone()
4922 .map(|root| std::fs::canonicalize(&root).unwrap_or(root))
4923 })
4924 }
4925
4926 pub fn revalidate_callgraph_store_generation(&self) {
4930 let (superseded, legacy_fallback) = {
4931 let guard = self
4932 .callgraph_store
4933 .read()
4934 .unwrap_or_else(std::sync::PoisonError::into_inner);
4935 guard
4936 .as_ref()
4937 .map(|store| (!store.is_current(), store.is_legacy_fallback()))
4938 .unwrap_or((false, false))
4939 };
4940 if !superseded {
4941 return;
4942 }
4943 if legacy_fallback && self.callgraph_store_rx.lock().is_some() {
4947 return;
4948 }
4949 let mut guard = self
4950 .callgraph_store
4951 .write()
4952 .unwrap_or_else(std::sync::PoisonError::into_inner);
4953 *guard = None;
4954 }
4955
4956 pub fn callgraph_store_for_ops(&self) -> CallgraphStoreAccess {
4957 self.callgraph_store_for_ops_with_wait(callgraph_build_wait_window())
4958 }
4959
4960 pub(crate) fn schedule_callgraph_store_warm(&self) -> CallgraphStoreAccess {
4968 self.callgraph_store_for_ops_with_wait(Duration::ZERO)
4969 }
4970
4971 fn callgraph_store_for_ops_with_wait(&self, wait: Duration) -> CallgraphStoreAccess {
4972 if !self.heavy_root_work_allowed() {
4973 return CallgraphStoreAccess::Unavailable;
4974 }
4975 if self.config().views.enabled && self.config().callgraph_store {
4976 if let Some(view) = self.pinned_view_runtime() {
4977 if view.manifest.is_some() {
4978 let Some(project_root) = self.callgraph_project_root() else {
4979 return CallgraphStoreAccess::Unavailable;
4980 };
4981 return match ReadonlyCallGraphStore::open_manifest_view(
4982 project_root,
4983 view.family,
4984 view.view_dir,
4985 view.generation.as_deref().expect("pinned generation"),
4986 view.query_pin,
4987 ) {
4988 Ok(store) => CallgraphStoreAccess::Ready(Arc::new(store)),
4989 Err(error) => CallgraphStoreAccess::Error(error),
4990 };
4991 }
4992 }
4993 }
4994 let operation_generation = self.configure_generation();
4995
4996 self.revalidate_callgraph_store_generation();
5000 let force_token = self.pending_callgraph_store_force_token();
5001 if force_token.is_none() {
5002 if let Some(store) = {
5003 let guard = self
5004 .callgraph_store
5005 .read()
5006 .unwrap_or_else(std::sync::PoisonError::into_inner);
5007 guard.as_ref().map(Arc::clone)
5008 } {
5009 self.clear_callgraph_store_build_denied();
5010 self.schedule_legacy_callgraph_migration_if_needed(
5011 store.as_ref(),
5012 store.project_root().to_path_buf(),
5013 self.callgraph_store_dir(),
5014 );
5015 return CallgraphStoreAccess::Ready(store);
5016 }
5017 }
5018
5019 if let Some(suspension) = self.callgraph_store_build_suspension() {
5020 return CallgraphStoreAccess::Suspended(suspension);
5021 }
5022 if let Some(reason) = self.callgraph_store_build_denial() {
5023 return CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason));
5024 }
5025
5026 let build_in_flight = self.callgraph_store_rx.lock().is_some();
5030
5031 let Some(project_root) = self.callgraph_project_root() else {
5032 return CallgraphStoreAccess::Unavailable;
5033 };
5034 let callgraph_dir = self.callgraph_store_dir();
5035
5036 if !build_in_flight {
5037 match CallGraphStore::cold_build_suspension(&callgraph_dir, &project_root) {
5038 Ok(Some(suspension)) => return CallgraphStoreAccess::Suspended(suspension),
5039 Ok(None) => {}
5040 Err(error) => return CallgraphStoreAccess::Error(error),
5041 }
5042 }
5043
5044 if !build_in_flight {
5045 if force_token.is_none() {
5046 match CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone()) {
5047 Ok(Some(store)) => {
5048 let store = Arc::new(store);
5049 let installed =
5050 self.run_if_subc_bound_generation(operation_generation, || {
5051 let mut guard = self
5052 .callgraph_store
5053 .write()
5054 .unwrap_or_else(std::sync::PoisonError::into_inner);
5055 *guard = Some(Arc::clone(&store));
5056 Arc::clone(&store)
5057 });
5058 let Some(store) = installed else {
5059 return CallgraphStoreAccess::Unavailable;
5060 };
5061 self.clear_callgraph_store_build_denied();
5062 self.schedule_legacy_callgraph_migration_if_needed(
5063 store.as_ref(),
5064 project_root.clone(),
5065 callgraph_dir.clone(),
5066 );
5067 return CallgraphStoreAccess::Ready(store);
5068 }
5069 Ok(None) => {
5070 if !self.callgraph_writer() {
5071 return CallgraphStoreAccess::Unavailable;
5072 }
5073 }
5074 Err(error) => {
5075 if !self.callgraph_writer() {
5076 return CallgraphStoreAccess::Unavailable;
5077 }
5078 crate::slog_warn!(
5079 "callgraph read-only open failed before writer promotion: {}",
5080 error
5081 );
5082 }
5083 }
5084 } else if !self.callgraph_writer() {
5085 return CallgraphStoreAccess::Unavailable;
5086 }
5087
5088 let work = if let Some(force_token) = force_token {
5096 crate::slog_info!(
5097 "callgraph cold-build decision: reason=corpus drift; action=force rebuild"
5098 );
5099 CallgraphBackgroundWork::ForceRebuild(force_token)
5100 } else {
5101 crate::slog_info!(
5102 "callgraph cold-build decision: reason=no current generation; action=ensure build"
5103 );
5104 CallgraphBackgroundWork::Ensure
5105 };
5106 let _ = self.spawn_callgraph_store_cold_build(
5110 project_root.clone(),
5111 callgraph_dir.clone(),
5112 work,
5113 );
5114 }
5115
5116 if !wait.is_zero() {
5117 let (received, receiver_generation, receiver_epoch) = {
5118 let rx_ref = self.callgraph_store_rx.lock();
5119 let Some(rx) = rx_ref.as_ref() else {
5120 return CallgraphStoreAccess::Building;
5121 };
5122 (
5123 rx.recv_timeout(wait),
5124 self.callgraph_store_rx_generation(),
5125 self.callgraph_store_rx_epoch(),
5126 )
5127 };
5128 match received {
5129 Ok(CallGraphStoreBuildEvent::Ready {
5130 store,
5131 fulfilled_force_token,
5132 publication_epoch,
5133 }) => {
5134 if self.callgraph_persist_epoch_flag().current() != publication_epoch {
5135 drop(store);
5139 let _ = self.with_current_callgraph_store_rx(
5140 receiver_generation,
5141 receiver_epoch,
5142 |receiver| {
5143 *receiver = None;
5144 },
5145 );
5146 return CallgraphStoreAccess::Building;
5147 }
5148 remove_callgraph_pointer_before_inline_reopen_for_test(&callgraph_dir, &store);
5151 drop(store);
5152 let reopened =
5153 CallGraphStore::open_readonly(callgraph_dir.clone(), project_root.clone());
5154 let mut pending = Vec::new();
5155 let outcome = self.with_current_callgraph_store_rx(
5156 receiver_generation,
5157 receiver_epoch,
5158 |receiver| {
5159 *receiver = None;
5160 match reopened {
5161 Ok(Some(store)) => {
5162 let ready = Arc::new(store);
5163 self.clear_callgraph_store_build_denied();
5164 *self
5165 .callgraph_store
5166 .write()
5167 .unwrap_or_else(std::sync::PoisonError::into_inner) =
5168 Some(Arc::clone(&ready));
5169 pending = self.take_pending_callgraph_store_paths();
5174 if let Some(force_token) = fulfilled_force_token {
5175 self.fulfill_callgraph_store_force_token(force_token);
5176 }
5177 CallgraphStoreAccess::Ready(ready)
5178 }
5179 Ok(None) => CallgraphStoreAccess::Building,
5180 Err(error) => CallgraphStoreAccess::Error(error),
5181 }
5182 },
5183 );
5184 let Some(outcome) = outcome else {
5185 return if self.subc_unbound_quiesced()
5186 || self.configure_generation() != receiver_generation
5187 {
5188 CallgraphStoreAccess::Unavailable
5189 } else {
5190 CallgraphStoreAccess::Building
5191 };
5192 };
5193 if !pending.is_empty() {
5194 let _ = self.enqueue_callgraph_store_refresh(pending);
5195 }
5196 if matches!(&outcome, CallgraphStoreAccess::Ready(_)) {
5197 let _ = self.request_tier2_refresh_pull();
5198 }
5199 return outcome;
5200 }
5201 Ok(CallGraphStoreBuildEvent::Suspended { suspension }) => {
5202 let suspended = self.with_current_callgraph_store_rx(
5203 receiver_generation,
5204 receiver_epoch,
5205 |receiver| {
5206 *receiver = None;
5207 self.record_callgraph_store_build_suspension(
5208 receiver_generation,
5209 suspension.clone(),
5210 );
5211 CallgraphStoreAccess::Suspended(suspension)
5212 },
5213 );
5214 return suspended.unwrap_or(CallgraphStoreAccess::Unavailable);
5215 }
5216 Ok(CallGraphStoreBuildEvent::Denied { reason }) => {
5217 let denied = self.with_current_callgraph_store_rx(
5218 receiver_generation,
5219 receiver_epoch,
5220 |receiver| {
5221 *receiver = None;
5222 self.record_callgraph_store_build_denied(
5223 receiver_generation,
5224 reason.clone(),
5225 );
5226 CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason))
5227 },
5228 );
5229 return denied.unwrap_or(CallgraphStoreAccess::Unavailable);
5230 }
5231 Ok(CallGraphStoreBuildEvent::Settled) => {
5232 let _ = self.with_current_callgraph_store_rx(
5233 receiver_generation,
5234 receiver_epoch,
5235 |receiver| *receiver = None,
5236 );
5237 return CallgraphStoreAccess::Building;
5238 }
5239 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
5240 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
5241 let _ = self.with_current_callgraph_store_rx(
5242 receiver_generation,
5243 receiver_epoch,
5244 |receiver| *receiver = None,
5245 );
5246 }
5247 }
5248 }
5249 CallgraphStoreAccess::Building
5250 }
5251
5252 fn schedule_legacy_callgraph_migration_if_needed(
5253 &self,
5254 store: &ReadonlyCallGraphStore,
5255 project_root: PathBuf,
5256 callgraph_dir: PathBuf,
5257 ) {
5258 if !store.is_legacy_fallback()
5259 || !self.callgraph_writer()
5260 || !self.heavy_root_work_allowed()
5261 {
5262 return;
5263 }
5264 let _ = self.spawn_callgraph_store_cold_build(
5265 project_root,
5266 callgraph_dir,
5267 CallgraphBackgroundWork::LegacyMigration,
5268 );
5269 }
5270
5271 fn configured_callgraph_keys(&self, current_root: &Path) -> BTreeSet<String> {
5272 let mut roots = self
5273 .configured_session_roots
5274 .lock()
5275 .iter()
5276 .map(|(root, _session)| root.clone())
5277 .collect::<BTreeSet<_>>();
5278 roots.insert(current_root.to_path_buf());
5279 roots
5280 .iter()
5281 .map(|root| self.memoized_artifact_cache_key(root))
5285 .collect()
5286 }
5287
5288 fn spawn_callgraph_store_cold_build(
5293 &self,
5294 project_root: PathBuf,
5295 callgraph_dir: PathBuf,
5296 work: CallgraphBackgroundWork,
5297 ) -> bool {
5298 if !self.heavy_root_work_allowed() || !self.callgraph_writer() {
5299 return false;
5300 }
5301 let generation = self.configure_generation();
5302 self.run_if_subc_bound_generation(generation, || {
5303 self.spawn_callgraph_store_cold_build_admitted(project_root, callgraph_dir, work)
5304 })
5305 .unwrap_or(false)
5306 }
5307
5308 fn spawn_callgraph_store_cold_build_admitted(
5310 &self,
5311 project_root: PathBuf,
5312 callgraph_dir: PathBuf,
5313 work: CallgraphBackgroundWork,
5314 ) -> bool {
5315 let session_id = crate::log_ctx::current_session();
5316 let chunk_size = self.config().callgraph_chunk_size;
5317 let build_generation = self.configure_generation();
5318 let configured_keys = self.configured_callgraph_keys(&project_root);
5319 let summary_logged = Arc::clone(&self.callgraph_legacy_migration_summary_logged);
5320
5321 let mut rx_guard = self.callgraph_store_rx.lock();
5322 if rx_guard.is_some() {
5323 return false;
5324 }
5325
5326 let limiter = self.cold_build_limiter();
5327 let request = crate::cold_build_limiter::ColdBuildAdmissionRequest::new(
5328 "callgraph-background",
5329 crate::cold_build_limiter::ColdBuildAdmissionClass::Maintenance,
5330 );
5331 let Some(permit) =
5332 crate::cold_build_limiter::try_acquire_classified_with_limiter(&limiter, &request)
5333 else {
5334 crate::slog_info!(
5335 "callgraph store background work deferred by cold build limit ({})",
5336 limiter.limit()
5337 );
5338 return false;
5339 };
5340
5341 let force_token = match work {
5342 CallgraphBackgroundWork::ForceRebuild(token) => Some(token),
5343 CallgraphBackgroundWork::Ensure | CallgraphBackgroundWork::LegacyMigration => None,
5344 };
5345 let (tx, rx) = crossbeam_channel::unbounded::<CallGraphStoreBuildEvent>();
5346 self.note_callgraph_store_rx_generation(build_generation);
5347 self.next_callgraph_store_rx_epoch();
5348 *rx_guard = Some(rx);
5349 let persist_epoch = self.next_callgraph_persist_epoch();
5350 let persist_epoch_flag = self.callgraph_persist_epoch_flag();
5351
5352 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.fetch_add(1, Ordering::SeqCst);
5353
5354 std::thread::spawn(move || {
5355 let _permit = permit;
5356 let mut settlement = CallGraphStoreBuildSettlement::new(tx, force_token, persist_epoch);
5357 crate::log_ctx::with_session(session_id, || {
5358 wait_on_callgraph_build_start_gate(&project_root);
5359 if persist_epoch_flag.current() != persist_epoch {
5360 crate::slog_info!(
5361 "callgraph store background work skipped for superseded epoch {}",
5362 persist_epoch
5363 );
5364 return;
5365 }
5366 let built = crate::callgraph_store::with_publish_epoch(
5367 persist_epoch_flag.clone(),
5368 persist_epoch,
5369 || match work {
5370 CallgraphBackgroundWork::LegacyMigration => {
5371 CallGraphStore::migrate_legacy_with_lease(
5372 callgraph_dir.clone(),
5373 project_root.clone(),
5374 )
5375 }
5376 CallgraphBackgroundWork::ForceRebuild(_) => {
5377 let files = crate::callgraph::walk_project_files(&project_root)
5378 .collect::<Vec<_>>();
5379 CallGraphStore::force_cold_build_with_lease_chunked(
5380 callgraph_dir.clone(),
5381 project_root.clone(),
5382 &files,
5383 chunk_size,
5384 )
5385 .map(|(store, _)| Some(store))
5386 }
5387 CallgraphBackgroundWork::Ensure => {
5388 let files = crate::callgraph::walk_project_files(&project_root)
5389 .collect::<Vec<_>>();
5390 CallGraphStore::ensure_built_with_lease_chunked(
5391 callgraph_dir.clone(),
5392 project_root.clone(),
5393 &files,
5394 chunk_size,
5395 )
5396 .map(|(store, _)| Some(store))
5397 }
5398 },
5399 );
5400 match built {
5401 Ok(Some(store)) => {
5402 if store.is_legacy_migration() {
5403 match crate::callgraph_store::all_legacy_partitions_migrated_for_keys(
5404 &callgraph_dir,
5405 &configured_keys,
5406 ) {
5407 Ok(true)
5408 if summary_logged
5409 .compare_exchange(
5410 false,
5411 true,
5412 Ordering::SeqCst,
5413 Ordering::SeqCst,
5414 )
5415 .is_ok() =>
5416 {
5417 crate::slog_info!(
5418 "all legacy callgraph partitions migrated for configured roots"
5419 );
5420 }
5421 Ok(_) => {}
5422 Err(error) => crate::slog_warn!(
5423 "failed to inspect legacy callgraph migration completion: {}",
5424 error
5425 ),
5426 }
5427 }
5428 if persist_epoch_flag.is_current(persist_epoch) {
5429 settlement.ready(store);
5430 } else {
5431 crate::slog_info!(
5432 "callgraph store warm build result discarded for superseded publication epoch {}",
5433 persist_epoch
5434 );
5435 }
5436 }
5437 Ok(None) => {}
5438 Err(crate::callgraph_store::CallGraphStoreError::Superseded) => {
5439 crate::slog_info!(
5440 "callgraph store disk publication skipped for superseded epoch {}",
5441 persist_epoch
5442 );
5443 }
5444 Err(crate::callgraph_store::CallGraphStoreError::Suspended(suspension)) => {
5445 crate::slog_warn!(
5446 "callgraph store background work suspended: {}",
5447 suspension.reason
5448 );
5449 settlement.suspended(suspension);
5450 }
5451 Err(crate::callgraph_store::CallGraphStoreError::Unavailable(reason))
5452 if reason.ends_with("could not acquire writer capability") =>
5453 {
5454 crate::slog_warn!(
5455 "callgraph store background work denied writer capability: {}",
5456 reason
5457 );
5458 settlement.denied(reason);
5459 }
5460 Err(error) => {
5461 crate::slog_warn!("callgraph store background work failed: {}", error);
5462 }
5463 }
5464 });
5465 crate::logging::release_index_build_start_waiters(
5466 crate::logging::IndexPlane::Callgraph,
5467 &project_root,
5468 );
5469 });
5470 true
5471 }
5472
5473 pub fn callgraph_store_rx(
5476 &self,
5477 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>> {
5478 &self.callgraph_store_rx
5479 }
5480
5481 #[doc(hidden)]
5485 pub fn with_current_callgraph_store_rx<R>(
5486 &self,
5487 generation: u64,
5488 epoch: u64,
5489 action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<CallGraphStoreBuildEvent>>) -> R,
5490 ) -> Option<R> {
5491 self.run_if_subc_bound_generation(generation, || {
5492 let mut receiver = self.callgraph_store_rx.lock();
5493 if receiver.is_none()
5494 || self.callgraph_store_rx_generation() != generation
5495 || self.callgraph_store_rx_epoch() != epoch
5496 {
5497 return None;
5498 }
5499 Some(action(&mut receiver))
5500 })
5501 .flatten()
5502 }
5503
5504 pub(crate) fn retire_callgraph_store_rx(&self) {
5505 let mut receiver = self.callgraph_store_rx.lock();
5506 *receiver = None;
5507 self.next_callgraph_store_rx_epoch();
5508 }
5509
5510 pub(crate) fn adopt_callgraph_store_rx_generation(&self, generation: u64) -> bool {
5513 let receiver = self.callgraph_store_rx.lock();
5514 if receiver.is_none() {
5515 return false;
5516 }
5517 self.note_callgraph_store_rx_generation(generation);
5518 true
5519 }
5520
5521 pub(crate) fn note_callgraph_store_rx_generation(&self, generation: u64) {
5522 self.callgraph_store_rx_generation
5523 .store(generation, Ordering::SeqCst);
5524 }
5525
5526 #[doc(hidden)]
5527 pub fn callgraph_store_rx_generation(&self) -> u64 {
5528 self.callgraph_store_rx_generation.load(Ordering::SeqCst)
5529 }
5530
5531 pub(crate) fn next_callgraph_store_rx_epoch(&self) -> u64 {
5532 self.callgraph_store_rx_epoch
5533 .fetch_add(1, Ordering::SeqCst)
5534 .wrapping_add(1)
5535 }
5536
5537 #[doc(hidden)]
5538 pub fn callgraph_store_rx_epoch(&self) -> u64 {
5539 self.callgraph_store_rx_epoch.load(Ordering::SeqCst)
5540 }
5541
5542 pub(crate) fn next_callgraph_persist_epoch(&self) -> u64 {
5543 self.callgraph_persist_epoch.next()
5544 }
5545
5546 #[doc(hidden)]
5547 pub fn callgraph_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
5548 self.callgraph_persist_epoch.clone()
5549 }
5550
5551 pub fn add_pending_callgraph_store_paths<I>(&self, paths: I)
5554 where
5555 I: IntoIterator<Item = PathBuf>,
5556 {
5557 self.pending_callgraph_store_paths.lock().extend(paths);
5558 }
5559
5560 pub fn enqueue_callgraph_store_refresh<I>(&self, paths: I) -> bool
5561 where
5562 I: IntoIterator<Item = PathBuf>,
5563 {
5564 let generation = self.configure_generation();
5565 self.enqueue_callgraph_store_refresh_for_generation(paths, generation)
5566 }
5567
5568 pub(crate) fn enqueue_callgraph_store_refresh_for_generation<I>(
5569 &self,
5570 paths: I,
5571 generation: u64,
5572 ) -> bool
5573 where
5574 I: IntoIterator<Item = PathBuf>,
5575 {
5576 let paths = paths.into_iter().collect::<Vec<_>>();
5577 if paths.is_empty() {
5578 return true;
5579 }
5580 if !self.config().callgraph_store || !self.heavy_root_work_allowed() {
5583 return true;
5584 }
5585 self.run_if_subc_bound_generation(generation, || {
5586 if !self.callgraph_writer() {
5587 self.add_pending_callgraph_store_paths(paths);
5588 return false;
5589 }
5590 let Some(project_root) = self.callgraph_project_root() else {
5591 self.add_pending_callgraph_store_paths(paths);
5592 return false;
5593 };
5594
5595 let ticket = crate::callgraph_store::CallgraphRefreshTicket::new(
5600 self.subc_lifecycle_admission(),
5601 self.configure_generation_flag(),
5602 generation,
5603 self.callgraph_persist_epoch_flag(),
5604 self.callgraph_persist_epoch_flag().current(),
5605 );
5606 crate::callgraph_store::enqueue_callgraph_store_refresh_fenced_with_state(
5607 self.callgraph_store_dir(),
5608 project_root,
5609 paths,
5610 Arc::clone(&self.pending_callgraph_store_paths),
5611 crate::callgraph_store::CallgraphRefreshState::new(
5612 Arc::clone(&self.callgraph_store),
5613 Arc::clone(&self.heavy_root_work_allowed),
5614 ),
5615 ticket,
5616 )
5617 })
5618 .unwrap_or(false)
5619 }
5620
5621 pub fn take_pending_callgraph_store_paths(&self) -> Vec<PathBuf> {
5629 let roots: Vec<PathBuf> = [
5630 self.canonical_cache_root_opt(),
5631 self.config().project_root.clone(),
5632 ]
5633 .into_iter()
5634 .flatten()
5635 .collect();
5636 std::mem::take(&mut *self.pending_callgraph_store_paths.lock())
5637 .into_iter()
5638 .filter(|path| {
5639 let in_root = pending_path_in_roots(path, &roots);
5640 if !in_root {
5641 crate::slog_debug!(
5642 "dropping pending callgraph path outside current root: {}",
5643 path.display()
5644 );
5645 }
5646 in_root
5647 })
5648 .collect()
5649 }
5650
5651 pub fn search_index(&self) -> &RwLock<Option<SearchIndex>> {
5653 &self.search_index
5654 }
5655
5656 pub(crate) fn search_exact_memo(
5657 &self,
5658 ) -> Arc<crate::commands::semantic_search::memo::ExactMemoStore> {
5659 Arc::clone(&self.search_exact_memo)
5660 }
5661
5662 pub fn search_index_rx(&self) -> &RwLock<Option<crossbeam_channel::Receiver<SearchIndex>>> {
5664 &self.search_index_rx
5665 }
5666
5667 pub(crate) fn install_search_index_rx(
5668 &self,
5669 receiver: crossbeam_channel::Receiver<SearchIndex>,
5670 generation: u64,
5671 ) -> u64 {
5672 let mut slot = self
5673 .search_index_rx
5674 .write()
5675 .unwrap_or_else(std::sync::PoisonError::into_inner);
5676 self.note_search_index_rx_generation(generation);
5677 let epoch = self.next_search_index_rx_epoch();
5678 *slot = Some(receiver);
5679 epoch
5680 }
5681
5682 pub(crate) fn search_index_rx_terminal_guard(&self, epoch: u64) -> ReceiverTerminalGuard {
5683 ReceiverTerminalGuard::new(Arc::clone(&self.search_index_rx_terminal_epoch), epoch)
5684 }
5685
5686 pub(crate) fn with_current_search_index_rx<R>(
5689 &self,
5690 generation: u64,
5691 epoch: u64,
5692 action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<SearchIndex>>) -> R,
5693 ) -> Option<R> {
5694 self.run_if_subc_bound_generation(generation, || {
5695 let mut receiver = self
5696 .search_index_rx
5697 .write()
5698 .unwrap_or_else(std::sync::PoisonError::into_inner);
5699 if receiver.is_none()
5700 || self.search_index_rx_generation() != generation
5701 || self.search_index_rx_epoch() != epoch
5702 {
5703 return None;
5704 }
5705 Some(action(&mut receiver))
5706 })
5707 .flatten()
5708 }
5709
5710 pub(crate) fn retire_search_index_rx(&self) {
5711 let mut receiver = self
5712 .search_index_rx
5713 .write()
5714 .unwrap_or_else(std::sync::PoisonError::into_inner);
5715 *receiver = None;
5716 self.next_search_index_rx_epoch();
5717 }
5718
5719 pub(crate) fn note_search_index_rx_generation(&self, generation: u64) {
5720 self.search_index_rx_generation
5721 .store(generation, Ordering::SeqCst);
5722 }
5723
5724 pub(crate) fn search_index_rx_generation(&self) -> u64 {
5725 self.search_index_rx_generation.load(Ordering::SeqCst)
5726 }
5727
5728 pub(crate) fn next_search_index_rx_epoch(&self) -> u64 {
5729 self.search_index_rx_epoch
5730 .fetch_add(1, Ordering::SeqCst)
5731 .wrapping_add(1)
5732 }
5733
5734 pub(crate) fn search_index_rx_epoch(&self) -> u64 {
5735 self.search_index_rx_epoch.load(Ordering::SeqCst)
5736 }
5737
5738 pub(crate) fn allow_search_index_disconnect_reschedule(&self) -> bool {
5742 const MAX_REPLACEMENTS_PER_GENERATION: u32 = 1;
5743 const QUERY_RETRY_COOLDOWN: Duration = Duration::from_secs(60);
5744 let generation = self.configure_generation();
5745 let mut state = self.search_index_disconnect_reschedule.lock();
5746 if state.0 != generation {
5747 *state = (generation, 0, None);
5748 }
5749 if state.1 >= MAX_REPLACEMENTS_PER_GENERATION {
5750 state.2 = Some(Instant::now() + QUERY_RETRY_COOLDOWN);
5751 return false;
5752 }
5753 state.1 += 1;
5754 true
5755 }
5756
5757 pub(crate) fn search_index_query_reload_allowed(&self) -> bool {
5758 let generation = self.configure_generation();
5759 let now = Instant::now();
5760 let mut state = self.search_index_disconnect_reschedule.lock();
5761 if state.0 != generation {
5762 *state = (generation, 0, None);
5763 return true;
5764 }
5765 let Some(retry_at) = state.2 else {
5766 return true;
5767 };
5768 if now < retry_at {
5769 return false;
5770 }
5771 state.2 = Some(now + Duration::from_secs(60));
5775 true
5776 }
5777
5778 pub(crate) fn note_search_index_load_succeeded(&self) {
5779 let generation = self.configure_generation();
5780 let mut state = self.search_index_disconnect_reschedule.lock();
5781 if state.0 == generation {
5782 state.2 = None;
5783 }
5784 }
5785
5786 pub(crate) fn next_search_persist_epoch(&self) -> u64 {
5787 self.search_persist_epoch.next()
5788 }
5789
5790 pub(crate) fn search_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
5791 self.search_persist_epoch.clone()
5792 }
5793
5794 pub fn add_pending_search_index_paths<I>(&self, paths: I)
5795 where
5796 I: IntoIterator<Item = PathBuf>,
5797 {
5798 let paths = paths.into_iter().collect::<Vec<_>>();
5799 if !paths.is_empty() {
5800 self.invalidate_warm_verify_memo();
5801 self.pending_search_index_paths.lock().extend(paths);
5802 }
5803 }
5804
5805 pub fn take_pending_search_index_paths(&self) -> Vec<PathBuf> {
5806 std::mem::take(&mut *self.pending_search_index_paths.lock())
5807 .into_iter()
5808 .collect()
5809 }
5810
5811 pub fn add_pending_semantic_index_paths<I>(&self, paths: I)
5812 where
5813 I: IntoIterator<Item = PathBuf>,
5814 {
5815 let paths = paths.into_iter().collect::<Vec<_>>();
5816 if !paths.is_empty() {
5817 self.invalidate_warm_verify_memo();
5818 self.pending_semantic_index_paths.lock().extend(paths);
5819 }
5820 }
5821
5822 pub(crate) fn invalidate_warm_verify_memo(&self) {
5823 if let Some(root) = self.canonical_cache_root_opt() {
5824 crate::cache_freshness::invalidate_verify_memo(&root);
5825 }
5826 }
5827
5828 pub fn take_pending_semantic_index_paths(&self) -> Vec<PathBuf> {
5829 std::mem::take(&mut *self.pending_semantic_index_paths.lock())
5830 .into_iter()
5831 .collect()
5832 }
5833
5834 pub fn mark_pending_semantic_corpus_refresh(&self) {
5835 *self.pending_semantic_corpus_refresh.lock() = true;
5836 }
5837
5838 pub fn take_pending_semantic_corpus_refresh(&self) -> bool {
5839 std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock())
5840 }
5841
5842 pub fn clear_pending_index_updates(&self) {
5843 self.clear_pending_index_updates_with_callgraph(true);
5844 }
5845
5846 pub(crate) fn clear_pending_index_updates_preserving_callgraph(&self) {
5847 self.clear_pending_index_updates_with_callgraph(false);
5848 }
5849
5850 fn clear_pending_index_updates_with_callgraph(&self, clear_callgraph: bool) {
5851 self.pending_search_index_paths.lock().clear();
5852 if clear_callgraph {
5853 self.pending_callgraph_store_paths.lock().clear();
5854 }
5855 self.pending_tier2_paths.lock().clear();
5856 self.pending_semantic_index_paths.lock().clear();
5857 *self.pending_semantic_corpus_refresh.lock() = false;
5858 }
5859
5860 pub(crate) fn take_pending_reconciliation_state(&self) -> PendingReconciliationState {
5868 PendingReconciliationState {
5869 search: std::mem::take(&mut *self.pending_search_index_paths.lock()),
5870 callgraph: std::mem::take(&mut *self.pending_callgraph_store_paths.lock()),
5871 tier2: std::mem::take(&mut *self.pending_tier2_paths.lock()),
5872 semantic: std::mem::take(&mut *self.pending_semantic_index_paths.lock()),
5873 corpus_refresh: std::mem::take(&mut *self.pending_semantic_corpus_refresh.lock()),
5874 }
5875 }
5876
5877 pub(crate) fn restore_pending_reconciliation_state(&self, state: PendingReconciliationState) {
5878 self.pending_search_index_paths.lock().extend(state.search);
5879 self.pending_callgraph_store_paths
5880 .lock()
5881 .extend(state.callgraph);
5882 self.pending_tier2_paths.lock().extend(state.tier2);
5883 self.pending_semantic_index_paths
5884 .lock()
5885 .extend(state.semantic);
5886 if state.corpus_refresh {
5887 *self.pending_semantic_corpus_refresh.lock() = true;
5888 }
5889 }
5890
5891 pub(crate) fn cancel_unbound_artifact_work(&self) {
5905 let search_refresh_cancelled = self
5911 .search_index_rx
5912 .read()
5913 .unwrap_or_else(std::sync::PoisonError::into_inner)
5914 .is_some();
5915 self.retire_search_index_rx();
5916 if search_refresh_cancelled {
5917 let mut resident = self
5918 .search_index
5919 .write()
5920 .unwrap_or_else(std::sync::PoisonError::into_inner);
5921 if resident.as_ref().is_some_and(|index| !index.ready) {
5922 *resident = None;
5923 }
5924 }
5925 self.retire_callgraph_store_rx();
5926 let semantic_cancelled = self.semantic_index_rx.lock().is_some();
5927 self.retire_semantic_index_rx();
5928 let semantic_refresh_cancelled = self.semantic_refresh_event_rx.lock().is_some();
5929 self.clear_semantic_refresh_worker();
5930 self.reset_semantic_cold_seed_gate_for_configure();
5931 let _ = self.inspect_manager.discard_completions();
5932 let _ = self.take_new_reuse_completions();
5933 if semantic_cancelled || semantic_refresh_cancelled {
5934 let has_index = self
5935 .semantic_index
5936 .read()
5937 .unwrap_or_else(std::sync::PoisonError::into_inner)
5938 .is_some();
5939 {
5943 let mut status = self
5944 .semantic_index_status
5945 .write()
5946 .unwrap_or_else(std::sync::PoisonError::into_inner);
5947 let refreshing = status.take_refreshing_files();
5948 if !refreshing.is_empty() {
5949 self.pending_semantic_index_paths.lock().extend(refreshing);
5950 }
5951 if status.corpus_refresh_in_flight() {
5952 *self.pending_semantic_corpus_refresh.lock() = true;
5953 }
5954 *status = if has_index {
5955 SemanticIndexStatus::ready()
5956 } else {
5957 SemanticIndexStatus::Disabled
5958 };
5959 }
5960 self.set_semantic_build_progress(None);
5961 }
5962 }
5963
5964 pub(crate) fn invalidate_artifacts_after_watcher_gap(&self) {
5968 self.next_search_persist_epoch();
5969 self.next_semantic_persist_epoch();
5970 self.next_callgraph_persist_epoch();
5971
5972 self.search_index
5973 .write()
5974 .unwrap_or_else(std::sync::PoisonError::into_inner)
5975 .take();
5976 self.semantic_index
5977 .write()
5978 .unwrap_or_else(std::sync::PoisonError::into_inner)
5979 .take();
5980 self.callgraph_store
5981 .write()
5982 .unwrap_or_else(std::sync::PoisonError::into_inner)
5983 .take();
5984 *self
5990 .semantic_index_status
5991 .write()
5992 .unwrap_or_else(std::sync::PoisonError::into_inner) = if self.config().semantic_search {
5993 SemanticIndexStatus::ready()
5994 } else {
5995 SemanticIndexStatus::Disabled
5996 };
5997 if self.callgraph_writer() {
6001 self.mark_callgraph_store_force_rebuild();
6002 }
6003
6004 if let Some(root) = self
6005 .canonical_cache_root_opt()
6006 .or_else(|| self.config().project_root.clone())
6007 {
6008 crate::cache_freshness::invalidate_verify_memo_strict(&root);
6009 }
6010 self.borrowed_index_cache.lock().clear();
6011 self.inspect_manager.evict_idle_caches();
6012 self.reset_symbol_cache();
6013 self.clear_tsconfig_membership_cache();
6014 }
6015
6016 fn drain_search_index_events_for_graceful_shutdown(&self) {
6017 crate::runtime_drain::drain_watcher_events(self);
6018 crate::runtime_drain::drain_search_index_events(self);
6019 }
6020
6021 fn search_index_build_in_progress(&self) -> bool {
6022 self.search_index_rx()
6023 .read()
6024 .unwrap_or_else(std::sync::PoisonError::into_inner)
6025 .is_some()
6026 }
6027
6028 fn wait_for_search_index_build_to_settle_on_graceful_shutdown(&self) {
6032 crate::runtime_drain::note_search_rebuild_shutdown_wait_for_test();
6033 let deadline = Instant::now() + GRACEFUL_SHUTDOWN_SEARCH_BUILD_WAIT;
6034 while self.search_index_build_in_progress() && Instant::now() < deadline {
6035 let remaining = deadline.saturating_duration_since(Instant::now());
6036 std::thread::sleep(remaining.min(GRACEFUL_SHUTDOWN_SEARCH_BUILD_POLL));
6037 self.drain_search_index_events_for_graceful_shutdown();
6038 }
6039 }
6040
6041 #[doc(hidden)]
6048 pub fn flush_search_index_on_graceful_shutdown(&self) -> bool {
6049 if self.shared_artifacts_read_only() {
6050 return false;
6051 }
6052
6053 self.drain_search_index_events_for_graceful_shutdown();
6054 if self.search_index_build_in_progress() {
6055 self.wait_for_search_index_build_to_settle_on_graceful_shutdown();
6056 self.drain_search_index_events_for_graceful_shutdown();
6057 }
6058
6059 if self.search_index_build_in_progress() {
6060 return false;
6061 }
6062
6063 let Some(canonical_root) = self.canonical_cache_root_opt() else {
6064 return false;
6065 };
6066 let config = self.config();
6067 let project_key = self.memoized_artifact_cache_key(&canonical_root);
6068 let cache_dir = crate::search_index::resolve_cache_dir_with_key(
6069 &project_key,
6070 config.storage_dir.as_deref(),
6071 );
6072
6073 {
6074 let search_index = self
6075 .search_index()
6076 .read()
6077 .unwrap_or_else(std::sync::PoisonError::into_inner);
6078 let Some(index) = search_index.as_ref() else {
6079 return false;
6080 };
6081 if !index.ready || !index.has_pending_disk_changes() {
6082 return false;
6083 }
6084 }
6085
6086 let _cache_lock = match crate::search_index::CacheLock::try_acquire_for_shutdown(
6087 &cache_dir,
6088 &canonical_root,
6089 ) {
6090 Ok(lock) => lock,
6091 Err(error) => {
6092 crate::slog_warn!(
6093 "search index: skipped shutdown flush because cache lock was unavailable: {}",
6094 error
6095 );
6096 return false;
6097 }
6098 };
6099
6100 let mut search_index = self
6101 .search_index()
6102 .write()
6103 .unwrap_or_else(std::sync::PoisonError::into_inner);
6104 let Some(index) = search_index.as_mut() else {
6105 return false;
6106 };
6107 if !index.ready || !index.has_pending_disk_changes() {
6108 return false;
6109 }
6110
6111 let git_head = index.stored_git_head().map(str::to_owned);
6112 index.write_to_disk(&cache_dir, git_head.as_deref())
6113 }
6114
6115 pub fn inspect_manager(&self) -> Arc<InspectManager> {
6116 Arc::clone(&self.inspect_manager)
6117 }
6118
6119 pub(crate) fn set_standing_artifact_exempt(&self, exempt: bool) {
6122 self.standing_artifact_exempt
6123 .store(exempt, Ordering::Release);
6124 }
6125
6126 pub(crate) fn cold_build_limiter(&self) -> Arc<crate::cold_build_limiter::ColdBuildLimiter> {
6127 Arc::clone(
6128 &self
6129 .cold_build_limiter
6130 .read()
6131 .unwrap_or_else(std::sync::PoisonError::into_inner),
6132 )
6133 }
6134
6135 #[doc(hidden)]
6138 pub fn isolate_cold_build_limiter_for_test(&self, limit: usize) {
6139 let limiter = crate::cold_build_limiter::isolated_limiter(limit);
6140 self.inspect_manager
6141 .set_cold_build_limiter(Arc::clone(&limiter));
6142 *self
6143 .cold_build_limiter
6144 .write()
6145 .unwrap_or_else(std::sync::PoisonError::into_inner) = limiter;
6146 }
6147
6148 pub fn add_pending_tier2_paths<I>(&self, paths: I)
6149 where
6150 I: IntoIterator<Item = PathBuf>,
6151 {
6152 self.pending_tier2_paths.lock().extend(paths);
6153 }
6154
6155 pub fn pending_tier2_paths(&self) -> Vec<PathBuf> {
6156 self.pending_tier2_paths.lock().iter().cloned().collect()
6157 }
6158
6159 pub fn remove_pending_tier2_paths<I>(&self, paths: I)
6160 where
6161 I: IntoIterator<Item = PathBuf>,
6162 {
6163 let mut pending = self.pending_tier2_paths.lock();
6164 for path in paths {
6165 pending.remove(&path);
6166 }
6167 }
6168
6169 pub fn has_new_reuse_completions(&self) -> bool {
6177 self.inspect_manager.reuse_completion_count()
6178 != self.last_seen_reuse_completions.load(Ordering::SeqCst)
6179 }
6180
6181 pub fn take_new_reuse_completions(&self) -> bool {
6182 let current = self.inspect_manager.reuse_completion_count();
6183 let previous = self
6184 .last_seen_reuse_completions
6185 .swap(current, Ordering::SeqCst);
6186 current != previous
6187 }
6188
6189 pub fn reset_tier2_refresh_scheduler(&self) {
6190 self.reset_tier2_refresh_scheduler_at(Instant::now());
6191 }
6192
6193 #[doc(hidden)]
6194 pub fn reset_tier2_refresh_scheduler_at(&self, now: Instant) {
6195 self.tier2_refresh_scheduler
6196 .lock()
6197 .reset_after_configure(now);
6198 }
6199
6200 pub fn request_tier2_refresh_pull(&self) -> bool {
6201 let can_schedule = self.inspect_writer()
6202 && self.heavy_root_work_allowed()
6203 && self.inspect_manager.automatic_tier2_refresh_allowed();
6204 self.tier2_refresh_scheduler
6205 .lock()
6206 .request_pull(can_schedule)
6207 }
6208
6209 pub fn tick_tier2_refresh_scheduler(
6210 &self,
6211 changed_path_count: usize,
6212 ) -> Option<Tier2TriggerReason> {
6213 self.tick_tier2_refresh_scheduler_at(Instant::now(), changed_path_count)
6214 }
6215
6216 #[doc(hidden)]
6217 pub fn tick_tier2_refresh_scheduler_at(
6218 &self,
6219 now: Instant,
6220 changed_path_count: usize,
6221 ) -> Option<Tier2TriggerReason> {
6222 let manager = self.inspect_manager();
6223 let can_write = self.inspect_writer()
6224 && self.heavy_root_work_allowed()
6225 && manager.automatic_tier2_refresh_allowed();
6226 let in_flight = manager.tier2_any_in_flight();
6227 let semantic_cold_seed_active = self.semantic_cold_seed_active();
6228 let decision = self.tier2_refresh_scheduler.lock().tick_with_semantic_gate(
6229 now,
6230 changed_path_count,
6231 can_write,
6232 in_flight,
6233 semantic_cold_seed_active,
6234 );
6235
6236 if let Some(reason) = decision {
6237 self.start_tier2_refresh(reason, manager);
6238 }
6239
6240 decision
6241 }
6242
6243 pub fn note_tier2_refresh_started(&self) {
6244 self.note_tier2_refresh_started_at(Instant::now());
6245 }
6246
6247 #[doc(hidden)]
6248 pub fn note_tier2_refresh_started_at(&self, now: Instant) {
6249 self.tier2_refresh_scheduler
6250 .lock()
6251 .note_external_scan_started(now);
6252 }
6253
6254 pub fn tier2_trigger_reason(&self) -> Option<&'static str> {
6255 self.tier2_refresh_scheduler
6256 .lock()
6257 .last_trigger_reason()
6258 .map(Tier2TriggerReason::as_str)
6259 }
6260
6261 #[doc(hidden)]
6262 pub fn tier2_pull_demand_pending(&self) -> bool {
6263 self.tier2_refresh_scheduler.lock().pull_demand_pending()
6264 }
6265
6266 fn start_tier2_refresh(&self, reason: Tier2TriggerReason, manager: Arc<InspectManager>) {
6267 let generation = self.configure_generation();
6268 if !self.inspect_writer()
6269 || !self.heavy_root_work_allowed()
6270 || !manager.automatic_tier2_refresh_allowed()
6271 || !self.config().inspect.enabled
6272 {
6273 return;
6274 }
6275 let _ = self.run_if_subc_bound_generation(generation, || {
6276 self.start_tier2_refresh_admitted(reason, manager);
6277 });
6278 }
6279
6280 fn start_tier2_refresh_admitted(
6281 &self,
6282 reason: Tier2TriggerReason,
6283 manager: Arc<InspectManager>,
6284 ) {
6285 let Some(snapshot) = self.tier2_refresh_snapshot() else {
6286 return;
6287 };
6288 let categories = Self::automatic_tier2_refresh_categories(&snapshot);
6289 let submission =
6290 manager.submit_tier2_run_with_reuse_serial_background(snapshot, categories);
6291 if !submission.deferred_categories.is_empty() {
6292 self.tier2_refresh_scheduler.lock().note_dispatch_deferred();
6293 crate::slog_info!(
6294 "tier2 refresh deferred by cold build limit: categories={:?}",
6295 submission
6296 .deferred_categories
6297 .iter()
6298 .map(|category| category.as_str())
6299 .collect::<Vec<_>>()
6300 );
6301 }
6302 if submission.has_new_work() {
6303 crate::slog_info!(
6304 "tier2 refresh scheduled: reason={}, categories={:?}",
6305 reason.as_str(),
6306 submission
6307 .newly_queued_categories
6308 .iter()
6309 .map(|category| category.as_str())
6310 .collect::<Vec<_>>()
6311 );
6312 }
6313 for error in submission.errors {
6314 crate::slog_warn!(
6315 "tier2 refresh schedule failed for {}: {}",
6316 error.category,
6317 error.message
6318 );
6319 }
6320 }
6321
6322 fn automatic_tier2_refresh_categories(snapshot: &InspectSnapshot) -> Vec<InspectCategory> {
6323 let callgraph_store_enabled = snapshot.config.callgraph_store;
6324 InspectCategory::active()
6325 .iter()
6326 .copied()
6327 .filter(|category| category.is_tier2())
6328 .filter(|category| {
6329 if *category == InspectCategory::DeadCode && !callgraph_store_enabled {
6330 return false;
6334 }
6335 true
6336 })
6337 .collect()
6338 }
6339
6340 #[doc(hidden)]
6341 pub fn automatic_tier2_refresh_categories_for_test(&self) -> Vec<InspectCategory> {
6342 self.tier2_refresh_snapshot()
6343 .map(|snapshot| Self::automatic_tier2_refresh_categories(&snapshot))
6344 .unwrap_or_default()
6345 }
6346
6347 fn tier2_refresh_snapshot(&self) -> Option<InspectSnapshot> {
6348 self.harness_opt()?;
6349 let config = self.config();
6350 let project_root = config
6351 .project_root
6352 .clone()
6353 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
6354 let project_root = crate::inspect::job::canonicalize_normalized(&project_root);
6358 Some(InspectSnapshot::new_with_capabilities(
6359 project_root,
6360 self.inspect_dir(),
6361 config,
6362 self.symbol_cache(),
6363 self.inspect_writer(),
6364 self.callgraph_writer(),
6365 ))
6366 }
6367
6368 pub fn symbol_cache(&self) -> SharedSymbolCache {
6370 Arc::clone(&self.symbol_cache)
6371 }
6372
6373 pub fn reset_symbol_cache(&self) -> u64 {
6375 self.symbol_cache
6376 .write()
6377 .map(|mut cache| cache.reset())
6378 .unwrap_or(0)
6379 }
6380
6381 pub fn semantic_index(&self) -> &RwLock<Option<SemanticIndex>> {
6383 &self.semantic_index
6384 }
6385
6386 pub fn semantic_index_rx(
6388 &self,
6389 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticIndexEvent>>> {
6390 &self.semantic_index_rx
6391 }
6392
6393 pub(crate) fn install_semantic_index_rx(
6394 &self,
6395 receiver: crossbeam_channel::Receiver<SemanticIndexEvent>,
6396 generation: u64,
6397 ) -> u64 {
6398 let mut slot = self.semantic_index_rx.lock();
6399 self.note_semantic_index_rx_generation(generation);
6400 let epoch = self.next_semantic_index_rx_epoch();
6401 *slot = Some(receiver);
6402 epoch
6403 }
6404
6405 pub(crate) fn semantic_index_rx_terminal_guard(&self, epoch: u64) -> ReceiverTerminalGuard {
6406 ReceiverTerminalGuard::new(Arc::clone(&self.semantic_index_rx_terminal_epoch), epoch)
6407 }
6408
6409 pub(crate) fn with_current_semantic_index_rx<R>(
6412 &self,
6413 generation: u64,
6414 epoch: u64,
6415 action: impl FnOnce(&mut Option<crossbeam_channel::Receiver<SemanticIndexEvent>>) -> R,
6416 ) -> Option<R> {
6417 self.run_if_subc_bound_generation(generation, || {
6418 let mut receiver = self.semantic_index_rx.lock();
6419 if receiver.is_none()
6420 || self.semantic_index_rx_generation() != generation
6421 || self.semantic_index_rx_epoch() != epoch
6422 {
6423 return None;
6424 }
6425 Some(action(&mut receiver))
6426 })
6427 .flatten()
6428 }
6429
6430 pub(crate) fn retire_semantic_index_rx(&self) {
6431 let mut receiver = self.semantic_index_rx.lock();
6432 *receiver = None;
6433 self.next_semantic_index_rx_epoch();
6434 }
6435
6436 pub(crate) fn adopt_semantic_index_rx_generation(&self, generation: u64) -> bool {
6440 let receiver = self.semantic_index_rx.lock();
6441 if receiver.is_none() {
6442 return false;
6443 }
6444 self.note_semantic_index_rx_generation(generation);
6445 true
6446 }
6447
6448 pub(crate) fn retire_semantic_index_rx_if_epoch(&self, expected_epoch: u64) -> Option<bool> {
6452 let mut receiver = self.semantic_index_rx.lock();
6453 if self.semantic_index_rx_epoch() != expected_epoch {
6454 return None;
6455 }
6456 let retired = receiver.take().is_some();
6457 if retired {
6458 self.next_semantic_index_rx_epoch();
6459 }
6460 Some(retired)
6461 }
6462
6463 pub(crate) fn note_semantic_index_rx_generation(&self, generation: u64) {
6464 self.semantic_index_rx_generation
6465 .store(generation, Ordering::SeqCst);
6466 }
6467
6468 pub(crate) fn semantic_index_rx_generation(&self) -> u64 {
6469 self.semantic_index_rx_generation.load(Ordering::SeqCst)
6470 }
6471
6472 pub(crate) fn next_semantic_index_rx_epoch(&self) -> u64 {
6473 self.semantic_index_rx_epoch
6474 .fetch_add(1, Ordering::SeqCst)
6475 .wrapping_add(1)
6476 }
6477
6478 pub(crate) fn semantic_index_rx_epoch(&self) -> u64 {
6479 self.semantic_index_rx_epoch.load(Ordering::SeqCst)
6480 }
6481
6482 pub(crate) fn next_semantic_persist_epoch(&self) -> u64 {
6483 self.semantic_persist_epoch.next()
6484 }
6485
6486 pub(crate) fn semantic_persist_epoch_flag(&self) -> crate::root_cache::ArtifactPublishEpoch {
6487 self.semantic_persist_epoch.clone()
6488 }
6489
6490 pub(crate) fn semantic_persist_lock(&self) -> Arc<parking_lot::Mutex<()>> {
6491 Arc::clone(&self.semantic_persist_lock)
6492 }
6493
6494 pub fn semantic_index_status(&self) -> &RwLock<SemanticIndexStatus> {
6495 &self.semantic_index_status
6496 }
6497
6498 pub(crate) fn set_semantic_build_progress(&self, progress: Option<SemanticBuildProgress>) {
6499 *self
6500 .semantic_build_progress
6501 .write()
6502 .unwrap_or_else(std::sync::PoisonError::into_inner) = progress;
6503 }
6504
6505 pub(crate) fn semantic_build_progress(&self) -> Option<SemanticBuildProgress> {
6506 self.semantic_build_progress
6507 .read()
6508 .unwrap_or_else(std::sync::PoisonError::into_inner)
6509 .clone()
6510 }
6511
6512 pub(crate) fn artifact_reload_guard(&self) -> parking_lot::MutexGuard<'_, ()> {
6513 self.artifact_reload_lock.lock()
6514 }
6515
6516 pub fn reset_semantic_cold_seed_gate_for_configure(&self) -> u64 {
6519 self.semantic_cold_seed_active
6520 .store(false, Ordering::SeqCst);
6521 self.semantic_cold_seed_generation
6522 .fetch_add(1, Ordering::SeqCst)
6523 .wrapping_add(1)
6524 }
6525
6526 pub fn semantic_cold_seed_active_flag(&self) -> Arc<AtomicBool> {
6527 Arc::clone(&self.semantic_cold_seed_active)
6528 }
6529
6530 pub fn semantic_cold_seed_generation_flag(&self) -> Arc<AtomicU64> {
6531 Arc::clone(&self.semantic_cold_seed_generation)
6532 }
6533
6534 pub fn semantic_cold_seed_generation(&self) -> u64 {
6535 self.semantic_cold_seed_generation.load(Ordering::SeqCst)
6536 }
6537
6538 pub fn semantic_cold_seed_active(&self) -> bool {
6539 self.semantic_cold_seed_active.load(Ordering::SeqCst)
6540 }
6541
6542 pub fn schedule_semantic_cold_seed_gate_for_configure(&self) {
6543 self.semantic_cold_seed_active.store(true, Ordering::SeqCst);
6544 }
6545
6546 pub fn clear_semantic_cold_seed_gate_and_resume_deferred_work(&self) {
6550 self.resume_semantic_cold_seed_deferred_work(false);
6551 }
6552
6553 pub fn resume_deferred_work_after_semantic_cold_seed_gate_cleared(&self) {
6556 self.resume_semantic_cold_seed_deferred_work(true);
6557 }
6558
6559 pub(crate) fn take_semantic_cold_seed_resume(&self, force: bool) -> SemanticColdSeedResume {
6560 let was_active = self.semantic_cold_seed_active.swap(false, Ordering::SeqCst);
6561 SemanticColdSeedResume {
6562 request_tier2: force || was_active,
6563 }
6564 }
6565
6566 pub(crate) fn apply_semantic_cold_seed_resume(&self, resume: SemanticColdSeedResume) {
6567 if resume.request_tier2 {
6568 let _ = self.request_tier2_refresh_pull();
6569 }
6570 }
6571
6572 fn resume_semantic_cold_seed_deferred_work(&self, force: bool) {
6573 let resume = self.take_semantic_cold_seed_resume(force);
6574 self.apply_semantic_cold_seed_resume(resume);
6575 }
6576
6577 #[doc(hidden)]
6578 pub fn set_semantic_cold_seed_active_for_test(&self, active: bool) {
6579 self.semantic_cold_seed_active
6580 .store(active, Ordering::SeqCst);
6581 }
6582
6583 pub fn install_semantic_refresh_worker(
6584 &self,
6585 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
6586 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
6587 worker_slot: SemanticRefreshWorkerSlot,
6588 ) {
6589 self.install_semantic_refresh_worker_for_build_epoch(
6590 sender,
6591 event_rx,
6592 worker_slot,
6593 self.semantic_index_rx_epoch(),
6594 );
6595 }
6596
6597 pub(crate) fn install_semantic_refresh_worker_for_build_epoch(
6598 &self,
6599 sender: crossbeam_channel::Sender<SemanticRefreshRequest>,
6600 event_rx: crossbeam_channel::Receiver<SemanticRefreshEvent>,
6601 worker_slot: SemanticRefreshWorkerSlot,
6602 build_epoch: u64,
6603 ) {
6604 self.clear_semantic_refresh_worker();
6605 {
6606 let mut receiver = self.semantic_refresh_event_rx.lock();
6607 let mut request = self.semantic_refresh_tx.lock();
6608 let mut worker = self.semantic_refresh_worker.lock();
6609 self.semantic_refresh_generation
6610 .store(self.configure_generation(), Ordering::SeqCst);
6611 self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
6612 self.semantic_refresh_build_epoch
6613 .store(build_epoch, Ordering::SeqCst);
6614 *receiver = Some(event_rx);
6615 *request = Some(sender);
6616 *worker = Some(worker_slot);
6617 }
6618 }
6619
6620 pub(crate) fn semantic_refresh_generation(&self) -> u64 {
6621 self.semantic_refresh_generation.load(Ordering::SeqCst)
6622 }
6623
6624 pub(crate) fn semantic_refresh_epoch(&self) -> u64 {
6625 self.semantic_refresh_epoch.load(Ordering::SeqCst)
6626 }
6627
6628 pub(crate) fn with_current_semantic_refresh_rx<R>(
6631 &self,
6632 generation: u64,
6633 epoch: u64,
6634 action: impl FnOnce() -> R,
6635 ) -> Option<R> {
6636 self.run_if_subc_bound_generation(generation, || {
6637 let receiver = self.semantic_refresh_event_rx.lock();
6638 if receiver.is_none()
6639 || self.semantic_refresh_generation() != generation
6640 || self.semantic_refresh_epoch() != epoch
6641 {
6642 return None;
6643 }
6644 Some(action())
6645 })
6646 .flatten()
6647 }
6648
6649 pub(crate) fn clear_semantic_refresh_worker_if_current(
6650 &self,
6651 generation: u64,
6652 epoch: u64,
6653 ) -> Option<u64> {
6654 let worker_slot = {
6655 let mut receiver = self.semantic_refresh_event_rx.lock();
6656 if receiver.is_none()
6657 || self.semantic_refresh_generation() != generation
6658 || self.semantic_refresh_epoch() != epoch
6659 {
6660 return None;
6661 }
6662 let disconnected_build_epoch = self.semantic_refresh_build_epoch.load(Ordering::SeqCst);
6663 self.semantic_refresh_build_epoch.store(0, Ordering::SeqCst);
6664 let mut request = self.semantic_refresh_tx.lock();
6665 let mut worker = self.semantic_refresh_worker.lock();
6666 *receiver = None;
6667 *request = None;
6668 self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
6669 self.invalidate_semantic_refresh_probe();
6670 (worker.take(), disconnected_build_epoch)
6671 };
6672 if let Some(worker_slot) = worker_slot.0 {
6673 if let Ok(mut handle) = worker_slot.lock() {
6674 drop(handle.take());
6675 }
6676 }
6677 Some(worker_slot.1)
6678 }
6679
6680 pub fn clear_semantic_refresh_worker(&self) {
6681 let worker_slot = {
6682 let mut receiver = self.semantic_refresh_event_rx.lock();
6683 let mut request = self.semantic_refresh_tx.lock();
6684 let mut worker = self.semantic_refresh_worker.lock();
6685 *receiver = None;
6686 *request = None;
6687 self.semantic_refresh_epoch.fetch_add(1, Ordering::SeqCst);
6688 self.semantic_refresh_build_epoch.store(0, Ordering::SeqCst);
6689 self.invalidate_semantic_refresh_probe();
6690 worker.take()
6691 };
6692 if let Some(worker_slot) = worker_slot {
6693 if let Ok(mut handle) = worker_slot.lock() {
6694 drop(handle.take());
6695 }
6696 }
6697 }
6698
6699 pub fn semantic_refresh_sender(
6700 &self,
6701 ) -> Option<crossbeam_channel::Sender<SemanticRefreshRequest>> {
6702 self.semantic_refresh_tx.lock().clone()
6703 }
6704
6705 pub(crate) fn semantic_refresh_retry_slots(
6706 &self,
6707 ) -> (
6708 Arc<parking_lot::Mutex<Option<crossbeam_channel::Sender<SemanticRefreshRequest>>>>,
6709 Arc<parking_lot::Mutex<BTreeSet<PathBuf>>>,
6710 ) {
6711 (
6712 Arc::clone(&self.semantic_refresh_tx),
6713 Arc::clone(&self.pending_semantic_index_paths),
6714 )
6715 }
6716
6717 pub fn semantic_refresh_event_rx(
6718 &self,
6719 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<SemanticRefreshEvent>>> {
6720 &self.semantic_refresh_event_rx
6721 }
6722
6723 pub fn with_semantic_refresh_retry_attempts_mut<R>(
6724 &self,
6725 f: impl FnOnce(&mut BTreeMap<PathBuf, usize>) -> R,
6726 ) -> R {
6727 let mut attempts = self.semantic_refresh_retry_attempts.lock();
6728 f(&mut attempts)
6729 }
6730
6731 pub fn clear_semantic_refresh_retry_attempts(&self, paths: &[PathBuf]) {
6732 let mut attempts = self.semantic_refresh_retry_attempts.lock();
6733 for path in paths {
6734 attempts.remove(path);
6735 }
6736 }
6737
6738 pub fn clear_all_semantic_refresh_retry_attempts(&self) {
6739 self.semantic_refresh_retry_attempts.lock().clear();
6740 }
6741
6742 pub fn semantic_refresh_circuit_is_open(&self) -> bool {
6743 self.semantic_refresh_circuit.open.load(Ordering::SeqCst)
6744 }
6745
6746 pub fn record_semantic_refresh_transient_failure(
6747 &self,
6748 trip_threshold: usize,
6749 reason: &str,
6750 ) -> bool {
6751 let failures = self
6752 .semantic_refresh_circuit
6753 .consecutive_transient_failures
6754 .fetch_add(1, Ordering::SeqCst)
6755 .saturating_add(1);
6756 if failures >= trip_threshold
6757 && !self
6758 .semantic_refresh_circuit
6759 .open
6760 .swap(true, Ordering::SeqCst)
6761 {
6762 crate::slog_warn!(
6763 "embedding backend appears down: {}; suspending active retries, will resume on next change or successful probe",
6764 reason,
6765 );
6766 }
6767 self.semantic_refresh_circuit_is_open()
6768 }
6769
6770 pub fn trip_semantic_refresh_circuit(&self, trip_threshold: usize, reason: &str) {
6771 self.semantic_refresh_circuit
6772 .consecutive_transient_failures
6773 .store(trip_threshold, Ordering::SeqCst);
6774 if !self
6775 .semantic_refresh_circuit
6776 .open
6777 .swap(true, Ordering::SeqCst)
6778 {
6779 crate::slog_warn!(
6780 "embedding backend appears down: {}; suspending active retries, will resume on next change or successful probe",
6781 reason,
6782 );
6783 }
6784 }
6785
6786 pub fn reset_semantic_refresh_transient_failure_count(&self) {
6787 self.semantic_refresh_circuit
6788 .consecutive_transient_failures
6789 .store(0, Ordering::SeqCst);
6790 }
6791
6792 pub fn reset_semantic_refresh_circuit_after_success(&self) {
6793 self.reset_semantic_refresh_transient_failure_count();
6794 self.semantic_refresh_circuit
6795 .probe_ready
6796 .store(false, Ordering::SeqCst);
6797 if self
6798 .semantic_refresh_circuit
6799 .open
6800 .swap(false, Ordering::SeqCst)
6801 {
6802 crate::slog_info!("embedding backend recovered; resuming normal refresh retries");
6803 }
6804 }
6805
6806 pub fn semantic_refresh_transient_failure_count(&self) -> usize {
6807 self.semantic_refresh_circuit
6808 .consecutive_transient_failures
6809 .load(Ordering::SeqCst)
6810 }
6811
6812 pub fn semantic_refresh_probe_is_scheduled(&self) -> bool {
6813 self.semantic_refresh_circuit
6814 .probe_in_flight
6815 .load(Ordering::SeqCst)
6816 || self.semantic_refresh_probe_ready()
6817 }
6818
6819 pub fn semantic_refresh_probe_ready(&self) -> bool {
6820 self.semantic_refresh_circuit
6821 .probe_ready
6822 .load(Ordering::SeqCst)
6823 }
6824
6825 pub fn take_semantic_refresh_probe_ready(&self) -> bool {
6826 self.semantic_refresh_circuit
6827 .probe_ready
6828 .swap(false, Ordering::SeqCst)
6829 }
6830
6831 fn invalidate_semantic_refresh_probe(&self) {
6832 self.semantic_refresh_circuit
6833 .probe_token
6834 .fetch_add(1, Ordering::SeqCst);
6835 self.semantic_refresh_circuit
6836 .probe_ready
6837 .store(false, Ordering::SeqCst);
6838 self.semantic_refresh_circuit
6839 .probe_in_flight
6840 .store(false, Ordering::SeqCst);
6841 }
6842
6843 pub fn ensure_semantic_refresh_probe_scheduled(&self, delay: Duration) {
6844 let receiver = self.semantic_refresh_event_rx.lock();
6845 if receiver.is_none()
6846 || self
6847 .semantic_refresh_circuit
6848 .probe_ready
6849 .load(Ordering::SeqCst)
6850 || self
6851 .semantic_refresh_circuit
6852 .probe_in_flight
6853 .swap(true, Ordering::SeqCst)
6854 {
6855 return;
6856 }
6857 let probe_token = self
6858 .semantic_refresh_circuit
6859 .probe_token
6860 .fetch_add(1, Ordering::SeqCst)
6861 .wrapping_add(1);
6862 drop(receiver);
6863
6864 let circuit = Arc::clone(&self.semantic_refresh_circuit);
6865 let session_id = crate::log_ctx::current_session();
6866 std::thread::spawn(move || {
6867 crate::log_ctx::with_session(session_id, || {
6868 std::thread::sleep(delay);
6869 if circuit.probe_token.load(Ordering::SeqCst) == probe_token {
6870 circuit.probe_ready.store(true, Ordering::SeqCst);
6871 circuit.probe_in_flight.store(false, Ordering::SeqCst);
6872 }
6873 });
6874 });
6875 }
6876
6877 pub fn semantic_embedding_model(
6879 &self,
6880 ) -> &parking_lot::Mutex<Option<crate::semantic_index::EmbeddingModel>> {
6881 &self.semantic_embedding_model
6882 }
6883
6884 pub fn watcher(&self) -> &parking_lot::Mutex<Option<RecommendedWatcher>> {
6886 &self.watcher
6887 }
6888
6889 pub(crate) fn watcher_counters(&self) -> Arc<WatcherCounters> {
6890 Arc::clone(
6891 &self
6892 .watcher_counters
6893 .read()
6894 .unwrap_or_else(std::sync::PoisonError::into_inner),
6895 )
6896 }
6897
6898 pub fn watcher_rx(
6900 &self,
6901 ) -> &parking_lot::Mutex<Option<crossbeam_channel::Receiver<WatcherDispatchEvent>>> {
6902 &self.watcher_rx
6903 }
6904
6905 pub(crate) fn watcher_drain_slice(
6907 &self,
6908 ) -> &parking_lot::Mutex<Option<WatcherDrainSliceState>> {
6909 &self.watcher_drain_slice
6910 }
6911
6912 pub fn watcher_drain_pending_path_count(&self) -> usize {
6914 self.watcher_drain_slice.lock().as_ref().map_or(0, |state| {
6915 let active_paths = match &state.phase {
6916 WatcherDrainPhase::Collect => 0,
6917 WatcherDrainPhase::Apply { paths, .. } => paths.len(),
6918 };
6919 active_paths + state.pending_paths.len()
6920 })
6921 }
6922
6923 pub fn watcher_drain_path_slice_count(&self) -> usize {
6925 self.watcher_drain_slice
6926 .lock()
6927 .as_ref()
6928 .map_or(0, |state| state.path_slice_count)
6929 }
6930
6931 pub fn install_watcher_runtime(
6934 &self,
6935 rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
6936 runtime: WatcherThreadHandle,
6937 ) {
6938 self.install_watcher_runtime_inner(rx, runtime, None);
6939 }
6940
6941 pub(crate) fn install_watcher_runtime_with_thread_id(
6942 &self,
6943 rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
6944 runtime: WatcherThreadHandle,
6945 thread_id: std::thread::ThreadId,
6946 ) {
6947 self.install_watcher_runtime_inner(rx, runtime, Some(thread_id));
6948 }
6949
6950 fn install_watcher_runtime_inner(
6951 &self,
6952 rx: crossbeam_channel::Receiver<WatcherDispatchEvent>,
6953 runtime: WatcherThreadHandle,
6954 _thread_id: Option<std::thread::ThreadId>,
6955 ) {
6956 let root = self.watcher_root_path();
6957 let gitignore_generation = self.gitignore_generation.load(Ordering::SeqCst);
6958 let _runtime_guard = self.watcher_runtime_lock.lock();
6959 let replaced = self.watcher_thread.lock().replace(runtime);
6960 self.app.watcher_started();
6961 if let Some(runtime) = replaced {
6962 Self::spawn_watcher_shutdown(Arc::clone(&self.app), root.clone(), runtime);
6963 }
6964 *self.watcher_rx.lock() = Some(rx);
6965 *self.watcher_drain_slice.lock() = None;
6966 *self.watcher_runtime_identity.lock() = Some(WatcherRuntimeIdentity {
6967 root,
6968 gitignore_generation,
6969 #[cfg(test)]
6970 thread_id: _thread_id,
6971 });
6972 }
6973
6974 pub(crate) fn watcher_runtime_matches(&self, root: &Path, gitignore_generation: u64) -> bool {
6975 let _runtime_guard = self.watcher_runtime_lock.lock();
6976 let thread_live = self
6977 .watcher_thread
6978 .lock()
6979 .as_ref()
6980 .is_some_and(|runtime| !runtime.is_finished());
6981 thread_live
6982 && self.watcher_rx.lock().is_some()
6983 && self
6984 .watcher_runtime_identity
6985 .lock()
6986 .as_ref()
6987 .is_some_and(|identity| {
6988 identity.root == root && identity.gitignore_generation == gitignore_generation
6989 })
6990 }
6991
6992 #[cfg(test)]
6993 pub(crate) fn watcher_runtime_thread_id_for_test(&self) -> Option<std::thread::ThreadId> {
6994 self.watcher_runtime_identity
6995 .lock()
6996 .as_ref()
6997 .and_then(|identity| identity.thread_id)
6998 }
6999
7000 fn watcher_root_path(&self) -> PathBuf {
7001 self.canonical_cache_root_opt()
7002 .or_else(|| self.config().project_root.clone())
7003 .unwrap_or_else(|| PathBuf::from("<unconfigured>"))
7004 }
7005
7006 fn spawn_watcher_shutdown(app: Arc<App>, root: PathBuf, runtime: WatcherThreadHandle) {
7007 const JOIN_TIMEOUT: Duration = Duration::from_secs(2);
7008 runtime.request_shutdown();
7011 std::thread::spawn(
7012 move || match runtime.shutdown_and_join_timeout(JOIN_TIMEOUT) {
7013 WatcherJoinOutcome::Joined => {
7014 app.watcher_stopped();
7015 crate::slog_info!("watcher stopped: {}", root.display());
7016 }
7017 WatcherJoinOutcome::TimedOut(join) => {
7018 crate::slog_warn!(
7019 "watcher stop timed out after {} ms: {}",
7020 JOIN_TIMEOUT.as_millis(),
7021 root.display()
7022 );
7023 std::thread::spawn(move || {
7024 let _ = join.join();
7025 app.watcher_stopped();
7026 crate::slog_info!("watcher stopped: {}", root.display());
7027 });
7028 }
7029 },
7030 );
7031 }
7032
7033 fn take_watcher_runtime(&self) -> Option<WatcherThreadHandle> {
7034 let _runtime_guard = self.watcher_runtime_lock.lock();
7035 let runtime = self.watcher_thread.lock().take();
7036 *self.watcher_rx.lock() = None;
7037 *self.watcher_drain_slice.lock() = None;
7038 *self.watcher.lock() = None;
7039 self.watcher_runtime_identity.lock().take();
7040 runtime
7041 }
7042
7043 pub fn stop_watcher_runtime(&self) {
7047 if let Some(runtime) = self.take_watcher_runtime() {
7048 Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
7049 }
7050 }
7051
7052 pub fn stop_watcher_runtime_in_background(&self) {
7054 self.stop_watcher_runtime();
7055 }
7056
7057 pub(crate) fn take_finished_watcher_runtime(&self) -> bool {
7062 let runtime = {
7063 let _runtime_guard = self.watcher_runtime_lock.lock();
7064 let finished = self
7065 .watcher_thread
7066 .lock()
7067 .as_ref()
7068 .is_some_and(|runtime| runtime.is_finished());
7069 if !finished {
7070 return false;
7071 }
7072 let runtime = self.watcher_thread.lock().take();
7073 *self.watcher_rx.lock() = None;
7074 *self.watcher_drain_slice.lock() = None;
7075 *self.watcher.lock() = None;
7076 self.watcher_runtime_identity.lock().take();
7077 runtime
7078 };
7079 if let Some(runtime) = runtime {
7080 Self::spawn_watcher_shutdown(Arc::clone(&self.app), self.watcher_root_path(), runtime);
7081 }
7082 true
7083 }
7084
7085 pub fn watcher_registry_count(&self) -> usize {
7088 self.app.watcher_count()
7089 }
7090
7091 pub(crate) fn watcher_runtime_active(&self) -> bool {
7092 let _runtime_guard = self.watcher_runtime_lock.lock();
7093 let thread_live = self
7098 .watcher_thread
7099 .lock()
7100 .as_ref()
7101 .is_some_and(|runtime| !runtime.is_finished());
7102 thread_live && self.watcher_rx.lock().is_some()
7103 }
7104
7105 pub fn artifact_eviction_blocked(&self) -> bool {
7109 if self.standing_artifact_exempt.load(Ordering::Acquire) {
7110 return true;
7111 }
7112 let semantic_refresh_in_flight = match &*self
7113 .semantic_index_status
7114 .read()
7115 .unwrap_or_else(std::sync::PoisonError::into_inner)
7116 {
7117 SemanticIndexStatus::Building { .. } => true,
7118 SemanticIndexStatus::Ready { refreshing, .. } => !refreshing.is_empty(),
7119 SemanticIndexStatus::Disabled | SemanticIndexStatus::Failed(_) => false,
7120 };
7121 if crate::runtime_drain::any_build_in_flight(self)
7122 || semantic_refresh_in_flight
7123 || self.inspect_manager.tier2_any_in_flight()
7124 || !self.bash_background.running_tasks().is_empty()
7125 || !self.pending_callgraph_store_paths.lock().is_empty()
7126 || !self.pending_search_index_paths.lock().is_empty()
7127 || !self.pending_tier2_paths.lock().is_empty()
7128 || !self.pending_semantic_index_paths.lock().is_empty()
7129 || *self.pending_semantic_corpus_refresh.lock()
7130 {
7131 return true;
7132 }
7133
7134 let search_has_pending_disk_changes = self
7135 .search_index
7136 .read()
7137 .unwrap_or_else(std::sync::PoisonError::into_inner)
7138 .as_ref()
7139 .is_some_and(SearchIndex::has_pending_disk_changes);
7140 search_has_pending_disk_changes
7141 }
7142
7143 pub fn evict_idle_artifacts(&self) -> bool {
7148 if self.artifact_eviction_blocked() {
7149 return false;
7150 }
7151
7152 self.callgraph_store
7153 .write()
7154 .unwrap_or_else(std::sync::PoisonError::into_inner)
7155 .take();
7156 self.search_index
7157 .write()
7158 .unwrap_or_else(std::sync::PoisonError::into_inner)
7159 .take();
7160 self.note_search_index_load_succeeded();
7163 self.semantic_index
7164 .write()
7165 .unwrap_or_else(std::sync::PoisonError::into_inner)
7166 .take();
7167 self.borrowed_index_cache.lock().clear();
7168 self.inspect_manager.evict_idle_caches();
7169 self.reset_symbol_cache();
7170 self.clear_tsconfig_membership_cache();
7171 true
7172 }
7173
7174 #[doc(hidden)]
7177 pub fn force_idle_teardown_for_test(self: &Arc<Self>) -> bool {
7178 if std::env::var("AFT_TEST_ALLOW_FORCE_IDLE_REAP").as_deref() != Ok("1") {
7179 return false;
7180 }
7181 if !self.evict_idle_artifacts() {
7182 return false;
7183 }
7184 self.stop_watcher_runtime_in_background();
7185 self.invalidate_artifacts_after_watcher_gap();
7186 true
7187 }
7188
7189 pub(crate) fn release_idle_reopenable_resources_in_background(self: &Arc<Self>) {
7193 let ctx = Arc::clone(self);
7194 std::thread::spawn(move || {
7195 if !ctx.subc_unbound_quiesced() {
7196 return;
7197 }
7198 {
7199 let mut lsp = ctx.lsp_manager.lock();
7200 if !ctx.subc_unbound_quiesced() {
7201 return;
7202 }
7203 lsp.shutdown_all();
7204 }
7205 let _ = ctx.subc_lifecycle.run_if_unbound(|| {
7206 ctx.bash_background.clear_db_pool();
7207 ctx.backup.lock().clear_db_pool();
7208 });
7209 });
7210 }
7211
7212 pub(crate) fn teardown_deleted_root(&self) {
7216 self.bash_background.detach();
7217 self.bash_background.clear_db_pool();
7218 self.backup.lock().clear_db_pool();
7219 self.lsp_manager.lock().shutdown_all();
7220 }
7221
7222 pub fn lsp(&self) -> parking_lot::MutexGuard<'_, LspManager> {
7224 self.lsp_manager.lock()
7225 }
7226
7227 pub fn lsp_notify_file_changed(&self, file_path: &Path, content: &str) {
7230 let config = self.config();
7231 if let Some(mut lsp) = self.lsp_manager.try_lock() {
7232 if let Err(e) = lsp.notify_file_changed_if_running(file_path, content, &config) {
7233 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
7234 }
7235 }
7236 }
7237
7238 pub fn lsp_clear_diagnostics_for_file(&self, file_path: &Path) -> bool {
7244 if let Some(mut lsp) = self.lsp_manager.try_lock() {
7245 lsp.clear_diagnostics_for_file(file_path)
7246 } else {
7247 false
7248 }
7249 }
7250
7251 pub fn lsp_mark_diagnostics_stale_for_file(&self, file_path: &Path) -> StaleDiagnosticsMark {
7255 if let Some(mut lsp) = self.lsp_manager.try_lock() {
7256 lsp.mark_diagnostics_stale_for_file(file_path)
7257 } else {
7258 StaleDiagnosticsMark::default()
7259 }
7260 }
7261
7262 pub fn lsp_resync_changed_file_for_diagnostics(&self, file_path: &Path) -> bool {
7270 if !file_path.is_file() {
7271 return false;
7272 }
7273
7274 let content = match std::fs::read_to_string(file_path) {
7275 Ok(content) => content,
7276 Err(err) => {
7277 crate::slog_warn!(
7278 "skipping LSP resync for {} after external edit: {}",
7279 file_path.display(),
7280 err
7281 );
7282 return false;
7283 }
7284 };
7285
7286 let config = self.config();
7287 if let Some(mut lsp) = self.lsp_manager.try_lock() {
7288 if let Err(err) = lsp.notify_file_changed(file_path, &content, &config) {
7289 crate::slog_warn!(
7290 "LSP resync failed for {} after external edit: {}",
7291 file_path.display(),
7292 err
7293 );
7294 return false;
7295 }
7296 true
7297 } else {
7298 false
7299 }
7300 }
7301
7302 pub fn lsp_notify_and_collect_diagnostics(
7312 &self,
7313 file_path: &Path,
7314 content: &str,
7315 timeout: std::time::Duration,
7316 ) -> crate::lsp::manager::PostEditWaitOutcome {
7317 let config = self.config();
7318 let Some(mut lsp) = self.lsp_manager.try_lock() else {
7319 return crate::lsp::manager::PostEditWaitOutcome::default();
7320 };
7321
7322 lsp.drain_events();
7325
7326 let pre_snapshot = lsp.snapshot_pre_edit_state(file_path);
7330
7331 let expected_versions = match lsp.notify_file_changed_versioned(file_path, content, &config)
7335 {
7336 Ok(v) => v,
7337 Err(e) => {
7338 crate::slog_warn!("sync error for {}: {}", file_path.display(), e);
7339 return crate::lsp::manager::PostEditWaitOutcome::default();
7340 }
7341 };
7342
7343 if expected_versions.is_empty() {
7346 return crate::lsp::manager::PostEditWaitOutcome::default();
7347 }
7348
7349 let diagnostics_deadline = Instant::now() + timeout;
7353 if let Err(err) = lsp.pull_file_diagnostics_with_timeout(file_path, &config, timeout) {
7354 crate::slog_warn!(
7355 "post-edit LSP diagnostic pull failed for {}: {}",
7356 file_path.display(),
7357 err
7358 );
7359 }
7360 let remaining = diagnostics_deadline.saturating_duration_since(Instant::now());
7361
7362 let mut wait = lsp.start_post_edit_diagnostics_wait(
7366 file_path,
7367 &expected_versions,
7368 &pre_snapshot,
7369 remaining,
7370 );
7371 let mut complete = lsp.poll_post_edit_diagnostics_wait(&mut wait, None);
7372 drop(lsp);
7373
7374 while !complete && !wait.deadline_reached() {
7375 let event = wait.next_event();
7378 let mut lsp = self.lsp_manager.lock();
7379 complete = lsp.poll_post_edit_diagnostics_wait(&mut wait, event);
7380 }
7381
7382 self.lsp_manager
7383 .lock()
7384 .finish_post_edit_diagnostics_wait(wait)
7385 }
7386
7387 fn custom_lsp_root_markers(&self) -> Vec<String> {
7390 self.config()
7391 .lsp_servers
7392 .iter()
7393 .flat_map(|s| s.root_markers.iter().cloned())
7394 .collect()
7395 }
7396
7397 fn notify_watched_config_files(&self, file_paths: &[PathBuf]) {
7398 let custom_markers = self.custom_lsp_root_markers();
7399 let config_paths: Vec<(PathBuf, FileChangeType)> = file_paths
7400 .iter()
7401 .filter(|path| is_config_file_path_with_custom(path, &custom_markers))
7402 .cloned()
7403 .map(|path| {
7404 let change_type = if path.exists() {
7405 FileChangeType::CHANGED
7406 } else {
7407 FileChangeType::DELETED
7408 };
7409 (path, change_type)
7410 })
7411 .collect();
7412
7413 self.notify_watched_config_events(&config_paths);
7414 }
7415
7416 fn multi_file_write_paths(params: &serde_json::Value) -> Option<Vec<PathBuf>> {
7417 let paths = params
7418 .get("multi_file_write_paths")
7419 .and_then(|value| value.as_array())?
7420 .iter()
7421 .filter_map(|value| value.as_str())
7422 .map(PathBuf::from)
7423 .collect::<Vec<_>>();
7424
7425 (!paths.is_empty()).then_some(paths)
7426 }
7427
7428 fn watched_file_events_from_params(
7440 params: &serde_json::Value,
7441 extra_markers: &[String],
7442 ) -> Option<Vec<(PathBuf, FileChangeType)>> {
7443 let events = params
7444 .get("multi_file_write_paths")
7445 .and_then(|value| value.as_array())?
7446 .iter()
7447 .filter_map(|entry| {
7448 let path = entry
7450 .get("path")
7451 .and_then(|value| value.as_str())
7452 .map(PathBuf::from)?;
7453
7454 if !is_config_file_path_with_custom(&path, extra_markers) {
7455 return None;
7456 }
7457
7458 let change_type = entry
7459 .get("type")
7460 .and_then(|value| value.as_str())
7461 .and_then(Self::parse_file_change_type)
7462 .unwrap_or_else(|| Self::change_type_from_current_state(&path));
7463
7464 Some((path, change_type))
7465 })
7466 .collect::<Vec<_>>();
7467
7468 (!events.is_empty()).then_some(events)
7469 }
7470
7471 fn parse_file_change_type(value: &str) -> Option<FileChangeType> {
7472 match value {
7473 "created" | "CREATED" | "Created" => Some(FileChangeType::CREATED),
7474 "changed" | "CHANGED" | "Changed" => Some(FileChangeType::CHANGED),
7475 "deleted" | "DELETED" | "Deleted" => Some(FileChangeType::DELETED),
7476 _ => None,
7477 }
7478 }
7479
7480 fn change_type_from_current_state(path: &Path) -> FileChangeType {
7481 if path.exists() {
7482 FileChangeType::CHANGED
7483 } else {
7484 FileChangeType::DELETED
7485 }
7486 }
7487
7488 fn notify_watched_config_events(&self, config_paths: &[(PathBuf, FileChangeType)]) {
7489 if config_paths.is_empty() {
7490 return;
7491 }
7492
7493 let config = self.config();
7494 if let Some(mut lsp) = self.lsp_manager.try_lock() {
7495 if let Err(e) = lsp.notify_files_watched_changed(config_paths, &config) {
7496 crate::slog_warn!("watched-file sync error: {}", e);
7497 }
7498 }
7499 }
7500
7501 pub fn lsp_notify_watched_config_file(&self, file_path: &Path, change_type: FileChangeType) {
7502 let custom_markers = self.custom_lsp_root_markers();
7503 if !is_config_file_path_with_custom(file_path, &custom_markers) {
7504 return;
7505 }
7506
7507 self.notify_watched_config_events(&[(file_path.to_path_buf(), change_type)]);
7508 }
7509
7510 pub fn lsp_post_multi_file_write(
7515 &self,
7516 file_path: &Path,
7517 content: &str,
7518 file_paths: &[PathBuf],
7519 params: &serde_json::Value,
7520 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
7521 self.notify_watched_config_files(file_paths);
7522 self.add_pending_tier2_paths(file_paths.iter().cloned());
7523 let _ = self.mark_status_bar_tier2_stale();
7524
7525 let wants_diagnostics = params
7526 .get("diagnostics")
7527 .and_then(|v| v.as_bool())
7528 .unwrap_or(false);
7529
7530 if !wants_diagnostics {
7531 self.lsp_notify_file_changed(file_path, content);
7532 return None;
7533 }
7534
7535 let wait_ms = params
7536 .get("wait_ms")
7537 .and_then(|v| v.as_u64())
7538 .unwrap_or(3000)
7539 .min(10_000);
7540
7541 Some(self.lsp_notify_and_collect_diagnostics(
7542 file_path,
7543 content,
7544 std::time::Duration::from_millis(wait_ms),
7545 ))
7546 }
7547
7548 pub fn lsp_post_write(
7565 &self,
7566 file_path: &Path,
7567 content: &str,
7568 params: &serde_json::Value,
7569 ) -> Option<crate::lsp::manager::PostEditWaitOutcome> {
7570 let wants_diagnostics = params
7571 .get("diagnostics")
7572 .and_then(|v| v.as_bool())
7573 .unwrap_or(false);
7574
7575 let custom_markers = self.custom_lsp_root_markers();
7576 if let Some(file_paths) = Self::multi_file_write_paths(params) {
7577 self.add_pending_tier2_paths(file_paths);
7578 } else {
7579 self.add_pending_tier2_paths([file_path.to_path_buf()]);
7580 }
7581 let _ = self.mark_status_bar_tier2_stale();
7582
7583 if !wants_diagnostics {
7584 if let Some(file_paths) = Self::multi_file_write_paths(params) {
7585 self.notify_watched_config_files(&file_paths);
7586 } else if let Some(config_events) =
7587 Self::watched_file_events_from_params(params, &custom_markers)
7588 {
7589 self.notify_watched_config_events(&config_events);
7590 }
7591 self.lsp_notify_file_changed(file_path, content);
7592 return None;
7593 }
7594
7595 let wait_ms = params
7596 .get("wait_ms")
7597 .and_then(|v| v.as_u64())
7598 .unwrap_or(3000)
7599 .min(10_000); if let Some(file_paths) = Self::multi_file_write_paths(params) {
7602 return self.lsp_post_multi_file_write(file_path, content, &file_paths, params);
7603 }
7604
7605 if let Some(config_events) = Self::watched_file_events_from_params(params, &custom_markers)
7606 {
7607 self.notify_watched_config_events(&config_events);
7608 }
7609
7610 Some(self.lsp_notify_and_collect_diagnostics(
7611 file_path,
7612 content,
7613 std::time::Duration::from_millis(wait_ms),
7614 ))
7615 }
7616
7617 fn resolved_path_restriction_root(&self, root: &Path) -> PathBuf {
7618 let mut memo = self.path_restriction_root_memo.lock();
7619 if let Some(cached) = memo.as_ref() {
7620 if cached.configured_root.as_os_str() == root.as_os_str()
7621 && cached.resolved_root.exists()
7622 {
7623 return cached.resolved_root.clone();
7624 }
7625 }
7626
7627 #[cfg(test)]
7633 self.path_restriction_root_canonicalizations
7634 .fetch_add(1, Ordering::SeqCst);
7635 let resolved_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
7636 *memo = Some(PathRestrictionRootMemo {
7637 configured_root: root.to_path_buf(),
7638 resolved_root: resolved_root.clone(),
7639 });
7640 resolved_root
7641 }
7642
7643 fn path_restriction_context(
7644 &self,
7645 req_id: &str,
7646 path: &Path,
7647 ) -> Result<Option<PathRestrictionContext>, crate::protocol::Response> {
7648 let config = self.config();
7649 let force_restrict = self.request_force_restrict(req_id);
7650 if !config.restrict_to_project_root && !force_restrict {
7651 return Ok(None);
7652 }
7653 let root = match &config.project_root {
7654 Some(root) => root.clone(),
7655 None if force_restrict => {
7656 return Err(crate::protocol::Response::error(
7657 req_id,
7658 "path_outside_root",
7659 "project root is required when path restriction is forced",
7660 ));
7661 }
7662 None => return Ok(None),
7663 };
7664 drop(config);
7665
7666 let raw_root = root.clone();
7667 let resolved_root = self.resolved_path_restriction_root(&root);
7668 let path_for_resolution = if path.is_relative() {
7669 raw_root.join(path)
7670 } else {
7671 path.to_path_buf()
7672 };
7673 Ok(Some(PathRestrictionContext {
7674 raw_root,
7675 resolved_root,
7676 path_for_resolution,
7677 }))
7678 }
7679
7680 pub fn resolve_relative_path(&self, path: &Path) -> PathBuf {
7693 if path.is_absolute() {
7694 return path.to_path_buf();
7695 }
7696 if let Some(root) = &self.config().project_root {
7697 return root.join(path);
7698 }
7699 std::env::current_dir()
7700 .unwrap_or_else(|_| PathBuf::from("."))
7701 .join(path)
7702 }
7703
7704 pub fn validate_path(
7713 &self,
7714 req_id: &str,
7715 path: &Path,
7716 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
7717 self.validate_path_with_artifact_session(req_id, path, None)
7718 }
7719
7720 pub fn validate_write_location(
7727 &self,
7728 req_id: &str,
7729 path: &Path,
7730 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
7731 let Some(PathRestrictionContext {
7732 raw_root,
7733 resolved_root,
7734 path_for_resolution,
7735 }) = self.path_restriction_context(req_id, path)?
7736 else {
7737 return Ok(path.to_path_buf());
7738 };
7739 let normalized = normalize_path(&path_for_resolution);
7740 let Some(file_name) = normalized.file_name() else {
7741 return self.validate_path(req_id, path);
7742 };
7743 let parent = normalized.parent().unwrap_or_else(|| Path::new(""));
7744 let resolved_parent = match std::fs::canonicalize(parent) {
7745 Ok(resolved) => resolved,
7746 Err(_) => {
7747 reject_escaping_symlink(req_id, path, parent, &resolved_root, &raw_root)?;
7748 resolve_with_existing_ancestors(parent)
7749 }
7750 };
7751 let resolved = normalize_path(&resolved_parent.join(file_name));
7752
7753 if !resolved.starts_with(&resolved_root) {
7754 return Err(path_error_response(req_id, path, &resolved_root));
7755 }
7756
7757 Ok(resolved)
7758 }
7759
7760 pub fn validate_read_path(
7766 &self,
7767 req_id: &str,
7768 session_id: &str,
7769 path: &Path,
7770 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
7771 self.validate_path_with_artifact_session(req_id, path, Some(session_id))
7772 }
7773
7774 fn validate_path_with_artifact_session(
7775 &self,
7776 req_id: &str,
7777 path: &Path,
7778 artifact_session_id: Option<&str>,
7779 ) -> Result<std::path::PathBuf, crate::protocol::Response> {
7780 let Some(PathRestrictionContext {
7781 raw_root,
7782 resolved_root,
7783 path_for_resolution,
7784 }) = self.path_restriction_context(req_id, path)?
7785 else {
7786 return Ok(path.to_path_buf());
7789 };
7790
7791 let resolved = match std::fs::canonicalize(&path_for_resolution) {
7796 Ok(resolved) => resolved,
7797 Err(_) => {
7798 let normalized = normalize_path(&path_for_resolution);
7799 reject_escaping_symlink(
7800 req_id,
7801 &path_for_resolution,
7802 &normalized,
7803 &resolved_root,
7804 &raw_root,
7805 )?;
7806 resolve_with_existing_ancestors(&normalized)
7807 }
7808 };
7809
7810 if !resolved.starts_with(&resolved_root) {
7811 let is_owned_bash_artifact = artifact_session_id.is_some_and(|session_id| {
7812 self.bash_background
7813 .is_session_owned_artifact_path(session_id, &resolved)
7814 });
7815 if !is_owned_bash_artifact {
7816 return Err(path_error_response(req_id, path, &resolved_root));
7817 }
7818 }
7819
7820 Ok(resolved)
7821 }
7822
7823 pub fn lsp_server_count(&self) -> usize {
7825 self.lsp_manager
7826 .try_lock()
7827 .map(|lsp| lsp.server_count())
7828 .unwrap_or(0)
7829 }
7830
7831 pub fn symbol_cache_stats(&self) -> serde_json::Value {
7833 let entries = self
7834 .symbol_cache
7835 .read()
7836 .map(|cache| cache.len())
7837 .unwrap_or(0);
7838 serde_json::json!({
7839 "local_entries": entries,
7840 "warm_entries": 0,
7841 })
7842 }
7843
7844 fn memory_estimates(&self) -> [crate::memory::MemoryEstimate; 9] {
7845 let semantic = match self.semantic_index.try_read() {
7846 Ok(index) => index
7847 .as_ref()
7848 .map(SemanticIndex::estimated_memory)
7849 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("entries", 0)),
7850 Err(TryLockError::Poisoned(error)) => error
7851 .into_inner()
7852 .as_ref()
7853 .map(SemanticIndex::estimated_memory)
7854 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("entries", 0)),
7855 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
7856 };
7857 let trigram = match self.search_index.try_read() {
7858 Ok(index) => index
7859 .as_ref()
7860 .map(SearchIndex::estimated_memory)
7861 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("files", 0)),
7862 Err(TryLockError::Poisoned(error)) => error
7863 .into_inner()
7864 .as_ref()
7865 .map(SearchIndex::estimated_memory)
7866 .unwrap_or_else(|| crate::memory::MemoryEstimate::estimated(0).count("files", 0)),
7867 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
7868 };
7869 let symbols = match self.symbol_cache.try_read() {
7870 Ok(cache) => cache.estimated_memory(),
7871 Err(TryLockError::Poisoned(error)) => error.into_inner().estimated_memory(),
7872 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
7873 };
7874 let callgraph = match self.callgraph_store.try_read() {
7875 Ok(store) => store
7876 .as_ref()
7877 .map(|store| store.estimated_memory())
7878 .unwrap_or_else(|| {
7879 crate::memory::MemoryEstimate::estimated(0).count("open_generation_handles", 0)
7880 }),
7881 Err(TryLockError::Poisoned(error)) => error
7882 .into_inner()
7883 .as_ref()
7884 .map(|store| store.estimated_memory())
7885 .unwrap_or_else(|| {
7886 crate::memory::MemoryEstimate::estimated(0).count("open_generation_handles", 0)
7887 }),
7888 Err(TryLockError::WouldBlock) => crate::memory::MemoryEstimate::busy(),
7889 };
7890 let callgraph_projection = self.inspect_manager.callgraph_projection_estimated_memory();
7891 let inspect = self.inspect_manager.estimated_memory();
7892 let bash = self.bash_background.estimated_memory();
7893 let lsp = self
7894 .lsp_manager
7895 .try_lock()
7896 .map(|lsp| lsp.estimated_memory())
7897 .unwrap_or_else(crate::memory::MemoryEstimate::busy);
7898 let parser_pool = crate::memory::MemoryEstimate::not_estimated()
7901 .count("pooled_parsers", 0)
7902 .gap("tree_sitter_parser_bytes");
7903 [
7904 semantic,
7905 trigram,
7906 symbols,
7907 callgraph,
7908 callgraph_projection,
7909 inspect,
7910 bash,
7911 lsp,
7912 parser_pool,
7913 ]
7914 }
7915
7916 pub fn memory_root_snapshot(&self) -> crate::memory::RootMemorySnapshot {
7920 let [semantic, trigram, symbols, callgraph, callgraph_projection, inspect, bash, lsp, parser_pool] =
7921 self.memory_estimates();
7922 crate::memory::RootMemorySnapshot::new(
7923 semantic,
7924 trigram,
7925 symbols,
7926 callgraph,
7927 callgraph_projection,
7928 inspect,
7929 bash,
7930 lsp,
7931 parser_pool,
7932 )
7933 }
7934
7935 pub(crate) fn memory_root_rollup(&self) -> crate::memory::RootMemoryRollup {
7938 let estimates = self.memory_estimates();
7939 crate::memory::RootMemoryRollup::from_estimates(&[
7940 &estimates[0],
7941 &estimates[1],
7942 &estimates[2],
7943 &estimates[3],
7944 &estimates[4],
7945 &estimates[5],
7946 &estimates[6],
7947 &estimates[7],
7948 &estimates[8],
7949 ])
7950 }
7951
7952 pub fn memory_snapshot(&self, current_root: Option<&Path>) -> crate::memory::MemorySnapshot {
7955 self.memory_snapshot_with_cap(current_root, true)
7956 }
7957
7958 pub fn memory_snapshot_uncapped(&self) -> crate::memory::MemorySnapshot {
7959 self.memory_snapshot_with_cap(None, false)
7960 }
7961
7962 fn memory_snapshot_with_cap(
7963 &self,
7964 current_root: Option<&Path>,
7965 cap_detail: bool,
7966 ) -> crate::memory::MemorySnapshot {
7967 let mut roots = BTreeMap::new();
7968 let (roots_status, contexts) = match self.app.try_memory_contexts() {
7969 Some(contexts) => ("ready", contexts),
7970 None => ("busy", Vec::new()),
7971 };
7972 for (root, context) in contexts {
7973 roots.insert(root.display().to_string(), context.memory_root_snapshot());
7974 }
7975 let current_label = current_root
7979 .map(|root| {
7980 cortexkit_paths::ProjectRootId::from_path(root)
7981 .map(|id| id.as_path().display().to_string())
7982 .unwrap_or_else(|_| root.display().to_string())
7983 })
7984 .unwrap_or_else(|| "<unconfigured>".to_string());
7985 roots
7986 .entry(current_label)
7987 .or_insert_with(|| self.memory_root_snapshot());
7988 if cap_detail {
7989 crate::memory::MemorySnapshot::new(roots_status, roots)
7990 } else {
7991 crate::memory::MemorySnapshot::new_uncapped(roots_status, roots)
7992 }
7993 }
7994}
7995
7996#[cfg(test)]
7997mod subc_lifecycle_admission_tests {
7998 use super::*;
7999
8000 #[test]
8001 fn route_teardown_does_not_supersede_disk_artifact_compatibility() {
8002 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
8003 ctx.note_configure_warm_key("config-a".to_string(), false);
8004 let content_generation = ctx.configure_content_generation();
8005 let lifecycle_generation = ctx.configure_generation();
8006 let search_epoch = ctx.next_search_persist_epoch();
8007 let semantic_epoch = ctx.next_semantic_persist_epoch();
8008 let search_persist_epoch = ctx.search_persist_epoch_flag();
8009 let semantic_persist_epoch = ctx.semantic_persist_epoch_flag();
8010
8011 ctx.mark_subc_unbound();
8012 assert!(ctx.configure_generation() > lifecycle_generation);
8013 assert_eq!(ctx.configure_content_generation(), content_generation);
8014 assert_eq!(search_persist_epoch.current(), search_epoch);
8015 assert_eq!(semantic_persist_epoch.current(), semantic_epoch);
8016
8017 ctx.mark_subc_bound();
8018 ctx.note_configure_warm_key("config-b".to_string(), false);
8019 assert!(ctx.configure_content_generation() > content_generation);
8020 let replacement_search_epoch = ctx.next_search_persist_epoch();
8021 let replacement_semantic_epoch = ctx.next_semantic_persist_epoch();
8022 assert!(replacement_search_epoch > search_epoch);
8023 assert!(replacement_semantic_epoch > semantic_epoch);
8024 assert_eq!(search_persist_epoch.current(), replacement_search_epoch);
8025 assert_eq!(semantic_persist_epoch.current(), replacement_semantic_epoch);
8026 }
8027
8028 #[test]
8029 fn lifecycle_gate_serializes_unbind_with_worker_start_commit() {
8030 let admission = SubcLifecycleAdmission::default();
8031 let generation = Arc::new(AtomicU64::new(11));
8032 let expected = generation.load(Ordering::SeqCst);
8033 let starts = Arc::new(AtomicUsize::new(0));
8034 let (entered_tx, entered_rx) = std::sync::mpsc::channel();
8035 let (release_tx, release_rx) = std::sync::mpsc::channel();
8036
8037 let worker_admission = admission.clone();
8038 let worker_generation = Arc::clone(&generation);
8039 let worker_starts = Arc::clone(&starts);
8040 let worker = std::thread::spawn(move || {
8041 worker_admission.run_if_current(&worker_generation, expected, || {
8042 entered_tx.send(()).unwrap();
8043 release_rx.recv().unwrap();
8044 worker_starts.fetch_add(1, Ordering::SeqCst);
8045 })
8046 });
8047 entered_rx.recv().unwrap();
8048
8049 let unbind_admission = admission.clone();
8050 let unbind_generation = Arc::clone(&generation);
8051 let (unbound_tx, unbound_rx) = std::sync::mpsc::channel();
8052 let unbind = std::thread::spawn(move || {
8053 unbind_admission.mark_unbound(&unbind_generation);
8054 unbound_tx.send(()).unwrap();
8055 });
8056
8057 assert!(
8058 unbound_rx
8059 .recv_timeout(std::time::Duration::from_millis(50))
8060 .is_err(),
8061 "unbind must wait for an admitted worker-start commit"
8062 );
8063 release_tx.send(()).unwrap();
8064 assert!(worker.join().unwrap().is_some());
8065 unbound_rx
8066 .recv_timeout(std::time::Duration::from_secs(1))
8067 .unwrap();
8068 unbind.join().unwrap();
8069 assert_eq!(starts.load(Ordering::SeqCst), 1);
8070 assert!(
8071 admission
8072 .run_if_current(&generation, generation.load(Ordering::SeqCst), || {
8073 starts.fetch_add(1, Ordering::SeqCst);
8074 })
8075 .is_none(),
8076 "worker starts after unbind must be denied"
8077 );
8078 }
8079
8080 #[test]
8081 fn health_snapshot_returns_busy_before_locking_artifact_receivers() {
8082 let ctx = Arc::new(AppContext::new(
8083 default_language_provider_factory(),
8084 Config::default(),
8085 ));
8086 let lifecycle_guard = ctx.subc_lifecycle.unbound.lock();
8087 let (started_tx, started_rx) = std::sync::mpsc::channel();
8088 let (snapshot_tx, snapshot_rx) = std::sync::mpsc::channel();
8089 let worker_ctx = Arc::clone(&ctx);
8090 let worker = std::thread::spawn(move || {
8091 started_tx.send(()).unwrap();
8092 snapshot_tx
8093 .send(worker_ctx.try_health_snapshot(Path::new("health-root")))
8094 .unwrap();
8095 });
8096 started_rx
8097 .recv_timeout(Duration::from_secs(1))
8098 .expect("health snapshot worker should start");
8099
8100 let snapshot = snapshot_rx.recv_timeout(Duration::from_secs(2));
8101 let callgraph_receiver_available = ctx.callgraph_store_rx.try_lock().is_some();
8102 drop(lifecycle_guard);
8103 worker.join().unwrap();
8104
8105 assert!(
8106 matches!(
8107 snapshot,
8108 Ok(RootHealthSnapshot {
8109 state: RootHealthState::Busy,
8110 ..
8111 })
8112 ),
8113 "health snapshots must report busy instead of waiting for lifecycle admission"
8114 );
8115 assert!(
8116 callgraph_receiver_available,
8117 "health snapshots must not hold the callgraph receiver while lifecycle admission is busy"
8118 );
8119 }
8120
8121 #[test]
8122 fn borrow_only_root_with_partial_tier2_aggregates_reports_disabled() {
8123 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
8124 ctx.set_artifact_owner(
8125 Some(crate::artifact_owner::ArtifactOwnerStatus {
8126 mode: crate::artifact_owner::ArtifactOwnerMode::ReadOnly,
8127 project_key: "borrowed".to_string(),
8128 manifest_path: "manifest.json".to_string(),
8129 owner_project_scope_key: "owner".to_string(),
8130 owner_checkout_path: "/owner".to_string(),
8131 note: None,
8132 }),
8133 None,
8134 );
8135 ctx.update_status_bar_tier2(Some(4), None, None, None, true);
8136
8137 let snapshot = ctx.try_health_snapshot(Path::new("borrow-only-root"));
8138
8139 assert_eq!(snapshot.tier2.expect("tier2 health").status, "disabled");
8140 }
8141
8142 #[test]
8143 fn worktree_guard_prevents_partial_tier2_from_reporting_building() {
8144 let root = tempfile::tempdir().unwrap();
8145 let ctx = AppContext::new(
8146 default_language_provider_factory(),
8147 Config {
8148 project_root: Some(root.path().to_path_buf()),
8149 ..Config::default()
8150 },
8151 );
8152 ctx.set_harness(crate::harness::Harness::Opencode);
8153 ctx.set_cache_writer_capabilities(true, true);
8154 ctx.update_status_bar_tier2(Some(4), None, None, None, true);
8155 assert_eq!(
8156 ctx.try_health_snapshot(Path::new("writer-root"))
8157 .tier2
8158 .expect("tier2 health")
8159 .status,
8160 "building"
8161 );
8162
8163 ctx.set_cache_role(true, None);
8164
8165 assert_eq!(
8166 ctx.try_health_snapshot(Path::new("worktree-root"))
8167 .tier2
8168 .expect("tier2 health")
8169 .status,
8170 "disabled"
8171 );
8172 let tier2_snapshot = ctx.tier2_refresh_snapshot().expect("tier2 snapshot");
8173 assert!(!tier2_snapshot.callgraph_writer);
8174 }
8175
8176 #[test]
8177 fn unbound_artifact_cancellation_clears_semantic_refresh_state() {
8178 let temp = tempfile::tempdir().unwrap();
8179 let ctx = AppContext::new(
8180 default_language_provider_factory(),
8181 Config {
8182 project_root: Some(temp.path().to_path_buf()),
8183 semantic_search: true,
8184 ..Config::default()
8185 },
8186 );
8187 *ctx.semantic_index()
8188 .write()
8189 .unwrap_or_else(std::sync::PoisonError::into_inner) =
8190 Some(SemanticIndex::new(temp.path().to_path_buf(), 3));
8191 let mut status = SemanticIndexStatus::ready();
8192 status.add_refreshing_file(temp.path().join("changed.rs"));
8193 *ctx.semantic_index_status()
8194 .write()
8195 .unwrap_or_else(std::sync::PoisonError::into_inner) = status;
8196 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
8197 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
8198 ctx.install_semantic_refresh_worker_for_build_epoch(
8199 request_tx,
8200 event_rx,
8201 Arc::new(Mutex::new(None)),
8202 ctx.semantic_index_rx_epoch(),
8203 );
8204
8205 ctx.cancel_unbound_artifact_work();
8206
8207 assert!(ctx.semantic_refresh_event_rx().lock().is_none());
8208 assert!(matches!(
8209 &*ctx
8210 .semantic_index_status()
8211 .read()
8212 .unwrap_or_else(std::sync::PoisonError::into_inner),
8213 SemanticIndexStatus::Ready { refreshing, .. } if refreshing.is_empty()
8214 ));
8215 }
8216
8217 #[test]
8218 fn terminal_empty_search_receiver_reports_completion_work() {
8219 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
8220 let (sender, receiver) = crossbeam_channel::unbounded();
8221 let epoch = ctx.install_search_index_rx(receiver, ctx.configure_generation());
8222 let terminal_guard = ctx.search_index_rx_terminal_guard(epoch);
8223 drop(sender);
8224 drop(terminal_guard);
8225
8226 assert!(
8227 ctx.completion_drains_have_work(),
8228 "an empty disconnected one-shot receiver must wake the completion drain"
8229 );
8230 }
8231
8232 #[test]
8233 fn conditional_semantic_receiver_retire_preserves_replacement_epoch() {
8234 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
8235 let (_old_sender, old_receiver) = crossbeam_channel::unbounded();
8236 let old_epoch = ctx.install_semantic_index_rx(old_receiver, ctx.configure_generation());
8237 let (_replacement_sender, replacement_receiver) = crossbeam_channel::unbounded();
8238 let replacement_epoch =
8239 ctx.install_semantic_index_rx(replacement_receiver, ctx.configure_generation());
8240
8241 assert!(replacement_epoch > old_epoch);
8242 assert_eq!(ctx.retire_semantic_index_rx_if_epoch(old_epoch), None);
8243 assert!(ctx.semantic_index_rx().lock().is_some());
8244 assert_eq!(ctx.semantic_index_rx_epoch(), replacement_epoch);
8245 }
8246
8247 #[test]
8248 fn stale_terminal_guard_cannot_hide_newer_finished_receiver() {
8249 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
8250 let (old_sender, old_receiver) = crossbeam_channel::unbounded();
8251 let old_epoch = ctx.install_search_index_rx(old_receiver, ctx.configure_generation());
8252 let old_guard = ctx.search_index_rx_terminal_guard(old_epoch);
8253 let (current_sender, current_receiver) = crossbeam_channel::unbounded();
8254 let current_epoch =
8255 ctx.install_search_index_rx(current_receiver, ctx.configure_generation());
8256 let current_guard = ctx.search_index_rx_terminal_guard(current_epoch);
8257 drop(old_sender);
8258 drop(current_sender);
8259
8260 drop(current_guard);
8261 drop(old_guard);
8262
8263 assert!(current_epoch > old_epoch);
8264 assert_eq!(
8265 ctx.search_index_rx_terminal_epoch.load(Ordering::SeqCst),
8266 current_epoch,
8267 "a stale worker must not move the terminal watermark backward"
8268 );
8269 assert!(ctx.completion_drains_have_work());
8270 }
8271
8272 #[test]
8273 fn finished_semantic_refresh_worker_reports_completion_work() {
8274 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
8275 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
8276 let (event_tx, event_rx) = crossbeam_channel::unbounded();
8277 let worker_slot = Arc::new(Mutex::new(Some(std::thread::spawn(|| {}))));
8278 ctx.install_semantic_refresh_worker_for_build_epoch(
8279 request_tx,
8280 event_rx,
8281 Arc::clone(&worker_slot),
8282 ctx.semantic_index_rx_epoch(),
8283 );
8284 drop(event_tx);
8285 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
8286 while !worker_slot
8287 .lock()
8288 .unwrap_or_else(std::sync::PoisonError::into_inner)
8289 .as_ref()
8290 .is_some_and(std::thread::JoinHandle::is_finished)
8291 {
8292 assert!(
8293 std::time::Instant::now() < deadline,
8294 "worker did not finish"
8295 );
8296 std::thread::yield_now();
8297 }
8298
8299 assert!(
8300 ctx.completion_drains_have_work(),
8301 "a finished refresh worker must wake the completion drain after its event queue empties"
8302 );
8303 }
8304
8305 #[test]
8306 fn unbound_lifecycle_rejects_all_deferred_worker_starts() {
8307 let admission = SubcLifecycleAdmission::default();
8308 let generation = Arc::new(AtomicU64::new(7));
8309 admission.mark_unbound(&generation);
8310 let expected = generation.load(Ordering::SeqCst);
8311 let starts = Arc::new(AtomicUsize::new(0));
8312
8313 let workers = (0..16)
8314 .map(|_| {
8315 let admission = admission.clone();
8316 let generation = Arc::clone(&generation);
8317 let starts = Arc::clone(&starts);
8318 std::thread::spawn(move || {
8319 admission.run_if_current(&generation, expected, || {
8320 starts.fetch_add(1, Ordering::SeqCst);
8321 })
8322 })
8323 })
8324 .collect::<Vec<_>>();
8325
8326 for worker in workers {
8327 assert!(worker.join().unwrap().is_none());
8328 }
8329 assert_eq!(starts.load(Ordering::SeqCst), 0);
8330 }
8331}
8332
8333#[cfg(test)]
8334mod force_restrict_tests {
8335 use super::*;
8336 use crate::language::StubProvider;
8337 use tempfile::TempDir;
8338
8339 fn test_context(project_root: Option<PathBuf>, restrict_to_project_root: bool) -> AppContext {
8340 AppContext::new(
8341 Box::new(StubProvider),
8342 Config {
8343 project_root,
8344 restrict_to_project_root,
8345 ..Config::default()
8346 },
8347 )
8348 }
8349
8350 #[test]
8351 fn standalone_validate_path_parity_without_force_restrict() {
8352 let root = TempDir::new().expect("root tempdir");
8353 let outside = TempDir::new().expect("outside tempdir");
8354 let outside_path = outside.path().join("outside.txt");
8355
8356 let unrestricted = test_context(Some(root.path().to_path_buf()), false);
8357 assert_eq!(
8358 unrestricted
8359 .validate_path("standalone-unrestricted", &outside_path)
8360 .expect("unrestricted standalone validates"),
8361 outside_path
8362 );
8363
8364 let restricted = test_context(Some(root.path().to_path_buf()), true);
8365 let err = restricted
8366 .validate_path("standalone-restricted", &outside_path)
8367 .expect_err("restricted standalone rejects outside root");
8368 assert_eq!(
8369 serde_json::to_value(err).unwrap()["code"],
8370 "path_outside_root"
8371 );
8372 }
8373
8374 #[test]
8375 fn path_restriction_root_memo_canonicalizes_once_for_1000_validations() {
8376 let root = TempDir::new().expect("root tempdir");
8377 let target = root.path().join("target.txt");
8378 std::fs::write(&target, "inside").expect("write target");
8379 let ctx = test_context(Some(root.path().to_path_buf()), true);
8380
8381 for request in 0..1_000 {
8382 let validated = ctx
8383 .validate_path(&format!("memo-{request}"), &target)
8384 .expect("in-root path validates");
8385 assert_eq!(validated, std::fs::canonicalize(&target).unwrap());
8386 }
8387
8388 assert_eq!(
8389 ctx.path_restriction_root_canonicalizations_for_test(),
8390 1,
8391 "the configured root should be canonicalized once instead of once per validation"
8392 );
8393 }
8394
8395 #[cfg(unix)]
8396 #[test]
8397 fn path_restriction_root_memo_recanonicalizes_after_cached_target_disappears() {
8398 let workspace = TempDir::new().expect("workspace tempdir");
8399 let first_target = workspace.path().join("first-target");
8400 let second_target = workspace.path().join("second-target");
8401 let configured_root = workspace.path().join("configured-root");
8402 std::fs::create_dir_all(&first_target).expect("create first target");
8403 std::fs::create_dir_all(&second_target).expect("create second target");
8404 std::os::unix::fs::symlink(&first_target, &configured_root)
8405 .expect("create configured-root symlink");
8406 std::fs::write(first_target.join("inside.txt"), "first").expect("write first target");
8407
8408 let ctx = test_context(Some(configured_root.clone()), true);
8409 assert_eq!(
8410 ctx.validate_path("first-target", Path::new("inside.txt"))
8411 .expect("first target validates"),
8412 std::fs::canonicalize(first_target.join("inside.txt")).unwrap()
8413 );
8414
8415 std::fs::remove_dir_all(&first_target).expect("remove first target");
8418 std::fs::remove_file(&configured_root).expect("remove old root symlink");
8419 std::os::unix::fs::symlink(&second_target, &configured_root)
8420 .expect("recreate configured-root symlink");
8421 std::fs::write(second_target.join("inside.txt"), "second").expect("write second target");
8422
8423 assert_eq!(
8424 ctx.validate_path("second-target", Path::new("inside.txt"))
8425 .expect("second target validates"),
8426 std::fs::canonicalize(second_target.join("inside.txt")).unwrap()
8427 );
8428 assert_eq!(ctx.path_restriction_root_canonicalizations_for_test(), 2);
8429 }
8430
8431 #[test]
8432 fn force_restrict_guard_refcounts_duplicate_request_ids() {
8433 let root = TempDir::new().expect("root tempdir");
8434 let outside = TempDir::new().expect("outside tempdir");
8435 let outside_path = outside.path().join("outside.txt");
8436 let ctx = test_context(Some(root.path().to_path_buf()), false);
8437
8438 assert!(ctx.validate_path("dup", &outside_path).is_ok());
8439 let guard1 = ctx.force_restrict_guard("dup");
8440 let guard2 = ctx.force_restrict_guard("dup");
8441 assert!(ctx.validate_path("dup", &outside_path).is_err());
8442 drop(guard1);
8443 assert!(
8444 ctx.validate_path("dup", &outside_path).is_err(),
8445 "duplicate guard must keep the request over-restricted"
8446 );
8447 drop(guard2);
8448 assert!(ctx.validate_path("dup", &outside_path).is_ok());
8449 }
8450
8451 #[test]
8452 fn with_force_restrict_cleans_up_after_normal_completion_and_panic() {
8453 let root = TempDir::new().expect("root tempdir");
8454 let outside = TempDir::new().expect("outside tempdir");
8455 let outside_path = outside.path().join("outside.txt");
8456 let ctx = test_context(Some(root.path().to_path_buf()), false);
8457
8458 ctx.with_force_restrict("normal", || {
8459 assert!(ctx.validate_path("normal", &outside_path).is_err());
8460 });
8461 assert!(!ctx.request_force_restrict("normal"));
8462 assert!(ctx.validate_path("normal", &outside_path).is_ok());
8463
8464 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
8465 ctx.with_force_restrict("panic", || {
8466 assert!(ctx.validate_path("panic", &outside_path).is_err());
8467 panic!("intentional force-restrict cleanup panic");
8468 });
8469 }));
8470 assert!(panicked.is_err());
8471 assert!(!ctx.request_force_restrict("panic"));
8472 assert!(ctx.validate_path("panic", &outside_path).is_ok());
8473 }
8474
8475 #[cfg(unix)]
8476 #[test]
8477 fn validate_write_location_keeps_final_symlink_as_the_authorized_location() {
8478 let root = TempDir::new().expect("root tempdir");
8479 let outside = tempfile::NamedTempFile::new().expect("outside file");
8480 let link = root.path().join("file.txt");
8481 std::os::unix::fs::symlink(outside.path(), &link).expect("create final symlink");
8482 let ctx = test_context(Some(root.path().to_path_buf()), false);
8483 let _guard = ctx.force_restrict_guard("write-location-final-link");
8484
8485 let validated = ctx
8486 .validate_write_location("write-location-final-link", &link)
8487 .expect("the in-root link location is writable");
8488
8489 assert_eq!(
8490 validated,
8491 std::fs::canonicalize(root.path()).unwrap().join("file.txt")
8492 );
8493 }
8494
8495 #[cfg(unix)]
8496 #[test]
8497 fn validate_write_location_rejects_symlinked_parent_escape() {
8498 let root = TempDir::new().expect("root tempdir");
8499 let outside = TempDir::new().expect("outside tempdir");
8500 let linked_parent = root.path().join("linked-parent");
8501 std::os::unix::fs::symlink(outside.path(), &linked_parent).expect("create parent symlink");
8502 let candidate = linked_parent.join("file.txt");
8503 let ctx = test_context(Some(root.path().to_path_buf()), false);
8504 let _guard = ctx.force_restrict_guard("write-location-parent-link");
8505
8506 let error = ctx
8507 .validate_write_location("write-location-parent-link", &candidate)
8508 .expect_err("a symlinked parent must not escape the project root");
8509
8510 assert_eq!(
8511 serde_json::to_value(error).unwrap()["code"],
8512 "path_outside_root"
8513 );
8514 }
8515
8516 #[cfg(unix)]
8517 #[test]
8518 fn validate_write_location_rejects_outside_link_to_inside_file() {
8519 let root = TempDir::new().expect("root tempdir");
8520 let outside = TempDir::new().expect("outside tempdir");
8521 let inside = root.path().join("inside.txt");
8522 std::fs::write(&inside, "inside").unwrap();
8523 let outside_link = outside.path().join("outside-link.txt");
8524 std::os::unix::fs::symlink(&inside, &outside_link).expect("create outside symlink");
8525 let ctx = test_context(Some(root.path().to_path_buf()), false);
8526 let _guard = ctx.force_restrict_guard("write-location-outside-link");
8527
8528 let error = ctx
8529 .validate_write_location("write-location-outside-link", &outside_link)
8530 .expect_err("an out-of-root lexical location must remain blocked");
8531
8532 assert_eq!(
8533 serde_json::to_value(error).unwrap()["code"],
8534 "path_outside_root"
8535 );
8536 }
8537
8538 #[test]
8539 fn forced_restrict_without_project_root_fails_closed() {
8540 let ctx = test_context(None, false);
8541 let _guard = ctx.force_restrict_guard("missing-root");
8542 let err = ctx
8543 .validate_path("missing-root", Path::new("relative.txt"))
8544 .expect_err("forced restriction without a root must fail closed");
8545 assert_eq!(
8546 serde_json::to_value(err).unwrap()["code"],
8547 "path_outside_root"
8548 );
8549
8550 let write_err = ctx
8551 .validate_write_location("missing-root", Path::new("relative.txt"))
8552 .expect_err("write-location validation must also fail closed");
8553 assert_eq!(
8554 serde_json::to_value(write_err).unwrap()["code"],
8555 "path_outside_root"
8556 );
8557 }
8558}
8559
8560#[cfg(test)]
8561mod callgraph_store_for_ops_tests {
8562 use super::*;
8563 use crate::inspect::{InspectCategory, InspectSnapshot, JobOutcome, JobScope};
8564 use crate::parser::TreeSitterProvider;
8565 use crate::protocol::RawRequest;
8566 use serde_json::json;
8567 use std::path::Path;
8568 use std::sync::Barrier;
8569 use tempfile::TempDir;
8570
8571 fn callgraph_build_wait_ms(ms: u64) -> super::CallgraphBuildWaitMsGuard {
8572 super::override_callgraph_build_wait_ms_for_test(ms)
8573 }
8574
8575 fn force_async_callgraph_builds() -> super::CallgraphBuildWaitMsGuard {
8576 callgraph_build_wait_ms(0)
8577 }
8578
8579 fn cold_build_context() -> Arc<AppContext> {
8580 let project = TempDir::new().expect("project tempdir");
8581 let storage = TempDir::new().expect("storage tempdir");
8582 let source_dir = project.path().join("src");
8583 std::fs::create_dir_all(&source_dir).expect("source dir");
8584 std::fs::write(
8585 source_dir.join("lib.rs"),
8586 "pub fn caller() { callee(); }\npub fn callee() {}\n",
8587 )
8588 .expect("source file");
8589
8590 Arc::new(AppContext::new(
8591 Box::new(TreeSitterProvider::new()),
8592 Config {
8593 project_root: Some(project.keep()),
8594 storage_dir: Some(storage.keep()),
8595 callgraph_chunk_size: 1,
8596 ..Config::default()
8597 },
8598 ))
8599 }
8600
8601 fn with_fake_home_env<R>(home: &Path, f: impl FnOnce() -> R) -> R {
8602 let _guard = crate::test_env::process_env_lock();
8603 let prev_home = std::env::var_os("HOME");
8604 let prev_userprofile = std::env::var_os("USERPROFILE");
8605 unsafe {
8606 std::env::set_var("HOME", home);
8607 std::env::set_var("USERPROFILE", home);
8608 }
8609 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
8610 unsafe {
8611 match prev_home {
8612 Some(value) => std::env::set_var("HOME", value),
8613 None => std::env::remove_var("HOME"),
8614 }
8615 match prev_userprofile {
8616 Some(value) => std::env::set_var("USERPROFILE", value),
8617 None => std::env::remove_var("USERPROFILE"),
8618 }
8619 }
8620 match result {
8621 Ok(value) => value,
8622 Err(payload) => std::panic::resume_unwind(payload),
8623 }
8624 }
8625
8626 fn configure_request_with_params(params: serde_json::Value) -> RawRequest {
8627 RawRequest {
8628 id: "cfg".to_string(),
8629 command: "configure".to_string(),
8630 lsp_hints: None,
8631 session_id: None,
8632 params,
8633 }
8634 }
8635
8636 fn user_tier(doc: serde_json::Value) -> serde_json::Value {
8637 json!({
8638 "tier": "user",
8639 "source": "/u/aft.jsonc",
8640 "doc": doc.to_string(),
8641 })
8642 }
8643
8644 fn configure_context(project_root: &Path, storage_dir: &Path) -> AppContext {
8645 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
8646 let response = crate::commands::configure::handle_configure(
8647 &configure_request_with_params(json!({
8648 "project_root": project_root,
8649 "harness": "opencode",
8650 "storage_dir": storage_dir,
8651 "config": [user_tier(json!({
8652 "callgraph_store": true,
8653 "search_index": true,
8654 "semantic_search": true,
8655 }))],
8656 })),
8657 &ctx,
8658 );
8659 assert!(response.success, "configure should succeed: {response:?}");
8660 ctx
8661 }
8662
8663 fn inspect_snapshot(ctx: &AppContext) -> InspectSnapshot {
8664 InspectSnapshot::new(
8665 ctx.canonical_cache_root(),
8666 ctx.inspect_dir(),
8667 ctx.config(),
8668 ctx.symbol_cache(),
8669 )
8670 }
8671
8672 fn empty_semantic_index_for_ctx(ctx: &AppContext) -> SemanticIndex {
8673 let project_root = ctx
8674 .config()
8675 .project_root
8676 .clone()
8677 .expect("test context has a project root");
8678 let files: Vec<PathBuf> = Vec::new();
8679 let mut embed = |_texts: Vec<String>| -> Result<Vec<Vec<f32>>, String> { Ok(Vec::new()) };
8680 SemanticIndex::build(&project_root, &files, &mut embed, 1)
8681 .expect("empty semantic index should build")
8682 }
8683
8684 #[test]
8685 fn home_root_gate_blocks_callgraph_store_entry_points() {
8686 let _wait_guard = force_async_callgraph_builds();
8687 let home = TempDir::new().expect("home tempdir");
8688 let storage = TempDir::new().expect("storage tempdir");
8689 let source_dir = home.path().join("src");
8690 std::fs::create_dir_all(&source_dir).expect("source dir");
8691 std::fs::write(
8692 source_dir.join("lib.rs"),
8693 "pub fn caller() { callee(); }\npub fn callee() {}\n",
8694 )
8695 .expect("source file");
8696
8697 with_fake_home_env(home.path(), || {
8698 let ctx = configure_context(home.path(), storage.path());
8699 assert!(
8700 !ctx.heavy_root_work_allowed(),
8701 "HOME root configure must close the heavy-root-work gate"
8702 );
8703 assert!(
8704 !ctx.config().callgraph_store,
8705 "HOME root configure must force-disable the callgraph store"
8706 );
8707 assert!(ctx.is_home_root());
8708 assert!(ctx
8709 .degraded_reasons()
8710 .iter()
8711 .any(|reason| reason == "home_root"));
8712 let status_request = RawRequest {
8713 id: "home-status".to_string(),
8714 command: "status".to_string(),
8715 lsp_hints: None,
8716 session_id: None,
8717 params: json!({}),
8718 };
8719 let status = crate::commands::status::handle_status(&status_request, &ctx);
8720 assert_eq!(status.data["features"]["callgraph_store"], false);
8721 crate::commands::configure::drain_deferred_configure_maintenance(&ctx);
8722 assert!(
8723 ctx.callgraph_store_rx().lock().is_none(),
8724 "HOME root maintenance must not schedule a callgraph build"
8725 );
8726 assert_eq!(
8727 ctx.try_health_snapshot(home.path())
8728 .callgraph_store
8729 .as_ref()
8730 .map(|component| component.status),
8731 Some("disabled"),
8732 "HOME root health must not advertise callgraph building"
8733 );
8734
8735 reset_callgraph_cold_build_spawn_count_for_test();
8736 assert!(matches!(
8737 ctx.callgraph_store_for_ops(),
8738 CallgraphStoreAccess::Unavailable
8739 ));
8740 assert!(
8741 ctx.ensure_callgraph_store()
8742 .expect("ensure_callgraph_store should not error")
8743 .is_none(),
8744 "shared gate must also block synchronous standalone callgraph builds"
8745 );
8746 assert_eq!(
8747 callgraph_cold_build_spawn_count_for_test(),
8748 0,
8749 "HOME root gate must not spawn a cold callgraph build"
8750 );
8751
8752 let navigation = RawRequest {
8753 id: "home-callers".to_string(),
8754 command: "callers".to_string(),
8755 lsp_hints: None,
8756 session_id: None,
8757 params: json!({
8758 "file": source_dir.join("lib.rs"),
8759 "symbol": "caller",
8760 }),
8761 };
8762 let response = crate::commands::callers::handle_callers(&navigation, &ctx);
8763 assert!(!response.success);
8764 assert_eq!(response.data["code"], "callgraph_disabled");
8765 assert_eq!(response.data["status"], "disabled");
8766 assert_eq!(response.data["reason"], "home_root");
8767 assert!(response.data["message"]
8768 .as_str()
8769 .is_some_and(|message| message.contains("disabled for home roots")));
8770 });
8771 }
8772
8773 #[test]
8774 fn home_root_gate_blocks_inspect_manager_submit_paths() {
8775 let home = TempDir::new().expect("home tempdir");
8776 let storage = TempDir::new().expect("storage tempdir");
8777 let source_dir = home.path().join("src");
8778 std::fs::create_dir_all(&source_dir).expect("source dir");
8779 std::fs::write(source_dir.join("lib.rs"), "pub fn one() {}\n").expect("source file");
8780
8781 with_fake_home_env(home.path(), || {
8782 let ctx = configure_context(home.path(), storage.path());
8783 let snapshot = inspect_snapshot(&ctx);
8784 let scope = JobScope::for_project(snapshot.project_root.clone());
8785 let manager = ctx.inspect_manager();
8786
8787 assert!(matches!(
8788 manager.submit_category(snapshot.clone(), InspectCategory::Metrics, scope.clone()),
8789 JobOutcome::Failed { .. }
8790 ));
8791
8792 let submission = manager.submit_tier2_run_with_reuse_serial_background(
8793 snapshot,
8794 vec![InspectCategory::DeadCode],
8795 );
8796 assert!(submission.queued_categories.is_empty());
8797 assert!(submission.newly_queued_categories.is_empty());
8798 assert!(submission.deferred_categories.is_empty());
8799 assert_eq!(submission.errors.len(), 1);
8800 assert!(
8801 !manager.tier2_any_in_flight(),
8802 "HOME root gate must reject Tier-2 submission before any job is queued"
8803 );
8804 });
8805 }
8806
8807 #[test]
8808 fn non_home_root_still_allows_callgraph_cold_builds() {
8809 let _env_guard = force_async_callgraph_builds();
8810 reset_callgraph_cold_build_spawn_count_for_test();
8811 let ctx = cold_build_context();
8812
8813 assert!(ctx.heavy_root_work_allowed());
8814 assert!(matches!(
8815 ctx.callgraph_store_for_ops(),
8816 CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
8817 ));
8818 assert_eq!(
8819 callgraph_cold_build_spawn_count_for_test(),
8820 1,
8821 "non-home roots must still be able to cold-build the callgraph store"
8822 );
8823
8824 let rx = ctx
8825 .callgraph_store_rx
8826 .lock()
8827 .as_ref()
8828 .cloned()
8829 .expect("non-home cold build should install an in-flight receiver");
8830 rx.recv_timeout(Duration::from_secs(30))
8831 .expect("background cold build should complete");
8832 *ctx.callgraph_store_rx.lock() = None;
8833 }
8834
8835 #[test]
8836 fn semantic_ready_event_resumes_tier2_without_rescheduling_callgraph() {
8837 let _env_guard = force_async_callgraph_builds();
8838 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
8839 let ctx = cold_build_context();
8840 let (tx, rx) = crossbeam_channel::unbounded();
8841 *ctx.semantic_index_rx().lock() = Some(rx);
8842 ctx.schedule_semantic_cold_seed_gate_for_configure();
8843
8844 assert!(matches!(
8845 ctx.callgraph_store_for_ops(),
8846 CallgraphStoreAccess::Building
8847 ));
8848 assert_eq!(
8849 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8850 1,
8851 "the semantic cold seed must not block callgraph admission"
8852 );
8853 tx.send(SemanticIndexEvent::Ready(empty_semantic_index_for_ctx(
8854 &ctx,
8855 )))
8856 .expect("send ready event");
8857
8858 crate::runtime_drain::drain_semantic_index_events(&ctx);
8859
8860 assert!(
8861 !ctx.semantic_cold_seed_active(),
8862 "semantic Ready must clear the scheduled cold gate"
8863 );
8864 assert!(
8865 ctx.tier2_pull_demand_pending(),
8866 "semantic Ready must resume deferred Tier-2 work"
8867 );
8868 assert_eq!(
8869 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8870 1,
8871 "semantic Ready must not schedule a duplicate callgraph warm"
8872 );
8873 let rx = ctx
8874 .callgraph_store_rx
8875 .lock()
8876 .as_ref()
8877 .cloned()
8878 .expect("callgraph warm should install an in-flight receiver");
8879 rx.recv_timeout(Duration::from_secs(30))
8880 .expect("background cold build should complete");
8881 *ctx.callgraph_store_rx.lock() = None;
8882 }
8883
8884 #[test]
8885 fn semantic_gate_cleared_event_resumes_tier2_without_rescheduling_callgraph() {
8886 let _env_guard = force_async_callgraph_builds();
8887 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
8888 let ctx = cold_build_context();
8889 ctx.schedule_semantic_cold_seed_gate_for_configure();
8890
8891 assert!(matches!(
8892 ctx.callgraph_store_for_ops(),
8893 CallgraphStoreAccess::Building
8894 ));
8895 assert_eq!(
8896 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8897 1,
8898 "the semantic cold seed must not block callgraph admission"
8899 );
8900 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
8901
8902 assert!(
8903 !ctx.semantic_cold_seed_active(),
8904 "cached-load or retry-wait clear must reopen the semantic cold gate"
8905 );
8906 assert!(
8907 ctx.tier2_pull_demand_pending(),
8908 "cached-load or retry-wait clear must resume deferred Tier-2 work"
8909 );
8910 assert_eq!(
8911 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8912 1,
8913 "clearing the semantic gate must not schedule a duplicate callgraph warm"
8914 );
8915 let rx = ctx
8916 .callgraph_store_rx
8917 .lock()
8918 .as_ref()
8919 .cloned()
8920 .expect("callgraph warm should install an in-flight receiver");
8921 rx.recv_timeout(Duration::from_secs(30))
8922 .expect("background cold build should complete");
8923 *ctx.callgraph_store_rx.lock() = None;
8924 }
8925
8926 #[test]
8927 fn semantic_cold_seed_gate_allows_callgraph_cold_spawn_immediately() {
8928 let _env_guard = force_async_callgraph_builds();
8929 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
8930 let ctx = cold_build_context();
8931
8932 ctx.set_semantic_cold_seed_active_for_test(true);
8933 assert!(matches!(
8934 ctx.callgraph_store_for_ops(),
8935 CallgraphStoreAccess::Building
8936 ));
8937 assert_eq!(
8938 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8939 1,
8940 "callgraph navigation must start while the semantic cold seed is active"
8941 );
8942
8943 ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
8944 assert_eq!(
8945 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
8946 1,
8947 "clearing the semantic cold gate must not schedule a second callgraph warm"
8948 );
8949
8950 let rx = ctx
8951 .callgraph_store_rx
8952 .lock()
8953 .as_ref()
8954 .cloned()
8955 .expect("callgraph warm should install an in-flight receiver");
8956 rx.recv_timeout(Duration::from_secs(30))
8957 .expect("background cold build should complete");
8958 *ctx.callgraph_store_rx.lock() = None;
8959 }
8960
8961 #[test]
8962 fn semantic_cold_seed_gate_clear_requests_tier2_pull() {
8963 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
8964 ctx.schedule_semantic_cold_seed_gate_for_configure();
8965
8966 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
8967
8968 assert!(
8969 !ctx.semantic_cold_seed_active(),
8970 "retry-wait or cached-load events must reopen the semantic cold gate"
8971 );
8972 assert!(
8973 ctx.tier2_pull_demand_pending(),
8974 "clearing the semantic cold gate should kick a Tier-2 pull refresh"
8975 );
8976 }
8977
8978 #[test]
8979 fn semantic_failed_event_clears_scheduled_gate_and_requests_tier2_pull() {
8980 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
8981 let (tx, rx) = crossbeam_channel::unbounded();
8982 *ctx.semantic_index_rx().lock() = Some(rx);
8983 ctx.schedule_semantic_cold_seed_gate_for_configure();
8984 tx.send(SemanticIndexEvent::Failed(
8985 "embedding backend failed".to_string(),
8986 ))
8987 .expect("send failed event");
8988
8989 crate::runtime_drain::drain_semantic_index_events(&ctx);
8990
8991 assert!(
8992 !ctx.semantic_cold_seed_active(),
8993 "semantic Failed must clear the scheduled cold gate"
8994 );
8995 assert!(
8996 ctx.tier2_pull_demand_pending(),
8997 "semantic Failed must resume deferred Tier-2 work"
8998 );
8999 }
9000
9001 #[test]
9002 fn semantic_disconnect_clears_scheduled_gate_and_requests_tier2_pull() {
9003 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
9004 let (tx, rx) = crossbeam_channel::unbounded::<SemanticIndexEvent>();
9005 *ctx.semantic_index_rx().lock() = Some(rx);
9006 ctx.schedule_semantic_cold_seed_gate_for_configure();
9007 drop(tx);
9008
9009 crate::runtime_drain::drain_semantic_index_events(&ctx);
9010
9011 assert!(
9012 !ctx.semantic_cold_seed_active(),
9013 "semantic worker disconnect must clear the scheduled cold gate"
9014 );
9015 assert!(
9016 ctx.tier2_pull_demand_pending(),
9017 "semantic worker disconnect must resume deferred Tier-2 work"
9018 );
9019 }
9020
9021 #[test]
9022 fn semantic_cold_seed_gate_is_per_context_for_tier2_scheduler() {
9023 let ctx_a = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
9024 let ctx_b = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
9025 let base = Instant::now();
9026 ctx_a.reset_tier2_refresh_scheduler_at(base);
9027 ctx_b.reset_tier2_refresh_scheduler_at(base);
9028 ctx_a.set_semantic_cold_seed_active_for_test(true);
9029
9030 assert_eq!(
9031 ctx_a.tick_tier2_refresh_scheduler_at(
9032 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
9033 0,
9034 ),
9035 None,
9036 "root A should defer Tier-2 while its semantic cold seed is active"
9037 );
9038 assert_eq!(
9039 ctx_b.tick_tier2_refresh_scheduler_at(
9040 base + crate::inspect::tier2_scheduler::TIER2_REFRESH_COLD_CACHE_DELAY,
9041 0,
9042 ),
9043 Some(Tier2TriggerReason::ConfigureWarm),
9044 "root B must not inherit root A's semantic cold gate"
9045 );
9046 }
9047
9048 #[test]
9049 fn query_wait_joins_callgraph_build_scheduled_without_wait() {
9050 let _env_guard = callgraph_build_wait_ms(10_000);
9051 let project = TempDir::new().expect("project tempdir");
9052 let storage = TempDir::new().expect("storage tempdir");
9053 std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
9054 let project_root = std::fs::canonicalize(project.path()).expect("canonical project root");
9055 let project_key = crate::search_index::artifact_cache_key(&project_root);
9056 crate::root_cache::configure_artifact_access(&project_root, &project_key, false);
9057 let ctx = Arc::new(AppContext::new(
9058 Box::new(TreeSitterProvider::new()),
9059 Config {
9060 project_root: Some(project_root.clone()),
9061 storage_dir: Some(storage.path().to_path_buf()),
9062 callgraph_chunk_size: 1,
9063 ..Config::default()
9064 },
9065 ));
9066 let (reached, release) = install_callgraph_build_start_gate(project_root);
9067
9068 assert!(matches!(
9069 ctx.schedule_callgraph_store_warm(),
9070 CallgraphStoreAccess::Building
9071 ));
9072 reached
9073 .recv_timeout(Duration::from_secs(2))
9074 .expect("scheduled callgraph worker did not reach start barrier");
9075
9076 let (result_tx, result_rx) = std::sync::mpsc::channel();
9077 let query_ctx = Arc::clone(&ctx);
9078 let query = std::thread::spawn(move || {
9079 result_tx
9080 .send(query_ctx.callgraph_store_for_ops())
9081 .expect("send query result");
9082 });
9083 assert!(
9084 matches!(
9085 result_rx.recv_timeout(Duration::from_millis(100)),
9086 Err(std::sync::mpsc::RecvTimeoutError::Timeout)
9087 ),
9088 "query returned while the scheduled callgraph build was still in flight"
9089 );
9090
9091 release.send(()).expect("release callgraph worker");
9092 assert!(matches!(
9093 result_rx
9094 .recv_timeout(Duration::from_secs(10))
9095 .expect("query did not settle after the callgraph build completed"),
9096 CallgraphStoreAccess::Ready(_)
9097 ));
9098 query.join().expect("callgraph query thread");
9099 }
9100
9101 #[test]
9102 fn inline_wait_settled_event_clears_superseded_receiver() {
9103 let _env_guard = callgraph_build_wait_ms(2_000);
9104 let project = TempDir::new().expect("project tempdir");
9105 let storage = TempDir::new().expect("storage tempdir");
9106 std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
9107 let project_root = std::fs::canonicalize(project.path()).expect("canonical project root");
9108 let ctx = Arc::new(AppContext::new(
9109 Box::new(TreeSitterProvider::new()),
9110 Config {
9111 project_root: Some(project.path().to_path_buf()),
9112 storage_dir: Some(storage.path().to_path_buf()),
9113 callgraph_chunk_size: 1,
9114 ..Config::default()
9115 },
9116 ));
9117 let (reached, release) = install_callgraph_build_start_gate(project_root);
9118 let request_ctx = Arc::clone(&ctx);
9119 let request = std::thread::spawn(move || request_ctx.callgraph_store_for_ops());
9120 reached
9121 .recv_timeout(Duration::from_secs(2))
9122 .expect("callgraph worker did not reach start barrier");
9123
9124 ctx.next_callgraph_persist_epoch();
9125 release.send(()).unwrap();
9126 assert!(matches!(
9127 request.join().expect("callgraph request thread"),
9128 CallgraphStoreAccess::Building
9129 ));
9130 assert!(
9131 ctx.callgraph_store_rx().lock().is_none(),
9132 "inline Settled handling must retire the matching receiver"
9133 );
9134 assert!(
9135 ctx.callgraph_store()
9136 .read()
9137 .unwrap_or_else(std::sync::PoisonError::into_inner)
9138 .is_none(),
9139 "Settled must not reopen and install an older persisted store"
9140 );
9141 }
9142
9143 #[test]
9144 fn pointer_removal_arm_is_scoped_to_its_callgraph_pointer() {
9145 let temp = TempDir::new().expect("pointer tempdir");
9146 let target = temp.path().join("target.current");
9147 let unrelated = temp.path().join("unrelated.current");
9148 std::fs::write(&target, "target-generation\n").expect("target pointer");
9149 std::fs::write(&unrelated, "unrelated-generation\n").expect("unrelated pointer");
9150 let _arm = install_callgraph_pointer_removal_arm(target.clone());
9151
9152 remove_armed_callgraph_pointer_for_test(&unrelated);
9155 assert!(
9156 unrelated.exists(),
9157 "unrelated pointer must remain published"
9158 );
9159 assert!(target.exists(), "target arm must remain pending");
9160
9161 remove_armed_callgraph_pointer_for_test(&target);
9162 assert!(!target.exists(), "target pointer should consume its arm");
9163 assert!(
9164 unrelated.exists(),
9165 "unrelated pointer must remain published"
9166 );
9167 }
9168
9169 #[test]
9170 fn inline_ready_without_published_pointer_settles_and_preserves_pending_paths() {
9171 let _env_guard = callgraph_build_wait_ms(2_000);
9172 let project = TempDir::new().expect("project tempdir");
9173 let storage = TempDir::new().expect("storage tempdir");
9174 std::fs::write(project.path().join("lib.rs"), "pub fn marker() {}\n").expect("source file");
9175 let ctx = AppContext::new(
9176 Box::new(TreeSitterProvider::new()),
9177 Config {
9178 project_root: Some(project.path().to_path_buf()),
9179 storage_dir: Some(storage.path().to_path_buf()),
9180 callgraph_chunk_size: 1,
9181 ..Config::default()
9182 },
9183 );
9184 let project_key = crate::search_index::artifact_cache_key(project.path());
9185 crate::root_cache::configure_artifact_access(project.path(), &project_key, false);
9186 let pending = project.path().join("pending.rs");
9187 ctx.add_pending_callgraph_store_paths([pending.clone()]);
9188 let pointer = ctx
9189 .callgraph_store_dir()
9190 .join(format!("{project_key}.current"));
9191 let _remove_pointer_guard = install_callgraph_pointer_removal_arm(pointer);
9192
9193 assert!(matches!(
9194 ctx.callgraph_store_for_ops(),
9195 CallgraphStoreAccess::Building
9196 ));
9197 assert!(
9198 ctx.callgraph_store_rx().lock().is_none(),
9199 "inline Ready must settle after the published pointer disappears"
9200 );
9201 assert_eq!(
9202 ctx.take_pending_callgraph_store_paths(),
9203 vec![pending],
9204 "inline reopen failure must preserve pending watcher paths"
9205 );
9206 }
9207
9208 #[test]
9209 fn take_pending_callgraph_store_paths_drops_paths_outside_current_root() {
9210 let project = TempDir::new().expect("project tempdir");
9211 let foreign = TempDir::new().expect("foreign tempdir");
9212 let ctx = AppContext::new(
9213 Box::new(TreeSitterProvider::new()),
9214 Config {
9215 project_root: Some(project.path().to_path_buf()),
9216 ..Config::default()
9217 },
9218 );
9219 let inside = project.path().join("kept.rs");
9220 let outside = foreign.path().join("previous-root-file.rs");
9224 let dotdot_escape = project
9227 .path()
9228 .join("..")
9229 .join(
9230 foreign
9231 .path()
9232 .file_name()
9233 .expect("foreign tempdir has a name"),
9234 )
9235 .join("escaped.rs");
9236 ctx.add_pending_callgraph_store_paths([inside.clone(), outside, dotdot_escape]);
9237
9238 assert_eq!(
9239 ctx.take_pending_callgraph_store_paths(),
9240 vec![inside],
9241 "pending replay must drop foreign and dot-dot-escaping paths"
9242 );
9243 }
9244
9245 #[test]
9246 fn watcher_gap_invalidation_keeps_semantic_reloadable_and_skips_readonly_force_token() {
9247 let project = TempDir::new().expect("project tempdir");
9248 let ctx = AppContext::new(
9249 Box::new(TreeSitterProvider::new()),
9250 Config {
9251 project_root: Some(project.path().to_path_buf()),
9252 semantic_search: true,
9253 ..Config::default()
9254 },
9255 );
9256 ctx.set_canonical_cache_root(project.path().to_path_buf());
9257 ctx.set_cache_writer_capabilities(false, true);
9260 *ctx.semantic_index_status()
9261 .write()
9262 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
9263
9264 ctx.invalidate_artifacts_after_watcher_gap();
9265
9266 assert!(
9267 matches!(
9268 &*ctx
9269 .semantic_index_status()
9270 .read()
9271 .unwrap_or_else(std::sync::PoisonError::into_inner),
9272 SemanticIndexStatus::Ready { .. }
9273 ),
9274 "semantic-enabled root must stay reloadable (Disabled has no self-healing path)"
9275 );
9276 assert_eq!(
9277 ctx.pending_callgraph_store_force_token(),
9278 None,
9279 "read-only root must not be stuck behind an unfulfillable force token"
9280 );
9281 }
9282
9283 #[test]
9284 fn watcher_gap_invalidation_marks_force_rebuild_for_writer_roots() {
9285 let project = TempDir::new().expect("project tempdir");
9286 let ctx = AppContext::new(
9287 Box::new(TreeSitterProvider::new()),
9288 Config {
9289 project_root: Some(project.path().to_path_buf()),
9290 ..Config::default()
9291 },
9292 );
9293 ctx.set_canonical_cache_root(project.path().to_path_buf());
9294 ctx.set_cache_writer_capabilities(true, true);
9295
9296 ctx.invalidate_artifacts_after_watcher_gap();
9297
9298 assert!(
9299 ctx.pending_callgraph_store_force_token().is_some(),
9300 "writer roots must still reconcile the store after the unobserved interval"
9301 );
9302 assert!(
9303 matches!(
9304 &*ctx
9305 .semantic_index_status()
9306 .read()
9307 .unwrap_or_else(std::sync::PoisonError::into_inner),
9308 SemanticIndexStatus::Disabled
9309 ),
9310 "semantic-disabled config maps to Disabled status"
9311 );
9312 }
9313
9314 #[cfg(unix)]
9315 #[test]
9316 fn take_pending_callgraph_store_paths_drops_symlink_dotdot_escape() {
9317 let project = TempDir::new().expect("project tempdir");
9318 let foreign = TempDir::new().expect("foreign tempdir");
9319 std::fs::create_dir_all(foreign.path().join("dir")).expect("foreign dir");
9320 std::fs::write(foreign.path().join("secret.rs"), "pub fn s() {}\n").expect("secret");
9321 let ctx = AppContext::new(
9322 Box::new(TreeSitterProvider::new()),
9323 Config {
9324 project_root: Some(project.path().to_path_buf()),
9325 ..Config::default()
9326 },
9327 );
9328 std::os::unix::fs::symlink(foreign.path().join("dir"), project.path().join("link"))
9333 .expect("plant symlink");
9334 let escape = project.path().join("link").join("..").join("secret.rs");
9335 let dead_component_escape = project
9340 .path()
9341 .join("link")
9342 .join("dead")
9343 .join("..")
9344 .join("..")
9345 .join("deep-secret.rs");
9346 std::fs::write(foreign.path().join("reentry-secret.rs"), "pub fn r() {}\n")
9351 .expect("reentry secret");
9352 let reentry_escape = project
9353 .path()
9354 .join("dead")
9355 .join("..")
9356 .join("link")
9357 .join("..")
9358 .join("reentry-secret.rs");
9359 std::os::unix::fs::symlink(
9364 foreign.path().join("nonexistent-target"),
9365 project.path().join("dangling"),
9366 )
9367 .expect("plant dangling symlink");
9368 let dangling_reentry = project
9369 .path()
9370 .join("dangling")
9371 .join("..")
9372 .join("via-dangling.rs");
9373 std::fs::write(project.path().join("plain.rs"), "pub fn p() {}\n").expect("plain file");
9376 let through_file = project
9377 .path()
9378 .join("plain.rs")
9379 .join("..")
9380 .join("via-file.rs");
9381 let kept = project.path().join("kept.rs");
9382 ctx.add_pending_callgraph_store_paths([
9383 escape,
9384 dead_component_escape,
9385 reentry_escape,
9386 dangling_reentry,
9387 through_file,
9388 kept.clone(),
9389 ]);
9390
9391 assert_eq!(
9392 ctx.take_pending_callgraph_store_paths(),
9393 vec![kept],
9394 "symlink-plus-dotdot escapes must be dropped with filesystem-first semantics"
9395 );
9396 }
9397
9398 #[cfg(windows)]
9399 #[test]
9400 fn take_pending_callgraph_store_paths_drops_drive_relative_paths() {
9401 let cwd = std::env::current_dir().expect("drive cwd");
9408 let cwd_file = PathBuf::from(format!(
9409 "{}under-drive-cwd.rs",
9410 cwd.components()
9411 .next()
9412 .map(|prefix| prefix.as_os_str().to_string_lossy().into_owned())
9413 .expect("drive prefix")
9414 ));
9415 assert!(cwd_file.is_relative(), "C:foo must classify as relative");
9416 assert!(
9417 !pending_path_in_roots(&cwd_file, &[cwd.clone()]),
9418 "drive-relative spelling must be rejected even when the drive CWD is inside the root"
9419 );
9420 assert!(
9421 !pending_path_in_roots(Path::new(r"\root-relative.rs"), &[cwd]),
9422 "root-relative spelling must be rejected"
9423 );
9424
9425 let project = TempDir::new().expect("project tempdir");
9426 let ctx = AppContext::new(
9427 Box::new(TreeSitterProvider::new()),
9428 Config {
9429 project_root: Some(project.path().to_path_buf()),
9430 ..Config::default()
9431 },
9432 );
9433 let kept = project.path().join("kept.rs");
9434 ctx.add_pending_callgraph_store_paths([
9435 PathBuf::from("C:drive-relative.rs"),
9436 PathBuf::from(r"\root-relative.rs"),
9437 kept.clone(),
9438 ]);
9439
9440 assert_eq!(
9441 ctx.take_pending_callgraph_store_paths(),
9442 vec![kept],
9443 "drive-relative and root-relative spellings must be rejected"
9444 );
9445 }
9446
9447 #[test]
9448 fn take_pending_callgraph_store_paths_keeps_relative_and_deleted_paths() {
9449 let project = TempDir::new().expect("project tempdir");
9450 let ctx = AppContext::new(
9451 Box::new(TreeSitterProvider::new()),
9452 Config {
9453 project_root: Some(project.path().to_path_buf()),
9454 ..Config::default()
9455 },
9456 );
9457 let relative = PathBuf::from("src/relative.rs");
9460 let deleted = project.path().join("never-created.rs");
9461 ctx.add_pending_callgraph_store_paths([relative.clone(), deleted.clone()]);
9462
9463 let mut taken = ctx.take_pending_callgraph_store_paths();
9464 taken.sort();
9465 let mut expected = vec![relative, deleted];
9466 expected.sort();
9467 assert_eq!(
9468 taken, expected,
9469 "root-relative and deleted in-root paths must survive the filter"
9470 );
9471 }
9472
9473 #[test]
9474 fn writer_denied_callgraph_build_is_terminal_not_building() {
9475 let _env_guard = callgraph_build_wait_ms(30_000);
9476 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
9477
9478 let denied_ctx = cold_build_context();
9479 let denied_reason = match denied_ctx.callgraph_store_for_ops() {
9480 CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason)) => reason,
9481 CallgraphStoreAccess::Building => {
9482 panic!("writer-denied build must not remain in the retryable Building state")
9483 }
9484 _ => panic!("unregistered root must terminate with an unavailable reason"),
9485 };
9486 assert!(
9487 denied_reason.contains("could not acquire writer capability"),
9488 "terminal status must explain the writer-capability denial: {denied_reason}"
9489 );
9490 assert!(matches!(
9491 denied_ctx.callgraph_store_for_ops(),
9492 CallgraphStoreAccess::Error(CallGraphStoreError::Unavailable(reason))
9493 if reason.contains("could not acquire writer capability")
9494 ));
9495 assert_eq!(
9496 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
9497 1,
9498 "polling a denied root must not spawn another doomed build"
9499 );
9500
9501 let writable_ctx = cold_build_context();
9504 let writable_root = writable_ctx
9505 .config()
9506 .project_root
9507 .clone()
9508 .expect("writable fixture root");
9509 let writable_key = crate::search_index::artifact_cache_key(&writable_root);
9510 crate::root_cache::configure_artifact_access(&writable_root, &writable_key, false);
9511 assert!(
9512 matches!(
9513 writable_ctx.callgraph_store_for_ops(),
9514 CallgraphStoreAccess::Ready(_)
9515 ),
9516 "removing the forced denial must change the terminal status"
9517 );
9518 }
9519
9520 #[test]
9521 fn concurrent_cold_callgraph_store_for_ops_spawns_one_build() {
9522 let _env_guard = force_async_callgraph_builds();
9523 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.store(0, Ordering::SeqCst);
9524
9525 let project = TempDir::new().expect("project tempdir");
9526 let storage = TempDir::new().expect("storage tempdir");
9527 let source_dir = project.path().join("src");
9528 std::fs::create_dir_all(&source_dir).expect("source dir");
9529 std::fs::write(
9530 source_dir.join("lib.rs"),
9531 "pub fn caller() { callee(); }\npub fn callee() {}\n",
9532 )
9533 .expect("source file");
9534
9535 let ctx = Arc::new(AppContext::new(
9536 Box::new(TreeSitterProvider::new()),
9537 Config {
9538 project_root: Some(project.path().to_path_buf()),
9539 storage_dir: Some(storage.path().to_path_buf()),
9540 callgraph_chunk_size: 1,
9541 ..Config::default()
9542 },
9543 ));
9544
9545 let barrier = Arc::new(Barrier::new(3));
9546 let handles = (0..2)
9547 .map(|_| {
9548 let ctx = Arc::clone(&ctx);
9549 let barrier = Arc::clone(&barrier);
9550 std::thread::spawn(move || {
9551 barrier.wait();
9552 matches!(
9553 ctx.callgraph_store_for_ops(),
9554 CallgraphStoreAccess::Building | CallgraphStoreAccess::Ready(_)
9555 )
9556 })
9557 })
9558 .collect::<Vec<_>>();
9559
9560 barrier.wait();
9561 for handle in handles {
9562 assert!(
9563 handle.join().expect("callgraph caller thread"),
9564 "cold callgraph ops should report Building or observe the installed store"
9565 );
9566 }
9567
9568 assert_eq!(
9569 CALLGRAPH_COLD_BUILD_SPAWN_COUNT.load(Ordering::SeqCst),
9570 1,
9571 "concurrent cold callers must share one background build"
9572 );
9573
9574 let rx = ctx
9575 .callgraph_store_rx
9576 .lock()
9577 .as_ref()
9578 .cloned()
9579 .expect("in-flight receiver installed before spawn");
9580 rx.recv_timeout(Duration::from_secs(30))
9581 .expect("background cold build should complete");
9582 *ctx.callgraph_store_rx.lock() = None;
9583 }
9584
9585 #[test]
9586 fn watcher_gap_invalidation_gates_resident_artifacts_and_forces_strict_verify() {
9587 let root = TempDir::new().expect("project tempdir");
9588 let canonical_root = std::fs::canonicalize(root.path()).expect("canonical project root");
9589 let ctx = AppContext::new(
9590 Box::new(TreeSitterProvider::new()),
9591 Config {
9592 project_root: Some(canonical_root.clone()),
9593 ..Config::default()
9594 },
9595 );
9596 *ctx.search_index
9597 .write()
9598 .unwrap_or_else(std::sync::PoisonError::into_inner) =
9599 Some(SearchIndex::build(&canonical_root));
9600 *ctx.semantic_index
9601 .write()
9602 .unwrap_or_else(std::sync::PoisonError::into_inner) =
9603 Some(SemanticIndex::new(canonical_root.clone(), 3));
9604 *ctx.semantic_index_status
9605 .write()
9606 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
9607
9608 let artifact = canonical_root.join("verify-artifact.bin");
9609 std::fs::write(&artifact, b"same-size").expect("write verification artifact");
9610 let generation =
9611 crate::cache_freshness::artifact_generation(&artifact).expect("artifact generation");
9612 crate::cache_freshness::record_verify_completed(
9613 &canonical_root,
9614 crate::cache_freshness::VerifyArtifact::Search,
9615 Some(generation),
9616 );
9617 assert_eq!(
9618 crate::cache_freshness::warm_verify_plan(
9619 &canonical_root,
9620 crate::cache_freshness::VerifyArtifact::Search,
9621 Some(generation),
9622 ),
9623 crate::cache_freshness::WarmVerifyPlan::Skip
9624 );
9625
9626 ctx.invalidate_artifacts_after_watcher_gap();
9627
9628 assert!(ctx
9629 .search_index
9630 .read()
9631 .unwrap_or_else(std::sync::PoisonError::into_inner)
9632 .is_none());
9633 assert!(ctx
9634 .semantic_index
9635 .read()
9636 .unwrap_or_else(std::sync::PoisonError::into_inner)
9637 .is_none());
9638 assert!(ctx.pending_callgraph_store_force_token().is_some());
9639 assert_eq!(
9640 crate::cache_freshness::warm_verify_plan(
9641 &canonical_root,
9642 crate::cache_freshness::VerifyArtifact::Search,
9643 Some(generation),
9644 ),
9645 crate::cache_freshness::WarmVerifyPlan::Strict
9646 );
9647 }
9648
9649 #[test]
9650 fn cancelled_semantic_refresh_transfers_refreshing_files_to_pending() {
9651 let root = TempDir::new().expect("project tempdir");
9652 let ctx = AppContext::new(
9653 Box::new(TreeSitterProvider::new()),
9654 Config {
9655 project_root: Some(root.path().to_path_buf()),
9656 semantic_search: true,
9657 ..Config::default()
9658 },
9659 );
9660 *ctx.semantic_index
9661 .write()
9662 .unwrap_or_else(std::sync::PoisonError::into_inner) =
9663 Some(SemanticIndex::new(root.path().to_path_buf(), 3));
9664 let refreshing_path = root.path().join("src/lib.rs");
9665 {
9666 let mut status = ctx
9667 .semantic_index_status
9668 .write()
9669 .unwrap_or_else(std::sync::PoisonError::into_inner);
9670 *status = SemanticIndexStatus::ready();
9671 status.start_refreshing_file(refreshing_path.clone());
9672 }
9673 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
9674 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
9675 ctx.install_semantic_refresh_worker_for_build_epoch(
9676 request_tx,
9677 event_rx,
9678 Arc::new(Mutex::new(None)),
9679 ctx.semantic_index_rx_epoch(),
9680 );
9681
9682 ctx.cancel_unbound_artifact_work();
9683
9684 assert_eq!(
9687 ctx.pending_semantic_index_paths
9688 .lock()
9689 .iter()
9690 .cloned()
9691 .collect::<Vec<_>>(),
9692 vec![refreshing_path],
9693 "cancelled in-flight refresh files must transfer to the pending set"
9694 );
9695 assert!(matches!(
9696 &*ctx
9697 .semantic_index_status
9698 .read()
9699 .unwrap_or_else(std::sync::PoisonError::into_inner),
9700 SemanticIndexStatus::Ready { refreshing, .. } if refreshing.is_empty()
9701 ));
9702 }
9703
9704 #[test]
9705 fn unbind_before_corpus_started_preserves_corpus_intent() {
9706 let root = TempDir::new().expect("project tempdir");
9711 let ctx = AppContext::new(
9712 Box::new(TreeSitterProvider::new()),
9713 Config {
9714 project_root: Some(root.path().to_path_buf()),
9715 semantic_search: true,
9716 ..Config::default()
9717 },
9718 );
9719 *ctx.semantic_index
9720 .write()
9721 .unwrap_or_else(std::sync::PoisonError::into_inner) =
9722 Some(SemanticIndex::new(root.path().to_path_buf(), 3));
9723 *ctx.semantic_index_status
9724 .write()
9725 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
9726 stage: "refreshing_corpus".to_string(),
9727 files: None,
9728 entries_done: None,
9729 entries_total: None,
9730 };
9731 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
9732 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
9733 ctx.install_semantic_refresh_worker_for_build_epoch(
9734 request_tx,
9735 event_rx,
9736 Arc::new(Mutex::new(None)),
9737 ctx.semantic_index_rx_epoch(),
9738 );
9739
9740 ctx.cancel_unbound_artifact_work();
9741
9742 assert!(
9743 *ctx.pending_semantic_corpus_refresh.lock(),
9744 "corpus intent stamped before CorpusStarted must survive the cancellation"
9745 );
9746 }
9747
9748 #[test]
9749 fn cancelled_search_corpus_refresh_drops_nonready_resident_index() {
9750 let root = TempDir::new().expect("project tempdir");
9751 let ctx = AppContext::new(
9752 Box::new(TreeSitterProvider::new()),
9753 Config {
9754 project_root: Some(root.path().to_path_buf()),
9755 ..Config::default()
9756 },
9757 );
9758 let mut refreshing = SearchIndex::new();
9762 refreshing.ready = false;
9763 *ctx.search_index
9764 .write()
9765 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(refreshing);
9766 let (_tx, rx) = crossbeam_channel::unbounded();
9767 ctx.install_search_index_rx(rx, ctx.configure_generation());
9768
9769 ctx.cancel_unbound_artifact_work();
9770
9771 assert!(
9772 ctx.search_index
9773 .read()
9774 .unwrap_or_else(std::sync::PoisonError::into_inner)
9775 .is_none(),
9776 "a cancelled corpus refresh must drop the non-ready resident so rebind reloads it"
9777 );
9778 assert!(ctx
9779 .search_index_rx
9780 .read()
9781 .unwrap_or_else(std::sync::PoisonError::into_inner)
9782 .is_none());
9783 }
9784
9785 #[test]
9786 fn active_semantic_file_refresh_blocks_idle_eviction_until_completion() {
9787 let root = TempDir::new().expect("project tempdir");
9788 let ctx = AppContext::new(
9789 Box::new(TreeSitterProvider::new()),
9790 Config {
9791 project_root: Some(root.path().to_path_buf()),
9792 ..Config::default()
9793 },
9794 );
9795 *ctx.semantic_index
9796 .write()
9797 .unwrap_or_else(std::sync::PoisonError::into_inner) =
9798 Some(SemanticIndex::new(root.path().to_path_buf(), 3));
9799 let refreshing_path = root.path().join("src/lib.rs");
9800 {
9801 let mut status = ctx
9802 .semantic_index_status
9803 .write()
9804 .unwrap_or_else(std::sync::PoisonError::into_inner);
9805 *status = SemanticIndexStatus::ready();
9806 status.start_refreshing_file(refreshing_path.clone());
9807 }
9808
9809 assert!(ctx.artifact_eviction_blocked());
9810 assert!(!ctx.evict_idle_artifacts());
9811 assert!(ctx
9812 .semantic_index
9813 .read()
9814 .unwrap_or_else(std::sync::PoisonError::into_inner)
9815 .is_some());
9816
9817 ctx.semantic_index_status
9818 .write()
9819 .unwrap_or_else(std::sync::PoisonError::into_inner)
9820 .complete_refreshing_file(&refreshing_path);
9821 assert!(ctx.evict_idle_artifacts());
9822 assert!(ctx
9823 .semantic_index
9824 .read()
9825 .unwrap_or_else(std::sync::PoisonError::into_inner)
9826 .is_none());
9827 }
9828}
9829
9830#[cfg(test)]
9831mod status_emitter_tests {
9832 use super::*;
9833 use crate::parser::TreeSitterProvider;
9834
9835 fn ctx_with_frame_rx() -> (AppContext, mpsc::Receiver<PushFrame>) {
9836 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
9837 let (tx, rx) = mpsc::channel();
9838 ctx.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
9839 let _ = tx.send(frame);
9840 }))));
9841 (ctx, rx)
9842 }
9843
9844 #[test]
9845 fn status_emitter_signal_triggers_push() {
9846 let (ctx, rx) = ctx_with_frame_rx();
9847 ctx.status_emitter().signal(ctx.build_status_snapshot());
9848 let frame = rx
9849 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
9850 .expect("status_changed push");
9851 assert!(matches!(frame, PushFrame::StatusChanged(_)));
9852 }
9853
9854 #[test]
9855 fn status_emitter_debounces_burst() {
9856 let (ctx, rx) = ctx_with_frame_rx();
9857 for _ in 0..10 {
9858 ctx.status_emitter().signal(ctx.build_status_snapshot());
9859 }
9860 let frame = rx
9861 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
9862 .expect("status_changed push");
9863 assert!(matches!(frame, PushFrame::StatusChanged(_)));
9864 assert!(rx.try_recv().is_err());
9865 }
9866
9867 #[test]
9868 fn status_emitter_separate_windows_separate_pushes() {
9869 let (ctx, rx) = ctx_with_frame_rx();
9870 ctx.status_emitter().signal(ctx.build_status_snapshot());
9871 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
9872 .expect("first push");
9873 ctx.status_emitter().signal(ctx.build_status_snapshot());
9874 rx.recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 500))
9875 .expect("second push");
9876 }
9877
9878 #[test]
9879 fn status_emitter_no_signal_no_push() {
9880 let (_ctx, rx) = ctx_with_frame_rx();
9881 assert!(rx
9882 .recv_timeout(Duration::from_millis(STATUS_DEBOUNCE_MS + 100))
9883 .is_err());
9884 }
9885
9886 #[test]
9887 fn status_emitter_shutdown_cleanly_exits_debounce_thread() {
9888 let (ctx, rx) = ctx_with_frame_rx();
9889 drop(ctx);
9890 assert!(rx.recv_timeout(Duration::from_millis(50)).is_err());
9891 }
9892
9893 #[test]
9894 fn progress_sender_slot_is_per_context_for_shared_app() {
9895 let app = App::default_shared();
9896 let ctx_a = AppContext::from_app(Arc::clone(&app), Config::default());
9897 let ctx_b = AppContext::from_app(app, Config::default());
9898 let (tx_a, rx_a) = mpsc::channel();
9899 let (tx_b, rx_b) = mpsc::channel();
9900
9901 ctx_a.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
9902 let _ = tx_a.send(frame);
9903 }))));
9904 ctx_b.set_progress_sender(Some(Arc::new(Box::new(move |frame| {
9905 let _ = tx_b.send(frame);
9906 }))));
9907
9908 ctx_a.emit_progress(ProgressFrame {
9909 frame_type: "progress",
9910 request_id: "ctx-a".to_string(),
9911 kind: crate::protocol::ProgressKind::Stdout,
9912 chunk: "a".to_string(),
9913 });
9914 ctx_b.emit_progress(ProgressFrame {
9915 frame_type: "progress",
9916 request_id: "ctx-b".to_string(),
9917 kind: crate::protocol::ProgressKind::Stdout,
9918 chunk: "b".to_string(),
9919 });
9920
9921 match rx_a
9922 .recv_timeout(Duration::from_millis(50))
9923 .expect("ctx A progress frame")
9924 {
9925 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-a"),
9926 other => panic!("unexpected frame for ctx A: {other:?}"),
9927 }
9928 assert!(rx_a.try_recv().is_err());
9929
9930 match rx_b
9931 .recv_timeout(Duration::from_millis(50))
9932 .expect("ctx B progress frame")
9933 {
9934 PushFrame::Progress(frame) => assert_eq!(frame.request_id, "ctx-b"),
9935 other => panic!("unexpected frame for ctx B: {other:?}"),
9936 }
9937 assert!(rx_b.try_recv().is_err());
9938 }
9939}
9940
9941#[cfg(test)]
9942mod health_warming_honesty_tests {
9943 use super::*;
9944 use crate::parser::TreeSitterProvider;
9945
9946 fn ctx_with_config(config: Config) -> AppContext {
9947 AppContext::new(Box::new(TreeSitterProvider::new()), config)
9948 }
9949
9950 fn health_search_status(ctx: &AppContext) -> &'static str {
9951 let root = std::path::Path::new("/tmp/health-warming-honesty-test");
9952 ctx.try_health_snapshot(root)
9953 .search_index
9954 .expect("search_index component present")
9955 .status
9956 }
9957
9958 fn health_tier2_status(ctx: &AppContext) -> &'static str {
9959 let root = std::path::Path::new("/tmp/health-warming-honesty-test");
9960 ctx.try_health_snapshot(root)
9961 .tier2
9962 .expect("tier2 component present")
9963 .status
9964 }
9965
9966 #[test]
9967 fn write_denied_search_index_reports_ready_not_building() {
9968 let config = Config {
9972 search_index: true,
9973 ..Config::default()
9974 };
9975 let ctx = ctx_with_config(config);
9976 let mut index = SearchIndex::new();
9977 index.build_denied = true;
9978 *ctx.search_index()
9979 .write()
9980 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
9981
9982 assert_eq!(
9983 health_search_status(&ctx),
9984 "ready",
9985 "a build-denied index is a terminal settled state and must not report building forever"
9986 );
9987 }
9988
9989 #[test]
9990 fn in_progress_search_index_still_reports_building() {
9991 let config = Config {
9995 search_index: true,
9996 ..Config::default()
9997 };
9998 let ctx = ctx_with_config(config);
9999 let index = SearchIndex::new(); *ctx.search_index()
10001 .write()
10002 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
10003
10004 assert_eq!(health_search_status(&ctx), "building");
10005 }
10006
10007 #[test]
10008 fn tier2_blocked_on_callgraph_reports_ready_not_building() {
10009 let ctx = ctx_with_config(Config::default()); ctx.update_status_bar_tier2(None, Some(3), Some(2), None, false);
10015 ctx.set_status_bar_tier2_dead_code_blocked_on_callgraph(true);
10016
10017 assert_eq!(
10018 health_tier2_status(&ctx),
10019 "ready",
10020 "tier2 complete except dead_code-blocked-on-callgraph must not stay building"
10021 );
10022 }
10023
10024 #[test]
10025 fn health_tier2_and_inspect_builder_state_read_the_same_registry() {
10026 let ctx = ctx_with_config(Config::default());
10029 ctx.update_status_bar_tier2(Some(1), Some(2), Some(3), None, false);
10030 ctx.inspect_manager()
10031 .set_tier2_in_flight_for_test(crate::inspect::InspectCategory::DeadCode, true);
10032
10033 assert_eq!(health_tier2_status(&ctx), "building");
10034 assert_eq!(
10035 ctx.inspect_manager()
10036 .tier2_builder_state(crate::inspect::InspectCategory::DeadCode),
10037 crate::inspect::InspectBuilderState::Building
10038 );
10039
10040 ctx.inspect_manager()
10041 .set_tier2_in_flight_for_test(crate::inspect::InspectCategory::DeadCode, false);
10042
10043 assert_eq!(health_tier2_status(&ctx), "ready");
10044 assert_eq!(
10045 ctx.inspect_manager()
10046 .tier2_builder_state(crate::inspect::InspectCategory::DeadCode),
10047 crate::inspect::InspectBuilderState::Absent
10048 );
10049
10050 ctx.inspect_manager().record_tier2_attempt_outcome_for_test(
10051 crate::inspect::InspectCategory::DeadCode,
10052 crate::inspect::JobOutcome::Fresh {
10053 payload: crate::inspect::scanners::dead_code::callgraph_unavailable_aggregate(0),
10054 },
10055 );
10056 assert_eq!(
10057 health_tier2_status(&ctx),
10058 "ready",
10059 "a finished callgraph_unavailable attempt must not keep health.tier2=building"
10060 );
10061 assert_eq!(
10062 ctx.inspect_manager()
10063 .tier2_builder_state(crate::inspect::InspectCategory::DeadCode),
10064 crate::inspect::InspectBuilderState::Absent
10065 );
10066 assert!(
10067 ctx.inspect_manager()
10068 .tier2_builder_state_detail(crate::inspect::InspectCategory::DeadCode)
10069 .starts_with("last attempt failed: callgraph_unavailable (attempt 1, first at "),
10070 "inspect refusals must carry the failed-attempt history the health surface no longer treats as busy"
10071 );
10072 }
10073
10074 #[test]
10075 fn tier2_missing_dead_code_without_callgraph_block_reports_building() {
10076 let ctx = ctx_with_config(Config::default());
10079 ctx.update_status_bar_tier2(None, Some(3), Some(2), None, false);
10080 ctx.set_status_bar_tier2_dead_code_blocked_on_callgraph(false);
10081
10082 assert_eq!(health_tier2_status(&ctx), "building");
10083 }
10084}
10085
10086#[cfg(test)]
10087mod status_bar_tests {
10088 use super::*;
10089 use crate::parser::TreeSitterProvider;
10090
10091 fn ctx() -> AppContext {
10092 AppContext::new(Box::new(TreeSitterProvider::new()), Config::default())
10093 }
10094
10095 #[test]
10096 fn truthful_values_omit_unproven_categories_and_legacy_projection_requires_all_counts() {
10097 let ctx = ctx();
10098 let values = ctx.status_bar_count_values();
10099 assert_eq!(values.errors, None);
10100 assert_eq!(values.warnings, None);
10101 assert_eq!(values.dead_code, None);
10102 assert_eq!(values.unused_exports, None);
10103 assert_eq!(values.duplicates, None);
10104 assert_eq!(values.todos, None);
10105 assert!(ctx.status_bar_counts().is_none());
10106
10107 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
10108 let values = ctx.status_bar_count_values();
10109 assert_eq!(values.dead_code, Some(5));
10110 assert_eq!(values.unused_exports, Some(3));
10111 assert_eq!(values.duplicates, Some(7));
10112 assert_eq!(values.todos, Some(2));
10113 assert_eq!(values.errors, None, "no analyzer report is not a clean E0");
10114 assert_eq!(
10115 values.warnings, None,
10116 "no analyzer report is not a clean W0"
10117 );
10118 assert!(!values.tier2_stale);
10119
10120 assert_eq!(
10121 ctx.status_bar_counts(),
10122 None,
10123 "the legacy numeric shape must not fabricate missing diagnostics"
10124 );
10125 }
10126
10127 #[test]
10128 fn changing_root_clears_project_scoped_status_counts() {
10129 let temp = tempfile::tempdir().expect("tempdir");
10130 let first_root = temp.path().join("first");
10131 let second_root = temp.path().join("second");
10132 std::fs::create_dir_all(&first_root).expect("create first root");
10133 std::fs::create_dir_all(&second_root).expect("create second root");
10134 let ctx = ctx();
10135 ctx.set_canonical_cache_root(first_root);
10136 ctx.update_status_bar_tier2(Some(5), Some(3), Some(7), Some(2), false);
10137 assert_eq!(ctx.status_bar_count_values().dead_code, Some(5));
10138
10139 ctx.set_canonical_cache_root(second_root);
10140
10141 let values = ctx.status_bar_count_values();
10142 assert_eq!(values.dead_code, None);
10143 assert_eq!(values.unused_exports, None);
10144 assert_eq!(values.duplicates, None);
10145 assert!(
10146 ctx.status_bar_counts().is_none(),
10147 "counts from the previous root must not appear in a newly bound root"
10148 );
10149 }
10150
10151 #[test]
10152 fn partial_tier2_keeps_proven_categories_and_cache_hit_preserves_omissions() {
10153 let ctx = ctx();
10154 ctx.update_status_bar_tier2(Some(5), None, None, None, true);
10155
10156 let first = ctx.status_bar_count_values();
10157 assert_eq!(first.dead_code, Some(5));
10158 assert_eq!(first.unused_exports, None);
10159 assert_eq!(first.duplicates, None);
10160 assert_eq!(first.todos, None);
10161 assert!(first.tier2_stale);
10162 assert!(ctx.status_bar_counts().is_none());
10163
10164 let cached = ctx.status_bar_count_values();
10165 assert_eq!(cached, first, "a cache hit must preserve every omission");
10166 let cache = ctx
10167 .status_bar_cached
10168 .read()
10169 .unwrap_or_else(std::sync::PoisonError::into_inner);
10170 assert!(cache.valid);
10171 assert_eq!(cache.counts.as_ref(), Some(&first));
10172 drop(cache);
10173
10174 ctx.update_status_bar_tier2(None, Some(3), None, None, true);
10175 let partial = ctx.status_bar_count_values();
10176 assert_eq!(partial.dead_code, Some(5));
10177 assert_eq!(partial.unused_exports, Some(3));
10178 assert_eq!(partial.duplicates, None);
10179
10180 ctx.update_status_bar_tier2(None, None, Some(7), None, false);
10181 let complete = ctx.status_bar_count_values();
10182 assert_eq!(complete.dead_code, Some(5));
10183 assert_eq!(complete.unused_exports, Some(3));
10184 assert_eq!(complete.duplicates, Some(7));
10185 }
10186
10187 #[test]
10188 fn update_with_none_todos_preserves_last_known_todos() {
10189 let ctx = ctx();
10190 ctx.update_status_bar_tier2(Some(1), Some(1), Some(1), Some(9), false);
10191 ctx.update_status_bar_tier2(Some(2), Some(2), Some(2), None, false);
10193 let counts = ctx.status_bar_count_values();
10194 assert_eq!(counts.todos, Some(9));
10195 assert_eq!(counts.dead_code, Some(2));
10196 }
10197
10198 #[test]
10199 fn update_with_none_count_preserves_last_known_count() {
10200 let ctx = ctx();
10201 ctx.update_status_bar_tier2(Some(10), Some(20), Some(30), None, false);
10202 ctx.update_status_bar_tier2(Some(11), None, None, None, false);
10205 let counts = ctx.status_bar_count_values();
10206 assert_eq!(counts.dead_code, Some(11));
10207 assert_eq!(counts.unused_exports, Some(20));
10208 assert_eq!(counts.duplicates, Some(30));
10209 }
10210
10211 #[test]
10212 fn mark_stale_sets_flag_after_any_proven_category() {
10213 let ctx = ctx();
10214 ctx.mark_status_bar_tier2_stale();
10215 assert!(!ctx.status_bar_count_values().tier2_stale);
10216
10217 ctx.update_status_bar_tier2(Some(4), None, None, None, false);
10218 ctx.mark_status_bar_tier2_stale();
10219 assert!(ctx.status_bar_count_values().tier2_stale);
10220
10221 ctx.update_status_bar_tier2(Some(4), None, None, None, false);
10223 assert!(!ctx.status_bar_count_values().tier2_stale);
10224 }
10225
10226 #[test]
10231 fn clearing_diagnostics_for_deleted_file_drops_status_bar_errors() {
10232 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
10233 use crate::lsp::registry::ServerKind;
10234 use crate::lsp::roots::ServerKey;
10235
10236 let ctx = ctx();
10237 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false); let file = std::path::PathBuf::from("/proj/gone.ts");
10240 {
10241 let mut lsp = ctx.lsp();
10242 lsp.diagnostics_store_mut_for_test().publish(
10243 ServerKey {
10244 kind: ServerKind::TypeScript,
10245 root: std::path::PathBuf::from("/proj"),
10246 },
10247 file.clone(),
10248 vec![StoredDiagnostic {
10249 file: file.clone(),
10250 line: 1,
10251 column: 1,
10252 end_line: 1,
10253 end_column: 2,
10254 severity: DiagnosticSeverity::Error,
10255 message: "boom".into(),
10256 code: None,
10257 source: None,
10258 }],
10259 );
10260 }
10261
10262 assert_eq!(ctx.status_bar_count_values().errors, Some(1));
10264
10265 let removed = ctx.lsp_clear_diagnostics_for_file(&file);
10267 assert!(removed);
10268 assert_eq!(ctx.status_bar_count_values().errors, None);
10269 }
10270
10271 #[test]
10272 fn status_bar_preserves_authoritative_counts_until_provisional_report_is_promoted() {
10273 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
10274 use crate::lsp::registry::ServerKind;
10275 use crate::lsp::roots::ServerKey;
10276
10277 let ctx = ctx();
10278 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
10279 let root = std::path::PathBuf::from("/proj");
10280 let file = root.join("src/main.rs");
10281 let key = ServerKey {
10282 kind: ServerKind::Rust,
10283 root,
10284 };
10285 let diagnostic = |severity, message: &str| StoredDiagnostic {
10286 file: file.clone(),
10287 line: 1,
10288 column: 1,
10289 end_line: 1,
10290 end_column: 2,
10291 severity,
10292 message: message.into(),
10293 code: None,
10294 source: None,
10295 };
10296
10297 {
10298 let mut lsp = ctx.lsp();
10299 lsp.diagnostics_store_mut_for_test().publish(
10300 key.clone(),
10301 file.clone(),
10302 vec![diagnostic(DiagnosticSeverity::Error, "settled error")],
10303 );
10304 }
10305 let counts = ctx.status_bar_counts().expect("populated");
10306 assert_eq!((counts.errors, counts.warnings), (1, 0));
10307
10308 {
10309 let mut lsp = ctx.lsp();
10310 lsp.diagnostics_store_mut_for_test()
10311 .publish_full_with_provisional(
10312 key.clone(),
10313 file.clone(),
10314 vec![diagnostic(
10315 DiagnosticSeverity::Warning,
10316 "latest warming warning",
10317 )],
10318 None,
10319 None,
10320 true,
10321 );
10322 }
10323 let counts = ctx.status_bar_counts().expect("populated");
10324 assert_eq!(
10325 (counts.errors, counts.warnings),
10326 (1, 0),
10327 "pre-quiescence diagnostics must not replace authoritative counts"
10328 );
10329
10330 {
10331 let mut lsp = ctx.lsp();
10332 assert!(lsp
10333 .diagnostics_store_mut_for_test()
10334 .promote_provisional_for_server(&key));
10335 }
10336 let counts = ctx.status_bar_counts().expect("populated");
10337 assert_eq!(
10338 (counts.errors, counts.warnings),
10339 (0, 1),
10340 "the latest report becomes authoritative at quiescence"
10341 );
10342 }
10343
10344 #[test]
10345 fn status_bar_filtered_counts_ignore_environmental_flap() {
10346 use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
10347 use crate::lsp::registry::ServerKind;
10348 use crate::lsp::roots::ServerKey;
10349
10350 let ctx = ctx();
10351 let root = if cfg!(windows) {
10352 std::path::PathBuf::from(r"C:\proj")
10353 } else {
10354 std::path::PathBuf::from("/proj")
10355 };
10356 ctx.set_canonical_cache_root(root.clone());
10357 ctx.update_status_bar_tier2(Some(0), Some(0), Some(0), Some(0), false);
10358
10359 let file = root.join("aft.jsonc");
10360 let key = ServerKey {
10361 kind: ServerKind::TypeScript,
10362 root: root.clone(),
10363 };
10364 let env = StoredDiagnostic {
10365 file: file.clone(),
10366 line: 1,
10367 column: 1,
10368 end_line: 1,
10369 end_column: 2,
10370 severity: DiagnosticSeverity::Error,
10371 message: "Failed to load schema from https://example.com/schema.json".into(),
10372 code: None,
10373 source: Some("json".into()),
10374 };
10375
10376 assert_eq!(ctx.status_bar_count_values().errors, None);
10377
10378 {
10379 let mut lsp = ctx.lsp();
10380 lsp.diagnostics_store_mut_for_test()
10381 .publish(key.clone(), file.clone(), vec![env]);
10382 }
10383 assert_eq!(
10384 ctx.status_bar_count_values().errors,
10385 Some(0),
10386 "an environmental-only report proves there are zero included errors"
10387 );
10388
10389 {
10390 let mut lsp = ctx.lsp();
10391 lsp.diagnostics_store_mut_for_test()
10392 .publish(key, file, vec![]);
10393 }
10394 assert_eq!(
10395 ctx.status_bar_count_values().errors,
10396 Some(0),
10397 "clearing the excluded diagnostic keeps the proven included count at zero"
10398 );
10399 }
10400}
10401
10402#[cfg(test)]
10403mod harness_path_tests {
10404 use super::*;
10405 use crate::harness::Harness;
10406 use crate::parser::TreeSitterProvider;
10407
10408 fn ctx_with_storage_and_harness(storage_dir: PathBuf, harness: Harness) -> AppContext {
10409 let ctx = AppContext::new(Box::new(TreeSitterProvider::new()), Config::default());
10410 ctx.update_config(|config| {
10411 config.storage_dir = Some(storage_dir);
10412 });
10413 ctx.set_harness(harness);
10414 ctx
10415 }
10416
10417 #[test]
10418 fn harness_dir_resolves_correctly() {
10419 let storage = PathBuf::from("/tmp/cortexkit/aft");
10420 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
10421
10422 assert_eq!(ctx.harness_dir(), storage.join("pi"));
10423 }
10424
10425 #[test]
10426 fn bash_tasks_dir_uses_hash_session() {
10427 let storage = PathBuf::from("/tmp/cortexkit/aft");
10428 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
10429
10430 assert_eq!(
10431 ctx.bash_tasks_dir("ses_abc"),
10432 storage
10433 .join("opencode")
10434 .join("bash-tasks")
10435 .join(hash_session("ses_abc"))
10436 );
10437 }
10438
10439 #[test]
10440 fn backups_dir_includes_path_hash() {
10441 let storage = PathBuf::from("/tmp/cortexkit/aft");
10442 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
10443
10444 assert_eq!(
10445 ctx.backups_dir("ses_abc", "pathhash"),
10446 storage
10447 .join("pi")
10448 .join("backups")
10449 .join(hash_session("ses_abc"))
10450 .join("pathhash")
10451 );
10452 }
10453
10454 #[test]
10455 fn filters_dir_under_harness() {
10456 let storage = PathBuf::from("/tmp/cortexkit/aft");
10457 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
10458
10459 assert_eq!(ctx.filters_dir(), storage.join("opencode").join("filters"));
10460 }
10461
10462 #[test]
10463 fn trust_file_is_host_global() {
10464 let storage = PathBuf::from("/tmp/cortexkit/aft");
10465 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Pi);
10466
10467 assert_eq!(
10468 ctx.trust_file(),
10469 storage.join("trusted-filter-projects.json")
10470 );
10471 }
10472
10473 #[test]
10474 fn same_session_different_harness_resolve_different_paths() {
10475 let storage = PathBuf::from("/tmp/cortexkit/aft");
10476 let opencode = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
10477 let pi = ctx_with_storage_and_harness(storage, Harness::Pi);
10478
10479 assert_ne!(
10480 opencode.bash_tasks_dir("ses_same"),
10481 pi.bash_tasks_dir("ses_same")
10482 );
10483 }
10484
10485 #[test]
10486 fn callgraph_and_inspect_dirs_are_root_keyed() {
10487 let temp = tempfile::tempdir().expect("tempdir");
10488 let storage = temp.path().join("storage");
10489 let root = temp.path().join("checkout");
10490 std::fs::create_dir_all(&root).expect("create root");
10491 let ctx = ctx_with_storage_and_harness(storage.clone(), Harness::Opencode);
10492 ctx.set_canonical_cache_root(root.clone());
10493
10494 assert_eq!(
10495 ctx.callgraph_store_dir(),
10496 storage
10497 .join("callgraph")
10498 .join(crate::search_index::artifact_cache_key(&root))
10499 );
10500 assert_eq!(
10501 ctx.inspect_dir(),
10502 storage
10503 .join("inspect")
10504 .join(crate::path_identity::project_scope_key(&root))
10505 );
10506 assert!(!ctx
10507 .callgraph_store_dir()
10508 .starts_with(storage.join("opencode")));
10509 assert!(!ctx.inspect_dir().starts_with(storage.join("opencode")));
10510 }
10511
10512 #[test]
10513 fn per_domain_capability_allows_inspect_writer_when_callgraph_read_only() {
10514 let storage = PathBuf::from("/tmp/cortexkit/aft");
10515 let ctx = ctx_with_storage_and_harness(storage, Harness::Opencode);
10516 ctx.set_cache_writer_capabilities(false, true);
10517
10518 assert!(ctx.shared_artifacts_read_only());
10519 assert!(!ctx.callgraph_writer());
10520 assert!(ctx.inspect_writer());
10521 }
10522}
10523
10524#[cfg(test)]
10525mod shared_db_tests {
10526 use super::*;
10527 use tempfile::tempdir;
10528
10529 #[test]
10530 fn app_contexts_share_one_database_connection() {
10531 let storage = tempdir().expect("storage tempdir");
10532 let root_one = tempdir().expect("first root tempdir");
10533 let root_two = tempdir().expect("second root tempdir");
10534 let app = App::default_shared();
10535 let ctx_one = AppContext::from_app(
10536 Arc::clone(&app),
10537 Config {
10538 project_root: Some(root_one.path().to_path_buf()),
10539 ..Config::default()
10540 },
10541 );
10542 let ctx_two = AppContext::from_app(
10543 Arc::clone(&app),
10544 Config {
10545 project_root: Some(root_two.path().to_path_buf()),
10546 ..Config::default()
10547 },
10548 );
10549 let path = storage.path().join("aft.db");
10550
10551 let first = app.open_db(&path).expect("open shared database");
10552 let second = app.open_db(&path).expect("reuse shared database");
10553
10554 assert!(Arc::ptr_eq(&first, &second));
10555 assert!(Arc::ptr_eq(
10556 &ctx_one.db().expect("first context database"),
10557 &ctx_two.db().expect("second context database")
10558 ));
10559 }
10560}
10561
10562#[cfg(test)]
10563mod gitignore_tests {
10564 use super::*;
10565 use std::fs;
10566 use std::path::Path;
10567 use tempfile::TempDir;
10568
10569 fn make_ctx_with_root(root: &Path) -> AppContext {
10570 let provider = Box::new(crate::parser::TreeSitterProvider::new());
10571 let config = Config {
10572 project_root: Some(root.to_path_buf()),
10573 ..Config::default()
10574 };
10575 AppContext::new(provider, config)
10576 }
10577
10578 fn is_ignored(ctx: &AppContext, path: &Path) -> bool {
10585 let Some(matcher) = ctx.gitignore() else {
10586 return false;
10587 };
10588 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
10589 if !canonical.starts_with(matcher.path()) {
10590 return false;
10591 }
10592 let is_dir = canonical.is_dir();
10593 matcher
10594 .matched_path_or_any_parents(&canonical, is_dir)
10595 .is_ignore()
10596 }
10597
10598 fn with_neutralized_global_gitignore<R>(f: impl FnOnce() -> R) -> R {
10611 let _guard = crate::test_env::process_env_lock();
10612 let tmp = TempDir::new().unwrap();
10613 let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
10614 let prev_home = std::env::var_os("HOME");
10615 let prev_userprofile = std::env::var_os("USERPROFILE");
10616 unsafe {
10619 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
10620 std::env::set_var("HOME", tmp.path());
10621 std::env::set_var("USERPROFILE", tmp.path());
10622 }
10623 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
10624 unsafe {
10625 match prev_xdg {
10626 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
10627 None => std::env::remove_var("XDG_CONFIG_HOME"),
10628 }
10629 match prev_home {
10630 Some(v) => std::env::set_var("HOME", v),
10631 None => std::env::remove_var("HOME"),
10632 }
10633 match prev_userprofile {
10634 Some(v) => std::env::set_var("USERPROFILE", v),
10635 None => std::env::remove_var("USERPROFILE"),
10636 }
10637 }
10638 match result {
10639 Ok(r) => r,
10640 Err(p) => std::panic::resume_unwind(p),
10641 }
10642 }
10643
10644 #[test]
10645 fn rebuild_gitignore_returns_none_without_project_root() {
10646 let provider = Box::new(crate::parser::TreeSitterProvider::new());
10647 let ctx = AppContext::new(provider, Config::default());
10648 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
10649 assert!(ctx.gitignore().is_none());
10650 }
10651
10652 #[test]
10653 fn rebuild_gitignore_returns_none_for_project_with_no_gitignore() {
10654 let tmp = TempDir::new().unwrap();
10655 let ctx = make_ctx_with_root(tmp.path());
10656 with_neutralized_global_gitignore(|| ctx.rebuild_gitignore());
10657 assert!(ctx.gitignore().is_none());
10658 }
10659
10660 #[test]
10661 fn matcher_filters_files_in_ignored_dist_dir() {
10662 let tmp = TempDir::new().unwrap();
10663 fs::write(tmp.path().join(".gitignore"), "dist/\nbuild/\n").unwrap();
10664 fs::create_dir_all(tmp.path().join("dist")).unwrap();
10665 fs::create_dir_all(tmp.path().join("src")).unwrap();
10666 let dist_file = tmp.path().join("dist").join("bundle.js");
10667 let src_file = tmp.path().join("src").join("app.ts");
10668 fs::write(&dist_file, "x").unwrap();
10669 fs::write(&src_file, "y").unwrap();
10670
10671 let ctx = make_ctx_with_root(tmp.path());
10672 ctx.rebuild_gitignore();
10673
10674 assert!(ctx.gitignore().is_some());
10675 assert!(
10676 is_ignored(&ctx, &dist_file),
10677 "dist/bundle.js should be ignored"
10678 );
10679 assert!(
10680 !is_ignored(&ctx, &src_file),
10681 "src/app.ts should NOT be ignored"
10682 );
10683 }
10684
10685 #[test]
10686 fn matcher_handles_node_modules_and_target() {
10687 let tmp = TempDir::new().unwrap();
10688 fs::write(tmp.path().join(".gitignore"), "node_modules/\ntarget/\n").unwrap();
10689 fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
10690 fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
10691 let nm_file = tmp.path().join("node_modules/foo/index.js");
10692 let target_file = tmp.path().join("target/debug/aft");
10693 fs::write(&nm_file, "x").unwrap();
10694 fs::write(&target_file, "x").unwrap();
10695
10696 let ctx = make_ctx_with_root(tmp.path());
10697 ctx.rebuild_gitignore();
10698
10699 assert!(is_ignored(&ctx, &nm_file));
10700 assert!(is_ignored(&ctx, &target_file));
10701 }
10702
10703 #[test]
10704 fn matcher_honors_negation_pattern() {
10705 let tmp = TempDir::new().unwrap();
10707 fs::write(tmp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
10708 let random_log = tmp.path().join("random.log");
10709 let important_log = tmp.path().join("important.log");
10710 fs::write(&random_log, "x").unwrap();
10711 fs::write(&important_log, "y").unwrap();
10712
10713 let ctx = make_ctx_with_root(tmp.path());
10714 ctx.rebuild_gitignore();
10715
10716 assert!(is_ignored(&ctx, &random_log));
10717 assert!(
10718 !is_ignored(&ctx, &important_log),
10719 "negation pattern should un-ignore important.log"
10720 );
10721 }
10722
10723 #[test]
10724 fn rebuild_picks_up_gitignore_changes() {
10725 let tmp = TempDir::new().unwrap();
10726 let ignore_path = tmp.path().join(".gitignore");
10727 fs::write(&ignore_path, "foo.txt\n").unwrap();
10728 let foo = tmp.path().join("foo.txt");
10729 let bar = tmp.path().join("bar.txt");
10730 fs::write(&foo, "").unwrap();
10731 fs::write(&bar, "").unwrap();
10732
10733 let ctx = make_ctx_with_root(tmp.path());
10734 ctx.rebuild_gitignore();
10735 assert!(is_ignored(&ctx, &foo));
10736 assert!(!is_ignored(&ctx, &bar));
10737
10738 fs::write(&ignore_path, "bar.txt\n").unwrap();
10740 ctx.rebuild_gitignore();
10741 assert!(!is_ignored(&ctx, &foo));
10742 assert!(is_ignored(&ctx, &bar));
10743 }
10744
10745 #[test]
10746 fn gitignore_loads_info_exclude_when_present() {
10747 let tmp = TempDir::new().unwrap();
10748 let info_dir = tmp.path().join(".git/info");
10749 fs::create_dir_all(&info_dir).unwrap();
10750 fs::write(info_dir.join("exclude"), "secrets.txt\n").unwrap();
10751 let secrets = tmp.path().join("secrets.txt");
10752 let public = tmp.path().join("public.txt");
10753 fs::write(&secrets, "token").unwrap();
10754 fs::write(&public, "ok").unwrap();
10755
10756 let ctx = make_ctx_with_root(tmp.path());
10757 ctx.rebuild_gitignore();
10758
10759 assert!(is_ignored(&ctx, &secrets));
10760 assert!(!is_ignored(&ctx, &public));
10761 }
10762
10763 #[test]
10764 fn matcher_picks_up_nested_gitignore() {
10765 let tmp = TempDir::new().unwrap();
10766 fs::write(tmp.path().join(".gitignore"), "").unwrap();
10768 let sub = tmp.path().join("packages/foo");
10769 fs::create_dir_all(&sub).unwrap();
10770 fs::write(sub.join(".gitignore"), "generated/\n").unwrap();
10771 let generated_file = sub.join("generated").join("out.js");
10772 fs::create_dir_all(generated_file.parent().unwrap()).unwrap();
10773 fs::write(&generated_file, "x").unwrap();
10774
10775 let ctx = make_ctx_with_root(tmp.path());
10776 ctx.rebuild_gitignore();
10777
10778 assert!(
10779 is_ignored(&ctx, &generated_file),
10780 "nested gitignore in packages/foo/.gitignore should ignore generated/"
10781 );
10782 }
10783}
10784
10785#[cfg(test)]
10786mod verify_memo_watcher_tests {
10787 use super::*;
10788
10789 #[test]
10790 fn pending_watcher_path_invalidates_root_verify_memo() {
10791 let root_dir = tempfile::tempdir().unwrap();
10792 let root = std::fs::canonicalize(root_dir.path()).unwrap();
10793 let artifact = root.join("cache.bin");
10794 std::fs::write(&artifact, b"generation").unwrap();
10795 let generation = crate::cache_freshness::artifact_generation(&artifact).unwrap();
10796 crate::cache_freshness::record_verify_completed(
10797 &root,
10798 crate::cache_freshness::VerifyArtifact::Search,
10799 Some(generation),
10800 );
10801 assert_eq!(
10802 crate::cache_freshness::warm_verify_plan(
10803 &root,
10804 crate::cache_freshness::VerifyArtifact::Search,
10805 Some(generation),
10806 ),
10807 crate::cache_freshness::WarmVerifyPlan::Skip
10808 );
10809
10810 let ctx = AppContext::from_app(
10811 App::default_shared(),
10812 Config {
10813 project_root: Some(root.clone()),
10814 ..Config::default()
10815 },
10816 );
10817 ctx.set_canonical_cache_root(root.clone());
10818 ctx.add_pending_search_index_paths([root.join("changed.rs")]);
10819 assert_eq!(
10820 crate::cache_freshness::warm_verify_plan(
10821 &root,
10822 crate::cache_freshness::VerifyArtifact::Search,
10823 Some(generation),
10824 ),
10825 crate::cache_freshness::WarmVerifyPlan::StatFirst
10826 );
10827 }
10828}
10829
10830#[cfg(test)]
10831mod watcher_runtime_state_tests {
10832 use super::*;
10833 use crate::language::StubProvider;
10834
10835 fn test_context() -> AppContext {
10836 AppContext::new(Box::new(StubProvider), Config::default())
10837 }
10838
10839 #[test]
10840 fn finished_watcher_thread_reports_inactive_and_is_reclaimed_with_invalidation() {
10841 let root = tempfile::tempdir().expect("project tempdir");
10842 let canonical_root = std::fs::canonicalize(root.path()).expect("canonical root");
10843 let ctx = AppContext::new(
10844 Box::new(StubProvider),
10845 Config {
10846 project_root: Some(canonical_root.clone()),
10847 ..Config::default()
10848 },
10849 );
10850 ctx.set_canonical_cache_root(canonical_root.clone());
10851 struct DisableWatcherGuard;
10855 impl Drop for DisableWatcherGuard {
10856 fn drop(&mut self) {
10857 unsafe { std::env::remove_var("AFT_TEST_DISABLE_FILE_WATCHER") };
10858 }
10859 }
10860 let _env_lock = crate::test_env::process_env_lock();
10861 unsafe { std::env::set_var("AFT_TEST_DISABLE_FILE_WATCHER", "1") };
10862 let _disable_watcher = DisableWatcherGuard;
10863 *ctx.search_index
10866 .write()
10867 .unwrap_or_else(std::sync::PoisonError::into_inner) =
10868 Some(crate::search_index::SearchIndex::new());
10869 let artifact = canonical_root.join("artifact.bin");
10870 std::fs::write(&artifact, b"artifact").expect("artifact");
10871 let generation = crate::cache_freshness::artifact_generation(&artifact);
10872 crate::cache_freshness::record_verify_completed(
10873 &canonical_root,
10874 crate::cache_freshness::VerifyArtifact::Search,
10875 generation,
10876 );
10877
10878 let (dispatch_tx, dispatch_rx) = crate::watcher_filter::watcher_dispatch_channel();
10879 let _dispatch_tx = dispatch_tx;
10880 let join = std::thread::spawn(|| {});
10883 ctx.install_watcher_runtime(
10884 dispatch_rx,
10885 WatcherThreadHandle::new(Arc::new(AtomicBool::new(false)), join),
10886 );
10887 let deadline = std::time::Instant::now() + Duration::from_secs(2);
10888 while ctx.watcher_runtime_active() {
10889 assert!(
10890 std::time::Instant::now() < deadline,
10891 "a finished watcher thread must report the runtime inactive"
10892 );
10893 std::thread::yield_now();
10894 }
10895
10896 crate::commands::configure::ensure_project_watcher(&ctx);
10899
10900 assert!(
10901 ctx.search_index
10902 .read()
10903 .unwrap_or_else(std::sync::PoisonError::into_inner)
10904 .is_none(),
10905 "corpse reclaim must drop resident artifacts (events since the failure are lost)"
10906 );
10907 assert_eq!(
10908 crate::cache_freshness::warm_verify_plan(
10909 &canonical_root,
10910 crate::cache_freshness::VerifyArtifact::Search,
10911 generation,
10912 ),
10913 crate::cache_freshness::WarmVerifyPlan::Strict,
10914 "corpse reclaim must force strict re-verification"
10915 );
10916 assert!(
10917 !ctx.take_finished_watcher_runtime(),
10918 "reclaim is one-shot; the corpse is gone after ensure_project_watcher"
10919 );
10920 }
10921
10922 #[test]
10923 fn watcher_runtime_requires_both_thread_and_dispatch_receiver() {
10924 let ctx = test_context();
10925 let (dispatch_tx, dispatch_rx) = crate::watcher_filter::watcher_dispatch_channel();
10926 let shutdown = Arc::new(AtomicBool::new(false));
10927 let thread_shutdown = Arc::clone(&shutdown);
10928 let join = std::thread::spawn(move || {
10929 while !thread_shutdown.load(Ordering::SeqCst) {
10930 std::thread::sleep(Duration::from_millis(1));
10931 }
10932 drop(dispatch_tx);
10933 });
10934 ctx.install_watcher_runtime(
10935 dispatch_rx,
10936 WatcherThreadHandle::new(Arc::clone(&shutdown), join),
10937 );
10938 assert!(ctx.watcher_runtime_active());
10939
10940 *ctx.watcher_rx.lock() = None;
10941 assert!(
10942 !ctx.watcher_runtime_active(),
10943 "a thread without its dispatch receiver is not a usable watcher runtime"
10944 );
10945 ctx.stop_watcher_runtime();
10946 }
10947}
10948
10949#[cfg(test)]
10950mod semantic_probe_tests {
10951 use super::*;
10952
10953 #[test]
10954 fn cleared_semantic_worker_invalidates_orphaned_probe_timer() {
10955 let root = tempfile::tempdir().unwrap();
10956 let ctx = AppContext::new(
10957 default_language_provider_factory(),
10958 Config {
10959 project_root: Some(root.path().to_path_buf()),
10960 ..Config::default()
10961 },
10962 );
10963 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
10964 let (_event_tx, event_rx) = crossbeam_channel::unbounded();
10965 let worker_slot = Arc::new(Mutex::new(None));
10966 ctx.install_semantic_refresh_worker_for_build_epoch(
10967 request_tx,
10968 event_rx,
10969 worker_slot,
10970 ctx.semantic_index_rx_epoch(),
10971 );
10972
10973 ctx.ensure_semantic_refresh_probe_scheduled(Duration::from_millis(20));
10974 assert!(ctx.semantic_refresh_probe_is_scheduled());
10975 ctx.clear_semantic_refresh_worker();
10976 std::thread::sleep(Duration::from_millis(50));
10977
10978 assert!(!ctx.semantic_refresh_probe_ready());
10979 assert!(!ctx.semantic_refresh_probe_is_scheduled());
10980 assert!(!ctx.completion_drains_have_work());
10981 }
10982}