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