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