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