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