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