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