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