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