1use crate as aft;
2use crate::context::{
3 AppContext, SemanticIndexEvent, SemanticIndexStatus, SemanticRefreshEvent,
4 SemanticRefreshRequest,
5};
6use crate::log_ctx;
7use crate::lsp::client::LspEvent;
8use crate::protocol::PushFrame;
9use crate::watcher_filter::{watcher_path_is_infra_skip, WatcherDispatchEvent};
10use std::collections::HashSet;
11use std::path::Path;
12use std::sync::Arc;
13use std::thread;
14use std::time::Duration;
15
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17pub struct DrainBatchOutcome {
18 pub processed: usize,
19 pub has_more: bool,
20}
21
22pub const WATCHER_EVENT_DRAIN_BATCH_CAP: usize = 256;
23pub const LSP_EVENT_DRAIN_BATCH_CAP: usize = 256;
24
25pub fn drain_configure_warning_events(ctx: &AppContext) {
26 crate::commands::configure::drain_deferred_configure_maintenance(ctx);
27 for (generation, frame) in ctx.drain_configure_warnings() {
28 if ctx.configure_generation() != generation {
29 aft::slog_info!(
30 "dropping stale configure_warnings for generation {} (current {})",
31 generation,
32 ctx.configure_generation()
33 );
34 continue;
35 }
36
37 if let Some(sender) = ctx.progress_sender_handle() {
38 sender(PushFrame::ConfigureWarnings(frame));
39 }
40 }
41}
42
43pub fn drain_inspect_events(ctx: &AppContext) {
44 let drained = ctx.inspect_manager().drain_completions();
45 let reuse_completed = ctx.take_new_reuse_completions();
50 if drained > 0 || reuse_completed {
55 if let Some(project_root) = ctx.config().project_root.clone() {
56 let (dead_code, unused_exports, duplicates) = ctx
57 .inspect_manager()
58 .latest_tier2_counts(ctx.inspect_dir(), project_root);
59 let stale = ctx.inspect_manager().tier2_any_in_flight();
65 ctx.update_status_bar_tier2(dead_code, unused_exports, duplicates, None, stale);
66 ctx.status_emitter().signal(ctx.build_status_snapshot());
74 }
75 }
76}
77
78pub fn drain_build_completions(ctx: &AppContext) {
83 drain_search_index_events(ctx);
84 drain_callgraph_store_events(ctx);
85 drain_semantic_index_events(ctx);
86}
87
88pub fn any_build_in_flight(ctx: &AppContext) -> bool {
93 {
94 let rx = ctx
95 .search_index_rx()
96 .read()
97 .unwrap_or_else(std::sync::PoisonError::into_inner);
98 if rx.is_some() {
99 return true;
100 }
101 }
102
103 {
104 let rx = ctx.callgraph_store_rx().lock();
105 if rx.is_some() {
106 return true;
107 }
108 }
109
110 {
111 let rx = ctx.semantic_index_rx().lock();
112 rx.is_some()
113 }
114}
115
116pub fn watcher_path_is_ignored_by_current_matcher(ctx: &AppContext, path: &Path) -> bool {
117 if watcher_path_is_infra_skip(path) {
118 return true;
119 }
120
121 if let Some(matcher) = ctx.gitignore() {
122 if path.starts_with(matcher.path()) {
123 let is_dir = path.is_dir();
124 return matcher
125 .matched_path_or_any_parents(path, is_dir)
126 .is_ignore();
127 }
128 }
129
130 false
131}
132
133fn replay_search_index_pending_updates(
134 ctx: &AppContext,
135 index: &mut crate::search_index::SearchIndex,
136 pending_paths: Vec<std::path::PathBuf>,
137) {
138 for path in pending_paths {
139 if path.exists() {
140 if watcher_path_is_ignored_by_current_matcher(ctx, &path) {
141 index.remove_file(&path);
142 } else {
143 index.update_file(&path);
144 }
145 } else {
146 index.remove_file(&path);
147 }
148 }
149}
150
151pub fn watcher_path_is_semantic_source(path: &Path) -> bool {
152 crate::semantic_index::is_semantic_indexed_extension(path)
153}
154
155pub fn mark_semantic_corpus_refresh_success(ctx: &AppContext) {
156 ctx.clear_all_semantic_refresh_retry_attempts();
157 ctx.reset_semantic_refresh_circuit_after_success();
158}
159
160pub fn drain_search_index_events(ctx: &AppContext) {
161 let (latest, disconnected) = {
162 let rx_ref = ctx
163 .search_index_rx()
164 .read()
165 .unwrap_or_else(std::sync::PoisonError::into_inner);
166 let Some(rx) = rx_ref.as_ref() else {
167 return;
168 };
169
170 let mut latest = None;
171 let mut disconnected = false;
172 loop {
173 match rx.try_recv() {
174 Ok(index) => latest = Some(index),
175 Err(crossbeam_channel::TryRecvError::Empty) => break,
176 Err(crossbeam_channel::TryRecvError::Disconnected) => {
177 disconnected = true;
178 break;
179 }
180 }
181 }
182 (latest, disconnected)
183 };
184
185 let mut status_changed = false;
186 let mut installed_index = false;
187 if let Some(mut index) = latest {
188 let pending_paths = ctx.take_pending_search_index_paths();
189 if !pending_paths.is_empty() {
190 replay_search_index_pending_updates(ctx, &mut index, pending_paths);
191 }
192 *ctx.search_index()
193 .write()
194 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
195 installed_index = true;
196 status_changed = true;
197 }
198
199 if disconnected || installed_index {
200 *ctx.search_index_rx()
201 .write()
202 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
203 if disconnected && !installed_index {
204 let _ = ctx.take_pending_search_index_paths();
205 }
206 status_changed = true;
207 }
208
209 if status_changed {
210 ctx.status_emitter().signal(ctx.build_status_snapshot());
211 }
212}
213
214pub fn drain_callgraph_store_events(ctx: &AppContext) {
220 let (latest, disconnected) = {
221 let rx_ref = ctx.callgraph_store_rx().lock();
222 let Some(rx) = rx_ref.as_ref() else {
223 return;
224 };
225
226 let mut latest = None;
227 let mut disconnected = false;
228 loop {
229 match rx.try_recv() {
230 Ok(store) => latest = Some(store),
231 Err(crossbeam_channel::TryRecvError::Empty) => break,
232 Err(crossbeam_channel::TryRecvError::Disconnected) => {
233 disconnected = true;
234 break;
235 }
236 }
237 }
238 (latest, disconnected)
239 };
240
241 let mut status_changed = false;
242 let mut installed = false;
243 if let Some(store) = latest {
244 let pending = ctx.take_pending_callgraph_store_paths();
247 if !pending.is_empty() {
248 if let Err(error) = store.refresh_files(&pending) {
249 crate::slog_warn!(
250 "callgraph store post-build pending refresh failed: {}",
251 error
252 );
253 if let Err(mark_error) = store.mark_files_stale(&pending) {
254 crate::slog_warn!(
255 "failed to mark callgraph store files stale after post-build refresh failure: {}",
256 mark_error
257 );
258 }
259 }
260 }
261 *ctx.callgraph_store()
262 .write()
263 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(store));
264 installed = true;
265 status_changed = true;
266 }
267
268 if disconnected || installed {
269 *ctx.callgraph_store_rx().lock() = None;
270 if disconnected && !installed {
271 let _ = ctx.take_pending_callgraph_store_paths();
274 }
275 status_changed = true;
276 }
277
278 if status_changed {
279 ctx.status_emitter().signal(ctx.build_status_snapshot());
280 }
281}
282
283pub fn drain_semantic_index_events(ctx: &AppContext) {
284 let (events, disconnected) = {
285 let rx_ref = ctx.semantic_index_rx().lock();
286 let Some(rx) = rx_ref.as_ref() else {
287 return;
288 };
289
290 let mut events = Vec::new();
291 let mut disconnected = false;
292 loop {
293 match rx.try_recv() {
294 Ok(event) => events.push(event),
295 Err(crossbeam_channel::TryRecvError::Empty) => break,
296 Err(crossbeam_channel::TryRecvError::Disconnected) => {
297 disconnected = true;
298 break;
299 }
300 }
301 }
302 (events, disconnected)
303 };
304
305 if events.is_empty() && !disconnected {
306 return;
307 }
308
309 let mut keep_receiver = true;
310 let mut status_changed = false;
311 let mut replay_refresh_paths = Vec::new();
312 let mut replay_corpus_refresh = false;
313 for event in events {
314 match event {
315 SemanticIndexEvent::Progress {
316 stage,
317 files,
318 entries_done,
319 entries_total,
320 } => {
321 *ctx.semantic_index_status()
322 .write()
323 .unwrap_or_else(std::sync::PoisonError::into_inner) =
324 SemanticIndexStatus::Building {
325 stage,
326 files,
327 entries_done,
328 entries_total,
329 };
330 status_changed = true;
337 }
338 SemanticIndexEvent::ColdSeedGateCleared => {
339 ctx.resume_deferred_work_after_semantic_cold_seed_gate_cleared();
340 }
341 SemanticIndexEvent::Ready(mut index) => {
342 mark_semantic_corpus_refresh_success(ctx);
343 let pending_paths = ctx.take_pending_semantic_index_paths();
344 for path in pending_paths {
345 if watcher_path_is_semantic_source(&path) {
346 index.invalidate_file(&path);
347 replay_refresh_paths.push(path);
348 }
349 }
350 replay_corpus_refresh = ctx.take_pending_semantic_corpus_refresh();
351 *ctx.semantic_index()
352 .write()
353 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
354 *ctx.semantic_index_status()
355 .write()
356 .unwrap_or_else(std::sync::PoisonError::into_inner) =
357 SemanticIndexStatus::ready();
358 keep_receiver = false;
359 status_changed = true;
360 ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
361 }
362 SemanticIndexEvent::Failed(error) => {
363 let _ = ctx.take_pending_semantic_index_paths();
364 let _ = ctx.take_pending_semantic_corpus_refresh();
365 *ctx.semantic_index()
366 .write()
367 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
368 ctx.clear_semantic_refresh_worker();
369 *ctx.semantic_index_status()
370 .write()
371 .unwrap_or_else(std::sync::PoisonError::into_inner) =
372 SemanticIndexStatus::Failed(error);
373 keep_receiver = false;
374 status_changed = true;
375 ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
376 }
377 }
378 }
379
380 if disconnected && keep_receiver {
381 let _ = ctx.take_pending_semantic_index_paths();
382 let _ = ctx.take_pending_semantic_corpus_refresh();
383 *ctx.semantic_index()
384 .write()
385 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
386 ctx.clear_semantic_refresh_worker();
387 *ctx.semantic_index_status()
388 .write()
389 .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Failed(
390 "semantic index build worker disconnected before reporting completion".to_string(),
391 );
392 keep_receiver = false;
393 status_changed = true;
394 ctx.clear_semantic_cold_seed_gate_and_resume_deferred_work();
395 }
396
397 if !keep_receiver {
398 *ctx.semantic_index_rx().lock() = None;
399 }
400
401 if replay_corpus_refresh {
402 if ctx.canonical_cache_root_opt().is_some() {
403 *ctx.semantic_index_status()
404 .write()
405 .unwrap_or_else(std::sync::PoisonError::into_inner) =
406 SemanticIndexStatus::Building {
407 stage: "refreshing_corpus".to_string(),
408 files: None,
409 entries_done: None,
410 entries_total: None,
411 };
412 let sent = ctx
413 .semantic_refresh_sender()
414 .is_some_and(|sender| sender.send(SemanticRefreshRequest::Corpus).is_ok());
415 if !sent {
416 *ctx.semantic_index_status()
417 .write()
418 .unwrap_or_else(std::sync::PoisonError::into_inner) =
419 SemanticIndexStatus::Failed(
420 "semantic corpus refresh worker unavailable".to_string(),
421 );
422 }
423 status_changed = true;
424 }
425 } else if !replay_refresh_paths.is_empty() {
426 {
427 let mut status = ctx
428 .semantic_index_status()
429 .write()
430 .unwrap_or_else(std::sync::PoisonError::into_inner);
431 if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
432 for path in &replay_refresh_paths {
433 status.add_refreshing_file(path.clone());
434 }
435 status_changed = true;
436 }
437 }
438 let sent = ctx.semantic_refresh_sender().is_some_and(|sender| {
439 sender
440 .send(SemanticRefreshRequest::Files {
441 paths: replay_refresh_paths.clone(),
442 })
443 .is_ok()
444 });
445 if !sent {
446 crate::slog_warn!(
447 "semantic refresh worker unavailable; dropping {} replayed file(s)",
448 replay_refresh_paths.len()
449 );
450 let mut status = ctx
451 .semantic_index_status()
452 .write()
453 .unwrap_or_else(std::sync::PoisonError::into_inner);
454 for path in &replay_refresh_paths {
455 status.cancel_refreshing_file(path);
456 }
457 status_changed = true;
458 }
459 }
460
461 if status_changed {
462 ctx.status_emitter().signal(ctx.build_status_snapshot());
463 }
464}
465
466pub const MAX_RETRY_ATTEMPTS: usize = 6;
467pub const BREAKER_TRIP_THRESHOLD: usize = 3;
468
469fn semantic_refresh_retry_backoff(attempt: usize) -> Duration {
474 if let Ok(raw) = std::env::var("AFT_SEMANTIC_RETRY_BACKOFF_MS") {
476 if let Ok(ms) = raw.parse::<u64>() {
477 return Duration::from_millis(ms);
478 }
479 }
480 const SCHEDULE_SECS: [u64; 3] = [15, 30, 60];
481 let secs = SCHEDULE_SECS
482 .get(attempt)
483 .copied()
484 .unwrap_or(*SCHEDULE_SECS.last().unwrap());
485 Duration::from_secs(secs)
486}
487
488struct SemanticRefreshRetryPlan {
489 retry_paths: Vec<std::path::PathBuf>,
490 capped_paths: Vec<std::path::PathBuf>,
491 delay: Option<Duration>,
492}
493
494fn next_semantic_refresh_retry_plan(
495 ctx: &AppContext,
496 paths: Vec<std::path::PathBuf>,
497) -> SemanticRefreshRetryPlan {
498 let mut retry_paths = Vec::new();
499 let mut capped_paths = Vec::new();
500 let mut max_attempt = 0usize;
501
502 ctx.with_semantic_refresh_retry_attempts_mut(|attempts| {
503 for path in paths {
504 let attempt = attempts.get(&path).copied().unwrap_or(0);
505 if attempt >= MAX_RETRY_ATTEMPTS {
506 capped_paths.push(path);
507 continue;
508 }
509 max_attempt = max_attempt.max(attempt);
510 attempts.insert(path.clone(), attempt.saturating_add(1));
511 retry_paths.push(path);
512 }
513 });
514
515 let delay = if retry_paths.is_empty() {
516 None
517 } else {
518 Some(semantic_refresh_retry_backoff(max_attempt))
519 };
520
521 SemanticRefreshRetryPlan {
522 retry_paths,
523 capped_paths,
524 delay,
525 }
526}
527
528fn clear_semantic_refresh_retry_attempts(ctx: &AppContext, paths: &[std::path::PathBuf]) {
529 ctx.clear_semantic_refresh_retry_attempts(paths);
530}
531
532fn clear_completed_pending_semantic_index_paths(
533 ctx: &AppContext,
534 completed_paths: &[std::path::PathBuf],
535) {
536 if completed_paths.is_empty() {
537 return;
538 }
539
540 let completed = completed_paths.iter().cloned().collect::<HashSet<_>>();
541 let remaining = ctx
542 .take_pending_semantic_index_paths()
543 .into_iter()
544 .filter(|path| !completed.contains(path))
545 .collect::<Vec<_>>();
546 if !remaining.is_empty() {
547 ctx.add_pending_semantic_index_paths(remaining);
548 }
549}
550
551fn semantic_refresh_probe_delay() -> Duration {
552 semantic_refresh_retry_backoff(usize::MAX)
553}
554
555pub fn semantic_refresh_circuit_is_open(ctx: &AppContext) -> bool {
556 ctx.semantic_refresh_circuit_is_open()
557}
558
559pub fn record_semantic_refresh_transient_failure(ctx: &AppContext) -> bool {
560 ctx.record_semantic_refresh_transient_failure(BREAKER_TRIP_THRESHOLD)
561}
562
563fn reset_semantic_refresh_transient_failure_count(ctx: &AppContext) {
564 ctx.reset_semantic_refresh_transient_failure_count();
565}
566
567fn reset_semantic_refresh_circuit_after_success(ctx: &AppContext) {
568 ctx.reset_semantic_refresh_circuit_after_success();
569}
570
571fn mark_semantic_refresh_success(ctx: &AppContext, completed_paths: &[std::path::PathBuf]) {
572 clear_semantic_refresh_retry_attempts(ctx, completed_paths);
573 clear_completed_pending_semantic_index_paths(ctx, completed_paths);
574 reset_semantic_refresh_circuit_after_success(ctx);
575}
576
577#[doc(hidden)]
578pub fn semantic_refresh_transient_failure_count_for_test(ctx: &AppContext) -> usize {
579 ctx.semantic_refresh_transient_failure_count()
580}
581
582#[doc(hidden)]
583pub fn semantic_refresh_probe_is_scheduled_for_test(ctx: &AppContext) -> bool {
584 ctx.semantic_refresh_probe_is_scheduled()
585}
586
587fn ensure_semantic_refresh_probe_scheduled(ctx: &AppContext) {
588 ctx.ensure_semantic_refresh_probe_scheduled(semantic_refresh_probe_delay());
589}
590
591fn maybe_fire_semantic_refresh_probe(ctx: &AppContext) {
592 if !ctx.take_semantic_refresh_probe_ready() {
593 return;
594 }
595 if !semantic_refresh_circuit_is_open(ctx) {
596 return;
597 }
598
599 let pending_paths = ctx.take_pending_semantic_index_paths();
600 if pending_paths.is_empty() {
601 return;
602 }
603
604 let sent = ctx.semantic_refresh_sender().is_some_and(|sender| {
605 sender
606 .send(SemanticRefreshRequest::Files {
607 paths: pending_paths.clone(),
608 })
609 .is_ok()
610 });
611 if !sent {
612 ctx.add_pending_semantic_index_paths(pending_paths);
613 }
614}
615
616pub fn schedule_semantic_refresh_retry(
617 ctx: &AppContext,
618 paths: Vec<std::path::PathBuf>,
619 error: &str,
620) -> bool {
621 if paths.is_empty() {
622 return false;
623 }
624 let Some(sender) = ctx.semantic_refresh_sender() else {
625 return false;
626 };
627
628 let SemanticRefreshRetryPlan {
629 retry_paths,
630 capped_paths,
631 delay,
632 } = next_semantic_refresh_retry_plan(ctx, paths);
633
634 if !capped_paths.is_empty() {
635 aft::slog_warn!(
636 "semantic refresh retry limit reached for {} file(s); preserving for next watcher/configure refresh",
637 capped_paths.len(),
638 );
639 ctx.add_pending_semantic_index_paths(capped_paths);
640 }
641
642 let Some(delay) = delay else {
643 return true;
644 };
645
646 let clean = aft::semantic_index::strip_transient_embedding_marker(error);
647 aft::slog_warn!(
648 "semantic refresh hit a transient backend error ({}); retrying {} file(s) in {}ms",
649 clean,
650 retry_paths.len(),
651 delay.as_millis(),
652 );
653
654 let session_id = log_ctx::current_session();
655 thread::spawn(move || {
656 log_ctx::with_session(session_id, || {
657 thread::sleep(delay);
658 let _ = sender.send(SemanticRefreshRequest::Files { paths: retry_paths });
659 });
660 });
661 true
662}
663
664pub fn drain_semantic_refresh_events(ctx: &AppContext) {
665 let (events, disconnected) = {
666 let rx_ref = ctx.semantic_refresh_event_rx().lock();
667 let Some(rx) = rx_ref.as_ref() else {
668 return;
669 };
670
671 let mut events = Vec::new();
672 let mut disconnected = false;
673 loop {
674 match rx.try_recv() {
675 Ok(event) => events.push(event),
676 Err(crossbeam_channel::TryRecvError::Empty) => break,
677 Err(crossbeam_channel::TryRecvError::Disconnected) => {
678 disconnected = true;
679 break;
680 }
681 }
682 }
683 (events, disconnected)
684 };
685
686 if events.is_empty() && !disconnected {
687 maybe_fire_semantic_refresh_probe(ctx);
688 return;
689 }
690
691 let had_events = !events.is_empty();
692 let mut status_changed = false;
693 let mut replay_refresh_paths = Vec::new();
694 for event in events {
695 match event {
696 SemanticRefreshEvent::Started { paths } => {
697 let mut status = ctx
698 .semantic_index_status()
699 .write()
700 .unwrap_or_else(std::sync::PoisonError::into_inner);
701 if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
702 for path in paths {
703 status.start_refreshing_file(path);
704 }
705 status_changed = true;
706 }
707 }
708 SemanticRefreshEvent::CorpusStarted { files } => {
709 *ctx.semantic_index_status()
710 .write()
711 .unwrap_or_else(std::sync::PoisonError::into_inner) =
712 SemanticIndexStatus::Building {
713 stage: "refreshing_corpus".to_string(),
714 files: Some(files),
715 entries_done: None,
716 entries_total: None,
717 };
718 status_changed = true;
719 }
720 SemanticRefreshEvent::Completed {
721 added_entries,
722 updated_metadata,
723 completed_paths,
724 } => {
725 if let Some(index) = ctx
726 .semantic_index()
727 .write()
728 .unwrap_or_else(std::sync::PoisonError::into_inner)
729 .as_mut()
730 {
731 index.apply_refresh_update(added_entries, updated_metadata, &completed_paths);
732 }
733 mark_semantic_refresh_success(ctx, &completed_paths);
734 let mut status = ctx
735 .semantic_index_status()
736 .write()
737 .unwrap_or_else(std::sync::PoisonError::into_inner);
738 if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
739 for path in &completed_paths {
740 status.complete_refreshing_file(path);
741 }
742 status_changed = true;
743 }
744 }
745 SemanticRefreshEvent::CorpusCompleted {
746 mut index,
747 changed,
748 added,
749 deleted,
750 total_processed,
751 } => {
752 aft::runtime_drain::mark_semantic_corpus_refresh_success(ctx);
753 if changed > 0 || added > 0 || deleted > 0 {
754 aft::slog_info!(
755 "semantic corpus refresh completed: {} changed, {} new, {} deleted, {} total processed",
756 changed,
757 added,
758 deleted,
759 total_processed
760 );
761 }
762 let pending_paths = ctx.take_pending_semantic_index_paths();
763 for path in pending_paths {
764 if !aft::runtime_drain::watcher_path_is_semantic_source(&path) {
765 continue;
766 }
767 index.invalidate_file(&path);
768 if !aft::runtime_drain::watcher_path_is_ignored_by_current_matcher(ctx, &path) {
769 replay_refresh_paths.push(path);
770 }
771 }
772 *ctx.semantic_index()
773 .write()
774 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
775 *ctx.semantic_index_status()
776 .write()
777 .unwrap_or_else(std::sync::PoisonError::into_inner) =
778 SemanticIndexStatus::ready();
779 status_changed = true;
780 }
781 SemanticRefreshEvent::Failed { paths, error } => {
782 if aft::semantic_index::embedding_failure_is_transient(&error) {
783 if record_semantic_refresh_transient_failure(ctx) {
784 ctx.add_pending_semantic_index_paths(paths);
785 ensure_semantic_refresh_probe_scheduled(ctx);
786 } else if !schedule_semantic_refresh_retry(ctx, paths.clone(), &error) {
787 aft::slog_warn!(
788 "semantic refresh worker unavailable; preserving {} transiently failed file(s) for retry",
789 paths.len(),
790 );
791 ctx.add_pending_semantic_index_paths(paths);
792 }
793 } else {
794 aft::slog_warn!("semantic refresh failed: {}", error);
795 reset_semantic_refresh_transient_failure_count(ctx);
796 clear_semantic_refresh_retry_attempts(ctx, &paths);
797 let mut status = ctx
798 .semantic_index_status()
799 .write()
800 .unwrap_or_else(std::sync::PoisonError::into_inner);
801 if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
802 for path in &paths {
803 status.complete_refreshing_file(path);
804 }
805 status_changed = true;
806 }
807 }
808 }
809 SemanticRefreshEvent::CorpusFailed { error } => {
810 if aft::semantic_index::embedding_failure_is_transient(&error) {
819 let clean = aft::semantic_index::strip_transient_embedding_marker(&error);
820 let has_index = ctx
821 .semantic_index()
822 .read()
823 .unwrap_or_else(std::sync::PoisonError::into_inner)
824 .is_some();
825 if has_index {
826 aft::slog_warn!(
827 "semantic corpus refresh hit a transient backend error ({}); keeping the existing index",
828 clean,
829 );
830 *ctx.semantic_index_status()
831 .write()
832 .unwrap_or_else(std::sync::PoisonError::into_inner) =
833 SemanticIndexStatus::ready();
834 } else {
835 aft::slog_warn!("semantic corpus refresh failed: {}", clean);
837 *ctx.semantic_index_status()
838 .write()
839 .unwrap_or_else(std::sync::PoisonError::into_inner) =
840 SemanticIndexStatus::Failed(clean);
841 }
842 status_changed = true;
843 } else {
844 aft::slog_warn!("semantic corpus refresh failed: {}", error);
845 let _ = ctx.take_pending_semantic_index_paths();
846 *ctx.semantic_index()
847 .write()
848 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
849 *ctx.semantic_index_status()
850 .write()
851 .unwrap_or_else(std::sync::PoisonError::into_inner) =
852 SemanticIndexStatus::Failed(error);
853 status_changed = true;
854 }
855 }
856 }
857 }
858
859 if disconnected {
860 ctx.clear_semantic_refresh_worker();
861 let refreshing_paths = {
862 let status = ctx
863 .semantic_index_status()
864 .read()
865 .unwrap_or_else(std::sync::PoisonError::into_inner);
866 match &*status {
867 SemanticIndexStatus::Ready { refreshing, .. } => refreshing.clone(),
868 _ => Vec::new(),
869 }
870 };
871 if !refreshing_paths.is_empty() {
872 let mut status = ctx
873 .semantic_index_status()
874 .write()
875 .unwrap_or_else(std::sync::PoisonError::into_inner);
876 for path in &refreshing_paths {
877 status.cancel_refreshing_file(path);
878 }
879 }
880 if !refreshing_paths.is_empty() || had_events {
881 status_changed = true;
882 }
883 }
884
885 if !replay_refresh_paths.is_empty() {
886 {
887 let mut status = ctx
888 .semantic_index_status()
889 .write()
890 .unwrap_or_else(std::sync::PoisonError::into_inner);
891 if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
892 for path in &replay_refresh_paths {
893 status.add_refreshing_file(path.clone());
894 }
895 status_changed = true;
896 }
897 }
898 let sent = ctx.semantic_refresh_sender().is_some_and(|sender| {
899 sender
900 .send(SemanticRefreshRequest::Files {
901 paths: replay_refresh_paths.clone(),
902 })
903 .is_ok()
904 });
905 if !sent {
906 aft::slog_warn!(
907 "semantic refresh worker unavailable; dropping {} replayed corpus file(s)",
908 replay_refresh_paths.len()
909 );
910 let mut status = ctx
911 .semantic_index_status()
912 .write()
913 .unwrap_or_else(std::sync::PoisonError::into_inner);
914 for path in &replay_refresh_paths {
915 status.cancel_refreshing_file(path);
916 }
917 status_changed = true;
918 }
919 }
920
921 maybe_fire_semantic_refresh_probe(ctx);
922
923 if status_changed {
924 ctx.status_emitter().signal(ctx.build_status_snapshot());
925 }
926}
927
928const SOURCE_EXTENSIONS: &[&str] = &[
930 "ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "py", "pyi", "rs", "go",
931];
932
933pub const WATCHER_BATCH_INLINE_CAP: usize = 256;
934
935pub fn watcher_path_is_tsconfig(path: &std::path::Path) -> bool {
943 path.file_name()
944 .and_then(|n| n.to_str())
945 .map(|n| {
946 n == "tsconfig.json"
947 || n == "jsconfig.json"
948 || ((n.starts_with("tsconfig.") || n.starts_with("jsconfig."))
949 && n.ends_with(".json"))
950 })
951 .unwrap_or(false)
952}
953
954pub fn watcher_path_is_source(path: &std::path::Path) -> bool {
955 path.extension()
956 .and_then(|ext| ext.to_str())
957 .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
958}
959
960pub fn watcher_path_is_callgraph_indexed(path: &std::path::Path) -> bool {
968 aft::parser::detect_language(path).is_some()
969}
970
971pub fn semantic_corpus_refresh_in_progress(ctx: &AppContext) -> bool {
972 let status = ctx
973 .semantic_index_status()
974 .read()
975 .unwrap_or_else(std::sync::PoisonError::into_inner);
976 matches!(
977 &*status,
978 SemanticIndexStatus::Building { stage, .. } if stage == "refreshing_corpus"
979 )
980}
981
982#[cfg(debug_assertions)]
983pub fn delay_search_rebuild_publish_for_debug() {
984 let Some(delay_ms) = std::env::var("AFT_TEST_SEARCH_REBUILD_PUBLISH_DELAY_MS")
985 .ok()
986 .and_then(|raw| raw.parse::<u64>().ok())
987 else {
988 return;
989 };
990 thread::sleep(Duration::from_millis(delay_ms));
991}
992
993#[cfg(not(debug_assertions))]
994pub fn delay_search_rebuild_publish_for_debug() {}
995
996pub fn spawn_search_corpus_refresh(
997 ctx: &AppContext,
998 root: std::path::PathBuf,
999 config: Arc<aft::config::Config>,
1000) {
1001 {
1002 let mut search_index = ctx
1003 .search_index()
1004 .write()
1005 .unwrap_or_else(std::sync::PoisonError::into_inner);
1006 if let Some(index) = search_index.as_mut() {
1007 index.ready = false;
1008 }
1009 }
1010
1011 let (tx, rx): (
1012 crossbeam_channel::Sender<aft::search_index::SearchIndex>,
1013 crossbeam_channel::Receiver<aft::search_index::SearchIndex>,
1014 ) = crossbeam_channel::unbounded();
1015 *ctx.search_index_rx()
1016 .write()
1017 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(rx);
1018 ctx.reset_symbol_cache();
1019
1020 let shared_artifacts_read_only = ctx.shared_artifacts_read_only();
1021 let session_id = log_ctx::current_session();
1022 thread::spawn(move || {
1023 log_ctx::with_session(session_id, || {
1024 let cache_dir =
1025 aft::search_index::resolve_cache_dir(&root, config.storage_dir.as_deref());
1026 let _cache_lock = if shared_artifacts_read_only {
1027 None
1028 } else {
1029 match aft::search_index::CacheLock::acquire(&cache_dir) {
1030 Ok(lock) => Some(lock),
1031 Err(error) => {
1032 aft::slog_warn!(
1033 "failed to acquire search cache lock for ignore refresh: {}",
1034 error
1035 );
1036 None
1037 }
1038 }
1039 };
1040 let mut index = aft::search_index::SearchIndex::build_with_limit_to_cache_dir(
1041 &root,
1042 config.search_index_max_file_size,
1043 &cache_dir,
1044 );
1045 delay_search_rebuild_publish_for_debug();
1046 if !shared_artifacts_read_only {
1047 let head = index.stored_git_head().map(str::to_owned);
1048 index.write_to_disk(&cache_dir, head.as_deref());
1049 }
1050 let _ = tx.send(index);
1051 });
1052 });
1053}
1054
1055pub fn refresh_project_corpus(
1056 ctx: &AppContext,
1057 reason: &str,
1058 _invalidate_ignore_paths: bool,
1059) -> bool {
1060 let Some(root) = ctx.canonical_cache_root_opt() else {
1061 return false;
1062 };
1063 if !ctx.heavy_root_work_allowed() {
1064 return false;
1065 }
1066 let config = ctx.config();
1067 let mut status_changed = false;
1068
1069 if !ctx.is_worktree_bridge() {
1070 let callgraph_store_resident = {
1092 let guard = ctx
1093 .callgraph_store()
1094 .read()
1095 .unwrap_or_else(std::sync::PoisonError::into_inner);
1096 guard.is_some()
1097 };
1098 if callgraph_store_resident || ctx.callgraph_store_rx().lock().is_some() {
1099 *ctx.callgraph_store()
1100 .write()
1101 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1102 ctx.mark_callgraph_store_force_rebuild();
1103 status_changed = true;
1104 aft::slog_info!(
1105 "callgraph store scheduled for background rebuild after {}",
1106 reason
1107 );
1108 }
1109 }
1110
1111 if config.search_index && !ctx.shared_artifacts_read_only() {
1112 spawn_search_corpus_refresh(ctx, root.clone(), config.clone());
1113 status_changed = true;
1114 aft::slog_info!("started search index refresh after {}", reason);
1115 }
1116
1117 if config.semantic_search && !ctx.shared_artifacts_read_only() {
1118 if let Some(sender) = ctx.semantic_refresh_sender() {
1119 *ctx.semantic_index_status()
1120 .write()
1121 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1122 SemanticIndexStatus::Building {
1123 stage: "refreshing_corpus".to_string(),
1124 files: None,
1125 entries_done: None,
1126 entries_total: None,
1127 };
1128 match sender.send(SemanticRefreshRequest::Corpus) {
1129 Ok(()) => {
1130 status_changed = true;
1131 }
1132 Err(error) => {
1133 *ctx.semantic_index_status()
1134 .write()
1135 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1136 SemanticIndexStatus::Failed(format!(
1137 "semantic corpus refresh worker unavailable: {error}"
1138 ));
1139 status_changed = true;
1140 }
1141 }
1142 } else if ctx.semantic_index_rx().lock().is_some() {
1143 ctx.mark_pending_semantic_corpus_refresh();
1144 }
1145 }
1146
1147 status_changed
1148}
1149
1150pub fn refresh_corpus_after_ignore_change(ctx: &AppContext) -> bool {
1151 refresh_project_corpus(ctx, "ignore-rule change", true)
1152}
1153
1154pub fn refresh_project_after_watcher_rescan(ctx: &AppContext) -> bool {
1155 if ctx.canonical_cache_root_opt().is_none() {
1156 return false;
1157 }
1158 ctx.clear_pending_index_updates();
1159 ctx.reset_symbol_cache();
1160 let _ = ctx.mark_status_bar_tier2_stale();
1161 ctx.clear_tsconfig_membership_cache();
1162 let mut status_changed = true;
1163
1164 status_changed |= refresh_project_corpus(ctx, "watcher overflow", false);
1165 status_changed
1166}
1167
1168pub fn refresh_callgraph_store_for_watcher(
1169 ctx: &AppContext,
1170 changed: &HashSet<std::path::PathBuf>,
1171) {
1172 if ctx.is_worktree_bridge() || !ctx.heavy_root_work_allowed() {
1173 return;
1174 }
1175 let source_paths = changed
1176 .iter()
1177 .filter(|path| watcher_path_is_callgraph_indexed(path))
1178 .cloned()
1179 .collect::<Vec<_>>();
1180 if source_paths.is_empty() {
1181 return;
1182 }
1183 ctx.revalidate_callgraph_store_generation();
1188 let store = {
1189 let guard = ctx
1190 .callgraph_store()
1191 .read()
1192 .unwrap_or_else(std::sync::PoisonError::into_inner);
1193 guard.as_ref().map(Arc::clone)
1194 };
1195 let Some(store) = store else {
1196 if ctx.callgraph_store_rx().lock().is_some() {
1201 ctx.add_pending_callgraph_store_paths(source_paths);
1202 }
1203 return;
1204 };
1205 if let Err(error) = store.refresh_files(&source_paths) {
1206 aft::slog_warn!("callgraph store refresh failed: {}", error);
1207 match store.mark_files_stale(&source_paths) {
1208 Ok(marked) => aft::slog_warn!(
1209 "marked {} callgraph store file(s) stale after refresh failure",
1210 marked.len()
1211 ),
1212 Err(mark_error) => aft::slog_warn!(
1213 "failed to mark callgraph store files stale after refresh failure: {}",
1214 mark_error
1215 ),
1216 }
1217 }
1218}
1219
1220pub fn drain_watcher_events(ctx: &AppContext) {
1226 let _ = drain_watcher_events_bounded(ctx, usize::MAX);
1227}
1228
1229pub fn drain_watcher_events_bounded(ctx: &AppContext, max_events: usize) -> DrainBatchOutcome {
1230 let mut changed: HashSet<std::path::PathBuf> = HashSet::new();
1231 let mut ignore_file_changed = false;
1232 let mut rescan_required = false;
1233 let mut watcher_failed = None;
1234 let mut root_deleted = false;
1235 let mut outcome = DrainBatchOutcome::default();
1236
1237 {
1238 let rx_ref = ctx.watcher_rx().lock();
1239 let rx = match rx_ref.as_ref() {
1240 Some(rx) => rx,
1241 None => {
1242 ctx.tick_tier2_refresh_scheduler(0);
1243 return outcome; }
1245 };
1246
1247 loop {
1248 if outcome.processed >= max_events {
1249 outcome.has_more = !rx.is_empty();
1250 break;
1251 }
1252 match rx.try_recv() {
1253 Ok(WatcherDispatchEvent::Paths(paths)) => {
1254 outcome.processed += 1;
1255 if !rescan_required {
1256 changed.extend(paths);
1257 }
1258 }
1259 Ok(WatcherDispatchEvent::RescanRequired) => {
1260 outcome.processed += 1;
1261 rescan_required = true;
1262 changed.clear();
1263 }
1264 Ok(WatcherDispatchEvent::IgnoreRulesChanged { path }) => {
1265 outcome.processed += 1;
1266 ignore_file_changed = true;
1267 log::debug!(
1268 "watcher: ignore rules changed at {}, rebuilding matcher",
1269 path.display()
1270 );
1271 if !rescan_required {
1272 if ctx.heavy_root_work_allowed() {
1273 ctx.rebuild_gitignore();
1274 } else {
1275 ctx.clear_gitignore();
1276 }
1277 }
1278 }
1279 Ok(WatcherDispatchEvent::RootDeleted) => {
1280 outcome.processed += 1;
1281 root_deleted = true;
1282 break;
1283 }
1284 Ok(WatcherDispatchEvent::Error(error)) => {
1285 outcome.processed += 1;
1286 watcher_failed = Some(error);
1287 break;
1288 }
1289 Err(crossbeam_channel::TryRecvError::Empty) => break,
1290 Err(crossbeam_channel::TryRecvError::Disconnected) => {
1291 watcher_failed = Some("watcher channel disconnected".to_string());
1292 break;
1293 }
1294 }
1295 }
1296 }
1297
1298 let mut watcher_status_changed = false;
1299 if root_deleted {
1300 ctx.stop_watcher_runtime();
1301 let _ = ctx.add_degraded_reason("project_root_deleted".to_string());
1302 aft::slog_warn!(
1303 "project root deleted; dropping watcher to avoid delete-storm: {:?}",
1304 ctx.canonical_cache_root_opt()
1305 );
1306 watcher_status_changed = true;
1307 changed.clear();
1308 rescan_required = false;
1309 } else if let Some(error) = watcher_failed {
1310 ctx.stop_watcher_runtime();
1311 let _ = ctx.add_degraded_reason("watcher_unavailable".to_string());
1312 aft::slog_warn!(
1313 "file watcher unavailable; continuing without live external-change invalidation: {}",
1314 error
1315 );
1316 watcher_status_changed = true;
1317 rescan_required = false;
1318 }
1319
1320 let heavy_root_work_allowed = ctx.heavy_root_work_allowed();
1321 let mut status_changed = watcher_status_changed;
1322 let mut project_corpus_refresh_requested = false;
1323 if rescan_required {
1324 aft::slog_warn!("watcher overflow: forcing project rescan");
1325 if heavy_root_work_allowed {
1326 ctx.rebuild_gitignore();
1327 } else {
1328 ctx.clear_gitignore();
1329 }
1330 status_changed |= refresh_project_after_watcher_rescan(ctx);
1331 project_corpus_refresh_requested = true;
1332 changed.clear();
1333 } else if ignore_file_changed {
1334 status_changed |= refresh_corpus_after_ignore_change(ctx);
1335 project_corpus_refresh_requested = true;
1336 }
1337
1338 let scheduler_changed_path_count = if rescan_required {
1339 aft::inspect::tier2_scheduler::TIER2_REFRESH_STORM_PATH_THRESHOLD + 1
1340 } else if ignore_file_changed {
1341 changed.len().max(1)
1342 } else {
1343 changed.len()
1344 };
1345 if changed.is_empty() {
1346 if status_changed {
1347 ctx.status_emitter().signal(ctx.build_status_snapshot());
1348 }
1349 ctx.tick_tier2_refresh_scheduler(scheduler_changed_path_count);
1350 return outcome;
1351 }
1352
1353 if heavy_root_work_allowed {
1354 ctx.add_pending_tier2_paths(changed.iter().cloned());
1355 }
1356
1357 if ctx.mark_status_bar_tier2_stale() {
1361 status_changed = true;
1362 }
1363
1364 if changed.iter().any(|path| watcher_path_is_tsconfig(path)) {
1369 ctx.clear_tsconfig_membership_cache();
1370 status_changed = true;
1371 }
1372
1373 let oversized_inline_batch = changed.len() > WATCHER_BATCH_INLINE_CAP;
1374 if oversized_inline_batch {
1375 aft::slog_warn!(
1376 "watcher batch of {} paths exceeds inline cap {}; scheduling corpus refresh",
1377 changed.len(),
1378 WATCHER_BATCH_INLINE_CAP
1379 );
1380 if !project_corpus_refresh_requested {
1381 status_changed |= refresh_project_corpus(ctx, "oversized watcher batch", false);
1382 }
1383 }
1384
1385 let search_build_in_progress = {
1386 let search_index_rx = ctx
1387 .search_index_rx()
1388 .read()
1389 .unwrap_or_else(std::sync::PoisonError::into_inner);
1390 search_index_rx.is_some()
1391 };
1392 if heavy_root_work_allowed
1393 && !ctx.shared_artifacts_read_only()
1394 && !oversized_inline_batch
1395 && search_build_in_progress
1396 {
1397 ctx.add_pending_search_index_paths(changed.iter().cloned());
1398 }
1399 let semantic_source_paths = changed
1400 .iter()
1401 .filter(|path| aft::runtime_drain::watcher_path_is_semantic_source(path))
1402 .cloned()
1403 .collect::<Vec<_>>();
1404 let semantic_build_in_progress = ctx.semantic_index_rx().lock().is_some();
1405 let semantic_corpus_refresh_in_progress = semantic_corpus_refresh_in_progress(ctx);
1406 if heavy_root_work_allowed
1407 && !ctx.shared_artifacts_read_only()
1408 && !oversized_inline_batch
1409 && (semantic_build_in_progress || semantic_corpus_refresh_in_progress)
1410 && !semantic_source_paths.is_empty()
1411 {
1412 ctx.add_pending_semantic_index_paths(semantic_source_paths.clone());
1413 }
1414
1415 if !ctx.shared_artifacts_read_only() {
1416 if let Ok(mut symbol_cache) = ctx.symbol_cache().write() {
1417 for path in &changed {
1418 symbol_cache.invalidate(path);
1419 }
1420 }
1421 }
1422
1423 let mut semantic_refresh_paths = Vec::new();
1424 if heavy_root_work_allowed && !oversized_inline_batch {
1425 refresh_callgraph_store_for_watcher(ctx, &changed);
1426
1427 if !ctx.shared_artifacts_read_only() {
1428 let mut index_ref = ctx
1429 .search_index()
1430 .write()
1431 .unwrap_or_else(std::sync::PoisonError::into_inner);
1432 if let Some(index) = index_ref.as_mut() {
1433 for path in &changed {
1434 if path.exists() {
1435 index.update_file(path);
1436 } else {
1437 index.remove_file(path);
1438 }
1439 }
1440 }
1441 }
1442
1443 let stale_paths = if ctx.shared_artifacts_read_only() {
1444 Vec::new()
1445 } else {
1446 let mut semantic_index_ref = ctx
1447 .semantic_index()
1448 .write()
1449 .unwrap_or_else(std::sync::PoisonError::into_inner);
1450 let mut stale_paths = Vec::new();
1451 if let Some(index) = semantic_index_ref.as_mut() {
1452 for path in &semantic_source_paths {
1453 index.invalidate_file(path);
1454 stale_paths.push(path.clone());
1455 }
1456 }
1457 stale_paths
1458 };
1459 if !stale_paths.is_empty() {
1460 let mut status = ctx
1461 .semantic_index_status()
1462 .write()
1463 .unwrap_or_else(std::sync::PoisonError::into_inner);
1464 if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
1465 for path in &stale_paths {
1466 status.add_refreshing_file(path.clone());
1467 }
1468 semantic_refresh_paths = stale_paths;
1469 status_changed = true;
1470 }
1471 }
1472 }
1473
1474 let mut lsp_resync_paths = Vec::new();
1486 for path in &changed {
1487 if !path.exists() {
1488 if ctx.lsp_clear_diagnostics_for_file(path) {
1489 status_changed = true;
1490 }
1491 continue;
1492 }
1493
1494 let stale = ctx.lsp_mark_diagnostics_stale_for_file(path);
1495 if stale.changed {
1496 status_changed = true;
1497 }
1498 if stale.had_entries {
1499 lsp_resync_paths.push(path.clone());
1500 }
1501 }
1502
1503 for path in &lsp_resync_paths {
1504 ctx.lsp_resync_changed_file_for_diagnostics(path);
1505 }
1506
1507 if !semantic_refresh_paths.is_empty() {
1508 let sent = ctx.semantic_refresh_sender().is_some_and(|sender| {
1509 sender
1510 .send(SemanticRefreshRequest::Files {
1511 paths: semantic_refresh_paths.clone(),
1512 })
1513 .is_ok()
1514 });
1515 if !sent {
1516 aft::slog_warn!(
1517 "semantic refresh worker unavailable; dropping {} refreshing file(s)",
1518 semantic_refresh_paths.len()
1519 );
1520 let mut status = ctx
1521 .semantic_index_status()
1522 .write()
1523 .unwrap_or_else(std::sync::PoisonError::into_inner);
1524 for path in &semantic_refresh_paths {
1525 status.cancel_refreshing_file(path);
1526 }
1527 status_changed = true;
1528 }
1529 }
1530
1531 aft::slog_info!("invalidated {} files", changed.len());
1532 if status_changed {
1533 ctx.status_emitter().signal(ctx.build_status_snapshot());
1534 }
1535 ctx.tick_tier2_refresh_scheduler(scheduler_changed_path_count);
1536 outcome
1537}
1538
1539pub fn drain_lsp_events(ctx: &AppContext) {
1540 let _ = drain_lsp_events_bounded(ctx, usize::MAX);
1541}
1542
1543pub fn drain_lsp_events_bounded(ctx: &AppContext, max_events: usize) -> DrainBatchOutcome {
1544 let drained = {
1545 let mut lsp = ctx.lsp();
1546 lsp.drain_events_bounded(max_events)
1547 };
1548 let outcome = DrainBatchOutcome {
1549 processed: drained.events.len(),
1550 has_more: drained.has_more,
1551 };
1552 let mut status_changed = drained.diagnostics_changed;
1553 for event in drained.events {
1554 match event {
1555 LspEvent::Notification {
1556 server_kind,
1557 root,
1558 method,
1559 params,
1560 } => {
1561 log::debug!(
1562 "[aft-lsp] notification {:?} {} {} {}",
1563 server_kind,
1564 root.display(),
1565 method,
1566 params.unwrap_or(serde_json::Value::Null)
1567 );
1568 }
1569 LspEvent::ServerRequest {
1570 server_kind,
1571 root,
1572 id,
1573 method,
1574 params,
1575 } => {
1576 log::debug!(
1577 "[aft-lsp] request {:?} {} {:?} {} {}",
1578 server_kind,
1579 root.display(),
1580 id,
1581 method,
1582 params.unwrap_or(serde_json::Value::Null)
1583 );
1584 }
1585 LspEvent::ServerExited { server_kind, root } => {
1586 aft::slog_info!("exited {:?} {}", server_kind, root.display());
1587 status_changed = true;
1588 }
1589 }
1590 }
1591 if status_changed {
1592 ctx.status_emitter().signal(ctx.build_status_snapshot());
1593 }
1594 outcome
1595}
1596
1597#[cfg(test)]
1598mod tests {
1599 use super::*;
1600 use crate::config::Config;
1601 use crate::context::{default_language_provider_factory, AppContext};
1602
1603 fn watcher_context(
1604 root: &Path,
1605 ) -> (AppContext, crossbeam_channel::Sender<WatcherDispatchEvent>) {
1606 let ctx = AppContext::new(default_language_provider_factory(), Config::default());
1607 ctx.update_config(|config| {
1608 config.project_root = Some(root.to_path_buf());
1609 });
1610 ctx.set_canonical_cache_root(root.to_path_buf());
1611 let (tx, rx) = crossbeam_channel::unbounded();
1612 *ctx.watcher_rx().lock() = Some(rx);
1613 (ctx, tx)
1614 }
1615
1616 #[test]
1617 fn watcher_drain_batch_cap_yields_with_events_remaining() {
1618 let temp = tempfile::tempdir().unwrap();
1619 let (ctx, tx) = watcher_context(temp.path());
1620 let cap = 3;
1621 for index in 0..(cap * 2 + 1) {
1622 tx.send(WatcherDispatchEvent::Paths(vec![temp
1623 .path()
1624 .join(format!("file-{index}.rs"))]))
1625 .unwrap();
1626 }
1627
1628 let first = drain_watcher_events_bounded(&ctx, cap);
1629
1630 assert_eq!(first.processed, cap);
1631 assert!(first.has_more);
1632 assert_eq!(ctx.pending_tier2_paths().len(), cap);
1633 }
1634
1635 #[test]
1636 fn watcher_drain_requeues_until_all_events_are_applied() {
1637 let temp = tempfile::tempdir().unwrap();
1638 let (ctx, tx) = watcher_context(temp.path());
1639 let cap = 4;
1640 let total = cap * 2 + 3;
1641 for index in 0..total {
1642 tx.send(WatcherDispatchEvent::Paths(vec![temp
1643 .path()
1644 .join(format!("file-{index}.rs"))]))
1645 .unwrap();
1646 }
1647
1648 let mut processed = 0;
1649 loop {
1650 let outcome = drain_watcher_events_bounded(&ctx, cap);
1651 assert!(outcome.processed <= cap);
1652 processed += outcome.processed;
1653 if !outcome.has_more {
1654 break;
1655 }
1656 }
1657
1658 assert_eq!(processed, total);
1659 assert_eq!(ctx.pending_tier2_paths().len(), total);
1660 }
1661}