1use crate as aft;
2use crate::callgraph_store::{invalidates_workspace_crate_prefix_cache, CallGraphStore};
3use crate::context::{
4 AppContext, CallGraphStoreBuildEvent, SemanticIndexEvent, SemanticIndexStatus,
5 SemanticRefreshEvent, SemanticRefreshRequest, WatcherDrainApplyPhase, WatcherDrainPhase,
6 WatcherDrainSliceState,
7};
8use crate::log_ctx;
9use crate::lsp::client::LspEvent;
10use crate::protocol::PushFrame;
11#[cfg(test)]
12use crate::watcher_filter::RescanReason;
13use crate::watcher_filter::{watcher_path_is_infra_skip, WatcherDispatchEvent};
14use std::collections::{BTreeSet, HashSet, VecDeque};
15use std::path::{Path, PathBuf};
16#[cfg(test)]
17use std::sync::atomic::AtomicU64;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::sync::{Arc, Mutex, OnceLock};
20use std::thread;
21use std::time::{Duration, Instant};
22
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub struct DrainBatchOutcome {
25 pub processed: usize,
26 pub has_more: bool,
27}
28
29pub const WATCHER_PATH_DRAIN_BATCH_CAP: usize = 2_048;
30pub const WATCHER_DRAIN_SLICE_BUDGET: Duration = Duration::from_millis(250);
31const VIEW_PUBLICATION_QUIET_WINDOW: Duration = Duration::from_secs(1);
34const WATCHER_DRAIN_UNIT_WARN_AFTER: Duration = Duration::from_secs(5);
35const WATCHER_DRAIN_UNIT_FINAL_AFTER: Duration = Duration::from_secs(30);
36pub const LSP_EVENT_DRAIN_BATCH_CAP: usize = 256;
37
38#[cfg(test)]
39struct ArtifactDrainCommitGate {
40 context_id: usize,
41 reached_tx: crossbeam_channel::Sender<()>,
42 release_rx: crossbeam_channel::Receiver<()>,
43}
44
45#[cfg(test)]
46static ARTIFACT_DRAIN_COMMIT_GATE: OnceLock<Mutex<Option<ArtifactDrainCommitGate>>> =
47 OnceLock::new();
48#[cfg(test)]
49static ARTIFACT_DRAIN_TEST_MUTEX: Mutex<()> = Mutex::new(());
50
51#[cfg(test)]
52struct SemanticRefreshRecoveryGate {
53 context_id: usize,
54 reached_tx: crossbeam_channel::Sender<()>,
55 release_rx: crossbeam_channel::Receiver<()>,
56}
57
58#[cfg(test)]
59static SEMANTIC_REFRESH_RECOVERY_GATE: OnceLock<Mutex<Option<SemanticRefreshRecoveryGate>>> =
60 OnceLock::new();
61
62#[cfg(test)]
63struct WatcherPhaseCommitGate {
64 target: PathBuf,
65 reached_tx: crossbeam_channel::Sender<()>,
66 release_rx: crossbeam_channel::Receiver<()>,
67}
68
69#[cfg(test)]
70static WATCHER_PHASE_COMMIT_GATE: std::sync::OnceLock<Mutex<Option<WatcherPhaseCommitGate>>> =
71 std::sync::OnceLock::new();
72
73#[cfg(test)]
74struct WatcherRescanGate {
75 context_id: usize,
76 reached_tx: crossbeam_channel::Sender<()>,
77 release_rx: crossbeam_channel::Receiver<()>,
78}
79
80#[cfg(test)]
81static WATCHER_RESCAN_GATE: OnceLock<Mutex<Option<WatcherRescanGate>>> = OnceLock::new();
82
83#[cfg(test)]
84fn install_watcher_rescan_gate_for_test(
85 ctx: &AppContext,
86) -> (
87 crossbeam_channel::Receiver<()>,
88 crossbeam_channel::Sender<()>,
89) {
90 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
91 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
92 *WATCHER_RESCAN_GATE
93 .get_or_init(|| Mutex::new(None))
94 .lock()
95 .expect("watcher rescan gate mutex poisoned") = Some(WatcherRescanGate {
96 context_id: ctx as *const AppContext as usize,
97 reached_tx,
98 release_rx,
99 });
100 (reached_rx, release_tx)
101}
102
103#[cfg(test)]
104fn wait_on_watcher_rescan_gate_for_test(ctx: &AppContext) {
105 let mut slot = WATCHER_RESCAN_GATE
106 .get_or_init(|| Mutex::new(None))
107 .lock()
108 .expect("watcher rescan gate mutex poisoned");
109 if !slot
110 .as_ref()
111 .is_some_and(|gate| gate.context_id == ctx as *const AppContext as usize)
112 {
113 return;
114 }
115 let gate = slot.take();
116 drop(slot);
117 if let Some(gate) = gate {
118 let _ = gate.reached_tx.send(());
119 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
120 }
121}
122
123#[cfg(not(test))]
124fn wait_on_watcher_rescan_gate_for_test(_ctx: &AppContext) {}
125
126#[cfg(test)]
127fn install_watcher_phase_commit_gate_for_test(
128 target: PathBuf,
129) -> (
130 crossbeam_channel::Receiver<()>,
131 crossbeam_channel::Sender<()>,
132) {
133 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
134 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
135 *WATCHER_PHASE_COMMIT_GATE
136 .get_or_init(|| Mutex::new(None))
137 .lock()
138 .expect("watcher phase commit gate mutex poisoned") = Some(WatcherPhaseCommitGate {
139 target,
140 reached_tx,
141 release_rx,
142 });
143 (reached_rx, release_tx)
144}
145
146#[cfg(test)]
147fn wait_on_watcher_phase_commit_gate_for_test(path: &Path) {
148 let mut slot = WATCHER_PHASE_COMMIT_GATE
149 .get_or_init(|| Mutex::new(None))
150 .lock()
151 .expect("watcher phase commit gate mutex poisoned");
152 if !slot.as_ref().is_some_and(|gate| gate.target == path) {
153 return;
154 }
155 let gate = slot.take();
156 drop(slot);
157 if let Some(gate) = gate {
158 let _ = gate.reached_tx.send(());
159 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
160 }
161}
162
163#[cfg(not(test))]
164fn wait_on_watcher_phase_commit_gate_for_test(_path: &Path) {}
165
166#[cfg(test)]
167fn install_artifact_drain_commit_gate_for_test(
168 ctx: &AppContext,
169) -> (
170 crossbeam_channel::Receiver<()>,
171 crossbeam_channel::Sender<()>,
172) {
173 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
174 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
175 *ARTIFACT_DRAIN_COMMIT_GATE
176 .get_or_init(|| Mutex::new(None))
177 .lock()
178 .expect("artifact drain commit gate mutex poisoned") = Some(ArtifactDrainCommitGate {
179 context_id: ctx as *const AppContext as usize,
180 reached_tx,
181 release_rx,
182 });
183 (reached_rx, release_tx)
184}
185
186#[cfg(test)]
187fn wait_on_artifact_drain_commit_gate_for_test(ctx: &AppContext) {
188 let mut slot = ARTIFACT_DRAIN_COMMIT_GATE
189 .get_or_init(|| Mutex::new(None))
190 .lock()
191 .expect("artifact drain commit gate mutex poisoned");
192 if !slot
193 .as_ref()
194 .is_some_and(|gate| gate.context_id == ctx as *const AppContext as usize)
195 {
196 return;
197 }
198 let gate = slot.take();
199 drop(slot);
200 if let Some(gate) = gate {
201 let _ = gate.reached_tx.send(());
202 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
203 }
204}
205
206#[cfg(not(test))]
207fn wait_on_artifact_drain_commit_gate_for_test(_ctx: &AppContext) {}
208
209#[cfg(test)]
210fn install_semantic_refresh_recovery_gate_for_test(
211 ctx: &AppContext,
212) -> (
213 crossbeam_channel::Receiver<()>,
214 crossbeam_channel::Sender<()>,
215) {
216 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
217 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
218 *SEMANTIC_REFRESH_RECOVERY_GATE
219 .get_or_init(|| Mutex::new(None))
220 .lock()
221 .expect("semantic refresh recovery gate mutex poisoned") =
222 Some(SemanticRefreshRecoveryGate {
223 context_id: ctx as *const AppContext as usize,
224 reached_tx,
225 release_rx,
226 });
227 (reached_rx, release_tx)
228}
229
230#[cfg(test)]
231fn wait_on_semantic_refresh_recovery_gate_for_test(ctx: &AppContext) {
232 let mut slot = SEMANTIC_REFRESH_RECOVERY_GATE
233 .get_or_init(|| Mutex::new(None))
234 .lock()
235 .expect("semantic refresh recovery gate mutex poisoned");
236 if !slot
237 .as_ref()
238 .is_some_and(|gate| gate.context_id == ctx as *const AppContext as usize)
239 {
240 return;
241 }
242 let gate = slot.take();
243 drop(slot);
244 if let Some(gate) = gate {
245 let _ = gate.reached_tx.send(());
246 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
247 }
248}
249
250#[cfg(not(test))]
251fn wait_on_semantic_refresh_recovery_gate_for_test(_ctx: &AppContext) {}
252
253struct WatcherDrainUnitGuard<'a> {
254 phase: &'static str,
255 path: &'a Path,
256 batch_len: usize,
257 started: Instant,
258}
259
260impl<'a> WatcherDrainUnitGuard<'a> {
261 fn start(phase: WatcherDrainApplyPhase, path: &'a Path) -> Self {
262 Self {
263 phase: watcher_drain_phase_name(phase),
264 path,
265 batch_len: 1,
266 started: Instant::now(),
267 }
268 }
269
270 fn start_batch(phase: WatcherDrainApplyPhase, path: &'a Path, batch_len: usize) -> Self {
271 Self {
272 phase: watcher_drain_phase_name(phase),
273 path,
274 batch_len,
275 started: Instant::now(),
276 }
277 }
278}
279
280impl Drop for WatcherDrainUnitGuard<'_> {
281 fn drop(&mut self) {
282 let elapsed = self.started.elapsed();
283 let (warn_after, final_after) = watcher_drain_unit_thresholds();
284 if elapsed < warn_after {
285 return;
286 }
287 let path = if self.batch_len == 1 {
288 self.path.display().to_string()
289 } else {
290 format!("{} (+{} paths)", self.path.display(), self.batch_len - 1)
291 };
292 emit_watcher_drain_unit_log(format!(
293 "watcher drain unit exceeded 5s: phase={} path={} elapsed={}ms",
294 self.phase,
295 path,
296 elapsed.as_millis()
297 ));
298 if elapsed >= final_after {
299 emit_watcher_drain_unit_log(format!(
300 "watcher drain unit completed after 30s: phase={} path={} elapsed={}ms",
301 self.phase,
302 path,
303 elapsed.as_millis()
304 ));
305 }
306 }
307}
308
309fn watcher_drain_unit_thresholds() -> (Duration, Duration) {
310 #[cfg(test)]
311 if let Some(thresholds) = WATCHER_UNIT_TEST_THRESHOLDS.with(std::cell::Cell::get) {
312 return thresholds;
313 }
314 (
315 WATCHER_DRAIN_UNIT_WARN_AFTER,
316 WATCHER_DRAIN_UNIT_FINAL_AFTER,
317 )
318}
319
320fn emit_watcher_drain_unit_log(line: String) {
321 log::warn!("{line}");
322 #[cfg(test)]
323 WATCHER_UNIT_TEST_LOGS.with(|logs| logs.borrow_mut().push(line));
324}
325
326#[cfg(test)]
327thread_local! {
328 static WATCHER_UNIT_TEST_DELAY: std::cell::Cell<Duration> = const { std::cell::Cell::new(Duration::ZERO) };
329 static WATCHER_UNIT_TEST_THRESHOLDS: std::cell::Cell<Option<(Duration, Duration)>> = const { std::cell::Cell::new(None) };
330 static WATCHER_UNIT_TEST_LOGS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
331}
332
333#[cfg(test)]
334fn delay_watcher_unit_for_test() {
335 let delay = WATCHER_UNIT_TEST_DELAY.with(std::cell::Cell::get);
336 if !delay.is_zero() {
337 thread::sleep(delay);
338 }
339}
340
341#[cfg(not(test))]
342fn delay_watcher_unit_for_test() {}
343
344pub fn drain_deferred_configure_maintenance(ctx: &AppContext) {
345 crate::commands::configure::drain_deferred_configure_maintenance(ctx);
346}
347
348#[derive(Debug)]
351pub struct StandaloneConfigureMaintenance {
352 inner: crate::commands::configure::ConfigureMaintenanceState,
353}
354
355impl Default for StandaloneConfigureMaintenance {
356 fn default() -> Self {
357 Self {
358 inner: crate::commands::configure::ConfigureMaintenanceState::standalone(),
359 }
360 }
361}
362
363impl StandaloneConfigureMaintenance {
364 pub fn has_pending(&mut self, ctx: &AppContext) -> bool {
365 crate::commands::configure::standalone_configure_maintenance_pending(ctx, &mut self.inner)
366 }
367
368 pub fn drain_prefix(&mut self, ctx: &AppContext) -> bool {
369 crate::commands::configure::drain_standalone_configure_prefix(ctx, &mut self.inner)
370 }
371
372 pub fn drain_one(&mut self, ctx: &AppContext) -> bool {
373 crate::commands::configure::drain_deferred_configure_maintenance_unit(ctx, &mut self.inner)
374 }
375}
376
377const STANDALONE_LOG_SWEEP_ATTEMPT_INTERVAL: Duration = Duration::from_secs(60);
378const CONFIGURE_MAINTENANCE_YIELD_LOG_INTERVAL: Duration = Duration::from_secs(5);
379static STANDALONE_LOG_SWEEP_LAST_ATTEMPT: OnceLock<Mutex<Option<Instant>>> = OnceLock::new();
380static STANDALONE_LOG_SWEEP_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
381static CONFIGURE_MAINTENANCE_YIELD_LAST_LOG: OnceLock<Mutex<Option<Instant>>> = OnceLock::new();
382
383pub fn spawn_standalone_log_maintenance() {
387 let now = Instant::now();
388 let should_attempt = STANDALONE_LOG_SWEEP_LAST_ATTEMPT
389 .get_or_init(|| Mutex::new(None))
390 .lock()
391 .map(|mut last_attempt| {
392 if last_attempt.is_some_and(|last| {
393 now.duration_since(last) < STANDALONE_LOG_SWEEP_ATTEMPT_INTERVAL
394 }) {
395 false
396 } else {
397 *last_attempt = Some(now);
398 true
399 }
400 })
401 .unwrap_or(false);
402 if !should_attempt || STANDALONE_LOG_SWEEP_IN_FLIGHT.swap(true, Ordering::AcqRel) {
403 return;
404 }
405
406 if let Err(error) = thread::Builder::new()
407 .name("aft-log-retention-sweep".to_string())
408 .spawn(|| {
409 crate::logging::maybe_sweep_logs();
410 STANDALONE_LOG_SWEEP_IN_FLIGHT.store(false, Ordering::Release);
411 })
412 {
413 STANDALONE_LOG_SWEEP_IN_FLIGHT.store(false, Ordering::Release);
414 crate::slog_warn!("failed to spawn log retention maintenance thread: {error}");
415 }
416}
417
418pub fn note_configure_maintenance_yield(queued: usize) {
422 if queued == 0 {
423 return;
424 }
425 let now = Instant::now();
426 let should_log = CONFIGURE_MAINTENANCE_YIELD_LAST_LOG
427 .get_or_init(|| Mutex::new(None))
428 .lock()
429 .map(|mut last_log| {
430 if last_log.is_some_and(|last| {
431 now.duration_since(last) < CONFIGURE_MAINTENANCE_YIELD_LOG_INTERVAL
432 }) {
433 false
434 } else {
435 *last_log = Some(now);
436 true
437 }
438 })
439 .unwrap_or(false);
440 if should_log {
441 crate::slog_info!(
442 "configure maintenance yielded to {} queued request(s)",
443 queued
444 );
445 }
446}
447
448pub fn drain_configure_warning_events(ctx: &AppContext) {
449 for (generation, frame) in ctx.drain_configure_warnings() {
450 if ctx.configure_generation() != generation {
451 aft::slog_info!(
452 "dropping stale configure_warnings for generation {} (current {})",
453 generation,
454 ctx.configure_generation()
455 );
456 continue;
457 }
458
459 if let Some(sender) = ctx.progress_sender_handle() {
460 sender(PushFrame::ConfigureWarnings(frame));
461 }
462 }
463}
464
465pub fn drain_inspect_events(ctx: &AppContext) {
466 drain_inspect_events_for_generation(ctx, ctx.configure_generation());
467}
468
469pub(crate) fn drain_inspect_events_for_generation(ctx: &AppContext, generation: u64) {
470 let Some((drained, reuse_completed)) = ctx.run_if_subc_bound_generation(generation, || {
471 let drained = ctx.inspect_manager().drain_completions();
472 (drained, ctx.take_new_reuse_completions())
477 }) else {
478 return;
479 };
480 if drained > 0 || reuse_completed {
485 if let Some(project_root) = ctx.config().project_root.clone() {
486 let inspect_dir = ctx.inspect_dir();
487 let (dead_code, unused_exports, duplicates) = ctx
488 .inspect_manager()
489 .latest_tier2_counts(inspect_dir.clone(), project_root.clone());
490 let stale = ctx.inspect_manager().tier2_any_in_flight();
496 ctx.update_status_bar_tier2(dead_code, unused_exports, duplicates, None, stale);
497 let blocked = ctx
502 .inspect_manager()
503 .dead_code_blocked_on_callgraph(inspect_dir, project_root);
504 ctx.set_status_bar_tier2_dead_code_blocked_on_callgraph(blocked);
505 ctx.status_emitter().signal(ctx.build_status_snapshot());
506 }
507 }
508}
509
510pub fn drain_build_completions(ctx: &AppContext) {
515 drain_search_index_events(ctx);
516 drain_callgraph_store_events(ctx);
517 drain_semantic_index_events(ctx);
518}
519
520pub fn any_build_in_flight(ctx: &AppContext) -> bool {
525 {
526 let rx = ctx
527 .search_index_rx()
528 .read()
529 .unwrap_or_else(std::sync::PoisonError::into_inner);
530 if rx.is_some() {
531 return true;
532 }
533 }
534
535 {
536 let rx = ctx.callgraph_store_rx().lock();
537 if rx.is_some() {
538 return true;
539 }
540 }
541
542 {
543 let rx = ctx.semantic_index_rx().lock();
544 rx.is_some()
545 }
546}
547
548pub fn watcher_path_is_ignored_by_current_matcher(ctx: &AppContext, path: &Path) -> bool {
549 if watcher_path_is_infra_skip(path) {
550 return true;
551 }
552
553 if let Some(matcher) = ctx.gitignore() {
554 if path.starts_with(matcher.path()) {
555 let is_dir = path.is_dir();
556 return matcher
557 .matched_path_or_any_parents(path, is_dir)
558 .is_ignore();
559 }
560 }
561
562 false
563}
564
565fn replay_search_index_pending_updates(
566 ctx: &AppContext,
567 index: &mut crate::search_index::SearchIndex,
568 pending_paths: Vec<std::path::PathBuf>,
569) {
570 for path in pending_paths {
571 if path.exists() {
572 if watcher_path_is_ignored_by_current_matcher(ctx, &path) {
573 index.remove_file(&path);
574 } else {
575 index.update_file(&path);
576 }
577 } else {
578 index.remove_file(&path);
579 }
580 }
581}
582
583pub fn watcher_path_is_semantic_source(path: &Path) -> bool {
584 crate::semantic_index::is_semantic_indexed_extension(path)
585}
586
587pub fn mark_semantic_corpus_refresh_success(ctx: &AppContext) {
588 ctx.clear_all_semantic_refresh_retry_attempts();
589 ctx.reset_semantic_refresh_circuit_after_success();
590}
591
592pub fn drain_search_index_events(ctx: &AppContext) {
593 let (latest, disconnected, receiver_generation, receiver_epoch) = {
594 let rx_ref = ctx
595 .search_index_rx()
596 .read()
597 .unwrap_or_else(std::sync::PoisonError::into_inner);
598 let Some(rx) = rx_ref.as_ref() else {
599 return;
600 };
601
602 let mut latest = None;
603 let mut disconnected = false;
604 loop {
605 match rx.try_recv() {
606 Ok(index) => latest = Some(index),
607 Err(crossbeam_channel::TryRecvError::Empty) => break,
608 Err(crossbeam_channel::TryRecvError::Disconnected) => {
609 disconnected = true;
610 break;
611 }
612 }
613 }
614 (
615 latest,
616 disconnected,
617 ctx.search_index_rx_generation(),
618 ctx.search_index_rx_epoch(),
619 )
620 };
621
622 let mut installed_index = false;
623 if let Some(mut index) = latest {
624 wait_on_artifact_drain_commit_gate_for_test(ctx);
625 installed_index = ctx
626 .with_current_search_index_rx(receiver_generation, receiver_epoch, |receiver| {
627 let pending_paths = ctx.take_pending_search_index_paths();
628 if !pending_paths.is_empty() {
629 replay_search_index_pending_updates(ctx, &mut index, pending_paths);
630 }
631 *ctx.search_index()
632 .write()
633 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
634 *receiver = None;
635 true
636 })
637 .unwrap_or(false);
638 if !installed_index {
639 return;
640 }
641 ctx.note_search_index_load_succeeded();
642 } else if disconnected {
643 let cleared = ctx
644 .with_current_search_index_rx(receiver_generation, receiver_epoch, |receiver| {
645 *receiver = None;
646 let mut search_index = ctx
647 .search_index()
648 .write()
649 .unwrap_or_else(std::sync::PoisonError::into_inner);
650 if search_index
655 .as_ref()
656 .is_some_and(|index| !index.ready && !index.build_denied)
657 {
658 *search_index = None;
659 }
660 true
661 })
662 .unwrap_or(false);
663 if !cleared {
664 return;
665 }
666 crate::commands::configure::restart_search_index_after_load_disconnect(ctx);
672 }
673
674 if installed_index || disconnected {
675 ctx.status_emitter().signal(ctx.build_status_snapshot());
676 }
677}
678
679pub fn drain_callgraph_store_events(ctx: &AppContext) {
680 let (
681 latest,
682 denied,
683 suspended,
684 settled,
685 disconnected,
686 fulfilled_force_token,
687 receiver_generation,
688 receiver_epoch,
689 ) = {
690 let rx_ref = ctx.callgraph_store_rx().lock();
691 let Some(rx) = rx_ref.as_ref() else {
692 return;
693 };
694
695 let mut latest = None;
696 let mut denied = None;
697 let mut suspended = None;
698 let mut settled = false;
699 let mut fulfilled_force_token = None;
700 let mut disconnected = false;
701 loop {
702 match rx.try_recv() {
703 Ok(CallGraphStoreBuildEvent::Ready {
704 store,
705 fulfilled_force_token: token,
706 publication_epoch,
707 }) => {
708 if ctx.callgraph_persist_epoch_flag().current() == publication_epoch {
709 latest = Some(store);
710 fulfilled_force_token = token;
711 } else {
712 drop(store);
717 settled = true;
718 }
719 }
720 Ok(CallGraphStoreBuildEvent::Denied { reason }) => denied = Some(reason),
721 Ok(CallGraphStoreBuildEvent::Suspended { suspension }) => {
722 suspended = Some(suspension)
723 }
724 Ok(CallGraphStoreBuildEvent::Settled) => settled = true,
725 Err(crossbeam_channel::TryRecvError::Empty) => break,
726 Err(crossbeam_channel::TryRecvError::Disconnected) => {
727 disconnected = true;
728 break;
729 }
730 }
731 }
732 (
733 latest,
734 denied,
735 suspended,
736 settled,
737 disconnected,
738 fulfilled_force_token,
739 ctx.callgraph_store_rx_generation(),
740 ctx.callgraph_store_rx_epoch(),
741 )
742 };
743
744 let ready_received = latest.is_some();
745 let terminal =
746 ready_received || denied.is_some() || suspended.is_some() || settled || disconnected;
747 if !terminal {
748 return;
749 }
750 if let Some(project_root) = ctx.callgraph_project_root() {
751 crate::logging::release_index_build_start_waiters(
752 crate::logging::IndexPlane::Callgraph,
753 &project_root,
754 );
755 }
756 wait_on_artifact_drain_commit_gate_for_test(ctx);
757
758 let mut reopened = None;
759 if let Some(store) = latest {
760 drop(store);
763 if let Some(project_root) = ctx.callgraph_project_root() {
764 match CallGraphStore::open_readonly(ctx.callgraph_store_dir(), project_root) {
765 Ok(Some(store)) => reopened = Some(Arc::new(store)),
766 Ok(None) => {
767 crate::slog_warn!(
768 "callgraph store build completed without a readable published generation"
769 );
770 }
771 Err(error) => {
772 crate::slog_warn!("failed to install read-only callgraph store: {}", error);
773 }
774 }
775 }
776 }
777
778 let mut pending = Vec::new();
779 let installed =
780 ctx.with_current_callgraph_store_rx(receiver_generation, receiver_epoch, |receiver| {
781 let installed = if let Some(store) = reopened {
782 ctx.clear_callgraph_store_build_denied();
783 *ctx.callgraph_store()
784 .write()
785 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(store);
786 pending = ctx
790 .take_pending_callgraph_store_paths()
791 .into_iter()
792 .filter(|path| {
793 invalidates_workspace_crate_prefix_cache(path)
794 || !watcher_path_is_generated_for_callgraph(ctx, path)
795 })
796 .collect();
797 true
798 } else {
799 false
800 };
801 if let Some(reason) = denied {
802 ctx.record_callgraph_store_build_denied(receiver_generation, reason);
803 }
804 if let Some(suspension) = suspended {
805 ctx.record_callgraph_store_build_suspension(receiver_generation, suspension);
806 }
807 if terminal {
808 *receiver = None;
809 }
810 if installed {
811 if let Some(force_token) = fulfilled_force_token {
812 ctx.fulfill_callgraph_store_force_token(force_token);
813 }
814 }
815 installed
816 });
817 let Some(installed) = installed else {
818 return;
819 };
820
821 if installed {
822 if !pending.is_empty() {
823 let _ = ctx.enqueue_callgraph_store_refresh(pending);
824 }
825 let _ = ctx.request_tier2_refresh_pull();
826 }
827 if terminal {
828 ctx.status_emitter().signal(ctx.build_status_snapshot());
829 }
830}
831
832pub fn drain_semantic_index_events(ctx: &AppContext) {
833 let (events, disconnected, receiver_generation, receiver_epoch) = {
834 let rx_ref = ctx.semantic_index_rx().lock();
835 let Some(rx) = rx_ref.as_ref() else {
836 return;
837 };
838
839 let mut events = Vec::new();
840 let mut disconnected = false;
841 loop {
842 match rx.try_recv() {
843 Ok(event) => events.push(event),
844 Err(crossbeam_channel::TryRecvError::Empty) => break,
845 Err(crossbeam_channel::TryRecvError::Disconnected) => {
846 disconnected = true;
847 break;
848 }
849 }
850 }
851 (
852 events,
853 disconnected,
854 ctx.semantic_index_rx_generation(),
855 ctx.semantic_index_rx_epoch(),
856 )
857 };
858
859 if events.is_empty() && !disconnected {
860 return;
861 }
862
863 wait_on_artifact_drain_commit_gate_for_test(ctx);
864 let mut terminal = false;
865 let mut status_changed = false;
866 let mut replay_refresh_paths = Vec::new();
867 let mut replay_corpus_refresh = false;
868 let mut cold_seed_resumes = Vec::new();
869
870 for event in events {
871 match event {
872 SemanticIndexEvent::Progress {
873 stage,
874 files,
875 entries_done,
876 entries_total,
877 } => {
878 let committed = ctx
879 .with_current_semantic_index_rx(
880 receiver_generation,
881 receiver_epoch,
882 |_receiver| {
883 *ctx.semantic_index_status()
884 .write()
885 .unwrap_or_else(std::sync::PoisonError::into_inner) =
886 SemanticIndexStatus::Building {
887 stage,
888 files,
889 entries_done,
890 entries_total,
891 };
892 true
893 },
894 )
895 .unwrap_or(false);
896 if !committed {
897 return;
898 }
899 status_changed = true;
900 }
901 SemanticIndexEvent::ColdSeedGateCleared => {
902 let resume = ctx.with_current_semantic_index_rx(
903 receiver_generation,
904 receiver_epoch,
905 |_receiver| ctx.take_semantic_cold_seed_resume(true),
906 );
907 let Some(resume) = resume else {
908 return;
909 };
910 cold_seed_resumes.push(resume);
911 }
912 SemanticIndexEvent::Ready(mut index) => {
913 let committed = ctx.with_current_semantic_index_rx(
914 receiver_generation,
915 receiver_epoch,
916 |receiver| {
917 mark_semantic_corpus_refresh_success(ctx);
918 let refresh_paths = ctx
919 .take_pending_semantic_index_paths()
920 .into_iter()
921 .filter(|path| watcher_path_is_semantic_source(path))
922 .collect::<Vec<_>>();
923 index.invalidate_files(&refresh_paths);
924 let corpus_refresh = ctx.take_pending_semantic_corpus_refresh()
925 && !ctx.shared_artifacts_read_only();
926 if let Some(root) = ctx.canonical_cache_root_opt() {
927 let semantic_config = ctx.config().semantic.clone();
928 let _ = index.adopt_frozen_base_for_root(&root, &semantic_config);
929 }
930 *ctx.semantic_index()
931 .write()
932 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
933 *ctx.semantic_index_status()
934 .write()
935 .unwrap_or_else(std::sync::PoisonError::into_inner) =
936 SemanticIndexStatus::ready();
937 *receiver = None;
938 (
939 ctx.take_semantic_cold_seed_resume(false),
940 refresh_paths,
941 corpus_refresh,
942 )
943 },
944 );
945 let Some((resume, refresh_paths, corpus_refresh)) = committed else {
946 return;
947 };
948 cold_seed_resumes.push(resume);
949 replay_refresh_paths.extend(refresh_paths);
950 replay_corpus_refresh = corpus_refresh;
951 terminal = true;
952 status_changed = true;
953 }
954 SemanticIndexEvent::Failed(error) => {
955 let committed = ctx.with_current_semantic_index_rx(
956 receiver_generation,
957 receiver_epoch,
958 |receiver| {
959 let _ = ctx.take_pending_semantic_index_paths();
960 let _ = ctx.take_pending_semantic_corpus_refresh();
961 *ctx.semantic_index()
962 .write()
963 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
964 ctx.clear_semantic_refresh_worker();
965 *ctx.semantic_index_status()
966 .write()
967 .unwrap_or_else(std::sync::PoisonError::into_inner) =
968 SemanticIndexStatus::Failed(error);
969 *receiver = None;
970 ctx.take_semantic_cold_seed_resume(false)
971 },
972 );
973 let Some(resume) = committed else {
974 return;
975 };
976 cold_seed_resumes.push(resume);
977 terminal = true;
978 status_changed = true;
979 }
980 }
981 }
982
983 if terminal
984 && matches!(
985 &*ctx
986 .semantic_index_status()
987 .read()
988 .unwrap_or_else(std::sync::PoisonError::into_inner),
989 SemanticIndexStatus::Ready { .. }
990 )
991 {
992 let _ = crate::commands::configure::ensure_ready_semantic_refresh_worker(ctx);
993 }
994
995 if terminal
996 && ctx.config().views.enabled
997 && matches!(
998 &*ctx
999 .semantic_index_status()
1000 .read()
1001 .unwrap_or_else(std::sync::PoisonError::into_inner),
1002 SemanticIndexStatus::Ready { .. }
1003 )
1004 {
1005 if let Some(pending) = semantic_ready_view_publication_paths(
1010 ctx.view_runtime_snapshot()
1011 .map(|snapshot| snapshot.pending_paths),
1012 ) {
1013 if let Err(error) = crate::executor::view_publication::schedule(ctx, pending, true) {
1014 aft::slog_warn!("semantic-ready view publication failed: {}", error);
1015 }
1016 }
1017 }
1018
1019 if disconnected && !terminal {
1020 let committed =
1021 ctx.with_current_semantic_index_rx(receiver_generation, receiver_epoch, |receiver| {
1022 let _ = ctx.take_pending_semantic_index_paths();
1023 let _ = ctx.take_pending_semantic_corpus_refresh();
1024 *ctx.semantic_index()
1025 .write()
1026 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1027 ctx.clear_semantic_refresh_worker();
1028 *ctx.semantic_index_status()
1029 .write()
1030 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1031 SemanticIndexStatus::Failed(
1032 "semantic index build worker disconnected before reporting completion"
1033 .to_string(),
1034 );
1035 *receiver = None;
1036 ctx.take_semantic_cold_seed_resume(false)
1037 });
1038 let Some(resume) = committed else {
1039 return;
1040 };
1041 cold_seed_resumes.push(resume);
1042 status_changed = true;
1043 }
1044
1045 if terminal || disconnected {
1046 ctx.set_semantic_build_progress(None);
1047 }
1048
1049 for resume in cold_seed_resumes {
1050 ctx.apply_semantic_cold_seed_resume(resume);
1051 }
1052
1053 if replay_corpus_refresh {
1054 let replayed = ctx.run_if_subc_bound_generation(receiver_generation, || {
1055 if ctx.semantic_index_rx_epoch() != receiver_epoch
1056 || ctx.canonical_cache_root_opt().is_none()
1057 {
1058 return false;
1059 }
1060 *ctx.semantic_index_status()
1061 .write()
1062 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1063 SemanticIndexStatus::Building {
1064 stage: "refreshing_corpus".to_string(),
1065 files: None,
1066 entries_done: None,
1067 entries_total: None,
1068 };
1069 let sent = ctx
1070 .semantic_refresh_sender()
1071 .is_some_and(|sender| sender.send(SemanticRefreshRequest::Corpus).is_ok());
1072 if !sent {
1073 *ctx.semantic_index_status()
1074 .write()
1075 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1076 SemanticIndexStatus::Failed(
1077 "semantic corpus refresh worker unavailable".to_string(),
1078 );
1079 }
1080 true
1081 });
1082 if replayed != Some(true) {
1083 return;
1084 }
1085 status_changed = true;
1086 } else if !replay_refresh_paths.is_empty() {
1087 let replayed = ctx.run_if_subc_bound_generation(receiver_generation, || {
1088 if ctx.semantic_index_rx_epoch() != receiver_epoch {
1089 return false;
1090 }
1091 {
1092 let mut status = ctx
1093 .semantic_index_status()
1094 .write()
1095 .unwrap_or_else(std::sync::PoisonError::into_inner);
1096 if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
1097 for path in &replay_refresh_paths {
1098 status.add_refreshing_file(path.clone());
1099 }
1100 }
1101 }
1102 let sent = ctx.semantic_refresh_sender().is_some_and(|sender| {
1103 sender
1104 .send(SemanticRefreshRequest::Files {
1105 paths: replay_refresh_paths.clone(),
1106 })
1107 .is_ok()
1108 });
1109 if !sent {
1110 crate::slog_warn!(
1111 "semantic refresh worker unavailable; dropping {} replayed file(s)",
1112 replay_refresh_paths.len()
1113 );
1114 let mut status = ctx
1115 .semantic_index_status()
1116 .write()
1117 .unwrap_or_else(std::sync::PoisonError::into_inner);
1118 for path in &replay_refresh_paths {
1119 status.cancel_refreshing_file(path);
1120 }
1121 }
1122 true
1123 });
1124 if replayed != Some(true) {
1125 return;
1126 }
1127 status_changed = true;
1128 }
1129
1130 if status_changed {
1131 ctx.status_emitter().signal(ctx.build_status_snapshot());
1132 }
1133}
1134
1135pub const MAX_RETRY_ATTEMPTS: usize = 6;
1136pub const BREAKER_TRIP_THRESHOLD: usize = 3;
1137
1138#[cfg(test)]
1139static SEMANTIC_REFRESH_RETRY_DELAY_OVERRIDE_MS: AtomicU64 = AtomicU64::new(u64::MAX);
1140
1141fn semantic_refresh_retry_backoff(attempt: usize) -> Duration {
1146 #[cfg(test)]
1147 {
1148 let override_ms = SEMANTIC_REFRESH_RETRY_DELAY_OVERRIDE_MS.load(Ordering::SeqCst);
1149 if override_ms != u64::MAX {
1150 return Duration::from_millis(override_ms);
1151 }
1152 }
1153 if let Ok(raw) = std::env::var("AFT_SEMANTIC_RETRY_BACKOFF_MS") {
1155 if let Ok(ms) = raw.parse::<u64>() {
1156 return Duration::from_millis(ms);
1157 }
1158 }
1159 const SCHEDULE_SECS: [u64; 3] = [15, 30, 60];
1160 let secs = SCHEDULE_SECS
1161 .get(attempt)
1162 .copied()
1163 .unwrap_or(*SCHEDULE_SECS.last().unwrap());
1164 Duration::from_secs(secs)
1165}
1166
1167struct SemanticRefreshRetryPlan {
1168 retry_paths: Vec<std::path::PathBuf>,
1169 capped_paths: Vec<std::path::PathBuf>,
1170 delay: Option<Duration>,
1171}
1172
1173fn next_semantic_refresh_retry_plan(
1174 ctx: &AppContext,
1175 paths: Vec<std::path::PathBuf>,
1176) -> SemanticRefreshRetryPlan {
1177 let mut retry_paths = Vec::new();
1178 let mut capped_paths = Vec::new();
1179 let mut max_attempt = 0usize;
1180
1181 ctx.with_semantic_refresh_retry_attempts_mut(|attempts| {
1182 for path in paths {
1183 let attempt = attempts.get(&path).copied().unwrap_or(0);
1184 if attempt >= MAX_RETRY_ATTEMPTS {
1185 capped_paths.push(path);
1186 continue;
1187 }
1188 max_attempt = max_attempt.max(attempt);
1189 attempts.insert(path.clone(), attempt.saturating_add(1));
1190 retry_paths.push(path);
1191 }
1192 });
1193
1194 let delay = if retry_paths.is_empty() {
1195 None
1196 } else {
1197 Some(semantic_refresh_retry_backoff(max_attempt))
1198 };
1199
1200 SemanticRefreshRetryPlan {
1201 retry_paths,
1202 capped_paths,
1203 delay,
1204 }
1205}
1206
1207fn clear_semantic_refresh_retry_attempts(ctx: &AppContext, paths: &[std::path::PathBuf]) {
1208 ctx.clear_semantic_refresh_retry_attempts(paths);
1209}
1210
1211fn clear_completed_pending_semantic_index_paths(
1212 ctx: &AppContext,
1213 completed_paths: &[std::path::PathBuf],
1214) {
1215 if completed_paths.is_empty() {
1216 return;
1217 }
1218
1219 let completed = completed_paths.iter().cloned().collect::<HashSet<_>>();
1220 let remaining = ctx
1221 .take_pending_semantic_index_paths()
1222 .into_iter()
1223 .filter(|path| !completed.contains(path))
1224 .collect::<Vec<_>>();
1225 if !remaining.is_empty() {
1226 ctx.add_pending_semantic_index_paths(remaining);
1227 }
1228}
1229
1230fn semantic_refresh_probe_delay() -> Duration {
1231 semantic_refresh_retry_backoff(usize::MAX)
1232}
1233
1234pub fn semantic_refresh_circuit_is_open(ctx: &AppContext) -> bool {
1235 ctx.semantic_refresh_circuit_is_open()
1236}
1237
1238pub fn record_semantic_refresh_transient_failure(ctx: &AppContext, error: &str) -> bool {
1239 let reason = aft::semantic_index::strip_transient_embedding_marker(error);
1240 ctx.record_semantic_refresh_transient_failure(BREAKER_TRIP_THRESHOLD, &reason)
1241}
1242
1243fn reset_semantic_refresh_transient_failure_count(ctx: &AppContext) {
1244 ctx.reset_semantic_refresh_transient_failure_count();
1245}
1246
1247fn reset_semantic_refresh_circuit_after_success(ctx: &AppContext) {
1248 ctx.reset_semantic_refresh_circuit_after_success();
1249}
1250
1251fn mark_semantic_refresh_success(ctx: &AppContext, completed_paths: &[std::path::PathBuf]) {
1252 clear_semantic_refresh_retry_attempts(ctx, completed_paths);
1253 clear_completed_pending_semantic_index_paths(ctx, completed_paths);
1254 reset_semantic_refresh_circuit_after_success(ctx);
1255}
1256
1257#[doc(hidden)]
1258pub fn semantic_refresh_transient_failure_count_for_test(ctx: &AppContext) -> usize {
1259 ctx.semantic_refresh_transient_failure_count()
1260}
1261
1262#[doc(hidden)]
1263pub fn semantic_refresh_probe_is_scheduled_for_test(ctx: &AppContext) -> bool {
1264 ctx.semantic_refresh_probe_is_scheduled()
1265}
1266
1267fn ensure_semantic_refresh_probe_scheduled(ctx: &AppContext) {
1268 ctx.ensure_semantic_refresh_probe_scheduled(semantic_refresh_probe_delay());
1269}
1270
1271fn maybe_fire_semantic_refresh_probe(ctx: &AppContext) {
1272 let generation = ctx.semantic_refresh_generation();
1273 let _ = ctx.run_if_subc_bound_generation(generation, || {
1274 if !ctx.take_semantic_refresh_probe_ready() {
1275 return;
1276 }
1277 if !semantic_refresh_circuit_is_open(ctx) {
1278 return;
1279 }
1280
1281 if ctx.take_pending_semantic_corpus_refresh() && !ctx.shared_artifacts_read_only() {
1282 let previous_status = {
1288 let mut status = ctx
1289 .semantic_index_status()
1290 .write()
1291 .unwrap_or_else(std::sync::PoisonError::into_inner);
1292 let previous = status.clone();
1293 *status = SemanticIndexStatus::Building {
1294 stage: "refreshing_corpus".to_string(),
1295 files: None,
1296 entries_done: None,
1297 entries_total: None,
1298 };
1299 previous
1300 };
1301 let sent = ctx
1302 .semantic_refresh_sender()
1303 .is_some_and(|sender| sender.send(SemanticRefreshRequest::Corpus).is_ok());
1304 if !sent {
1305 *ctx.semantic_index_status()
1306 .write()
1307 .unwrap_or_else(std::sync::PoisonError::into_inner) = previous_status;
1308 ctx.mark_pending_semantic_corpus_refresh();
1309 }
1310 return;
1311 }
1312
1313 let pending_paths = ctx.take_pending_semantic_index_paths();
1314 if pending_paths.is_empty() {
1315 return;
1316 }
1317
1318 let sent = ctx.semantic_refresh_sender().is_some_and(|sender| {
1319 sender
1320 .send(SemanticRefreshRequest::Files {
1321 paths: pending_paths.clone(),
1322 })
1323 .is_ok()
1324 });
1325 if !sent {
1326 ctx.add_pending_semantic_index_paths(pending_paths);
1327 }
1328 });
1329}
1330
1331pub fn schedule_semantic_refresh_retry(
1332 ctx: &AppContext,
1333 paths: Vec<std::path::PathBuf>,
1334 error: &str,
1335) -> bool {
1336 if paths.is_empty() {
1337 return false;
1338 }
1339 if ctx.semantic_refresh_sender().is_none() {
1340 return false;
1341 };
1342
1343 let SemanticRefreshRetryPlan {
1344 retry_paths,
1345 capped_paths,
1346 delay,
1347 } = next_semantic_refresh_retry_plan(ctx, paths);
1348
1349 if !capped_paths.is_empty() {
1350 aft::slog_warn!(
1351 "semantic refresh retry limit reached for {} file(s); preserving for next watcher/configure refresh",
1352 capped_paths.len(),
1353 );
1354 ctx.add_pending_semantic_index_paths(capped_paths);
1355 }
1356
1357 let Some(delay) = delay else {
1358 return true;
1359 };
1360
1361 let clean = aft::semantic_index::strip_transient_embedding_marker(error);
1362 aft::slog_warn!(
1363 "semantic refresh hit a transient backend error ({}); retrying {} file(s) in {}ms",
1364 clean,
1365 retry_paths.len(),
1366 delay.as_millis(),
1367 );
1368
1369 let session_id = log_ctx::current_session();
1370 let generation = ctx.semantic_refresh_generation();
1371 let generation_flag = ctx.configure_generation_flag();
1372 let lifecycle = ctx.subc_lifecycle_admission();
1373 let (sender_slot, pending_paths_slot) = ctx.semantic_refresh_retry_slots();
1374 thread::spawn(move || {
1375 log_ctx::with_session(session_id, || {
1376 thread::sleep(delay);
1377 let _ = lifecycle.run_if_current(&generation_flag, generation, || {
1378 let sent = sender_slot.lock().as_ref().is_some_and(|sender| {
1379 sender
1380 .send(SemanticRefreshRequest::Files {
1381 paths: retry_paths.clone(),
1382 })
1383 .is_ok()
1384 });
1385 if !sent {
1386 pending_paths_slot.lock().extend(retry_paths);
1387 }
1388 });
1389 });
1390 });
1391 true
1392}
1393
1394pub fn drain_semantic_refresh_events(ctx: &AppContext) {
1395 let (events, disconnected, receiver_generation, receiver_epoch) = {
1396 let rx_ref = ctx.semantic_refresh_event_rx().lock();
1397 let Some(rx) = rx_ref.as_ref() else {
1398 return;
1399 };
1400
1401 let mut events = Vec::new();
1402 let mut disconnected = false;
1403 loop {
1404 match rx.try_recv() {
1405 Ok(event) => events.push(event),
1406 Err(crossbeam_channel::TryRecvError::Empty) => break,
1407 Err(crossbeam_channel::TryRecvError::Disconnected) => {
1408 disconnected = true;
1409 break;
1410 }
1411 }
1412 }
1413 (
1414 events,
1415 disconnected,
1416 ctx.semantic_refresh_generation(),
1417 ctx.semantic_refresh_epoch(),
1418 )
1419 };
1420
1421 if events.is_empty() && !disconnected {
1422 maybe_fire_semantic_refresh_probe(ctx);
1423 return;
1424 }
1425
1426 wait_on_artifact_drain_commit_gate_for_test(ctx);
1427 let committed = ctx.with_current_semantic_refresh_rx(
1428 receiver_generation,
1429 receiver_epoch,
1430 || {
1431 let had_events = !events.is_empty();
1432 let mut status_changed = false;
1433 let mut replay_refresh_paths = Vec::new();
1434 let mut schedule_breaker_probe = false;
1435 let mut view_refresh_completed = false;
1436 for event in events {
1437 match event {
1438 SemanticRefreshEvent::Started { paths } => {
1439 let mut status = ctx
1440 .semantic_index_status()
1441 .write()
1442 .unwrap_or_else(std::sync::PoisonError::into_inner);
1443 if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
1444 for path in paths {
1445 status.start_refreshing_file(path);
1446 }
1447 status_changed = true;
1448 }
1449 }
1450 SemanticRefreshEvent::CorpusStarted { files } => {
1451 *ctx.semantic_index_status()
1452 .write()
1453 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1454 SemanticIndexStatus::Building {
1455 stage: "refreshing_corpus".to_string(),
1456 files: Some(files),
1457 entries_done: None,
1458 entries_total: None,
1459 };
1460 status_changed = true;
1461 }
1462 SemanticRefreshEvent::Completed {
1463 added_entries,
1464 updated_metadata,
1465 completed_paths,
1466 } => {
1467 if let Some(index) = ctx
1468 .semantic_index()
1469 .write()
1470 .unwrap_or_else(std::sync::PoisonError::into_inner)
1471 .as_mut()
1472 {
1473 index.apply_refresh_update(added_entries, updated_metadata, &completed_paths);
1474 }
1475 mark_semantic_refresh_success(ctx, &completed_paths);
1476 view_refresh_completed = true;
1477 let mut status = ctx
1478 .semantic_index_status()
1479 .write()
1480 .unwrap_or_else(std::sync::PoisonError::into_inner);
1481 if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
1482 for path in &completed_paths {
1483 status.complete_refreshing_file(path);
1484 }
1485 status_changed = true;
1486 }
1487 }
1488 SemanticRefreshEvent::CorpusCompleted {
1489 mut index,
1490 changed,
1491 added,
1492 deleted,
1493 total_processed,
1494 } => {
1495 aft::runtime_drain::mark_semantic_corpus_refresh_success(ctx);
1496 if changed > 0 || added > 0 || deleted > 0 {
1497 aft::slog_info!(
1498 "semantic corpus refresh completed: {} changed, {} new, {} deleted, {} total processed",
1499 changed,
1500 added,
1501 deleted,
1502 total_processed
1503 );
1504 }
1505 let pending_paths = ctx.take_pending_semantic_index_paths();
1506 let mut invalidated_paths = Vec::new();
1507 for path in pending_paths {
1508 if !aft::runtime_drain::watcher_path_is_semantic_source(&path) {
1509 continue;
1510 }
1511 if !aft::runtime_drain::watcher_path_is_ignored_by_current_matcher(ctx, &path) {
1512 replay_refresh_paths.push(path.clone());
1513 }
1514 invalidated_paths.push(path);
1515 }
1516 index.invalidate_files(&invalidated_paths);
1517 *ctx.semantic_index()
1518 .write()
1519 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
1520 *ctx.semantic_index_status()
1521 .write()
1522 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1523 SemanticIndexStatus::ready();
1524 view_refresh_completed = true;
1525 status_changed = true;
1526 }
1527 SemanticRefreshEvent::Failed { paths, error } => {
1528 if aft::semantic_index::embedding_failure_is_transient(&error) {
1529 if record_semantic_refresh_transient_failure(ctx, &error) {
1530 ctx.add_pending_semantic_index_paths(paths);
1531 schedule_breaker_probe = true;
1532 } else if !schedule_semantic_refresh_retry(ctx, paths.clone(), &error) {
1533 aft::slog_warn!(
1534 "semantic refresh worker unavailable; preserving {} transiently failed file(s) for retry",
1535 paths.len(),
1536 );
1537 ctx.add_pending_semantic_index_paths(paths);
1538 }
1539 } else {
1540 aft::slog_warn!("semantic refresh failed: {}", error);
1541 reset_semantic_refresh_transient_failure_count(ctx);
1542 clear_semantic_refresh_retry_attempts(ctx, &paths);
1543 let mut status = ctx
1544 .semantic_index_status()
1545 .write()
1546 .unwrap_or_else(std::sync::PoisonError::into_inner);
1547 if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
1548 for path in &paths {
1549 status.complete_refreshing_file(path);
1550 }
1551 status_changed = true;
1552 }
1553 }
1554 }
1555 SemanticRefreshEvent::CorpusFailed { paths, error } => {
1556 if aft::semantic_index::embedding_failure_is_transient(&error) {
1565 let clean = aft::semantic_index::strip_transient_embedding_marker(&error);
1566 let has_index = ctx
1567 .semantic_index()
1568 .read()
1569 .unwrap_or_else(std::sync::PoisonError::into_inner)
1570 .is_some();
1571 ctx.add_pending_semantic_index_paths(paths);
1576 ctx.trip_semantic_refresh_circuit(BREAKER_TRIP_THRESHOLD, &clean);
1577 schedule_breaker_probe = true;
1578 if has_index {
1579 aft::slog_warn!(
1580 "semantic corpus refresh hit a transient backend error ({}); keeping the existing index",
1581 clean,
1582 );
1583 *ctx.semantic_index_status()
1584 .write()
1585 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1586 SemanticIndexStatus::ready();
1587 } else {
1588 aft::slog_warn!("semantic corpus refresh failed: {}", clean);
1590 *ctx.semantic_index_status()
1591 .write()
1592 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1593 SemanticIndexStatus::Failed(clean);
1594 }
1595 status_changed = true;
1596 } else {
1597 aft::slog_warn!("semantic corpus refresh failed: {}", error);
1598 let _ = ctx.take_pending_semantic_index_paths();
1599 *ctx.semantic_index()
1600 .write()
1601 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1602 *ctx.semantic_index_status()
1603 .write()
1604 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1605 SemanticIndexStatus::Failed(error);
1606 status_changed = true;
1607 }
1608 }
1609 }
1610 }
1611
1612 if disconnected {
1613 let refreshing_paths = {
1614 let status = ctx
1615 .semantic_index_status()
1616 .read()
1617 .unwrap_or_else(std::sync::PoisonError::into_inner);
1618 match &*status {
1619 SemanticIndexStatus::Ready { refreshing, .. } => refreshing.clone(),
1620 _ => Vec::new(),
1621 }
1622 };
1623 if !refreshing_paths.is_empty() {
1624 let mut status = ctx
1625 .semantic_index_status()
1626 .write()
1627 .unwrap_or_else(std::sync::PoisonError::into_inner);
1628 for path in &refreshing_paths {
1629 status.cancel_refreshing_file(path);
1630 }
1631 }
1632 if !refreshing_paths.is_empty() || had_events {
1633 status_changed = true;
1634 }
1635 }
1636
1637 if !replay_refresh_paths.is_empty() {
1638 {
1639 let mut status = ctx
1640 .semantic_index_status()
1641 .write()
1642 .unwrap_or_else(std::sync::PoisonError::into_inner);
1643 if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
1644 for path in &replay_refresh_paths {
1645 status.add_refreshing_file(path.clone());
1646 }
1647 status_changed = true;
1648 }
1649 }
1650 let sent = ctx.semantic_refresh_sender().is_some_and(|sender| {
1651 sender
1652 .send(SemanticRefreshRequest::Files {
1653 paths: replay_refresh_paths.clone(),
1654 })
1655 .is_ok()
1656 });
1657 if !sent {
1658 aft::slog_warn!(
1659 "semantic refresh worker unavailable; dropping {} replayed corpus file(s)",
1660 replay_refresh_paths.len()
1661 );
1662 let mut status = ctx
1663 .semantic_index_status()
1664 .write()
1665 .unwrap_or_else(std::sync::PoisonError::into_inner);
1666 for path in &replay_refresh_paths {
1667 status.cancel_refreshing_file(path);
1668 }
1669 status_changed = true;
1670 }
1671 }
1672
1673 (status_changed, schedule_breaker_probe, view_refresh_completed)
1674 },
1675 );
1676 let Some((mut status_changed, schedule_breaker_probe, view_refresh_completed)) = committed
1677 else {
1678 return;
1679 };
1680 if schedule_breaker_probe && semantic_refresh_circuit_is_open(ctx) {
1681 ensure_semantic_refresh_probe_scheduled(ctx);
1682 }
1683 if disconnected {
1684 if let Some(disconnected_build_epoch) =
1685 ctx.clear_semantic_refresh_worker_if_current(receiver_generation, receiver_epoch)
1686 {
1687 wait_on_semantic_refresh_recovery_gate_for_test(ctx);
1688 let _ = crate::commands::configure::restart_semantic_artifacts_after_refresh_disconnect(
1689 ctx,
1690 disconnected_build_epoch,
1691 );
1692 status_changed = true;
1693 }
1694 }
1695
1696 maybe_fire_semantic_refresh_probe(ctx);
1697
1698 if view_refresh_completed {
1699 publish_semantic_ready_view(ctx);
1700 }
1701
1702 if status_changed {
1703 ctx.status_emitter().signal(ctx.build_status_snapshot());
1704 }
1705}
1706
1707fn publish_semantic_ready_view(ctx: &AppContext) {
1708 let Some(paths) = semantic_ready_view_publication_paths(
1709 ctx.view_runtime_snapshot()
1710 .map(|snapshot| snapshot.pending_paths),
1711 ) else {
1712 return;
1713 };
1714 let Some(root) = ctx.canonical_cache_root_opt() else {
1715 return;
1716 };
1717 match crate::executor::view_publication::schedule(ctx, paths, !ctx.shared_artifacts_read_only())
1718 {
1719 Ok(()) => crate::slog_info!(
1720 "content-addressed view publication scheduled after semantic refresh root={}",
1721 root.display()
1722 ),
1723 Err(error) => crate::slog_warn!(
1724 "content-addressed view publication after semantic refresh failed: {}",
1725 error
1726 ),
1727 }
1728}
1729
1730const SOURCE_EXTENSIONS: &[&str] = &[
1732 "ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "py", "pyi", "rs", "go",
1733];
1734
1735pub const WATCHER_BATCH_INLINE_CAP: usize = 256;
1736
1737pub fn watcher_path_is_tsconfig(path: &std::path::Path) -> bool {
1745 path.file_name()
1746 .and_then(|n| n.to_str())
1747 .map(|n| {
1748 n == "tsconfig.json"
1749 || n == "jsconfig.json"
1750 || ((n.starts_with("tsconfig.") || n.starts_with("jsconfig."))
1751 && n.ends_with(".json"))
1752 })
1753 .unwrap_or(false)
1754}
1755
1756pub fn watcher_path_is_source(path: &std::path::Path) -> bool {
1757 path.extension()
1758 .and_then(|ext| ext.to_str())
1759 .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
1760}
1761
1762pub fn watcher_path_is_callgraph_indexed(path: &std::path::Path) -> bool {
1770 aft::parser::detect_language(path).is_some()
1771}
1772
1773pub fn semantic_corpus_refresh_in_progress(ctx: &AppContext) -> bool {
1774 let status = ctx
1775 .semantic_index_status()
1776 .read()
1777 .unwrap_or_else(std::sync::PoisonError::into_inner);
1778 matches!(
1779 &*status,
1780 SemanticIndexStatus::Building { stage, .. } if stage == "refreshing_corpus"
1781 )
1782}
1783
1784struct SearchRebuildPublishGate {
1785 reached_tx: crossbeam_channel::Sender<()>,
1786 release_rx: crossbeam_channel::Receiver<()>,
1787}
1788
1789static SEARCH_REBUILD_PUBLISH_GATE: OnceLock<Mutex<Option<SearchRebuildPublishGate>>> =
1790 OnceLock::new();
1791static SEARCH_REBUILD_SHUTDOWN_WAIT_SIGNAL: OnceLock<Mutex<Option<crossbeam_channel::Sender<()>>>> =
1792 OnceLock::new();
1793
1794#[doc(hidden)]
1795pub fn install_search_rebuild_publish_gate_for_test() -> (
1796 crossbeam_channel::Receiver<()>,
1797 crossbeam_channel::Receiver<()>,
1798 crossbeam_channel::Sender<()>,
1799) {
1800 let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
1801 let (shutdown_waiting_tx, shutdown_waiting_rx) = crossbeam_channel::bounded(1);
1802 let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1803 *SEARCH_REBUILD_PUBLISH_GATE
1804 .get_or_init(|| Mutex::new(None))
1805 .lock()
1806 .expect("search rebuild publish gate mutex poisoned") = Some(SearchRebuildPublishGate {
1807 reached_tx,
1808 release_rx,
1809 });
1810 *SEARCH_REBUILD_SHUTDOWN_WAIT_SIGNAL
1811 .get_or_init(|| Mutex::new(None))
1812 .lock()
1813 .expect("search rebuild shutdown wait signal mutex poisoned") = Some(shutdown_waiting_tx);
1814 (reached_rx, shutdown_waiting_rx, release_tx)
1815}
1816
1817pub(crate) fn note_search_rebuild_shutdown_wait_for_test() {
1818 let signal = SEARCH_REBUILD_SHUTDOWN_WAIT_SIGNAL
1819 .get_or_init(|| Mutex::new(None))
1820 .lock()
1821 .expect("search rebuild shutdown wait signal mutex poisoned")
1822 .take();
1823 if let Some(signal) = signal {
1824 let _ = signal.send(());
1825 }
1826}
1827
1828fn wait_on_search_rebuild_publish_gate_for_test() {
1829 let gate = SEARCH_REBUILD_PUBLISH_GATE
1830 .get_or_init(|| Mutex::new(None))
1831 .lock()
1832 .expect("search rebuild publish gate mutex poisoned")
1833 .take();
1834 if let Some(gate) = gate {
1835 let _ = gate.reached_tx.send(());
1836 let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1837 }
1838}
1839
1840pub fn spawn_search_corpus_refresh(
1841 ctx: &AppContext,
1842 root: std::path::PathBuf,
1843 config: Arc<aft::config::Config>,
1844) {
1845 let generation = ctx.configure_generation();
1846 let _ = ctx.run_if_subc_bound_generation(generation, || {
1847 spawn_search_corpus_refresh_admitted(ctx, root, config, generation);
1848 });
1849}
1850
1851fn spawn_search_corpus_refresh_admitted(
1852 ctx: &AppContext,
1853 root: std::path::PathBuf,
1854 config: Arc<aft::config::Config>,
1855 generation: u64,
1856) {
1857 {
1858 let mut search_index = ctx
1859 .search_index()
1860 .write()
1861 .unwrap_or_else(std::sync::PoisonError::into_inner);
1862 if let Some(index) = search_index.as_mut() {
1863 index.ready = false;
1864 }
1865 }
1866
1867 let (tx, rx): (
1868 crossbeam_channel::Sender<aft::search_index::SearchIndex>,
1869 crossbeam_channel::Receiver<aft::search_index::SearchIndex>,
1870 ) = crossbeam_channel::unbounded();
1871 let receiver_epoch = ctx.install_search_index_rx(rx, generation);
1872 let receiver_terminal_guard = ctx.search_index_rx_terminal_guard(receiver_epoch);
1873 ctx.reset_symbol_cache();
1874
1875 let shared_artifacts_read_only = ctx.shared_artifacts_read_only();
1876 let project_key = ctx.memoized_artifact_cache_key(&root);
1877 let session_id = log_ctx::current_session();
1878 let generation_flag = ctx.configure_generation_flag();
1879 let content_generation = ctx.configure_content_generation();
1880 let content_generation_flag = ctx.configure_content_generation_flag();
1881 let persist_epoch_flag = ctx.search_persist_epoch_flag();
1882 let persist_epoch = ctx.next_search_persist_epoch();
1883 let lifecycle = ctx.subc_lifecycle_admission();
1884 let cold_build_limiter = ctx.cold_build_limiter();
1885 thread::spawn(move || {
1886 let _terminal_guard = receiver_terminal_guard;
1887 log_ctx::with_session(session_id, || {
1888 let Some(_permit) = crate::cold_build_limiter::acquire_blocking_while_with_limiter(
1889 &cold_build_limiter,
1890 "search corpus refresh",
1891 || lifecycle.is_current(&generation_flag, generation),
1892 ) else {
1893 return;
1894 };
1895 if !lifecycle.is_current(&generation_flag, generation)
1896 || persist_epoch_flag.current() != persist_epoch
1897 {
1898 return;
1899 }
1900 let cache_dir = aft::search_index::resolve_cache_dir_with_key(
1901 &project_key,
1902 config.storage_dir.as_deref(),
1903 );
1904 let cache_lock = if shared_artifacts_read_only {
1905 None
1906 } else {
1907 match aft::search_index::CacheLock::acquire(&cache_dir, &root) {
1908 Ok(lock) => Some(lock),
1909 Err(error) => {
1910 aft::slog_warn!(
1911 "failed to acquire search cache lock for ignore refresh: {}",
1912 error
1913 );
1914 None
1915 }
1916 }
1917 };
1918 let mut index = aft::search_index::SearchIndex::build_with_limit_to_cache_dir(
1919 &root,
1920 config.search_index_max_file_size,
1921 &cache_dir,
1922 );
1923 wait_on_search_rebuild_publish_gate_for_test();
1924 if cache_lock.is_some()
1927 && content_generation_flag.load(std::sync::atomic::Ordering::SeqCst)
1928 == content_generation
1929 {
1930 let _ = persist_epoch_flag.run_if_current(persist_epoch, || {
1931 let head = index.stored_git_head().map(str::to_owned);
1932 index.write_to_disk(&cache_dir, head.as_deref());
1933 });
1934 }
1935 let _ = lifecycle.run_if_current(&generation_flag, generation, || {
1936 let _ = tx.send(index);
1937 });
1938 });
1939 });
1940}
1941
1942pub fn refresh_project_corpus(
1943 ctx: &AppContext,
1944 reason: &str,
1945 _invalidate_ignore_paths: bool,
1946) -> bool {
1947 let generation = ctx.configure_generation();
1948 ctx.run_if_subc_bound_generation(generation, || {
1949 let Some(root) = ctx.canonical_cache_root_opt() else {
1950 return false;
1951 };
1952 let config = ctx.config();
1953 let mut status_changed = false;
1954
1955 if ctx.callgraph_writer() {
1956 let callgraph_store_resident = {
1978 let guard = ctx
1979 .callgraph_store()
1980 .read()
1981 .unwrap_or_else(std::sync::PoisonError::into_inner);
1982 guard.is_some()
1983 };
1984 if callgraph_store_resident || ctx.callgraph_store_rx().lock().is_some() {
1985 *ctx.callgraph_store()
1986 .write()
1987 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1988 ctx.mark_callgraph_store_force_rebuild();
1989 status_changed = true;
1990 aft::slog_info!(
1991 "callgraph store scheduled for background rebuild after {}",
1992 reason
1993 );
1994 }
1995 }
1996
1997 if config.search_index && !ctx.shared_artifacts_read_only() {
1998 spawn_search_corpus_refresh_admitted(ctx, root.clone(), config.clone(), generation);
1999 status_changed = true;
2000 aft::slog_info!("started search index refresh after {}", reason);
2001 }
2002
2003 if config.semantic_search && !ctx.shared_artifacts_read_only() {
2004 if let Some(sender) = ctx.semantic_refresh_sender() {
2005 *ctx.semantic_index_status()
2006 .write()
2007 .unwrap_or_else(std::sync::PoisonError::into_inner) =
2008 SemanticIndexStatus::Building {
2009 stage: "refreshing_corpus".to_string(),
2010 files: None,
2011 entries_done: None,
2012 entries_total: None,
2013 };
2014 match sender.send(SemanticRefreshRequest::Corpus) {
2015 Ok(()) => {
2016 status_changed = true;
2017 }
2018 Err(error) => {
2019 *ctx.semantic_index_status()
2020 .write()
2021 .unwrap_or_else(std::sync::PoisonError::into_inner) =
2022 SemanticIndexStatus::Failed(format!(
2023 "semantic corpus refresh worker unavailable: {error}"
2024 ));
2025 status_changed = true;
2026 }
2027 }
2028 } else if ctx.semantic_index_rx().lock().is_some() {
2029 ctx.mark_pending_semantic_corpus_refresh();
2030 }
2031 }
2032
2033 status_changed
2034 })
2035 .unwrap_or(false)
2036}
2037
2038pub fn refresh_corpus_after_ignore_change(ctx: &AppContext) -> bool {
2039 refresh_project_corpus(ctx, "ignore-rule change", true)
2040}
2041
2042fn watcher_rescan_rss_bytes() -> Option<u64> {
2043 crate::memory::rss_bytes()
2044}
2045
2046fn watcher_rescan_rss_delta(before: Option<u64>, after: Option<u64>) -> Option<i64> {
2047 let delta = i128::from(after?) - i128::from(before?);
2048 Some(delta.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64)
2049}
2050
2051pub fn refresh_project_after_watcher_rescan(ctx: &AppContext) -> bool {
2052 if ctx.canonical_cache_root_opt().is_none() {
2053 return false;
2054 }
2055 let generation = ctx.configure_generation();
2056 let Some(mut status_changed) = ctx.run_if_subc_bound_generation(generation, || {
2057 if let Some(root) = ctx.canonical_cache_root_opt() {
2058 crate::cache_freshness::invalidate_verify_memo_strict(&root);
2063 }
2064 ctx.clear_pending_index_updates();
2065 ctx.reset_symbol_cache();
2066 let _ = ctx.mark_status_bar_tier2_stale();
2067 ctx.clear_tsconfig_membership_cache();
2068 true
2069 }) else {
2070 return false;
2071 };
2072
2073 status_changed |= refresh_project_corpus(ctx, "watcher overflow", false);
2074
2075 let hardened = ctx.run_if_subc_bound_generation(generation, || {
2081 let config = ctx.config();
2082 if ctx.callgraph_writer()
2083 && config.callgraph_store
2084 && ctx.pending_callgraph_store_force_token().is_none()
2085 {
2086 ctx.mark_callgraph_store_force_rebuild();
2090 }
2091 if ctx.shared_artifacts_read_only() {
2092 ctx.search_index()
2096 .write()
2097 .unwrap_or_else(std::sync::PoisonError::into_inner)
2098 .take();
2099 if config.semantic_search {
2100 ctx.semantic_index()
2101 .write()
2102 .unwrap_or_else(std::sync::PoisonError::into_inner)
2103 .take();
2104 ctx.clear_semantic_refresh_worker();
2105 }
2106 } else if config.semantic_search
2107 && ctx.semantic_refresh_sender().is_none()
2108 && ctx.semantic_index_rx().lock().is_none()
2109 {
2110 ctx.mark_pending_semantic_corpus_refresh();
2113 }
2114 });
2115 status_changed |= hardened.is_some();
2116 status_changed
2117}
2118
2119fn watcher_path_is_generated_for_callgraph(ctx: &AppContext, path: &Path) -> bool {
2120 ctx.callgraph_project_root()
2121 .is_some_and(|project_root| crate::inspect::is_generated_file(&project_root, path))
2122}
2123
2124pub fn refresh_callgraph_store_for_watcher(
2125 ctx: &AppContext,
2126 changed: &HashSet<std::path::PathBuf>,
2127) {
2128 if !ctx.heavy_root_work_allowed() {
2129 return;
2130 }
2131 let refresh_paths = changed
2132 .iter()
2133 .filter(|path| {
2134 invalidates_workspace_crate_prefix_cache(path)
2135 || (watcher_path_is_callgraph_indexed(path)
2136 && !watcher_path_is_generated_for_callgraph(ctx, path))
2137 })
2138 .cloned()
2139 .collect::<Vec<_>>();
2140 if refresh_paths.is_empty() {
2141 return;
2142 }
2143 ctx.enqueue_callgraph_store_refresh(refresh_paths);
2147}
2148
2149pub fn drain_watcher_events(ctx: &AppContext) {
2155 loop {
2156 let outcome = drain_watcher_events_bounded(ctx, WATCHER_PATH_DRAIN_BATCH_CAP);
2157 if !outcome.has_more {
2158 break;
2159 }
2160 }
2161}
2162
2163fn watcher_drain_phase_name(stage: WatcherDrainApplyPhase) -> &'static str {
2164 match stage {
2165 WatcherDrainApplyPhase::PendingTier2 => "pending_tier2",
2166 WatcherDrainApplyPhase::PendingIndexes => "pending_indexes",
2167 WatcherDrainApplyPhase::SymbolCache => "symbol_cache",
2168 WatcherDrainApplyPhase::Callgraph => "callgraph",
2169 WatcherDrainApplyPhase::SearchIndex => "search_index",
2170 WatcherDrainApplyPhase::SemanticIndex => "semantic_index",
2171 WatcherDrainApplyPhase::LspDiagnostics => "lsp_diagnostics",
2172 WatcherDrainApplyPhase::Complete => "complete",
2173 }
2174}
2175
2176fn apply_watcher_path_phase(
2177 stage: WatcherDrainApplyPhase,
2178 paths: &mut VecDeque<PathBuf>,
2179 remaining: &mut usize,
2180 started: Instant,
2181 budget: Duration,
2182 mut apply: impl FnMut(&Path),
2183) -> bool {
2184 while *remaining > 0 {
2185 let path = paths
2186 .pop_front()
2187 .expect("watcher apply phase tracks its remaining paths");
2188 {
2189 let _watchdog = WatcherDrainUnitGuard::start(stage, &path);
2190 delay_watcher_unit_for_test();
2191 wait_on_watcher_phase_commit_gate_for_test(&path);
2192 apply(&path);
2193 }
2194 paths.push_back(path);
2195 *remaining -= 1;
2196 if started.elapsed() >= budget {
2197 return false;
2198 }
2199 }
2200 true
2201}
2202
2203fn apply_callgraph_watcher_phase(
2204 ctx: &AppContext,
2205 paths: &mut VecDeque<PathBuf>,
2206 remaining: &mut usize,
2207 started: Instant,
2208 budget: Duration,
2209 enabled: bool,
2210 mut refresh: impl FnMut(&AppContext, &HashSet<PathBuf>),
2211) -> bool {
2212 let mut changed = HashSet::new();
2213 if enabled {
2214 changed.extend(
2218 paths
2219 .iter()
2220 .filter(|path| invalidates_workspace_crate_prefix_cache(path))
2221 .cloned(),
2222 );
2223 }
2224 let mut generated_skipped = 0usize;
2225 let completed = apply_watcher_path_phase(
2226 WatcherDrainApplyPhase::Callgraph,
2227 paths,
2228 remaining,
2229 started,
2230 budget,
2231 |path| {
2232 if !enabled {
2233 return;
2234 }
2235 if invalidates_workspace_crate_prefix_cache(path) {
2236 changed.insert(path.to_path_buf());
2237 } else if watcher_path_is_callgraph_indexed(path) {
2238 if watcher_path_is_generated_for_callgraph(ctx, path) {
2239 generated_skipped += 1;
2240 } else {
2241 changed.insert(path.to_path_buf());
2242 }
2243 }
2244 },
2245 );
2246 if generated_skipped > 0 {
2247 log::debug!(
2248 "callgraph refresh skipped {} generated file(s)",
2249 generated_skipped
2250 );
2251 }
2252 if !changed.is_empty() {
2253 let first = changed
2254 .iter()
2255 .min()
2256 .expect("non-empty callgraph watcher batch has a first path");
2257 let _watchdog = WatcherDrainUnitGuard::start_batch(
2258 WatcherDrainApplyPhase::Callgraph,
2259 first,
2260 changed.len(),
2261 );
2262 delay_watcher_unit_for_test();
2263 refresh(ctx, &changed);
2264 }
2265 completed
2266}
2267
2268fn next_watcher_apply_phase(stage: WatcherDrainApplyPhase) -> WatcherDrainApplyPhase {
2269 match stage {
2270 WatcherDrainApplyPhase::PendingTier2 => WatcherDrainApplyPhase::PendingIndexes,
2271 WatcherDrainApplyPhase::PendingIndexes => WatcherDrainApplyPhase::SymbolCache,
2272 WatcherDrainApplyPhase::SymbolCache => WatcherDrainApplyPhase::Callgraph,
2273 WatcherDrainApplyPhase::Callgraph => WatcherDrainApplyPhase::SearchIndex,
2274 WatcherDrainApplyPhase::SearchIndex => WatcherDrainApplyPhase::SemanticIndex,
2275 WatcherDrainApplyPhase::SemanticIndex => WatcherDrainApplyPhase::LspDiagnostics,
2276 WatcherDrainApplyPhase::LspDiagnostics => WatcherDrainApplyPhase::Complete,
2277 WatcherDrainApplyPhase::Complete => WatcherDrainApplyPhase::Complete,
2278 }
2279}
2280
2281fn apply_watcher_slice(ctx: &AppContext, state: &mut WatcherDrainSliceState, started: Instant) {
2282 let WatcherDrainPhase::Apply {
2283 mut stage,
2284 mut paths,
2285 mut remaining,
2286 oversized_inline_batch,
2287 } = std::mem::take(&mut state.phase)
2288 else {
2289 return;
2290 };
2291 let lifecycle_generation = ctx.configure_generation();
2292 if ctx
2298 .run_if_subc_bound_generation(lifecycle_generation, || ())
2299 .is_none()
2300 {
2301 state.phase = WatcherDrainPhase::Apply {
2302 stage,
2303 paths,
2304 remaining,
2305 oversized_inline_batch,
2306 };
2307 return;
2308 }
2309 if !paths.is_empty() || remaining > 0 {
2310 let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2311 ctx.invalidate_warm_verify_memo();
2312 });
2313 }
2314 let heavy_root_work_allowed = ctx.heavy_root_work_allowed();
2315 let shared_artifacts_read_only = ctx.shared_artifacts_read_only();
2316 let apply_ram_search_updates = !shared_artifacts_read_only || ctx.ram_overlay_active();
2317 let mut semantic_refresh_paths = std::mem::take(&mut state.semantic_refresh_paths);
2318 let mut status_changed = state.status_changed;
2319
2320 loop {
2321 let completed = match stage {
2322 WatcherDrainApplyPhase::PendingTier2 => apply_watcher_path_phase(
2323 WatcherDrainApplyPhase::PendingTier2,
2324 &mut paths,
2325 &mut remaining,
2326 started,
2327 WATCHER_DRAIN_SLICE_BUDGET,
2328 |path| {
2329 if heavy_root_work_allowed && ctx.inspect_writer() {
2330 let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2331 ctx.add_pending_tier2_paths([path.to_path_buf()]);
2332 });
2333 }
2334 },
2335 ),
2336 WatcherDrainApplyPhase::PendingIndexes => {
2337 let search_build_in_progress = ctx
2338 .search_index_rx()
2339 .read()
2340 .unwrap_or_else(std::sync::PoisonError::into_inner)
2341 .is_some();
2342 let semantic_build_in_progress = ctx.semantic_index_rx().lock().is_some();
2343 let semantic_corpus_refresh_in_progress = semantic_corpus_refresh_in_progress(ctx);
2344 apply_watcher_path_phase(
2345 WatcherDrainApplyPhase::PendingIndexes,
2346 &mut paths,
2347 &mut remaining,
2348 started,
2349 WATCHER_DRAIN_SLICE_BUDGET,
2350 |path| {
2351 if heavy_root_work_allowed
2352 && !shared_artifacts_read_only
2353 && search_build_in_progress
2354 {
2355 let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2356 ctx.add_pending_search_index_paths([path.to_path_buf()])
2357 });
2358 }
2359 if heavy_root_work_allowed
2360 && (!shared_artifacts_read_only || ctx.ram_overlay_active())
2361 && (semantic_build_in_progress || semantic_corpus_refresh_in_progress)
2362 && watcher_path_is_semantic_source(path)
2363 {
2364 let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2365 ctx.add_pending_semantic_index_paths([path.to_path_buf()])
2366 });
2367 }
2368 },
2369 )
2370 }
2371 WatcherDrainApplyPhase::SymbolCache => apply_watcher_path_phase(
2372 WatcherDrainApplyPhase::SymbolCache,
2373 &mut paths,
2374 &mut remaining,
2375 started,
2376 WATCHER_DRAIN_SLICE_BUDGET,
2377 |path| {
2378 if apply_ram_search_updates {
2379 let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2380 if let Ok(mut symbol_cache) = ctx.symbol_cache().write() {
2381 symbol_cache.invalidate(path);
2382 }
2383 });
2384 }
2385 },
2386 ),
2387 WatcherDrainApplyPhase::Callgraph => apply_callgraph_watcher_phase(
2388 ctx,
2389 &mut paths,
2390 &mut remaining,
2391 started,
2392 WATCHER_DRAIN_SLICE_BUDGET,
2393 heavy_root_work_allowed,
2394 |ctx, changed| {
2395 let _ = ctx.enqueue_callgraph_store_refresh_for_generation(
2396 changed.iter().cloned(),
2397 lifecycle_generation,
2398 );
2399 },
2400 ),
2401 WatcherDrainApplyPhase::SearchIndex => apply_watcher_path_phase(
2402 WatcherDrainApplyPhase::SearchIndex,
2403 &mut paths,
2404 &mut remaining,
2405 started,
2406 WATCHER_DRAIN_SLICE_BUDGET,
2407 |path| {
2408 if heavy_root_work_allowed && apply_ram_search_updates {
2409 let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2410 let mut index_ref = ctx
2411 .search_index()
2412 .write()
2413 .unwrap_or_else(std::sync::PoisonError::into_inner);
2414 if let Some(index) = index_ref.as_mut() {
2415 if path.exists() {
2416 index.update_file(path);
2417 } else {
2418 index.remove_file(path);
2419 }
2420 }
2421 });
2422 }
2423 },
2424 ),
2425 WatcherDrainApplyPhase::SemanticIndex => {
2426 let mut invalidated_paths = Vec::new();
2429 let completed = apply_watcher_path_phase(
2430 WatcherDrainApplyPhase::SemanticIndex,
2431 &mut paths,
2432 &mut remaining,
2433 started,
2434 WATCHER_DRAIN_SLICE_BUDGET,
2435 |path| {
2436 if heavy_root_work_allowed && watcher_path_is_semantic_source(path) {
2437 invalidated_paths.push(path.to_path_buf());
2438 }
2439 },
2440 );
2441
2442 if !invalidated_paths.is_empty() {
2443 let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2446 let invalidated = {
2447 let mut semantic_index_ref = ctx
2448 .semantic_index()
2449 .write()
2450 .unwrap_or_else(std::sync::PoisonError::into_inner);
2451 semantic_index_ref.as_mut().is_some_and(|index| {
2452 index.invalidate_files(&invalidated_paths);
2453 true
2454 })
2455 };
2456 if invalidated && ctx.semantic_refresh_sender().is_some() {
2457 let mut status = ctx
2458 .semantic_index_status()
2459 .write()
2460 .unwrap_or_else(std::sync::PoisonError::into_inner);
2461 if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
2462 for path in invalidated_paths {
2463 status.add_refreshing_file(path.clone());
2464 semantic_refresh_paths.push(path);
2465 }
2466 status_changed = true;
2467 }
2468 }
2469 });
2470 }
2471 completed
2472 }
2473 WatcherDrainApplyPhase::LspDiagnostics => apply_watcher_path_phase(
2474 WatcherDrainApplyPhase::LspDiagnostics,
2475 &mut paths,
2476 &mut remaining,
2477 started,
2478 WATCHER_DRAIN_SLICE_BUDGET,
2479 |path| {
2480 let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2481 if !path.exists() {
2482 status_changed |= ctx.lsp_clear_diagnostics_for_file(path);
2483 return;
2484 }
2485 let stale = ctx.lsp_mark_diagnostics_stale_for_file(path);
2486 status_changed |= stale.changed;
2487 if stale.had_entries {
2488 ctx.lsp_resync_changed_file_for_diagnostics(path);
2489 }
2490 });
2491 },
2492 ),
2493 WatcherDrainApplyPhase::Complete => true,
2494 };
2495
2496 if ctx
2504 .run_if_subc_bound_generation(lifecycle_generation, || ())
2505 .is_none()
2506 {
2507 state.status_changed = status_changed;
2508 state.semantic_refresh_paths = semantic_refresh_paths;
2509 remaining = paths.len();
2510 state.phase = WatcherDrainPhase::Apply {
2511 stage,
2512 paths,
2513 remaining,
2514 oversized_inline_batch,
2515 };
2516 return;
2517 }
2518
2519 if !completed {
2520 state.status_changed = status_changed;
2521 state.semantic_refresh_paths = semantic_refresh_paths;
2522 state.phase = WatcherDrainPhase::Apply {
2523 stage,
2524 paths,
2525 remaining,
2526 oversized_inline_batch,
2527 };
2528 return;
2529 }
2530
2531 if stage == WatcherDrainApplyPhase::Complete {
2532 break;
2533 }
2534 stage = next_watcher_apply_phase(stage);
2535 remaining = paths.len();
2536 if started.elapsed() >= WATCHER_DRAIN_SLICE_BUDGET {
2537 state.status_changed = status_changed;
2538 state.semantic_refresh_paths = semantic_refresh_paths;
2539 state.phase = WatcherDrainPhase::Apply {
2540 stage,
2541 paths,
2542 remaining,
2543 oversized_inline_batch,
2544 };
2545 return;
2546 }
2547 }
2548
2549 if !semantic_refresh_paths.is_empty() {
2550 match ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2556 ctx.semantic_refresh_sender().is_some_and(|sender| {
2557 sender
2558 .send(SemanticRefreshRequest::Files {
2559 paths: semantic_refresh_paths.clone(),
2560 })
2561 .is_ok()
2562 })
2563 }) {
2564 Some(true) => {}
2565 Some(false) => {
2566 aft::slog_warn!(
2567 "semantic refresh worker unavailable; dropping {} refreshing file(s)",
2568 semantic_refresh_paths.len()
2569 );
2570 let mut status = ctx
2571 .semantic_index_status()
2572 .write()
2573 .unwrap_or_else(std::sync::PoisonError::into_inner);
2574 for path in &semantic_refresh_paths {
2575 status.cancel_refreshing_file(path);
2576 }
2577 status_changed = true;
2578 }
2579 None => {
2580 state.status_changed = status_changed;
2581 state.semantic_refresh_paths = semantic_refresh_paths;
2582 state.phase = WatcherDrainPhase::Apply {
2583 stage: WatcherDrainApplyPhase::Complete,
2584 paths,
2585 remaining: 0,
2586 oversized_inline_batch,
2587 };
2588 return;
2589 }
2590 }
2591 }
2592
2593 aft::slog_info!("invalidated {} files", paths.len());
2594 if status_changed {
2595 ctx.status_emitter().signal(ctx.build_status_snapshot());
2596 }
2597 ctx.tick_tier2_refresh_scheduler(state.scheduler_changed_path_count);
2598 state.phase = WatcherDrainPhase::Collect;
2599 state.status_changed = false;
2600 state.scheduler_changed_path_count = 0;
2601 state.semantic_refresh_paths.clear();
2602}
2603
2604fn semantic_ready_view_publication_paths(
2610 pending: Option<BTreeSet<Vec<u8>>>,
2611) -> Option<BTreeSet<Vec<u8>>> {
2612 pending.filter(|paths| !paths.is_empty())
2613}
2614
2615fn publish_view_if_quiet(ctx: &AppContext, state: &mut WatcherDrainSliceState) {
2616 if !ctx.config().views.enabled
2617 || !matches!(state.phase, WatcherDrainPhase::Collect)
2618 || state
2619 .view_publication_due
2620 .is_none_or(|due| Instant::now() < due)
2621 {
2622 return;
2623 }
2624 let Some(root) = ctx.canonical_cache_root_opt() else {
2625 return;
2626 };
2627 let changed = state
2628 .view_publication_paths
2629 .iter()
2630 .filter_map(|path| path.strip_prefix(&root).ok())
2631 .filter_map(|path| aft::views::RelPath::from_os_path(path).ok())
2632 .map(|path| path.as_bytes().to_vec())
2633 .collect::<BTreeSet<_>>();
2634 match crate::executor::view_publication::schedule(
2635 ctx,
2636 changed,
2637 !ctx.shared_artifacts_read_only(),
2638 ) {
2639 Ok(()) => {
2640 aft::slog_info!(
2641 "content-addressed view publication scheduled root={}",
2642 root.display()
2643 );
2644 state.view_publication_paths.clear();
2645 state.view_publication_due = None;
2646 }
2647 Err(error) => {
2648 aft::slog_warn!(
2649 "content-addressed view publication failed root={} error={}",
2650 root.display(),
2651 error
2652 );
2653 state.view_publication_due = Some(Instant::now() + Duration::from_secs(1));
2654 }
2655 }
2656}
2657
2658pub fn drain_watcher_events_bounded(ctx: &AppContext, max_paths: usize) -> DrainBatchOutcome {
2659 let started = Instant::now();
2660 let configure_generation = ctx.configure_generation();
2661 let content_generation = ctx.configure_content_generation();
2662 let mut outcome = DrainBatchOutcome::default();
2663 if ctx
2667 .run_if_subc_bound_generation(configure_generation, || ())
2668 .is_none()
2669 {
2670 return outcome;
2671 }
2672 let mut state = match ctx.watcher_drain_slice().lock().take() {
2673 Some(state) if state.configure_generation == configure_generation => state,
2674 Some(mut state) if state.configure_content_generation == content_generation => {
2679 state.configure_generation = configure_generation;
2680 state
2681 }
2682 _ => WatcherDrainSliceState::new(configure_generation, content_generation),
2683 };
2684 let mut dispatch_events_received = 0usize;
2685 let mut watcher_failed = None;
2686 let mut root_deleted = false;
2687
2688 {
2689 let rx_ref = ctx.watcher_rx().lock();
2690 let Some(rx) = rx_ref.as_ref() else {
2691 ctx.tick_tier2_refresh_scheduler(0);
2692 return outcome;
2693 };
2694
2695 loop {
2696 match rx.try_recv() {
2697 Ok(WatcherDispatchEvent::Paths(paths)) => {
2698 dispatch_events_received += 1;
2699 if !state.rescan_required {
2700 state.pending_paths.extend(paths);
2701 }
2702 }
2703 Ok(WatcherDispatchEvent::RescanRequired(reason)) => {
2704 dispatch_events_received += 1;
2705 state.rescan_required = true;
2706 state.rescan_reason = reason;
2707 state.pending_paths.clear();
2708 state.phase = WatcherDrainPhase::Collect;
2709 state.semantic_refresh_paths.clear();
2710 state.scheduler_changed_path_count = 0;
2711 }
2712 Ok(WatcherDispatchEvent::IgnoreRulesChanged { path }) => {
2713 dispatch_events_received += 1;
2714 state.ignore_changed = true;
2715 log::debug!(
2716 "watcher: ignore rules changed at {}, rebuilding matcher",
2717 path.display()
2718 );
2719 if !state.rescan_required {
2720 let heavy_root_work_allowed = ctx.heavy_root_work_allowed();
2721 let _ = ctx.run_if_subc_bound_generation(configure_generation, || {
2722 if heavy_root_work_allowed {
2723 ctx.rebuild_gitignore();
2724 } else {
2725 ctx.clear_gitignore();
2726 }
2727 });
2728 }
2729 }
2730 Ok(WatcherDispatchEvent::RootDeleted) => {
2731 dispatch_events_received += 1;
2732 root_deleted = true;
2733 break;
2734 }
2735 Ok(WatcherDispatchEvent::Error(error)) => {
2736 dispatch_events_received += 1;
2737 watcher_failed = Some(error);
2738 break;
2739 }
2740 Err(crossbeam_channel::TryRecvError::Empty) => break,
2741 Err(crossbeam_channel::TryRecvError::Disconnected) => {
2742 watcher_failed = Some("watcher channel disconnected".to_string());
2743 break;
2744 }
2745 }
2746 if started.elapsed() >= WATCHER_DRAIN_SLICE_BUDGET {
2747 break;
2748 }
2749 }
2750 }
2751
2752 crate::logging::note_watcher_events(dispatch_events_received);
2753 let receiver_has_more_after_receive = ctx
2754 .watcher_rx()
2755 .lock()
2756 .as_ref()
2757 .is_some_and(|rx| !rx.is_empty());
2758
2759 if root_deleted {
2760 ctx.stop_watcher_runtime_in_background();
2761 let _ = ctx.add_degraded_reason("project_root_deleted".to_string());
2762 aft::slog_warn!(
2763 "project root deleted; dropping watcher to avoid delete-storm: {:?}",
2764 ctx.canonical_cache_root_opt()
2765 );
2766 ctx.status_emitter().signal(ctx.build_status_snapshot());
2767 return outcome;
2768 }
2769 if let Some(error) = watcher_failed {
2770 ctx.stop_watcher_runtime_in_background();
2771 let _ = ctx.add_degraded_reason("watcher_unavailable".to_string());
2772 aft::slog_warn!(
2773 "file watcher unavailable; continuing without live external-change invalidation: {}",
2774 error
2775 );
2776 ctx.status_emitter().signal(ctx.build_status_snapshot());
2777 return outcome;
2778 }
2779
2780 if state.rescan_required && receiver_has_more_after_receive {
2781 outcome.has_more = true;
2782 *ctx.watcher_drain_slice().lock() = Some(state);
2783 return outcome;
2784 }
2785
2786 if state.rescan_required {
2787 crate::logging::note_watcher_overflow();
2788 let root = ctx
2789 .canonical_cache_root_opt()
2790 .or_else(|| ctx.config().project_root.clone())
2791 .unwrap_or_else(|| PathBuf::from("<unconfigured>"));
2792 let watcher_counters = ctx.watcher_counters();
2793 watcher_counters.start_rescan();
2794 let mut rescan_reason = state.rescan_reason;
2795 loop {
2796 let interval = watcher_counters.begin_rescan(rescan_reason);
2797 wait_on_watcher_rescan_gate_for_test(ctx);
2798 let app = ctx.app();
2799 let db = app.db();
2800 crate::watcher_filter::persist_watcher_observations(
2801 &root,
2802 &watcher_counters,
2803 db.as_ref(),
2804 );
2805 if ctx.heavy_root_work_allowed() {
2806 ctx.rebuild_gitignore();
2807 } else {
2808 ctx.clear_gitignore();
2809 }
2810 let rss_before = watcher_rescan_rss_bytes();
2811 let rescan_started = Instant::now();
2812 state.status_changed |= refresh_project_after_watcher_rescan(ctx);
2813 let cost_ms = rescan_started.elapsed().as_millis().min(u64::MAX as u128) as u64;
2814 let rss_delta_bytes = watcher_rescan_rss_delta(rss_before, watcher_rescan_rss_bytes());
2815 watcher_counters.finish_rescan(cost_ms, rss_delta_bytes);
2816 crate::logging::log_watcher_rescan(
2817 &root,
2818 rescan_reason,
2819 cost_ms,
2820 rss_delta_bytes,
2821 interval.raw_events,
2822 );
2823 let Some(next_reason) = watcher_counters.finish_rescan_walk() else {
2824 break;
2825 };
2826 rescan_reason = next_reason;
2827 }
2828 state.scheduler_changed_path_count =
2829 aft::inspect::tier2_scheduler::TIER2_REFRESH_STORM_PATH_THRESHOLD + 1;
2830 if state.status_changed {
2831 ctx.status_emitter().signal(ctx.build_status_snapshot());
2832 }
2833 ctx.tick_tier2_refresh_scheduler(state.scheduler_changed_path_count);
2834 if ctx
2840 .run_if_subc_bound_generation(configure_generation, || ())
2841 .is_some()
2842 {
2843 state.rescan_required = false;
2844 state.ignore_changed = false;
2845 if ctx.config().views.enabled {
2846 state.view_publication_paths.clear();
2847 state.view_publication_due = Some(Instant::now() + VIEW_PUBLICATION_QUIET_WINDOW);
2848 }
2849 }
2850 state.status_changed = false;
2851 state.scheduler_changed_path_count = 0;
2852 } else if matches!(state.phase, WatcherDrainPhase::Collect) {
2853 let ignore_changed = state.ignore_changed;
2854 if ignore_changed {
2855 state.status_changed |= refresh_corpus_after_ignore_change(ctx);
2856 if ctx
2859 .run_if_subc_bound_generation(configure_generation, || ())
2860 .is_some()
2861 {
2862 state.ignore_changed = false;
2863 if ctx.config().views.enabled {
2864 state.view_publication_paths.clear();
2865 state.view_publication_due =
2866 Some(Instant::now() + VIEW_PUBLICATION_QUIET_WINDOW);
2867 }
2868 }
2869 }
2870
2871 if max_paths > 0 && !state.pending_paths.is_empty() {
2872 let mut unique = HashSet::new();
2873 let mut paths = VecDeque::new();
2874 while outcome.processed < max_paths {
2875 let Some(path) = state.pending_paths.pop_front() else {
2876 break;
2877 };
2878 outcome.processed += 1;
2879 if unique.insert(path.clone()) {
2880 paths.push_back(path);
2881 }
2882 }
2883 crate::logging::note_drain_paths(outcome.processed);
2884
2885 if paths.is_empty() {
2886 if state.status_changed {
2887 ctx.status_emitter().signal(ctx.build_status_snapshot());
2888 }
2889 ctx.tick_tier2_refresh_scheduler(usize::from(ignore_changed));
2890 state.status_changed = false;
2891 } else {
2892 if ctx.config().views.enabled {
2893 state.view_publication_paths.extend(paths.iter().cloned());
2894 state.view_publication_due =
2895 Some(Instant::now() + VIEW_PUBLICATION_QUIET_WINDOW);
2896 }
2897 state.path_slice_count += 1;
2898 state.scheduler_changed_path_count = if ignore_changed {
2899 paths.len().max(1)
2900 } else {
2901 paths.len()
2902 };
2903 if ctx
2904 .run_if_subc_bound_generation(configure_generation, || {
2905 ctx.mark_status_bar_tier2_stale()
2906 })
2907 .unwrap_or(false)
2908 {
2909 state.status_changed = true;
2910 }
2911 if paths.iter().any(|path| watcher_path_is_tsconfig(path))
2912 && ctx
2913 .run_if_subc_bound_generation(configure_generation, || {
2914 ctx.clear_tsconfig_membership_cache();
2915 })
2916 .is_some()
2917 {
2918 state.status_changed = true;
2919 }
2920
2921 let oversized_inline_batch = paths.len() > WATCHER_BATCH_INLINE_CAP;
2922 if oversized_inline_batch {
2923 aft::slog_warn!(
2924 "watcher batch of {} paths exceeds inline cap {}; applying bounded incremental refresh",
2925 paths.len(),
2926 WATCHER_BATCH_INLINE_CAP
2927 );
2928 }
2929 let remaining = paths.len();
2930 state.phase = WatcherDrainPhase::Apply {
2931 stage: WatcherDrainApplyPhase::PendingTier2,
2932 paths,
2933 remaining,
2934 oversized_inline_batch,
2935 };
2936 }
2937 } else if ignore_changed {
2938 if state.status_changed {
2939 ctx.status_emitter().signal(ctx.build_status_snapshot());
2940 }
2941 ctx.tick_tier2_refresh_scheduler(1);
2942 state.status_changed = false;
2943 }
2944 }
2945
2946 if matches!(state.phase, WatcherDrainPhase::Apply { .. })
2947 && started.elapsed() < WATCHER_DRAIN_SLICE_BUDGET
2948 {
2949 apply_watcher_slice(ctx, &mut state, started);
2950 }
2951
2952 publish_view_if_quiet(ctx, &mut state);
2953
2954 let receiver_has_more = ctx
2955 .watcher_rx()
2956 .lock()
2957 .as_ref()
2958 .is_some_and(|rx| !rx.is_empty());
2959 outcome.has_more = state.has_pending_work() || receiver_has_more;
2960 if state.configure_content_generation == ctx.configure_content_generation() {
2964 *ctx.watcher_drain_slice().lock() = Some(state);
2965 }
2966 outcome
2967}
2968
2969pub fn drain_lsp_events(ctx: &AppContext) {
2970 let _ = drain_lsp_events_bounded(ctx, usize::MAX);
2971}
2972
2973pub fn shutdown_idle_lsp(ctx: &AppContext) {
2977 shutdown_idle_lsp_at(ctx, Instant::now(), ctx.last_request_at());
2978}
2979
2980pub fn shutdown_idle_lsp_at(ctx: &AppContext, now: Instant, last_activity: Instant) {
2981 let ttl = ctx.config().idle.lsp_ttl();
2982 let idle = now.saturating_duration_since(last_activity);
2983 if idle < ttl {
2984 return;
2985 }
2986 let clients = {
2987 let mut lsp = ctx.lsp();
2988 if lsp.server_count() == 0 {
2989 return;
2990 }
2991 lsp.take_all_clients()
2992 };
2993 let n = clients.len();
2994 if n == 0 {
2995 return;
2996 }
2997 let root = ctx
2998 .config()
2999 .project_root
3000 .as_ref()
3001 .map(|path| path.display().to_string())
3002 .unwrap_or_else(|| "standalone".to_string());
3003 aft::slog_info!(
3004 "idle lsp reap {root}: shut down {n} server(s) after {}m (ttl {}m)",
3005 idle.as_secs() / 60,
3006 ctx.config().idle.lsp_ttl_minutes
3007 );
3008 crate::lsp::manager::LspManager::spawn_idle_lsp_reap(clients);
3009}
3010
3011pub fn drain_lsp_events_bounded(ctx: &AppContext, max_events: usize) -> DrainBatchOutcome {
3012 let drained = {
3013 let mut lsp = ctx.lsp();
3014 lsp.drain_events_bounded(max_events)
3015 };
3016 let outcome = DrainBatchOutcome {
3017 processed: drained.events.len(),
3018 has_more: drained.has_more,
3019 };
3020 let mut status_changed = drained.diagnostics_changed;
3021 for event in drained.events {
3022 match event {
3023 LspEvent::Notification {
3024 server_kind,
3025 root,
3026 method,
3027 params,
3028 } => {
3029 log::debug!(
3030 "[aft-lsp] notification {:?} {} {} {}",
3031 server_kind,
3032 root.display(),
3033 method,
3034 params.unwrap_or(serde_json::Value::Null)
3035 );
3036 }
3037 LspEvent::ServerRequest {
3038 server_kind,
3039 root,
3040 id,
3041 method,
3042 params,
3043 } => {
3044 log::debug!(
3045 "[aft-lsp] request {:?} {} {:?} {} {}",
3046 server_kind,
3047 root.display(),
3048 id,
3049 method,
3050 params.unwrap_or(serde_json::Value::Null)
3051 );
3052 }
3053 LspEvent::ServerExited {
3054 server_kind,
3055 root,
3056 reason,
3057 } => {
3058 aft::slog_info!("exited {:?} {} ({reason})", server_kind, root.display());
3059 status_changed = true;
3060 }
3061 }
3062 }
3063 if status_changed {
3064 ctx.status_emitter().signal(ctx.build_status_snapshot());
3065 }
3066 outcome
3067}
3068
3069#[cfg(test)]
3070pub(crate) fn configure_search_order_context_for_test(
3071 root: &Path,
3072 storage: &Path,
3073) -> (AppContext, std::path::PathBuf) {
3074 std::fs::write(root.join(".gitignore"), "ignored.rs\n").unwrap();
3075 std::fs::write(root.join("ignored.rs"), "fn ignored_marker() {}\n").unwrap();
3076
3077 let ctx = AppContext::new(
3078 crate::context::default_language_provider_factory(),
3079 crate::config::Config {
3080 project_root: Some(root.to_path_buf()),
3081 storage_dir: Some(storage.to_path_buf()),
3082 ..crate::config::Config::default()
3083 },
3084 );
3085 let canonical_root = std::fs::canonicalize(root).unwrap();
3086 let ignored_path = canonical_root.join("ignored.rs");
3087 ctx.set_canonical_cache_root(canonical_root.clone());
3088 ctx.set_harness(crate::harness::Harness::Opencode);
3089 ctx.enqueue_configure_maintenance(crate::context::ConfigureMaintenanceJob {
3090 generation: ctx.configure_generation(),
3091 root_path: root.to_path_buf(),
3092 canonical_cache_root: canonical_root,
3093 harness: crate::harness::Harness::Opencode,
3094 storage_root: storage.to_path_buf(),
3095 harness_dir: storage.join("opencode"),
3096 session_id: "order-test".to_string(),
3097 home_match: false,
3098 format_tool_cache_clear_needed: false,
3099 run_bash_replay: false,
3100 refresh_project_runtime: true,
3101 sync_bash_compress_flag: false,
3102 reset_filter_registry: false,
3103 clear_failed_spawns: false,
3104 warm_callgraph_store: false,
3105 supersede_search_artifact_persistence: false,
3106 supersede_callgraph_artifact_persistence: false,
3107 supersede_semantic_artifact_persistence: false,
3108 search_artifact_load_start: None,
3109 semantic_artifact_load_start: None,
3110 })
3111 .expect("test configure maintenance queue has capacity");
3112
3113 let (search_tx, search_rx) = crossbeam_channel::unbounded();
3114 search_tx
3115 .send(crate::search_index::SearchIndex::new())
3116 .unwrap();
3117 drop(search_tx);
3118 *ctx.search_index_rx()
3119 .write()
3120 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(search_rx);
3121 ctx.add_pending_search_index_paths([ignored_path.clone()]);
3122 (ctx, ignored_path)
3123}
3124
3125#[cfg(test)]
3126mod tests {
3127 use super::*;
3128 use crate::config::Config;
3129 use crate::context::{default_language_provider_factory, AppContext};
3130
3131 #[test]
3132 fn semantic_ready_publication_is_skipped_unless_paths_are_pending() {
3133 assert_eq!(semantic_ready_view_publication_paths(None), None);
3134 assert_eq!(
3135 semantic_ready_view_publication_paths(Some(BTreeSet::new())),
3136 None,
3137 "an up-to-date view must not republish when a refresh completes"
3138 );
3139 let pending = BTreeSet::from([b"src/lib.rs".to_vec()]);
3140 assert_eq!(
3141 semantic_ready_view_publication_paths(Some(pending.clone())),
3142 Some(pending),
3143 "only the paths still owed a semantic plane are published"
3144 );
3145 }
3146
3147 fn watcher_context(
3148 root: &Path,
3149 ) -> (AppContext, crossbeam_channel::Sender<WatcherDispatchEvent>) {
3150 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
3151 ctx.update_config(|config| {
3152 config.project_root = Some(root.to_path_buf());
3153 });
3154 ctx.set_canonical_cache_root(root.to_path_buf());
3155 let (tx, rx) = crossbeam_channel::unbounded();
3156 *ctx.watcher_rx().lock() = Some(rx);
3157 (ctx, tx)
3158 }
3159
3160 #[test]
3161 fn watcher_semantic_phase_batches_invalidation_into_one_retain_pass() {
3162 let root = tempfile::tempdir().unwrap();
3163 let root_path = root.path().canonicalize().unwrap();
3164 let files = (0..4)
3165 .map(|ordinal| {
3166 let file = root_path.join(format!("source_{ordinal}.rs"));
3167 std::fs::write(&file, format!("pub fn source_{ordinal}() {{}}\n")).unwrap();
3168 file
3169 })
3170 .collect::<Vec<_>>();
3171 let mut embed = |texts: Vec<String>| {
3172 Ok::<_, String>(texts.into_iter().map(|_| vec![1.0, 0.5]).collect())
3173 };
3174 let index = crate::semantic_index::SemanticIndex::build(
3175 &root_path,
3176 &files,
3177 &mut embed,
3178 files.len(),
3179 )
3180 .unwrap();
3181 assert!(index.entry_count() >= files.len());
3182
3183 let (ctx, watcher_tx) = watcher_context(&root_path);
3184 ctx.mark_subc_bound();
3185 ctx.set_heavy_root_work_allowed(true);
3186 ctx.set_cache_writer_capabilities(true, true);
3187 *ctx.semantic_index()
3188 .write()
3189 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
3190 *ctx.semantic_index_status()
3191 .write()
3192 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
3193 watcher_tx
3194 .send(WatcherDispatchEvent::Paths(files.clone()))
3195 .unwrap();
3196
3197 let outcome = drain_watcher_events_bounded(&ctx, files.len());
3198
3199 assert_eq!(outcome.processed, files.len());
3200 assert!(!outcome.has_more);
3201 let index = ctx
3202 .semantic_index()
3203 .read()
3204 .unwrap_or_else(std::sync::PoisonError::into_inner);
3205 let index = index.as_ref().unwrap();
3206 assert_eq!(index.entry_count(), 0);
3207 assert_eq!(index.removal_retain_passes_for_test(), 1);
3208 }
3209
3210 #[test]
3211 fn newer_watcher_refresh_prevents_older_configure_build_from_overwriting_disk() {
3212 let root = tempfile::tempdir().unwrap();
3213 let storage = tempfile::tempdir().unwrap();
3214 let root_path = root.path().canonicalize().unwrap();
3215 let source = root_path.join("marker.rs");
3216 std::fs::write(&source, "fn old_generation_marker() {}\n").unwrap();
3217
3218 let ctx = AppContext::new(
3219 default_language_provider_factory(),
3220 Config {
3221 project_root: Some(root_path.clone()),
3222 storage_dir: Some(storage.path().to_path_buf()),
3223 ..Config::default()
3224 },
3225 );
3226 ctx.set_canonical_cache_root(root_path.clone());
3227 ctx.set_harness(crate::harness::Harness::Opencode);
3228
3229 let project_key = ctx.memoized_artifact_cache_key(&root_path);
3230 let cache_dir =
3231 crate::search_index::resolve_cache_dir_with_key(&project_key, Some(storage.path()));
3232 let mut older_index = crate::search_index::SearchIndex::build(&root_path);
3233 let older_epoch = ctx.next_search_persist_epoch();
3234 let persist_epoch = ctx.search_persist_epoch_flag();
3235 let (older_reached_tx, older_reached_rx) = std::sync::mpsc::channel();
3236 let (older_release_tx, older_release_rx) = std::sync::mpsc::channel();
3237 let older_root = root_path.clone();
3238 let older_cache = cache_dir.clone();
3239 let older_writer = std::thread::spawn(move || {
3240 older_reached_tx.send(()).unwrap();
3241 older_release_rx.recv().unwrap();
3242 let _lock = crate::search_index::CacheLock::acquire(&older_cache, &older_root)
3243 .expect("older build should acquire the persistence lock");
3244 let _ = persist_epoch.run_if_current(older_epoch, || {
3245 older_index.write_to_disk(&older_cache, None);
3246 });
3247 });
3248 older_reached_rx
3249 .recv_timeout(Duration::from_secs(2))
3250 .expect("older configure build did not reach its persistence barrier");
3251
3252 std::fs::write(&source, "fn new_watcher_marker() {}\n").unwrap();
3253 spawn_search_corpus_refresh(&ctx, root_path.clone(), ctx.config());
3254 let refresh_rx = ctx
3255 .search_index_rx()
3256 .read()
3257 .unwrap_or_else(std::sync::PoisonError::into_inner)
3258 .as_ref()
3259 .expect("watcher refresh receiver")
3260 .clone();
3261 refresh_rx
3262 .recv_timeout(Duration::from_secs(12))
3263 .expect("watcher refresh did not complete");
3264
3265 older_release_tx.send(()).unwrap();
3266 older_writer.join().unwrap();
3267
3268 let disk = crate::search_index::SearchIndex::read_from_disk(&cache_dir, &root_path)
3269 .expect("persisted search index");
3270 assert_eq!(
3271 disk.grep("new_watcher_marker", true, &[], &[], &root_path, 10)
3272 .matches
3273 .len(),
3274 1,
3275 "newer watcher refresh must remain on disk"
3276 );
3277 assert!(
3278 disk.grep("old_generation_marker", true, &[], &[], &root_path, 10)
3279 .matches
3280 .is_empty(),
3281 "older configure build must not overwrite the newer watcher refresh"
3282 );
3283 }
3284
3285 #[test]
3286 fn watcher_phase_dequeued_before_unbind_cannot_index_after_teardown() {
3287 let temp = tempfile::tempdir().unwrap();
3288 let root = std::fs::canonicalize(temp.path()).unwrap();
3289 let root = root.as_path();
3290 let source = root.join("changed.rs");
3291 std::fs::write(&source, "fn watcher_marker() {}\n").unwrap();
3292 let (ctx, watcher_tx) = watcher_context(root);
3293 *ctx.search_index()
3294 .write()
3295 .unwrap_or_else(std::sync::PoisonError::into_inner) =
3296 Some(crate::search_index::SearchIndex::new());
3297 watcher_tx
3298 .send(WatcherDispatchEvent::Paths(vec![source.clone()]))
3299 .unwrap();
3300
3301 let ctx = Arc::new(ctx);
3302 let (reached_rx, release_tx) = install_watcher_phase_commit_gate_for_test(source.clone());
3303 let drain_ctx = Arc::clone(&ctx);
3304 let drain = std::thread::spawn(move || {
3305 while drain_watcher_events_bounded(&drain_ctx, WATCHER_PATH_DRAIN_BATCH_CAP).has_more {}
3306 });
3307 reached_rx
3308 .recv_timeout(Duration::from_secs(2))
3309 .expect("watcher phase did not reach its commit barrier");
3310 ctx.mark_subc_unbound();
3311 release_tx.send(()).unwrap();
3312 drain.join().unwrap();
3313
3314 {
3315 let search = ctx
3316 .search_index()
3317 .read()
3318 .unwrap_or_else(std::sync::PoisonError::into_inner);
3319 assert!(
3320 search
3321 .as_ref()
3322 .expect("search index")
3323 .grep("watcher_marker", true, &[], &[], root, 10)
3324 .matches
3325 .is_empty(),
3326 "watcher work dequeued before teardown must not mutate the index after unbind"
3327 );
3328 }
3329
3330 ctx.mark_subc_bound();
3333 let mut guard = 0;
3334 while drain_watcher_events_bounded(&ctx, WATCHER_PATH_DRAIN_BATCH_CAP).has_more {
3335 guard += 1;
3336 assert!(guard < 16, "rebased replay must finish");
3337 }
3338 let search = ctx
3339 .search_index()
3340 .read()
3341 .unwrap_or_else(std::sync::PoisonError::into_inner);
3342 assert_eq!(
3343 search
3344 .as_ref()
3345 .expect("search index")
3346 .grep("watcher_marker", true, &[], &[], root, 10)
3347 .matches
3348 .len(),
3349 1,
3350 "post-rebind replay must apply the retained watcher path"
3351 );
3352 }
3353
3354 #[test]
3355 fn standalone_configure_tail_precedes_completed_search_install() {
3356 let root = tempfile::tempdir().unwrap();
3357 let storage = tempfile::tempdir().unwrap();
3358 let (ctx, ignored_path) =
3359 configure_search_order_context_for_test(root.path(), storage.path());
3360 assert!(!watcher_path_is_ignored_by_current_matcher(
3361 &ctx,
3362 &ignored_path
3363 ));
3364
3365 drain_deferred_configure_maintenance(&ctx);
3366 drain_configure_warning_events(&ctx);
3367 drain_search_index_events(&ctx);
3368
3369 assert!(watcher_path_is_ignored_by_current_matcher(
3370 &ctx,
3371 &ignored_path
3372 ));
3373 assert_eq!(
3374 ctx.search_index()
3375 .read()
3376 .unwrap_or_else(std::sync::PoisonError::into_inner)
3377 .as_ref()
3378 .expect("completed search index installed")
3379 .file_count(),
3380 0,
3381 "configure must install the ignore matcher before pending paths replay"
3382 );
3383 ctx.stop_watcher_runtime();
3384 }
3385
3386 #[test]
3387 fn post_ack_semantic_ready_transition_pushes_status_changed() {
3388 let root = tempfile::tempdir().unwrap();
3389 let config = Config {
3390 project_root: Some(root.path().to_path_buf()),
3391 semantic_search: true,
3392 ..Config::default()
3393 };
3394 let ctx = AppContext::new(default_language_provider_factory(), config);
3395 ctx.set_canonical_cache_root(root.path().to_path_buf());
3396 *ctx.semantic_index_status()
3397 .write()
3398 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
3399 stage: "loading_artifacts".to_string(),
3400 files: None,
3401 entries_done: None,
3402 entries_total: None,
3403 };
3404 let (event_tx, event_rx) = crossbeam_channel::unbounded();
3405 *ctx.semantic_index_rx().lock() = Some(event_rx);
3406 let (push_tx, push_rx) = std::sync::mpsc::channel();
3407 ctx.set_progress_sender(Some(std::sync::Arc::new(Box::new(move |frame| {
3408 let _ = push_tx.send(frame);
3409 }))));
3410
3411 event_tx
3412 .send(SemanticIndexEvent::Ready(
3413 crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
3414 ))
3415 .unwrap();
3416 drain_semantic_index_events(&ctx);
3417
3418 assert!(matches!(
3419 &*ctx
3420 .semantic_index_status()
3421 .read()
3422 .unwrap_or_else(std::sync::PoisonError::into_inner),
3423 SemanticIndexStatus::Ready { .. }
3424 ));
3425 let pushed = push_rx
3426 .recv_timeout(Duration::from_secs(2))
3427 .expect("ready transition should push status_changed");
3428 assert!(matches!(
3429 pushed,
3430 crate::protocol::PushFrame::StatusChanged(_)
3431 ));
3432 }
3433
3434 #[test]
3435 fn watcher_overflow_invalidates_artifact_freshness_memo() {
3436 let root = tempfile::tempdir().unwrap();
3437 let artifact = root.path().join("semantic.bin");
3438 std::fs::write(&artifact, b"artifact").unwrap();
3439 let canonical_root = std::fs::canonicalize(root.path()).unwrap();
3440 let generation = crate::cache_freshness::artifact_generation(&artifact);
3441 let ticket = crate::cache_freshness::capture_verify_ticket(&canonical_root);
3442 assert!(
3443 crate::cache_freshness::record_verify_completed_if_unchanged(
3444 &canonical_root,
3445 crate::cache_freshness::VerifyArtifact::Semantic,
3446 generation,
3447 ticket,
3448 )
3449 );
3450 assert_eq!(
3451 crate::cache_freshness::warm_verify_plan(
3452 &canonical_root,
3453 crate::cache_freshness::VerifyArtifact::Semantic,
3454 generation,
3455 ),
3456 crate::cache_freshness::WarmVerifyPlan::Skip
3457 );
3458
3459 let ctx = AppContext::new(
3460 default_language_provider_factory(),
3461 Config {
3462 project_root: Some(canonical_root.clone()),
3463 ..Config::default()
3464 },
3465 );
3466 ctx.set_canonical_cache_root(canonical_root.clone());
3467 refresh_project_after_watcher_rescan(&ctx);
3468
3469 assert_eq!(
3470 crate::cache_freshness::warm_verify_plan(
3471 &canonical_root,
3472 crate::cache_freshness::VerifyArtifact::Semantic,
3473 generation,
3474 ),
3475 crate::cache_freshness::WarmVerifyPlan::Strict,
3476 "lost watcher events force STRICT verification: stat-first would \
3477 miss same-size, preserved-mtime edits made during the gap"
3478 );
3479 }
3480
3481 #[test]
3482 fn superseded_callgraph_worker_settles_receiver_and_allows_retry() {
3483 let root = tempfile::tempdir().unwrap();
3484 let storage = tempfile::tempdir().unwrap();
3485 std::fs::write(root.path().join("lib.rs"), "pub fn marker() {}\n").unwrap();
3486 let ctx = AppContext::new(
3487 default_language_provider_factory(),
3488 Config {
3489 project_root: Some(root.path().to_path_buf()),
3490 storage_dir: Some(storage.path().to_path_buf()),
3491 callgraph_chunk_size: 1,
3492 ..Config::default()
3493 },
3494 );
3495 let generation = ctx.configure_generation();
3496 let (worker_tx, worker_rx) = crossbeam_channel::unbounded();
3497 ctx.note_callgraph_store_rx_generation(generation);
3498 ctx.next_callgraph_store_rx_epoch();
3499 *ctx.callgraph_store_rx().lock() = Some(worker_rx);
3500
3501 drain_callgraph_store_events(&ctx);
3502 assert!(
3503 ctx.callgraph_store_rx().lock().is_some(),
3504 "an empty running receiver remains in flight"
3505 );
3506 ctx.next_callgraph_persist_epoch();
3507 worker_tx.send(CallGraphStoreBuildEvent::Settled).unwrap();
3508 drain_callgraph_store_events(&ctx);
3509 assert!(
3510 ctx.callgraph_store_rx().lock().is_none(),
3511 "a superseded worker must explicitly retire its receiver"
3512 );
3513
3514 assert!(matches!(
3515 ctx.callgraph_store_for_ops(),
3516 crate::context::CallgraphStoreAccess::Building
3517 | crate::context::CallgraphStoreAccess::Ready(_)
3518 ));
3519 assert!(
3520 ctx.callgraph_store_rx().lock().is_some()
3521 || ctx
3522 .callgraph_store()
3523 .read()
3524 .unwrap_or_else(std::sync::PoisonError::into_inner)
3525 .is_some(),
3526 "a later operation must be able to retry the callgraph build"
3527 );
3528 }
3529
3530 #[test]
3531 fn failed_forced_callgraph_build_preserves_durable_demand() {
3532 let root = tempfile::tempdir().unwrap();
3533 let ctx = AppContext::new(
3534 default_language_provider_factory(),
3535 Config {
3536 project_root: Some(root.path().to_path_buf()),
3537 ..Config::default()
3538 },
3539 );
3540 let force_token = ctx.mark_callgraph_store_force_rebuild();
3541 assert_eq!(ctx.pending_callgraph_store_force_token(), Some(force_token));
3542
3543 let generation = ctx.configure_generation();
3544 let (tx, rx) = crossbeam_channel::unbounded();
3545 ctx.note_callgraph_store_rx_generation(generation);
3546 ctx.next_callgraph_store_rx_epoch();
3547 *ctx.callgraph_store_rx().lock() = Some(rx);
3548 tx.send(CallGraphStoreBuildEvent::Settled).unwrap();
3549 drain_callgraph_store_events(&ctx);
3550
3551 assert!(
3552 ctx.pending_callgraph_store_force_token().is_some(),
3553 "the current failed forced build must preserve retry demand"
3554 );
3555 assert!(ctx.callgraph_store_rx().lock().is_none());
3556 }
3557
3558 #[test]
3559 fn newer_forced_callgraph_demand_survives_older_publication() {
3560 let root = tempfile::tempdir().unwrap();
3561 let storage = tempfile::tempdir().unwrap();
3562 let source = root.path().join("lib.rs");
3563 std::fs::write(&source, "pub fn marker() {}\n").unwrap();
3564 let project_root = std::fs::canonicalize(root.path()).unwrap();
3565 let ctx = AppContext::new(
3566 default_language_provider_factory(),
3567 Config {
3568 project_root: Some(project_root.clone()),
3569 storage_dir: Some(storage.path().to_path_buf()),
3570 ..Config::default()
3571 },
3572 );
3573 ctx.set_canonical_cache_root(project_root.clone());
3574 let project_key = crate::search_index::artifact_cache_key(&project_root);
3575 crate::root_cache::configure_artifact_access(&project_root, &project_key, false);
3576 let (store, _stats) = CallGraphStore::cold_build_with_lease_chunked(
3577 ctx.callgraph_store_dir(),
3578 project_root,
3579 &[source],
3580 1,
3581 )
3582 .unwrap();
3583 let older = ctx.mark_callgraph_store_force_rebuild();
3584 let generation = ctx.configure_generation();
3585 let (tx, rx) = crossbeam_channel::unbounded();
3586 ctx.note_callgraph_store_rx_generation(generation);
3587 ctx.next_callgraph_store_rx_epoch();
3588 *ctx.callgraph_store_rx().lock() = Some(rx);
3589 tx.send(CallGraphStoreBuildEvent::Ready {
3590 store,
3591 fulfilled_force_token: Some(older),
3592 publication_epoch: ctx.callgraph_persist_epoch_flag().current(),
3593 })
3594 .unwrap();
3595 let newer = ctx.mark_callgraph_store_force_rebuild();
3596
3597 drain_callgraph_store_events(&ctx);
3598
3599 assert!(ctx.callgraph_store().read().unwrap().is_some());
3600 assert_eq!(ctx.pending_callgraph_store_force_token(), Some(newer));
3601 assert!(matches!(
3602 ctx.callgraph_store_for_ops(),
3603 crate::context::CallgraphStoreAccess::Building
3604 ));
3605 assert!(ctx.callgraph_store_rx().lock().is_some());
3606
3607 let deadline = Instant::now() + Duration::from_secs(10);
3608 while ctx.pending_callgraph_store_force_token().is_some() {
3609 drain_callgraph_store_events(&ctx);
3610 assert!(
3611 Instant::now() < deadline,
3612 "newer forced callgraph rebuild did not publish"
3613 );
3614 std::thread::sleep(Duration::from_millis(5));
3615 }
3616 assert!(ctx.callgraph_store().read().unwrap().is_some());
3617 }
3618
3619 #[test]
3620 fn callgraph_ready_without_published_pointer_settles_and_preserves_pending_paths() {
3621 let root = tempfile::tempdir().unwrap();
3622 let storage = tempfile::tempdir().unwrap();
3623 let source = root.path().join("lib.rs");
3624 std::fs::write(&source, "pub fn marker() {}\n").unwrap();
3625 let project_root = std::fs::canonicalize(root.path()).unwrap();
3626 let ctx = AppContext::new(
3627 default_language_provider_factory(),
3628 Config {
3629 project_root: Some(project_root.clone()),
3630 storage_dir: Some(storage.path().to_path_buf()),
3631 callgraph_chunk_size: 1,
3632 ..Config::default()
3633 },
3634 );
3635 ctx.set_canonical_cache_root(project_root.clone());
3636 let project_key = crate::search_index::artifact_cache_key(&project_root);
3637 crate::root_cache::configure_artifact_access(&project_root, &project_key, false);
3638 let callgraph_dir = ctx.callgraph_store_dir();
3639 let (store, _stats) = CallGraphStore::cold_build_with_lease_chunked(
3640 callgraph_dir.clone(),
3641 project_root,
3642 &[source],
3643 1,
3644 )
3645 .unwrap();
3646 let pointer = callgraph_dir.join(format!("{}.current", store.project_key()));
3647 std::fs::remove_file(pointer).unwrap();
3648
3649 let pending = root.path().join("pending.rs");
3650 ctx.add_pending_callgraph_store_paths([pending.clone()]);
3651 let generation = ctx.configure_generation();
3652 let (tx, rx) = crossbeam_channel::unbounded();
3653 {
3654 let mut receiver = ctx.callgraph_store_rx().lock();
3655 ctx.note_callgraph_store_rx_generation(generation);
3656 ctx.next_callgraph_store_rx_epoch();
3657 *receiver = Some(rx);
3658 }
3659 tx.send(CallGraphStoreBuildEvent::Ready {
3660 store,
3661 fulfilled_force_token: None,
3662 publication_epoch: ctx.callgraph_persist_epoch_flag().current(),
3663 })
3664 .unwrap();
3665 drop(tx);
3666
3667 drain_callgraph_store_events(&ctx);
3668
3669 assert!(
3670 ctx.callgraph_store_rx().lock().is_none(),
3671 "Ready is terminal even when reopening the pointer fails"
3672 );
3673 assert_eq!(
3674 ctx.take_pending_callgraph_store_paths(),
3675 vec![pending],
3676 "failed reopen must preserve pending watcher paths for the retry"
3677 );
3678 }
3679
3680 #[test]
3681 fn callgraph_ready_transition_schedules_tier2_dead_code_rescan() {
3682 let root = tempfile::tempdir().unwrap();
3688 let storage = tempfile::tempdir().unwrap();
3689 let source = root.path().join("lib.rs");
3690 std::fs::write(&source, "pub fn marker() {}\n").unwrap();
3691 let project_root = std::fs::canonicalize(root.path()).unwrap();
3692 let ctx = AppContext::new(
3693 default_language_provider_factory(),
3694 Config {
3695 project_root: Some(project_root.clone()),
3696 storage_dir: Some(storage.path().to_path_buf()),
3697 callgraph_chunk_size: 1,
3698 ..Config::default()
3699 },
3700 );
3701 ctx.set_canonical_cache_root(project_root.clone());
3702 let project_key = crate::search_index::artifact_cache_key(&project_root);
3703 crate::root_cache::configure_artifact_access(&project_root, &project_key, false);
3704 let (store, _stats) = CallGraphStore::cold_build_with_lease_chunked(
3705 ctx.callgraph_store_dir(),
3706 project_root,
3707 &[source],
3708 1,
3709 )
3710 .unwrap();
3711
3712 assert!(
3713 !ctx.tier2_pull_demand_pending(),
3714 "no tier2 pull demand before the callgraph store is ready"
3715 );
3716
3717 let generation = ctx.configure_generation();
3718 let (tx, rx) = crossbeam_channel::unbounded();
3719 ctx.note_callgraph_store_rx_generation(generation);
3720 ctx.next_callgraph_store_rx_epoch();
3721 *ctx.callgraph_store_rx().lock() = Some(rx);
3722 tx.send(CallGraphStoreBuildEvent::Ready {
3723 store,
3724 fulfilled_force_token: None,
3725 publication_epoch: ctx.callgraph_persist_epoch_flag().current(),
3726 })
3727 .unwrap();
3728 drop(tx);
3729
3730 drain_callgraph_store_events(&ctx);
3731
3732 assert!(
3733 ctx.callgraph_store().read().unwrap().is_some(),
3734 "the ready callgraph store must install"
3735 );
3736 assert!(
3737 ctx.tier2_pull_demand_pending(),
3738 "the callgraph-ready transition must schedule a tier2 refresh pull so dead_code is rescanned against the ready store"
3739 );
3740 }
3741
3742 #[test]
3743 fn stale_callgraph_receiver_cannot_clear_newer_same_generation_receiver() {
3744 let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
3745 let root = tempfile::tempdir().unwrap();
3746 let ctx = Arc::new(AppContext::new(
3747 default_language_provider_factory(),
3748 Config {
3749 project_root: Some(root.path().to_path_buf()),
3750 ..Config::default()
3751 },
3752 ));
3753 let generation = ctx.configure_generation();
3754 let (old_tx, old_rx) = crossbeam_channel::unbounded();
3755 ctx.note_callgraph_store_rx_generation(generation);
3756 ctx.next_callgraph_store_rx_epoch();
3757 *ctx.callgraph_store_rx().lock() = Some(old_rx);
3758 old_tx.send(CallGraphStoreBuildEvent::Settled).unwrap();
3759 let (reached, release) = install_artifact_drain_commit_gate_for_test(&ctx);
3760
3761 let drain_ctx = Arc::clone(&ctx);
3762 let drain = std::thread::spawn(move || drain_callgraph_store_events(&drain_ctx));
3763 reached
3764 .recv_timeout(Duration::from_secs(2))
3765 .expect("stale callgraph receiver was not dequeued");
3766
3767 let (_new_tx, new_rx) = crossbeam_channel::unbounded();
3768 ctx.note_callgraph_store_rx_generation(generation);
3769 ctx.next_callgraph_store_rx_epoch();
3770 *ctx.callgraph_store_rx().lock() = Some(new_rx);
3771 release.send(()).unwrap();
3772 drain.join().unwrap();
3773
3774 assert!(
3775 ctx.callgraph_store_rx().lock().is_some(),
3776 "a stale callgraph drain must not clear the replacement receiver"
3777 );
3778 assert!(
3779 ctx.pending_callgraph_store_force_token().is_none(),
3780 "a stale terminal event must not create force demand for its replacement"
3781 );
3782 }
3783
3784 #[test]
3785 fn dequeued_search_completion_cannot_clear_newer_same_generation_receiver() {
3786 let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
3787 let root = tempfile::tempdir().unwrap();
3788 let ctx = Arc::new(AppContext::new(
3789 default_language_provider_factory(),
3790 Config {
3791 project_root: Some(root.path().to_path_buf()),
3792 ..Config::default()
3793 },
3794 ));
3795 let generation = ctx.configure_generation();
3796 let (old_tx, old_rx) = crossbeam_channel::unbounded();
3797 old_tx
3798 .send(crate::search_index::SearchIndex::new())
3799 .unwrap();
3800 ctx.note_search_index_rx_generation(generation);
3801 ctx.next_search_index_rx_epoch();
3802 *ctx.search_index_rx()
3803 .write()
3804 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(old_rx);
3805 let (reached, release) = install_artifact_drain_commit_gate_for_test(&ctx);
3806
3807 let drain_ctx = Arc::clone(&ctx);
3808 let drain = std::thread::spawn(move || drain_search_index_events(&drain_ctx));
3809 reached
3810 .recv_timeout(Duration::from_secs(2))
3811 .expect("old search completion was not dequeued");
3812
3813 let (_new_tx, new_rx) = crossbeam_channel::unbounded();
3814 ctx.note_search_index_rx_generation(generation);
3815 ctx.next_search_index_rx_epoch();
3816 *ctx.search_index_rx()
3817 .write()
3818 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(new_rx);
3819 release.send(()).unwrap();
3820 drain.join().unwrap();
3821
3822 assert!(
3823 ctx.search_index()
3824 .read()
3825 .unwrap_or_else(std::sync::PoisonError::into_inner)
3826 .is_none(),
3827 "an older same-generation receiver must not publish after replacement"
3828 );
3829 assert!(
3830 ctx.search_index_rx()
3831 .read()
3832 .unwrap_or_else(std::sync::PoisonError::into_inner)
3833 .is_some(),
3834 "an older same-generation drain must not clear the newer receiver"
3835 );
3836 }
3837
3838 #[test]
3839 fn rescan_arriving_while_unbound_executes_fully_after_rebind() {
3840 let temp = tempfile::tempdir().unwrap();
3841 let root = std::fs::canonicalize(temp.path()).unwrap();
3842 let (ctx, watcher_tx) = watcher_context(&root);
3843 let artifact = root.join("artifact.bin");
3846 std::fs::write(&artifact, b"artifact").unwrap();
3847 let generation = crate::cache_freshness::artifact_generation(&artifact);
3848 let ticket = crate::cache_freshness::capture_verify_ticket(&root);
3849 assert!(
3850 crate::cache_freshness::record_verify_completed_if_unchanged(
3851 &root,
3852 crate::cache_freshness::VerifyArtifact::Search,
3853 generation,
3854 ticket,
3855 )
3856 );
3857 watcher_tx
3858 .send(WatcherDispatchEvent::RescanRequired(RescanReason::Unknown))
3859 .unwrap();
3860
3861 ctx.mark_subc_unbound();
3864 drain_watcher_events_bounded(&ctx, WATCHER_PATH_DRAIN_BATCH_CAP);
3865 assert_eq!(
3866 crate::cache_freshness::warm_verify_plan(
3867 &root,
3868 crate::cache_freshness::VerifyArtifact::Search,
3869 generation,
3870 ),
3871 crate::cache_freshness::WarmVerifyPlan::Skip,
3872 "an unbound drain must not run (or half-run) the rescan"
3873 );
3874
3875 ctx.mark_subc_bound();
3878 let mut guard = 0;
3879 while drain_watcher_events_bounded(&ctx, WATCHER_PATH_DRAIN_BATCH_CAP).has_more {
3880 guard += 1;
3881 assert!(guard < 16, "rescan replay must finish");
3882 }
3883 assert_eq!(
3884 crate::cache_freshness::warm_verify_plan(
3885 &root,
3886 crate::cache_freshness::VerifyArtifact::Search,
3887 generation,
3888 ),
3889 crate::cache_freshness::WarmVerifyPlan::Strict,
3890 "the post-rebind drain must execute the retained rescan strictly"
3891 );
3892 assert!(
3893 !ctx.watcher_drain_slice()
3894 .lock()
3895 .as_ref()
3896 .is_some_and(|state| state.rescan_required),
3897 "a fully-bound rescan must be acknowledged"
3898 );
3899 }
3900
3901 #[test]
3902 fn budget_interrupted_stage_rewinds_when_unbind_lands_mid_stage() {
3903 let temp = tempfile::tempdir().unwrap();
3908 let root = std::fs::canonicalize(temp.path()).unwrap();
3909 let first = root.join("first.rs");
3910 let second = root.join("second.rs");
3911 std::fs::write(&first, "fn first_marker() {}\n").unwrap();
3912 std::fs::write(&second, "fn second_marker() {}\n").unwrap();
3913 let (ctx, watcher_tx) = watcher_context(&root);
3914 *ctx.search_index()
3915 .write()
3916 .unwrap_or_else(std::sync::PoisonError::into_inner) =
3917 Some(crate::search_index::SearchIndex::new());
3918 watcher_tx
3919 .send(WatcherDispatchEvent::Paths(vec![
3920 first.clone(),
3921 second.clone(),
3922 ]))
3923 .unwrap();
3924
3925 let ctx = Arc::new(ctx);
3928 let (reached_rx, release_tx) = install_watcher_phase_commit_gate_for_test(second.clone());
3929 let drain_ctx = Arc::clone(&ctx);
3930 let drain = std::thread::spawn(move || {
3931 while drain_watcher_events_bounded(&drain_ctx, WATCHER_PATH_DRAIN_BATCH_CAP).has_more {}
3932 });
3933 reached_rx
3934 .recv_timeout(Duration::from_secs(2))
3935 .expect("watcher phase did not reach the second path");
3936 ctx.mark_subc_unbound();
3937 release_tx.send(()).unwrap();
3938 drain.join().unwrap();
3939
3940 ctx.mark_subc_bound();
3941 let mut guard = 0;
3942 while drain_watcher_events_bounded(&ctx, WATCHER_PATH_DRAIN_BATCH_CAP).has_more {
3943 guard += 1;
3944 assert!(guard < 32, "rebased replay must finish");
3945 }
3946 let search = ctx
3947 .search_index()
3948 .read()
3949 .unwrap_or_else(std::sync::PoisonError::into_inner);
3950 let index = search.as_ref().expect("search index");
3951 for (marker, path) in [("first_marker", &first), ("second_marker", &second)] {
3952 assert_eq!(
3953 index.grep(marker, true, &[], &[], &root, 10).matches.len(),
3954 1,
3955 "post-rebind replay must apply {} ({})",
3956 marker,
3957 path.display()
3958 );
3959 }
3960 }
3961
3962 #[test]
3963 fn pending_paths_retained_across_transient_unbind_repair_next_installed_index() {
3964 let temp = tempfile::tempdir().unwrap();
3965 let root = std::fs::canonicalize(temp.path()).unwrap();
3966 let source = root.join("edited-during-unbind.rs");
3967 std::fs::write(&source, "fn repaired_marker() {}\n").unwrap();
3968 let ctx = AppContext::new(
3969 default_language_provider_factory(),
3970 Config {
3971 project_root: Some(root.clone()),
3972 ..Config::default()
3973 },
3974 );
3975 ctx.set_canonical_cache_root(root.clone());
3976
3977 ctx.add_pending_search_index_paths([source.clone()]);
3982 ctx.mark_subc_unbound();
3983 ctx.cancel_unbound_artifact_work();
3984 assert!(ctx.search_index_rx().read().unwrap().is_none());
3985
3986 ctx.mark_subc_bound();
3989 let (tx, rx) = crossbeam_channel::unbounded();
3990 let mut stale_index = crate::search_index::SearchIndex::build(&root);
3991 stale_index.remove_file(&source);
3993 tx.send(stale_index).unwrap();
3994 ctx.install_search_index_rx(rx, ctx.configure_generation());
3995
3996 drain_search_index_events(&ctx);
3997
3998 let search = ctx
3999 .search_index()
4000 .read()
4001 .unwrap_or_else(std::sync::PoisonError::into_inner);
4002 assert_eq!(
4003 search
4004 .as_ref()
4005 .expect("installed search index")
4006 .grep("repaired_marker", true, &[], &[], &root, 10)
4007 .matches
4008 .len(),
4009 1,
4010 "retained pending path must repair the stale artifact on install"
4011 );
4012 }
4013
4014 #[test]
4015 fn disconnected_search_refresh_clears_nonready_index_and_preserves_pending_paths() {
4016 let root = tempfile::tempdir().unwrap();
4017 let ctx = AppContext::new(
4018 default_language_provider_factory(),
4019 Config {
4020 project_root: Some(root.path().to_path_buf()),
4021 ..Config::default()
4022 },
4023 );
4024 let mut index = crate::search_index::SearchIndex::new();
4025 index.ready = false;
4026 *ctx.search_index()
4027 .write()
4028 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
4029 let pending = root.path().join("pending.rs");
4030 ctx.add_pending_search_index_paths([pending.clone()]);
4031 let generation = ctx.configure_generation();
4032 let (tx, rx) = crossbeam_channel::unbounded();
4033 drop(tx);
4034 ctx.install_search_index_rx(rx, generation);
4035
4036 drain_search_index_events(&ctx);
4037
4038 assert!(
4039 ctx.search_index()
4040 .read()
4041 .unwrap_or_else(std::sync::PoisonError::into_inner)
4042 .is_none(),
4043 "a disconnected refresh must not leave a permanently non-ready index"
4044 );
4045 assert!(ctx.search_index_rx().read().unwrap().is_none());
4046 assert_eq!(ctx.take_pending_search_index_paths(), vec![pending]);
4047 }
4048
4049 #[test]
4050 fn search_index_disconnect_reschedule_caps_at_one_per_generation() {
4051 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
4056 assert!(
4057 ctx.allow_search_index_disconnect_reschedule(),
4058 "the first automatic replacement in a generation must be allowed"
4059 );
4060 assert!(
4061 !ctx.allow_search_index_disconnect_reschedule(),
4062 "a second automatic replacement in the same generation must be denied"
4063 );
4064 assert!(
4065 !ctx.search_index_query_reload_allowed(),
4066 "queued queries must use fallback during the retry cooldown"
4067 );
4068 ctx.advance_configure_generation();
4069 assert!(
4070 ctx.search_index_query_reload_allowed(),
4071 "a new configure generation must clear the retry cooldown"
4072 );
4073 assert!(
4074 ctx.allow_search_index_disconnect_reschedule(),
4075 "advancing the configure generation must reset the replacement cap"
4076 );
4077 }
4078
4079 #[test]
4080 fn lost_search_load_disconnect_schedules_one_replacement_that_installs() {
4081 let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4082 let temp = tempfile::tempdir().unwrap();
4083 let root = std::fs::canonicalize(temp.path()).unwrap();
4084 let storage = temp.path().join("storage");
4085 std::fs::create_dir_all(&storage).unwrap();
4086 std::fs::write(
4087 root.join("lib.rs"),
4088 "pub fn LostLoadNeedle() -> bool { true }\n",
4089 )
4090 .unwrap();
4091 let ctx = AppContext::new(
4092 default_language_provider_factory(),
4093 Config {
4094 project_root: Some(root.clone()),
4095 storage_dir: Some(storage),
4096 search_index: true,
4097 semantic_search: false,
4098 callgraph_store: false,
4099 ..Config::default()
4100 },
4101 );
4102 ctx.set_canonical_cache_root(root.clone());
4103
4104 let mut stranded = crate::search_index::SearchIndex::new();
4109 stranded.ready = false;
4110 *ctx.search_index()
4111 .write()
4112 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stranded);
4113 let generation = ctx.configure_generation();
4114 let (tx, rx) = crossbeam_channel::unbounded::<crate::search_index::SearchIndex>();
4115 drop(tx); ctx.install_search_index_rx(rx, generation);
4117
4118 drain_search_index_events(&ctx);
4119
4120 assert!(
4121 ctx.search_index_rx().read().unwrap().is_some(),
4122 "a lost load must schedule a replacement search-index load (fresh receiver installed)"
4123 );
4124
4125 let deadline = std::time::Instant::now() + Duration::from_secs(20);
4128 loop {
4129 drain_search_index_events(&ctx);
4130 let ready = ctx
4131 .search_index()
4132 .read()
4133 .unwrap_or_else(std::sync::PoisonError::into_inner)
4134 .as_ref()
4135 .is_some_and(|index| index.ready);
4136 if ready {
4137 break;
4138 }
4139 assert!(
4140 std::time::Instant::now() < deadline,
4141 "replacement search-index load did not install before the deadline"
4142 );
4143 std::thread::sleep(Duration::from_millis(20));
4144 }
4145
4146 let matches = ctx
4147 .search_index()
4148 .read()
4149 .unwrap_or_else(std::sync::PoisonError::into_inner)
4150 .as_ref()
4151 .expect("installed replacement index")
4152 .grep("LostLoadNeedle", true, &[], &[], &root, 10)
4153 .matches
4154 .len();
4155 assert_eq!(
4156 matches, 1,
4157 "the replacement index must actually serve queries"
4158 );
4159
4160 let mut stranded_again = crate::search_index::SearchIndex::new();
4164 stranded_again.ready = false;
4165 *ctx.search_index()
4166 .write()
4167 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stranded_again);
4168 let (tx2, rx2) = crossbeam_channel::unbounded::<crate::search_index::SearchIndex>();
4169 drop(tx2);
4170 ctx.install_search_index_rx(rx2, ctx.configure_generation());
4171
4172 drain_search_index_events(&ctx);
4173
4174 assert!(
4175 ctx.search_index_rx().read().unwrap().is_none(),
4176 "the cap must prevent a second automatic replacement in the same generation"
4177 );
4178 }
4179
4180 #[test]
4181 fn dequeued_search_completion_cannot_publish_after_unbind() {
4182 let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4183 let root = tempfile::tempdir().unwrap();
4184 let ctx = Arc::new(AppContext::new(
4185 default_language_provider_factory(),
4186 Config {
4187 project_root: Some(root.path().to_path_buf()),
4188 ..Config::default()
4189 },
4190 ));
4191 ctx.set_canonical_cache_root(root.path().to_path_buf());
4192 let generation = ctx.configure_generation();
4193 let (tx, rx) = crossbeam_channel::unbounded();
4194 tx.send(crate::search_index::SearchIndex::new()).unwrap();
4195 ctx.note_search_index_rx_generation(generation);
4196 *ctx.search_index_rx()
4197 .write()
4198 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(rx);
4199 let (reached, release) = install_artifact_drain_commit_gate_for_test(&ctx);
4200
4201 let drain_ctx = Arc::clone(&ctx);
4202 let drain = std::thread::spawn(move || drain_search_index_events(&drain_ctx));
4203 reached
4204 .recv_timeout(Duration::from_secs(2))
4205 .expect("search completion was not dequeued");
4206 ctx.mark_subc_unbound();
4207 release.send(()).unwrap();
4208 drain.join().unwrap();
4209
4210 assert!(
4211 ctx.search_index()
4212 .read()
4213 .unwrap_or_else(std::sync::PoisonError::into_inner)
4214 .is_none(),
4215 "a dequeued completion must re-check lifecycle admission at commit"
4216 );
4217 }
4218
4219 #[test]
4220 fn dequeued_semantic_completion_cannot_publish_after_unbind() {
4221 let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4222 let root = tempfile::tempdir().unwrap();
4223 let ctx = Arc::new(AppContext::new(
4224 default_language_provider_factory(),
4225 Config {
4226 project_root: Some(root.path().to_path_buf()),
4227 semantic_search: true,
4228 ..Config::default()
4229 },
4230 ));
4231 ctx.set_canonical_cache_root(root.path().to_path_buf());
4232 let generation = ctx.configure_generation();
4233 let (tx, rx) = crossbeam_channel::unbounded();
4234 tx.send(SemanticIndexEvent::Ready(
4235 crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
4236 ))
4237 .unwrap();
4238 ctx.note_semantic_index_rx_generation(generation);
4239 *ctx.semantic_index_rx().lock() = Some(rx);
4240 let (reached, release) = install_artifact_drain_commit_gate_for_test(&ctx);
4241
4242 let drain_ctx = Arc::clone(&ctx);
4243 let drain = std::thread::spawn(move || drain_semantic_index_events(&drain_ctx));
4244 reached
4245 .recv_timeout(Duration::from_secs(2))
4246 .expect("semantic completion was not dequeued");
4247 ctx.mark_subc_unbound();
4248 release.send(()).unwrap();
4249 drain.join().unwrap();
4250
4251 assert!(
4252 ctx.semantic_index()
4253 .read()
4254 .unwrap_or_else(std::sync::PoisonError::into_inner)
4255 .is_none(),
4256 "a dequeued completion must re-check lifecycle admission at commit"
4257 );
4258 }
4259
4260 #[test]
4261 fn dequeued_semantic_refresh_cannot_publish_after_unbind() {
4262 let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4263 let root = tempfile::tempdir().unwrap();
4264 let ctx = Arc::new(AppContext::new(
4265 default_language_provider_factory(),
4266 Config {
4267 project_root: Some(root.path().to_path_buf()),
4268 semantic_search: true,
4269 ..Config::default()
4270 },
4271 ));
4272 ctx.set_canonical_cache_root(root.path().to_path_buf());
4273 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
4274 let (event_tx, event_rx) = crossbeam_channel::unbounded();
4275 ctx.install_semantic_refresh_worker_for_build_epoch(
4276 request_tx,
4277 event_rx,
4278 Arc::new(Mutex::new(None)),
4279 ctx.semantic_index_rx_epoch(),
4280 );
4281 event_tx
4282 .send(SemanticRefreshEvent::CorpusCompleted {
4283 index: crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
4284 changed: 0,
4285 added: 0,
4286 deleted: 0,
4287 total_processed: 0,
4288 })
4289 .unwrap();
4290 let (reached, release) = install_artifact_drain_commit_gate_for_test(&ctx);
4291
4292 let drain_ctx = Arc::clone(&ctx);
4293 let drain = std::thread::spawn(move || drain_semantic_refresh_events(&drain_ctx));
4294 reached
4295 .recv_timeout(Duration::from_secs(2))
4296 .expect("semantic refresh completion was not dequeued");
4297 ctx.mark_subc_unbound();
4298 release.send(()).unwrap();
4299 drain.join().unwrap();
4300
4301 assert!(
4302 ctx.semantic_index()
4303 .read()
4304 .unwrap_or_else(std::sync::PoisonError::into_inner)
4305 .is_none(),
4306 "a dequeued refresh must re-check lifecycle admission at commit"
4307 );
4308 }
4309
4310 #[test]
4311 fn dequeued_semantic_refresh_cannot_publish_after_bound_replacement() {
4312 let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4313 let root = tempfile::tempdir().unwrap();
4314 let ctx = Arc::new(AppContext::new(
4315 default_language_provider_factory(),
4316 Config {
4317 project_root: Some(root.path().to_path_buf()),
4318 semantic_search: true,
4319 ..Config::default()
4320 },
4321 ));
4322 ctx.set_canonical_cache_root(root.path().to_path_buf());
4323 let (old_request_tx, _old_request_rx) = crossbeam_channel::unbounded();
4324 let (old_event_tx, old_event_rx) = crossbeam_channel::unbounded();
4325 ctx.install_semantic_refresh_worker_for_build_epoch(
4326 old_request_tx,
4327 old_event_rx,
4328 Arc::new(Mutex::new(None)),
4329 ctx.semantic_index_rx_epoch(),
4330 );
4331 old_event_tx
4332 .send(SemanticRefreshEvent::CorpusCompleted {
4333 index: crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
4334 changed: 0,
4335 added: 0,
4336 deleted: 0,
4337 total_processed: 0,
4338 })
4339 .unwrap();
4340 let (reached, release) = install_artifact_drain_commit_gate_for_test(&ctx);
4341
4342 let drain_ctx = Arc::clone(&ctx);
4343 let drain = std::thread::spawn(move || drain_semantic_refresh_events(&drain_ctx));
4344 reached
4345 .recv_timeout(Duration::from_secs(2))
4346 .expect("old semantic refresh completion was not dequeued");
4347
4348 let (new_request_tx, _new_request_rx) = crossbeam_channel::unbounded();
4349 let (_new_event_tx, new_event_rx) = crossbeam_channel::unbounded();
4350 ctx.install_semantic_refresh_worker_for_build_epoch(
4351 new_request_tx,
4352 new_event_rx,
4353 Arc::new(Mutex::new(None)),
4354 ctx.semantic_index_rx_epoch(),
4355 );
4356 release.send(()).unwrap();
4357 drain.join().unwrap();
4358
4359 assert!(
4360 ctx.semantic_index()
4361 .read()
4362 .unwrap_or_else(std::sync::PoisonError::into_inner)
4363 .is_none(),
4364 "an old refresh event must not be relabeled as the replacement worker"
4365 );
4366 assert!(
4367 ctx.semantic_refresh_event_rx().lock().is_some(),
4368 "the stale drain must not clear the replacement refresh receiver"
4369 );
4370 }
4371
4372 #[test]
4373 fn current_semantic_refresh_disconnect_requests_full_reload() {
4374 let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4375 crate::commands::configure::set_semantic_refresh_restart_result_for_test(Some(true));
4376 struct RestartOverrideReset;
4377 impl Drop for RestartOverrideReset {
4378 fn drop(&mut self) {
4379 crate::commands::configure::set_semantic_refresh_restart_result_for_test(None);
4380 }
4381 }
4382 let _reset = RestartOverrideReset;
4383
4384 let root = tempfile::tempdir().unwrap();
4385 let ctx = AppContext::new(
4386 default_language_provider_factory(),
4387 Config {
4388 project_root: Some(root.path().to_path_buf()),
4389 semantic_search: true,
4390 ..Config::default()
4391 },
4392 );
4393 ctx.set_canonical_cache_root(root.path().to_path_buf());
4394 *ctx.semantic_index()
4395 .write()
4396 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(
4397 crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
4398 );
4399 *ctx.semantic_index_status()
4400 .write()
4401 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
4402 let (_build_tx, build_rx) = crossbeam_channel::unbounded();
4403 let disconnected_build_epoch =
4404 ctx.install_semantic_index_rx(build_rx, ctx.configure_generation());
4405 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
4406 let (event_tx, event_rx) = crossbeam_channel::unbounded();
4407 ctx.install_semantic_refresh_worker_for_build_epoch(
4408 request_tx,
4409 event_rx,
4410 Arc::new(Mutex::new(None)),
4411 disconnected_build_epoch,
4412 );
4413 drop(event_tx);
4414
4415 drain_semantic_refresh_events(&ctx);
4416
4417 assert_eq!(
4418 crate::commands::configure::semantic_refresh_restart_attempts_for_test(),
4419 1
4420 );
4421 assert!(
4422 ctx.semantic_index()
4423 .read()
4424 .unwrap_or_else(std::sync::PoisonError::into_inner)
4425 .is_none(),
4426 "recovery must force a full reload rather than retain an index without a refresh worker"
4427 );
4428 assert!(ctx.semantic_refresh_event_rx().lock().is_none());
4429 assert!(
4430 ctx.semantic_index_rx().lock().is_none(),
4431 "a build receiver from the disconnected refresh generation must not be adopted"
4432 );
4433 assert!(matches!(
4434 &*ctx
4435 .semantic_index_status()
4436 .read()
4437 .unwrap_or_else(std::sync::PoisonError::into_inner),
4438 SemanticIndexStatus::Building { stage, .. } if stage == "restarting_refresh_worker"
4439 ));
4440 }
4441
4442 #[test]
4443 fn finished_refresh_worker_wakes_maintenance_after_last_event_is_drained() {
4444 let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4445 crate::commands::configure::set_semantic_refresh_restart_result_for_test(Some(true));
4446 struct RestartOverrideReset;
4447 impl Drop for RestartOverrideReset {
4448 fn drop(&mut self) {
4449 crate::commands::configure::set_semantic_refresh_restart_result_for_test(None);
4450 }
4451 }
4452 let _reset = RestartOverrideReset;
4453
4454 let root = tempfile::tempdir().unwrap();
4455 let ctx = AppContext::new(
4456 default_language_provider_factory(),
4457 Config {
4458 project_root: Some(root.path().to_path_buf()),
4459 semantic_search: true,
4460 ..Config::default()
4461 },
4462 );
4463 ctx.set_canonical_cache_root(root.path().to_path_buf());
4464 *ctx.semantic_index()
4465 .write()
4466 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(
4467 crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
4468 );
4469 *ctx.semantic_index_status()
4470 .write()
4471 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
4472
4473 let (request_tx, _request_rx) = crossbeam_channel::unbounded();
4474 let (event_tx, event_rx) = crossbeam_channel::unbounded();
4475 let (event_sent_tx, event_sent_rx) = crossbeam_channel::bounded(1);
4476 let (finish_tx, finish_rx) = crossbeam_channel::bounded(1);
4477 let worker = std::thread::spawn(move || {
4478 event_tx
4479 .send(SemanticRefreshEvent::Started { paths: Vec::new() })
4480 .unwrap();
4481 event_sent_tx.send(()).unwrap();
4482 finish_rx.recv().unwrap();
4483 });
4484 let worker_slot = Arc::new(Mutex::new(Some(worker)));
4485 ctx.install_semantic_refresh_worker_for_build_epoch(
4486 request_tx,
4487 event_rx,
4488 Arc::clone(&worker_slot),
4489 ctx.semantic_index_rx_epoch(),
4490 );
4491 event_sent_rx.recv_timeout(Duration::from_secs(2)).unwrap();
4492 drain_semantic_refresh_events(&ctx);
4493 assert!(
4494 !ctx.completion_drains_have_work(),
4495 "a live worker with an empty event queue should not cause maintenance churn"
4496 );
4497
4498 finish_tx.send(()).unwrap();
4499 let deadline = Instant::now() + Duration::from_secs(2);
4500 while !ctx.completion_drains_have_work() {
4501 assert!(
4502 Instant::now() < deadline,
4503 "finished refresh worker did not wake maintenance"
4504 );
4505 std::thread::yield_now();
4506 }
4507 drain_semantic_refresh_events(&ctx);
4508
4509 assert_eq!(
4510 crate::commands::configure::semantic_refresh_restart_attempts_for_test(),
4511 1
4512 );
4513 assert!(ctx.semantic_refresh_event_rx().lock().is_none());
4514 }
4515
4516 #[test]
4517 fn semantic_disconnect_does_not_overwrite_replacement_loader_state() {
4518 let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4519 let root = tempfile::tempdir().unwrap();
4520 let ctx = Arc::new(AppContext::new(
4521 default_language_provider_factory(),
4522 Config {
4523 project_root: Some(root.path().to_path_buf()),
4524 semantic_search: true,
4525 ..Config::default()
4526 },
4527 ));
4528 *ctx.semantic_index()
4529 .write()
4530 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(
4531 crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
4532 );
4533 *ctx.semantic_index_status()
4534 .write()
4535 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
4536 let (old_request_tx, _old_request_rx) = crossbeam_channel::unbounded();
4537 let (old_event_tx, old_event_rx) = crossbeam_channel::unbounded();
4538 ctx.install_semantic_refresh_worker_for_build_epoch(
4539 old_request_tx,
4540 old_event_rx,
4541 Arc::new(Mutex::new(None)),
4542 ctx.semantic_index_rx_epoch(),
4543 );
4544 drop(old_event_tx);
4545 let (reached, release) = install_semantic_refresh_recovery_gate_for_test(&ctx);
4546
4547 let drain_ctx = Arc::clone(&ctx);
4548 let drain = std::thread::spawn(move || drain_semantic_refresh_events(&drain_ctx));
4549 reached
4550 .recv_timeout(Duration::from_secs(2))
4551 .expect("old worker was not cleared before recovery");
4552
4553 let (build_tx, build_rx) = crossbeam_channel::unbounded::<SemanticIndexEvent>();
4554 ctx.install_semantic_index_rx(build_rx, ctx.configure_generation());
4555 let (new_request_tx, _new_request_rx) = crossbeam_channel::unbounded();
4556 let (new_event_tx, new_event_rx) = crossbeam_channel::unbounded();
4557 ctx.install_semantic_refresh_worker_for_build_epoch(
4558 new_request_tx,
4559 new_event_rx,
4560 Arc::new(Mutex::new(None)),
4561 ctx.semantic_index_rx_epoch(),
4562 );
4563 *ctx.semantic_index_status()
4564 .write()
4565 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
4566 stage: "replacement_loader".to_string(),
4567 files: None,
4568 entries_done: None,
4569 entries_total: None,
4570 };
4571 release.send(()).unwrap();
4572 drain.join().unwrap();
4573
4574 assert!(ctx.semantic_index_rx().lock().is_some());
4575 assert!(ctx.semantic_refresh_event_rx().lock().is_some());
4576 assert!(matches!(
4577 &*ctx
4578 .semantic_index_status()
4579 .read()
4580 .unwrap_or_else(std::sync::PoisonError::into_inner),
4581 SemanticIndexStatus::Building { stage, .. } if stage == "replacement_loader"
4582 ));
4583 drop(build_tx);
4584 drop(new_event_tx);
4585 }
4586
4587 #[test]
4588 fn semantic_disconnect_preserves_newer_build_receiver_before_refresh_install() {
4589 let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4590 let root = tempfile::tempdir().unwrap();
4591 let ctx = Arc::new(AppContext::new(
4592 default_language_provider_factory(),
4593 Config {
4594 project_root: Some(root.path().to_path_buf()),
4595 semantic_search: true,
4596 ..Config::default()
4597 },
4598 ));
4599 ctx.set_canonical_cache_root(root.path().to_path_buf());
4600 let (old_request_tx, _old_request_rx) = crossbeam_channel::unbounded();
4601 let (old_event_tx, old_event_rx) = crossbeam_channel::unbounded();
4602 ctx.install_semantic_refresh_worker_for_build_epoch(
4603 old_request_tx,
4604 old_event_rx,
4605 Arc::new(Mutex::new(None)),
4606 ctx.semantic_index_rx_epoch(),
4607 );
4608 drop(old_event_tx);
4609 let (reached, release) = install_semantic_refresh_recovery_gate_for_test(&ctx);
4610
4611 let drain_ctx = Arc::clone(&ctx);
4612 let drain = std::thread::spawn(move || drain_semantic_refresh_events(&drain_ctx));
4613 reached
4614 .recv_timeout(Duration::from_secs(2))
4615 .expect("semantic refresh recovery did not reach the post-clear gate");
4616
4617 let (_replacement_tx, replacement_rx) = crossbeam_channel::unbounded();
4618 ctx.install_semantic_index_rx(replacement_rx, ctx.configure_generation());
4619 *ctx.semantic_index_status()
4620 .write()
4621 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
4622 stage: "replacement_loader".to_string(),
4623 files: None,
4624 entries_done: None,
4625 entries_total: None,
4626 };
4627 release.send(()).unwrap();
4628 drain.join().unwrap();
4629
4630 assert!(
4631 ctx.semantic_index_rx().lock().is_some(),
4632 "the old disconnect must not retire a newer build receiver while its refresh worker is being installed"
4633 );
4634 assert!(matches!(
4635 &*ctx
4636 .semantic_index_status()
4637 .read()
4638 .unwrap_or_else(std::sync::PoisonError::into_inner),
4639 SemanticIndexStatus::Building { stage, .. } if stage == "replacement_loader"
4640 ));
4641 }
4642
4643 #[test]
4644 fn delayed_semantic_retry_targets_same_generation_replacement_worker() {
4645 let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4646 SEMANTIC_REFRESH_RETRY_DELAY_OVERRIDE_MS.store(20, Ordering::SeqCst);
4647 struct RetryDelayReset;
4648 impl Drop for RetryDelayReset {
4649 fn drop(&mut self) {
4650 SEMANTIC_REFRESH_RETRY_DELAY_OVERRIDE_MS.store(u64::MAX, Ordering::SeqCst);
4651 }
4652 }
4653 let _delay_reset = RetryDelayReset;
4654
4655 let root = tempfile::tempdir().unwrap();
4656 let ctx = AppContext::new(
4657 default_language_provider_factory(),
4658 Config {
4659 project_root: Some(root.path().to_path_buf()),
4660 semantic_search: true,
4661 ..Config::default()
4662 },
4663 );
4664 let (old_request_tx, _old_request_rx) = crossbeam_channel::unbounded();
4665 let (_old_event_tx, old_event_rx) = crossbeam_channel::unbounded();
4666 ctx.install_semantic_refresh_worker_for_build_epoch(
4667 old_request_tx,
4668 old_event_rx,
4669 Arc::new(Mutex::new(None)),
4670 ctx.semantic_index_rx_epoch(),
4671 );
4672 let retry_path = root.path().join("retry.rs");
4673 assert!(schedule_semantic_refresh_retry(
4674 &ctx,
4675 vec![retry_path.clone()],
4676 "transient embedding failure",
4677 ));
4678
4679 let (new_request_tx, new_request_rx) = crossbeam_channel::unbounded();
4680 let (_new_event_tx, new_event_rx) = crossbeam_channel::unbounded();
4681 ctx.install_semantic_refresh_worker_for_build_epoch(
4682 new_request_tx,
4683 new_event_rx,
4684 Arc::new(Mutex::new(None)),
4685 ctx.semantic_index_rx_epoch(),
4686 );
4687
4688 let request = new_request_rx
4689 .recv_timeout(Duration::from_secs(2))
4690 .expect("retry should resolve the replacement sender when it fires");
4691 assert!(matches!(
4692 request,
4693 SemanticRefreshRequest::Files { paths } if paths == vec![retry_path]
4694 ));
4695 }
4696
4697 #[test]
4698 fn watcher_drain_batch_cap_yields_with_events_remaining() {
4699 let temp = tempfile::tempdir().unwrap();
4700 let (ctx, tx) = watcher_context(temp.path());
4701 let cap = 3;
4702 for index in 0..(cap * 2 + 1) {
4703 tx.send(WatcherDispatchEvent::Paths(vec![temp
4704 .path()
4705 .join(format!("file-{index}.rs"))]))
4706 .unwrap();
4707 }
4708
4709 let first = drain_watcher_events_bounded(&ctx, cap);
4710
4711 assert_eq!(first.processed, cap);
4712 assert!(first.has_more);
4713 assert_eq!(ctx.pending_tier2_paths().len(), cap);
4714 }
4715
4716 #[test]
4717 fn watcher_drain_requeues_until_all_events_are_applied() {
4718 let temp = tempfile::tempdir().unwrap();
4719 let (ctx, tx) = watcher_context(temp.path());
4720 let cap = 4;
4721 let total = cap * 2 + 3;
4722 for index in 0..total {
4723 tx.send(WatcherDispatchEvent::Paths(vec![temp
4724 .path()
4725 .join(format!("file-{index}.rs"))]))
4726 .unwrap();
4727 }
4728
4729 let mut processed = 0;
4730 loop {
4731 let outcome = drain_watcher_events_bounded(&ctx, cap);
4732 assert!(outcome.processed <= cap);
4733 processed += outcome.processed;
4734 if !outcome.has_more {
4735 break;
4736 }
4737 }
4738
4739 assert_eq!(processed, total);
4740 assert_eq!(ctx.pending_tier2_paths().len(), total);
4741 }
4742}
4743
4744#[cfg(test)]
4745mod watcher_slice_tests {
4746 use super::*;
4747 use crate::config::Config;
4748 use crate::context::{default_language_provider_factory, AppContext};
4749
4750 fn context_with_watcher(
4751 root: &Path,
4752 ) -> (AppContext, crossbeam_channel::Sender<WatcherDispatchEvent>) {
4753 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
4754 ctx.update_config(|config| config.project_root = Some(root.to_path_buf()));
4755 ctx.set_canonical_cache_root(root.to_path_buf());
4756 let (tx, rx) = crossbeam_channel::unbounded();
4757 *ctx.watcher_rx().lock() = Some(rx);
4758 (ctx, tx)
4759 }
4760
4761 #[test]
4762 fn watcher_batch_schedules_view_publication_within_its_own_quiet_window() {
4763 let temp = tempfile::tempdir().unwrap();
4764 let (ctx, tx) = context_with_watcher(temp.path());
4765 ctx.update_config(|config| config.views.enabled = true);
4766 tx.send(WatcherDispatchEvent::Paths(vec![temp
4767 .path()
4768 .join("changed.rs")]))
4769 .unwrap();
4770 let applied_at = Instant::now();
4771
4772 let outcome = drain_watcher_events_bounded(&ctx, 1);
4773
4774 assert_eq!(outcome.processed, 1);
4775 let due = ctx
4776 .watcher_drain_slice()
4777 .lock()
4778 .as_ref()
4779 .and_then(|state| state.view_publication_due)
4780 .expect("watcher batch should schedule view publication");
4781 assert!(due > applied_at);
4782 assert!(
4783 due <= applied_at + VIEW_PUBLICATION_QUIET_WINDOW + Duration::from_millis(250),
4784 "view publication due {due:?} exceeded its own quiet window from {applied_at:?}"
4785 );
4786 assert!(
4787 due < applied_at + crate::commands::configure::semantic_refresh_quiet_window(),
4788 "view publication inherited the semantic refresh quiet window"
4789 );
4790 }
4791
4792 fn set_watcher_unit_test_seam(delay: Duration, thresholds: Option<(Duration, Duration)>) {
4793 WATCHER_UNIT_TEST_DELAY.with(|value| value.set(delay));
4794 WATCHER_UNIT_TEST_THRESHOLDS.with(|value| value.set(thresholds));
4795 WATCHER_UNIT_TEST_LOGS.with(|logs| logs.borrow_mut().clear());
4796 }
4797
4798 fn clear_watcher_unit_test_seam() {
4799 set_watcher_unit_test_seam(Duration::ZERO, None);
4800 }
4801
4802 #[test]
4803 fn callgraph_phase_batches_all_indexed_paths_into_one_refresh() {
4804 let temp = tempfile::tempdir().unwrap();
4805 let (ctx, _) = context_with_watcher(temp.path());
4806 let generated = temp.path().join("compiled.ts");
4807 std::fs::write(&generated, "// @generated\nexport const compiled = true;\n").unwrap();
4808 let mut paths = VecDeque::from([
4809 temp.path().join("a.rs"),
4810 temp.path().join("b.ts"),
4811 generated,
4812 temp.path().join("ignored.txt"),
4813 temp.path().join("Cargo.toml"),
4814 ]);
4815 let mut remaining = paths.len();
4816 let mut refreshed = Vec::new();
4817
4818 let completed = apply_callgraph_watcher_phase(
4819 &ctx,
4820 &mut paths,
4821 &mut remaining,
4822 Instant::now(),
4823 WATCHER_DRAIN_SLICE_BUDGET,
4824 true,
4825 |_, changed| refreshed.push(changed.clone()),
4826 );
4827
4828 assert!(completed);
4829 assert_eq!(remaining, 0);
4830 assert_eq!(refreshed.len(), 1);
4831 assert_eq!(refreshed[0].len(), 3);
4832 assert!(refreshed[0].contains(&temp.path().join("Cargo.toml")));
4833 }
4834
4835 #[test]
4836 fn callgraph_phase_includes_manifest_before_budget_yield() {
4837 let temp = tempfile::tempdir().unwrap();
4838 let (ctx, _) = context_with_watcher(temp.path());
4839 let source = temp.path().join("first.rs");
4840 let manifest = temp.path().join("Cargo.toml");
4841 let mut paths = VecDeque::from([source.clone(), manifest.clone()]);
4842 let mut remaining = paths.len();
4843 let mut refreshed = Vec::new();
4844 set_watcher_unit_test_seam(Duration::from_millis(2), None);
4845
4846 let completed = apply_callgraph_watcher_phase(
4847 &ctx,
4848 &mut paths,
4849 &mut remaining,
4850 Instant::now(),
4851 Duration::from_millis(1),
4852 true,
4853 |_, changed| refreshed.push(changed.clone()),
4854 );
4855 clear_watcher_unit_test_seam();
4856
4857 assert!(!completed);
4858 assert_eq!(remaining, 1);
4859 assert_eq!(refreshed.len(), 1);
4860 assert_eq!(refreshed[0], HashSet::from([source, manifest]));
4861 }
4862
4863 #[test]
4864 fn callgraph_phase_flushes_once_per_slice_before_requeue() {
4865 let temp = tempfile::tempdir().unwrap();
4866 let (ctx, _) = context_with_watcher(temp.path());
4867 let mut paths =
4868 VecDeque::from([temp.path().join("first.rs"), temp.path().join("second.rs")]);
4869 let mut remaining = paths.len();
4870 let mut refreshed = Vec::new();
4871 set_watcher_unit_test_seam(Duration::from_millis(2), None);
4872
4873 let first_completed = apply_callgraph_watcher_phase(
4874 &ctx,
4875 &mut paths,
4876 &mut remaining,
4877 Instant::now(),
4878 Duration::from_millis(1),
4879 true,
4880 |_, changed| refreshed.push(changed.clone()),
4881 );
4882 assert!(!first_completed);
4883 assert_eq!(remaining, 1);
4884 assert_eq!(refreshed.len(), 1, "the yielded slice must flush its batch");
4885
4886 let second_completed = apply_callgraph_watcher_phase(
4887 &ctx,
4888 &mut paths,
4889 &mut remaining,
4890 Instant::now(),
4891 Duration::from_millis(1),
4892 true,
4893 |_, changed| refreshed.push(changed.clone()),
4894 );
4895 clear_watcher_unit_test_seam();
4896
4897 assert!(!second_completed);
4898 assert_eq!(remaining, 0);
4899 assert_eq!(refreshed.len(), 2);
4900 assert!(refreshed.iter().all(|batch| batch.len() == 1));
4901 }
4902
4903 #[test]
4904 fn watcher_unit_watchdog_names_slow_phase_and_path() {
4905 let temp = tempfile::tempdir().unwrap();
4906 let slow_path = temp.path().join("slow.rs");
4907 let mut paths = VecDeque::from([slow_path.clone()]);
4908 let mut remaining = 1;
4909 set_watcher_unit_test_seam(
4910 Duration::from_millis(5),
4911 Some((Duration::from_millis(1), Duration::from_secs(1))),
4912 );
4913
4914 let completed = apply_watcher_path_phase(
4915 WatcherDrainApplyPhase::SemanticIndex,
4916 &mut paths,
4917 &mut remaining,
4918 Instant::now(),
4919 WATCHER_DRAIN_SLICE_BUDGET,
4920 |_| {},
4921 );
4922 let logs = WATCHER_UNIT_TEST_LOGS.with(|logs| logs.borrow().clone());
4923 clear_watcher_unit_test_seam();
4924
4925 assert!(completed);
4926 assert_eq!(logs.len(), 1);
4927 assert!(logs[0].contains("watcher drain unit exceeded 5s"));
4928 assert!(logs[0].contains("phase=semantic_index"));
4929 assert!(logs[0].contains(&format!("path={}", slow_path.display())));
4930 }
4931
4932 #[test]
4933 fn watcher_callgraph_refresh_defers_when_ready_store_is_unavailable() {
4934 let temp = tempfile::tempdir().unwrap();
4935 let (ctx, _) = context_with_watcher(temp.path());
4936 ctx.update_config(|config| config.callgraph_store = true);
4937 ctx.set_cache_role(false, None);
4938 let source = temp.path().join("pending.rs");
4939 let generated = temp.path().join("compiled.ts");
4940 std::fs::write(&generated, "// @generated\nexport const compiled = true;\n").unwrap();
4941
4942 refresh_callgraph_store_for_watcher(&ctx, &HashSet::from([source.clone(), generated]));
4943
4944 let deadline = Instant::now() + Duration::from_secs(12);
4945 loop {
4946 let pending = ctx.take_pending_callgraph_store_paths();
4947 if !pending.is_empty() {
4948 assert_eq!(pending, vec![source]);
4949 break;
4950 }
4951 assert!(
4952 Instant::now() < deadline,
4953 "refresh worker did not defer the unavailable store batch"
4954 );
4955 std::thread::sleep(Duration::from_millis(5));
4956 }
4957 }
4958
4959 #[test]
4960 fn watcher_callgraph_refresh_keeps_worktree_paths_pending() {
4961 let temp = tempfile::tempdir().unwrap();
4962 let (ctx, _) = context_with_watcher(temp.path());
4963 ctx.update_config(|config| config.callgraph_store = true);
4964 ctx.set_cache_role(true, None);
4965 let source = temp.path().join("worktree.rs");
4966
4967 refresh_callgraph_store_for_watcher(&ctx, &HashSet::from([source.clone()]));
4968
4969 assert_eq!(ctx.take_pending_callgraph_store_paths(), vec![source]);
4970 }
4971
4972 #[test]
4973 fn watcher_single_dispatch_event_is_sliced_by_path_count() {
4974 let temp = tempfile::tempdir().unwrap();
4975 let (ctx, tx) = context_with_watcher(temp.path());
4976 let path_count = 1_024;
4977 let path_cap = 256;
4978 tx.send(WatcherDispatchEvent::Paths(
4979 (0..path_count)
4980 .map(|index| temp.path().join(format!("single-event-{index}.txt")))
4981 .collect(),
4982 ))
4983 .unwrap();
4984
4985 let mut slices = 0;
4986 let mut processed = 0;
4987 loop {
4988 let outcome = drain_watcher_events_bounded(&ctx, path_cap);
4989 slices += 1;
4990 processed += outcome.processed;
4991 assert!(outcome.processed <= path_cap);
4992 if !outcome.has_more {
4993 break;
4994 }
4995 assert!(
5000 slices <= path_count,
5001 "single dispatch event did not converge"
5002 );
5003 }
5004
5005 assert_eq!(processed, path_count);
5006 assert!(
5009 slices >= 4,
5010 "expected at least 4 path-budgeted slices, got {slices}"
5011 );
5012 assert_eq!(ctx.pending_tier2_paths().len(), path_count);
5013 }
5014
5015 #[test]
5016 fn watcher_overflows_during_rescan_coalesce_one_followup_without_filter_stall() {
5017 let temp = tempfile::tempdir().unwrap();
5018 let root = std::fs::canonicalize(temp.path()).unwrap();
5019 let ctx = Arc::new(AppContext::new(
5020 default_language_provider_factory(),
5021 Config::default(),
5022 ));
5023 ctx.update_config(|config| config.project_root = Some(root.clone()));
5024 ctx.set_canonical_cache_root(root.clone());
5025 let counters = ctx.watcher_counters();
5026 let matcher = Arc::new(std::sync::RwLock::new(None));
5027 let matcher_generation = Arc::new(AtomicU64::new(0));
5028 let shutdown = Arc::new(AtomicBool::new(false));
5029 let (dispatch_tx, dispatch_rx) = crossbeam_channel::bounded(8);
5030 *ctx.watcher_rx().lock() = Some(dispatch_rx);
5031 let (raw_sender_tx, raw_sender_rx) = crossbeam_channel::bounded(1);
5032 let filter_shutdown = Arc::clone(&shutdown);
5033 let filter_root = root.clone();
5034 let filter = std::thread::spawn(move || {
5035 crate::watcher_filter::run_watcher_thread(
5036 crate::watcher_filter::WatcherFilterConfig::new(filter_root, None),
5037 Vec::new(),
5038 matcher,
5039 matcher_generation,
5040 dispatch_tx,
5041 filter_shutdown,
5042 move |_root, _extra, raw_tx| {
5043 raw_sender_tx.send(raw_tx).unwrap();
5044 Ok::<(), std::io::Error>(())
5045 },
5046 );
5047 });
5048 let raw_tx = raw_sender_rx.recv_timeout(Duration::from_secs(2)).unwrap();
5049 let overflow =
5050 || notify::Event::new(notify::EventKind::Other).set_flag(notify::event::Flag::Rescan);
5051 raw_tx.send(Ok(overflow())).unwrap();
5052 let deadline = Instant::now() + Duration::from_secs(2);
5053 while (counters.snapshot().overflows_total != 1
5054 || ctx
5055 .watcher_rx()
5056 .lock()
5057 .as_ref()
5058 .is_none_or(crossbeam_channel::Receiver::is_empty))
5059 && Instant::now() < deadline
5060 {
5061 std::thread::yield_now();
5062 }
5063 assert_eq!(counters.snapshot().overflows_total, 1);
5064 assert!(ctx
5065 .watcher_rx()
5066 .lock()
5067 .as_ref()
5068 .is_some_and(|receiver| !receiver.is_empty()));
5069 let _ = crate::watcher_filter::take_watcher_overflow_logs_for_test();
5070
5071 let (rescan_reached, release_rescan) = install_watcher_rescan_gate_for_test(&ctx);
5072 let drain_ctx = Arc::clone(&ctx);
5073 let drain = std::thread::spawn(move || drain_watcher_events(&drain_ctx));
5074 rescan_reached
5075 .recv_timeout(Duration::from_secs(2))
5076 .expect("first rescan did not reach the test gate");
5077
5078 raw_tx.send(Ok(overflow())).unwrap();
5079 raw_tx.send(Ok(overflow())).unwrap();
5080 let callback_deadline = Instant::now() + Duration::from_millis(50);
5081 let mut overflow_lines = Vec::new();
5082 while overflow_lines
5083 .iter()
5084 .filter(|line: &&String| {
5085 line.contains(&format!("root={}", root.display()))
5086 && line.contains("rescan_in_progress=true")
5087 })
5088 .count()
5089 != 2
5090 && Instant::now() < callback_deadline
5091 {
5092 overflow_lines.extend(crate::watcher_filter::take_watcher_overflow_logs_for_test());
5093 std::thread::yield_now();
5094 }
5095 assert_eq!(
5096 overflow_lines
5097 .iter()
5098 .filter(|line| {
5099 line.contains(&format!("root={}", root.display()))
5100 && line.contains("rescan_in_progress=true")
5101 })
5102 .count(),
5103 2,
5104 "the filter must finish attributing drops without waiting for the rescan: {overflow_lines:?}"
5105 );
5106 assert_eq!(counters.snapshot().overflows_total, 3);
5107 assert!(ctx
5108 .watcher_rx()
5109 .lock()
5110 .as_ref()
5111 .is_some_and(crossbeam_channel::Receiver::is_empty));
5112 release_rescan.send(()).unwrap();
5113 drain.join().unwrap();
5114
5115 let snapshot = counters.snapshot();
5116 assert_eq!(snapshot.overflows_during_rescan, 2);
5117 assert_eq!(snapshot.rescans_unknown_total, 2);
5118 assert!(!counters.rescan_in_progress());
5119 assert!(ctx
5120 .watcher_rx()
5121 .lock()
5122 .as_ref()
5123 .is_some_and(crossbeam_channel::Receiver::is_empty));
5124
5125 shutdown.store(true, Ordering::SeqCst);
5126 drop(raw_tx);
5127 filter.join().unwrap();
5128 }
5129
5130 #[test]
5131 fn watcher_rescan_supersedes_pending_paths() {
5132 let temp = tempfile::tempdir().unwrap();
5133 let (ctx, tx) = context_with_watcher(temp.path());
5134 tx.send(WatcherDispatchEvent::Paths(
5135 (0..5)
5136 .map(|index| temp.path().join(format!("before-rescan-{index}.txt")))
5137 .collect(),
5138 ))
5139 .unwrap();
5140 let first = drain_watcher_events_bounded(&ctx, 2);
5141 assert_eq!(first.processed, 2);
5142 assert!(first.has_more);
5143 assert_eq!(ctx.watcher_drain_pending_path_count(), 3);
5144
5145 let counters = ctx.watcher_counters();
5146 counters.note_raw_event();
5147 counters.note_raw_event();
5148 tx.send(WatcherDispatchEvent::RescanRequired(
5149 RescanReason::KernelDropped,
5150 ))
5151 .unwrap();
5152 let (second, lines) =
5153 crate::logging::capture_index_events(|| drain_watcher_events_bounded(&ctx, 2));
5154
5155 assert_eq!(second.processed, 0);
5156 assert!(!second.has_more);
5157 assert_eq!(ctx.watcher_drain_pending_path_count(), 0);
5158 let snapshot = counters.snapshot();
5159 assert_eq!(snapshot.raw_events_total, 2);
5160 assert_eq!(snapshot.raw_events_since_last_rescan, 0);
5161 assert_eq!(snapshot.rescans_kernel_dropped_total, 1);
5162 assert_eq!(snapshot.rescans_user_dropped_total, 0);
5163 assert_eq!(snapshot.rescans_unknown_total, 0);
5164 assert!(snapshot.last_rescan_at_ms.is_some());
5165 assert!(snapshot.last_rescan_cost_ms.is_some());
5166 let root_name = temp
5171 .path()
5172 .file_name()
5173 .unwrap()
5174 .to_string_lossy()
5175 .into_owned();
5176 let rescan_lines = lines
5177 .iter()
5178 .filter(|line| {
5179 line.contains("kind=watcher_rescan plane=watcher") && line.contains(&root_name)
5180 })
5181 .collect::<Vec<_>>();
5182 assert_eq!(rescan_lines.len(), 1, "lines: {lines:?}");
5183 assert!(rescan_lines[0].contains("reason=kernel_dropped"));
5184 assert!(rescan_lines[0].contains("raw_events_since_last=2"));
5185 }
5186
5187 #[test]
5188 fn watcher_lifecycle_generation_change_rebases_continuation() {
5189 let temp = tempfile::tempdir().unwrap();
5190 let (ctx, tx) = context_with_watcher(temp.path());
5191 tx.send(WatcherDispatchEvent::Paths(
5192 (0..5)
5193 .map(|index| temp.path().join(format!("old-generation-{index}.txt")))
5194 .collect(),
5195 ))
5196 .unwrap();
5197 let first = drain_watcher_events_bounded(&ctx, 2);
5198 assert_eq!(first.processed, 2);
5199 assert!(first.has_more);
5200
5201 ctx.advance_configure_generation();
5205 let second = drain_watcher_events_bounded(&ctx, 2);
5206 assert_eq!(second.processed, 2);
5207 assert!(second.has_more);
5208
5209 let mut guard = 0;
5210 while drain_watcher_events_bounded(&ctx, 2).has_more {
5211 guard += 1;
5212 assert!(guard < 16, "rebased continuation must finish draining");
5213 }
5214 assert_eq!(ctx.watcher_drain_pending_path_count(), 0);
5215 assert_eq!(
5216 ctx.pending_tier2_paths().len(),
5217 5,
5218 "every path survives the lifecycle-only generation change"
5219 );
5220 }
5221
5222 #[test]
5223 fn watcher_content_generation_change_discards_continuation() {
5224 let temp = tempfile::tempdir().unwrap();
5225 let (ctx, tx) = context_with_watcher(temp.path());
5226 tx.send(WatcherDispatchEvent::Paths(
5227 (0..5)
5228 .map(|index| temp.path().join(format!("old-content-{index}.txt")))
5229 .collect(),
5230 ))
5231 .unwrap();
5232 let first = drain_watcher_events_bounded(&ctx, 2);
5233 assert_eq!(first.processed, 2);
5234 assert!(first.has_more);
5235
5236 ctx.configure_content_generation_flag()
5239 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5240 ctx.advance_configure_generation();
5241 let second = drain_watcher_events_bounded(&ctx, 2);
5242
5243 assert_eq!(second.processed, 0);
5244 assert!(!second.has_more);
5245 assert_eq!(ctx.watcher_drain_pending_path_count(), 0);
5246 assert_eq!(ctx.pending_tier2_paths().len(), 2);
5247 }
5248
5249 fn install_search_index(
5250 ctx: &AppContext,
5251 root: &Path,
5252 storage: &Path,
5253 file: &Path,
5254 contents: &[u8],
5255 ) -> (PathBuf, PathBuf) {
5256 let canonical = std::fs::canonicalize(root).expect("canonical root");
5257 ctx.update_config(|config| {
5258 config.storage_dir = Some(storage.to_path_buf());
5259 config.search_index = true;
5260 });
5261 ctx.set_canonical_cache_root(canonical.clone());
5262 let cache_dir = crate::search_index::resolve_cache_dir(&canonical, Some(storage));
5263 let mut index = crate::search_index::SearchIndex::build(&canonical);
5264 index.index_file(file, contents);
5265 let git_head = index.stored_git_head().map(str::to_owned);
5266 assert!(
5267 index.write_to_disk(&cache_dir, git_head.as_deref()),
5268 "owner write of cache.bin must succeed before borrow-only setup"
5269 );
5270 *ctx.search_index()
5271 .write()
5272 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
5273 (canonical, cache_dir)
5274 }
5275
5276 fn grep_count(ctx: &AppContext, pattern: &str, root: &Path) -> usize {
5277 let index = ctx
5278 .search_index()
5279 .read()
5280 .unwrap_or_else(std::sync::PoisonError::into_inner);
5281 index
5282 .as_ref()
5283 .expect("resident search index")
5284 .grep(pattern, true, &[], &[], root, 10)
5285 .matches
5286 .len()
5287 }
5288
5289 fn cache_bin_hash(path: &Path) -> blake3::Hash {
5290 blake3::hash(&std::fs::read(path).expect("read cache.bin"))
5291 }
5292
5293 fn search_index_has_pending_disk_changes(ctx: &AppContext) -> bool {
5294 ctx.search_index()
5295 .read()
5296 .unwrap_or_else(std::sync::PoisonError::into_inner)
5297 .as_ref()
5298 .is_some_and(|index| index.has_pending_disk_changes())
5299 }
5300
5301 fn mark_borrow_only(ctx: &AppContext, canonical: &Path, cache_dir: &Path) {
5302 let project_key = cache_dir
5303 .file_name()
5304 .and_then(|name| name.to_str())
5305 .expect("cache key")
5306 .to_string();
5307 crate::root_cache::configure_artifact_access(canonical, &project_key, true);
5308 ctx.set_cache_role(true, None);
5309 }
5310
5311 #[test]
5312 fn ram_overlay_is_off_by_default_and_transport_independent() {
5313 let temp = tempfile::tempdir().unwrap();
5314 let (ctx, _) = context_with_watcher(temp.path());
5315 ctx.set_cache_role(true, None);
5316 assert!(
5317 ctx.shared_artifacts_read_only(),
5318 "worktree role is borrow-only"
5319 );
5320 assert!(
5321 !ctx.ram_overlay_active(),
5322 "worktree.ram_overlay defaults off"
5323 );
5324
5325 ctx.update_config(|config| config.worktree.ram_overlay = true);
5326 assert!(
5327 ctx.ram_overlay_active(),
5328 "overlay follows config + borrow-only, not bind/transport identity"
5329 );
5330
5331 ctx.set_cache_role(false, None);
5332 assert!(
5333 !ctx.ram_overlay_active(),
5334 "owner roots stay on the normal writer path even when the gate is on"
5335 );
5336 }
5337
5338 #[test]
5339 fn borrow_only_watcher_arms_are_noops_until_overlay_flips_on() {
5340 let root = tempfile::tempdir().unwrap();
5341 let storage = tempfile::tempdir().unwrap();
5342 let (ctx, tx) = context_with_watcher(root.path());
5343 let file = std::fs::canonicalize(root.path())
5344 .expect("canonical root")
5345 .join("overlay.rs");
5346 std::fs::write(&file, "old overlay token\n").expect("write source");
5347 let (canonical, cache_dir) = install_search_index(
5348 &ctx,
5349 root.path(),
5350 storage.path(),
5351 &file,
5352 b"old overlay token\n",
5353 );
5354 mark_borrow_only(&ctx, &canonical, &cache_dir);
5355 assert!(ctx.shared_artifacts_read_only());
5356 assert!(!ctx.ram_overlay_active());
5357 assert!(
5358 !search_index_has_pending_disk_changes(&ctx),
5359 "precondition: compacted owner write leaves no RAM delta"
5360 );
5361
5362 std::fs::write(&file, "new overlay token\n").expect("edit source");
5363 tx.send(WatcherDispatchEvent::Paths(vec![file.clone()]))
5364 .unwrap();
5365 drain_watcher_events(&ctx);
5366 assert!(
5367 !search_index_has_pending_disk_changes(&ctx),
5368 "gate off: borrow-only watcher must not mutate the RAM delta"
5369 );
5370
5371 ctx.update_config(|config| config.worktree.ram_overlay = true);
5372 assert!(ctx.ram_overlay_active());
5373 tx.send(WatcherDispatchEvent::Paths(vec![file])).unwrap();
5374 drain_watcher_events(&ctx);
5375 assert!(
5376 search_index_has_pending_disk_changes(&ctx),
5377 "flipping the predicate on must let the same watcher arm apply"
5378 );
5379 assert_eq!(
5380 grep_count(&ctx, "new overlay token", &canonical),
5381 1,
5382 "overlay-on drain must make the disk edit searchable from RAM"
5383 );
5384 }
5385
5386 #[test]
5387 fn ram_overlay_search_reflects_edits_without_writing_cache_bin() {
5388 let root = tempfile::tempdir().unwrap();
5389 let storage = tempfile::tempdir().unwrap();
5390 let (ctx, tx) = context_with_watcher(root.path());
5391 let file = std::fs::canonicalize(root.path())
5392 .expect("canonical root")
5393 .join("overlay.rs");
5394 std::fs::write(&file, "old overlay token\n").expect("write source");
5395 let (canonical, cache_dir) = install_search_index(
5396 &ctx,
5397 root.path(),
5398 storage.path(),
5399 &file,
5400 b"old overlay token\n",
5401 );
5402 mark_borrow_only(&ctx, &canonical, &cache_dir);
5403 ctx.update_config(|config| config.worktree.ram_overlay = true);
5404
5405 *ctx.semantic_index()
5406 .write()
5407 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(
5408 crate::semantic_index::SemanticIndex::new(canonical.clone(), 8),
5409 );
5410 *ctx.semantic_index_status()
5411 .write()
5412 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
5413
5414 let cache_path = cache_dir.join("cache.bin");
5415 let before = cache_bin_hash(&cache_path);
5416
5417 std::fs::write(&file, "new overlay token\n").expect("edit source");
5418 tx.send(WatcherDispatchEvent::Paths(vec![file.clone()]))
5419 .unwrap();
5420 drain_watcher_events(&ctx);
5421
5422 assert_eq!(
5423 grep_count(&ctx, "new overlay token", &canonical),
5424 1,
5425 "overlay must serve the edit from the RAM delta"
5426 );
5427 assert_eq!(grep_count(&ctx, "old overlay token", &canonical), 0);
5428 assert_eq!(
5429 cache_bin_hash(&cache_path),
5430 before,
5431 "overlay must not change on-disk cache.bin bytes"
5432 );
5433
5434 {
5435 let family_key = "shared-family-overlay-key";
5438 crate::root_cache::configure_artifact_access(&canonical, family_key, true);
5439 let family_dir = storage.path().join(family_key);
5440 let mut index = ctx
5441 .search_index()
5442 .write()
5443 .unwrap_or_else(std::sync::PoisonError::into_inner);
5444 assert!(
5445 !index
5446 .as_mut()
5447 .expect("resident search index")
5448 .write_to_disk(&family_dir, None),
5449 "direct persist must fail-closed for a borrow-only shared key"
5450 );
5451 }
5452 assert!(
5453 !ctx.flush_search_index_on_graceful_shutdown(),
5454 "shutdown must not flush a borrow-only overlay root"
5455 );
5456 assert_eq!(
5457 cache_bin_hash(&cache_path),
5458 before,
5459 "shutdown and persist must leave cache.bin byte-identical"
5460 );
5461
5462 let refreshing = match &*ctx
5463 .semantic_index_status()
5464 .read()
5465 .unwrap_or_else(std::sync::PoisonError::into_inner)
5466 {
5467 SemanticIndexStatus::Ready { refreshing, .. } => refreshing.clone(),
5468 other => panic!("expected Ready semantic status, got {other:?}"),
5469 };
5470 assert!(
5471 refreshing.is_empty(),
5472 "a context without a semantic refresh worker must not report refresh work"
5473 );
5474 }
5475
5476 #[test]
5477 fn owner_root_still_uses_normal_path_when_overlay_gate_is_on() {
5478 let root = tempfile::tempdir().unwrap();
5479 let storage = tempfile::tempdir().unwrap();
5480 let (ctx, tx) = context_with_watcher(root.path());
5481 let file = std::fs::canonicalize(root.path())
5482 .expect("canonical root")
5483 .join("owner.rs");
5484 std::fs::write(&file, "old owner token\n").expect("write source");
5485 let (canonical, cache_dir) = install_search_index(
5486 &ctx,
5487 root.path(),
5488 storage.path(),
5489 &file,
5490 b"old owner token\n",
5491 );
5492 ctx.update_config(|config| config.worktree.ram_overlay = true);
5493 assert!(!ctx.shared_artifacts_read_only(), "owner root is a writer");
5494 assert!(!ctx.ram_overlay_active());
5495
5496 std::fs::write(&file, "new owner token\n").expect("edit source");
5497 tx.send(WatcherDispatchEvent::Paths(vec![file])).unwrap();
5498 drain_watcher_events(&ctx);
5499 assert_eq!(grep_count(&ctx, "new owner token", &canonical), 1);
5500 assert_eq!(grep_count(&ctx, "old owner token", &canonical), 0);
5501
5502 assert!(
5503 ctx.flush_search_index_on_graceful_shutdown(),
5504 "owner persist path must still flush when the overlay gate is on"
5505 );
5506 let mut restored = crate::search_index::SearchIndex::read_from_disk(&cache_dir, &canonical)
5507 .expect("reload owner cache.bin");
5508 restored.set_ready(true);
5509 assert_eq!(
5510 restored
5511 .grep("new owner token", true, &[], &[], &canonical, 10)
5512 .matches
5513 .len(),
5514 1,
5515 "owner shutdown flush must persist the RAM delta"
5516 );
5517 }
5518}
5519
5520#[cfg(all(test, unix))]
5522mod idle_lsp_tests {
5523 use super::shutdown_idle_lsp_at;
5524 use crate::config::Config;
5525 use crate::context::AppContext;
5526 use crate::lsp::child_registry::LspChildRegistry;
5527 use crate::lsp::client::LspClient;
5528 use crate::lsp::registry::ServerKind;
5529 use std::collections::HashMap;
5530 use std::path::Path;
5531 use std::time::{Duration, Instant};
5532
5533 #[cfg(unix)]
5534 fn spawn_sleep_client(ctx: &AppContext, root: std::path::PathBuf) -> u32 {
5535 let registry = LspChildRegistry::new();
5536 ctx.lsp().set_child_registry(registry.clone());
5537 let client = LspClient::spawn(
5538 ServerKind::TypeScript,
5539 root,
5540 Path::new("sh"),
5541 &["-c".to_string(), "exec sleep 60".to_string()],
5542 &HashMap::new(),
5543 ctx.lsp().event_sender_for_test(),
5544 registry,
5545 )
5546 .expect("spawn idle-lsp stand-in");
5547 let pid = client.child_pid();
5548 ctx.lsp().insert_client_for_test(client);
5549 pid
5550 }
5551
5552 #[cfg(unix)]
5553 fn wait_until_dead(pid: u32, timeout: Duration) -> bool {
5554 let started = Instant::now();
5555 while started.elapsed() < timeout {
5556 if !crate::bash_background::process::is_process_alive(pid) {
5557 return true;
5558 }
5559 std::thread::sleep(Duration::from_millis(20));
5560 }
5561 false
5562 }
5563
5564 #[cfg(unix)]
5565 #[test]
5566 fn idle_lsp_ttl_shuts_down_stale_client_and_keeps_recent() {
5567 let tmp = tempfile::tempdir().unwrap();
5568 let mut stale_config = Config::default();
5569 stale_config.idle.lsp_ttl_minutes = 1;
5570 let stale = AppContext::new(
5571 crate::context::default_language_provider_factory(),
5572 stale_config,
5573 );
5574 let pid = spawn_sleep_client(&stale, tmp.path().to_path_buf());
5575 assert_eq!(stale.lsp().server_count(), 1);
5576 let now = Instant::now();
5577 stale.set_last_request_at_for_test(now - Duration::from_secs(61));
5578 let started = Instant::now();
5579 shutdown_idle_lsp_at(&stale, now, stale.last_request_at());
5580 assert!(
5581 started.elapsed() < Duration::from_millis(250),
5582 "idle lsp reap must return without waiting on Shutdown, took {:?}",
5583 started.elapsed()
5584 );
5585 assert_eq!(
5586 stale.lsp().server_count(),
5587 0,
5588 "a root idle longer than lsp_ttl_minutes must shut down its language servers"
5589 );
5590 assert!(
5591 wait_until_dead(pid, Duration::from_secs(6)),
5592 "idle-reaped child must die within SHUTDOWN_TIMEOUT + 1s"
5593 );
5594
5595 let mut fresh_config = Config::default();
5596 fresh_config.idle.lsp_ttl_minutes = 1;
5597 let fresh = AppContext::new(
5598 crate::context::default_language_provider_factory(),
5599 fresh_config,
5600 );
5601 spawn_sleep_client(&fresh, tmp.path().to_path_buf());
5602 assert_eq!(fresh.lsp().server_count(), 1);
5603 shutdown_idle_lsp_at(&fresh, Instant::now(), fresh.last_request_at());
5604 assert_eq!(
5605 fresh.lsp().server_count(),
5606 1,
5607 "a root with recent activity must keep its language servers"
5608 );
5609 }
5610}