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