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