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