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