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