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