Skip to main content

aft/
runtime_drain.rs

1use crate as aft;
2use crate::callgraph_store::{invalidates_workspace_crate_prefix_cache, CallGraphStore};
3use crate::context::{
4    AppContext, CallGraphStoreBuildEvent, SemanticIndexEvent, SemanticIndexStatus,
5    SemanticRefreshEvent, SemanticRefreshRequest, WatcherDrainApplyPhase, WatcherDrainPhase,
6    WatcherDrainSliceState,
7};
8use crate::log_ctx;
9use crate::lsp::client::LspEvent;
10use crate::protocol::PushFrame;
11#[cfg(test)]
12use crate::watcher_filter::RescanReason;
13use crate::watcher_filter::{watcher_path_is_infra_skip, WatcherDispatchEvent};
14use std::collections::{BTreeSet, HashSet, VecDeque};
15use std::path::{Path, PathBuf};
16#[cfg(test)]
17use std::sync::atomic::AtomicU64;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::sync::{Arc, Mutex, OnceLock};
20use std::thread;
21use std::time::{Duration, Instant};
22
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub struct DrainBatchOutcome {
25    pub processed: usize,
26    pub has_more: bool,
27}
28
29pub const WATCHER_PATH_DRAIN_BATCH_CAP: usize = 2_048;
30pub const WATCHER_DRAIN_SLICE_BUDGET: Duration = Duration::from_millis(250);
31// View assembly may waste one superseded build, but it must not inherit the
32// semantic embedder's human-edit debounce before publishing callgraph data.
33const VIEW_PUBLICATION_QUIET_WINDOW: Duration = Duration::from_secs(1);
34const WATCHER_DRAIN_UNIT_WARN_AFTER: Duration = Duration::from_secs(5);
35const WATCHER_DRAIN_UNIT_FINAL_AFTER: Duration = Duration::from_secs(30);
36pub const LSP_EVENT_DRAIN_BATCH_CAP: usize = 256;
37
38#[cfg(test)]
39struct ArtifactDrainCommitGate {
40    context_id: usize,
41    reached_tx: crossbeam_channel::Sender<()>,
42    release_rx: crossbeam_channel::Receiver<()>,
43}
44
45#[cfg(test)]
46static ARTIFACT_DRAIN_COMMIT_GATE: OnceLock<Mutex<Option<ArtifactDrainCommitGate>>> =
47    OnceLock::new();
48#[cfg(test)]
49static ARTIFACT_DRAIN_TEST_MUTEX: Mutex<()> = Mutex::new(());
50
51#[cfg(test)]
52struct SemanticRefreshRecoveryGate {
53    context_id: usize,
54    reached_tx: crossbeam_channel::Sender<()>,
55    release_rx: crossbeam_channel::Receiver<()>,
56}
57
58#[cfg(test)]
59static SEMANTIC_REFRESH_RECOVERY_GATE: OnceLock<Mutex<Option<SemanticRefreshRecoveryGate>>> =
60    OnceLock::new();
61
62#[cfg(test)]
63struct WatcherPhaseCommitGate {
64    target: PathBuf,
65    reached_tx: crossbeam_channel::Sender<()>,
66    release_rx: crossbeam_channel::Receiver<()>,
67}
68
69#[cfg(test)]
70static WATCHER_PHASE_COMMIT_GATE: std::sync::OnceLock<Mutex<Option<WatcherPhaseCommitGate>>> =
71    std::sync::OnceLock::new();
72
73#[cfg(test)]
74struct WatcherRescanGate {
75    context_id: usize,
76    reached_tx: crossbeam_channel::Sender<()>,
77    release_rx: crossbeam_channel::Receiver<()>,
78}
79
80#[cfg(test)]
81static WATCHER_RESCAN_GATE: OnceLock<Mutex<Option<WatcherRescanGate>>> = OnceLock::new();
82
83#[cfg(test)]
84fn install_watcher_rescan_gate_for_test(
85    ctx: &AppContext,
86) -> (
87    crossbeam_channel::Receiver<()>,
88    crossbeam_channel::Sender<()>,
89) {
90    let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
91    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
92    *WATCHER_RESCAN_GATE
93        .get_or_init(|| Mutex::new(None))
94        .lock()
95        .expect("watcher rescan gate mutex poisoned") = Some(WatcherRescanGate {
96        context_id: ctx as *const AppContext as usize,
97        reached_tx,
98        release_rx,
99    });
100    (reached_rx, release_tx)
101}
102
103#[cfg(test)]
104fn wait_on_watcher_rescan_gate_for_test(ctx: &AppContext) {
105    let mut slot = WATCHER_RESCAN_GATE
106        .get_or_init(|| Mutex::new(None))
107        .lock()
108        .expect("watcher rescan gate mutex poisoned");
109    if !slot
110        .as_ref()
111        .is_some_and(|gate| gate.context_id == ctx as *const AppContext as usize)
112    {
113        return;
114    }
115    let gate = slot.take();
116    drop(slot);
117    if let Some(gate) = gate {
118        let _ = gate.reached_tx.send(());
119        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
120    }
121}
122
123#[cfg(not(test))]
124fn wait_on_watcher_rescan_gate_for_test(_ctx: &AppContext) {}
125
126#[cfg(test)]
127fn install_watcher_phase_commit_gate_for_test(
128    target: PathBuf,
129) -> (
130    crossbeam_channel::Receiver<()>,
131    crossbeam_channel::Sender<()>,
132) {
133    let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
134    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
135    *WATCHER_PHASE_COMMIT_GATE
136        .get_or_init(|| Mutex::new(None))
137        .lock()
138        .expect("watcher phase commit gate mutex poisoned") = Some(WatcherPhaseCommitGate {
139        target,
140        reached_tx,
141        release_rx,
142    });
143    (reached_rx, release_tx)
144}
145
146#[cfg(test)]
147fn wait_on_watcher_phase_commit_gate_for_test(path: &Path) {
148    let mut slot = WATCHER_PHASE_COMMIT_GATE
149        .get_or_init(|| Mutex::new(None))
150        .lock()
151        .expect("watcher phase commit gate mutex poisoned");
152    if !slot.as_ref().is_some_and(|gate| gate.target == path) {
153        return;
154    }
155    let gate = slot.take();
156    drop(slot);
157    if let Some(gate) = gate {
158        let _ = gate.reached_tx.send(());
159        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
160    }
161}
162
163#[cfg(not(test))]
164fn wait_on_watcher_phase_commit_gate_for_test(_path: &Path) {}
165
166#[cfg(test)]
167fn install_artifact_drain_commit_gate_for_test(
168    ctx: &AppContext,
169) -> (
170    crossbeam_channel::Receiver<()>,
171    crossbeam_channel::Sender<()>,
172) {
173    let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
174    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
175    *ARTIFACT_DRAIN_COMMIT_GATE
176        .get_or_init(|| Mutex::new(None))
177        .lock()
178        .expect("artifact drain commit gate mutex poisoned") = Some(ArtifactDrainCommitGate {
179        context_id: ctx as *const AppContext as usize,
180        reached_tx,
181        release_rx,
182    });
183    (reached_rx, release_tx)
184}
185
186#[cfg(test)]
187fn wait_on_artifact_drain_commit_gate_for_test(ctx: &AppContext) {
188    let mut slot = ARTIFACT_DRAIN_COMMIT_GATE
189        .get_or_init(|| Mutex::new(None))
190        .lock()
191        .expect("artifact drain commit gate mutex poisoned");
192    if !slot
193        .as_ref()
194        .is_some_and(|gate| gate.context_id == ctx as *const AppContext as usize)
195    {
196        return;
197    }
198    let gate = slot.take();
199    drop(slot);
200    if let Some(gate) = gate {
201        let _ = gate.reached_tx.send(());
202        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
203    }
204}
205
206#[cfg(not(test))]
207fn wait_on_artifact_drain_commit_gate_for_test(_ctx: &AppContext) {}
208
209#[cfg(test)]
210fn install_semantic_refresh_recovery_gate_for_test(
211    ctx: &AppContext,
212) -> (
213    crossbeam_channel::Receiver<()>,
214    crossbeam_channel::Sender<()>,
215) {
216    let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
217    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
218    *SEMANTIC_REFRESH_RECOVERY_GATE
219        .get_or_init(|| Mutex::new(None))
220        .lock()
221        .expect("semantic refresh recovery gate mutex poisoned") =
222        Some(SemanticRefreshRecoveryGate {
223            context_id: ctx as *const AppContext as usize,
224            reached_tx,
225            release_rx,
226        });
227    (reached_rx, release_tx)
228}
229
230#[cfg(test)]
231fn wait_on_semantic_refresh_recovery_gate_for_test(ctx: &AppContext) {
232    let mut slot = SEMANTIC_REFRESH_RECOVERY_GATE
233        .get_or_init(|| Mutex::new(None))
234        .lock()
235        .expect("semantic refresh recovery gate mutex poisoned");
236    if !slot
237        .as_ref()
238        .is_some_and(|gate| gate.context_id == ctx as *const AppContext as usize)
239    {
240        return;
241    }
242    let gate = slot.take();
243    drop(slot);
244    if let Some(gate) = gate {
245        let _ = gate.reached_tx.send(());
246        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
247    }
248}
249
250#[cfg(not(test))]
251fn wait_on_semantic_refresh_recovery_gate_for_test(_ctx: &AppContext) {}
252
253struct WatcherDrainUnitGuard<'a> {
254    phase: &'static str,
255    path: &'a Path,
256    batch_len: usize,
257    started: Instant,
258}
259
260impl<'a> WatcherDrainUnitGuard<'a> {
261    fn start(phase: WatcherDrainApplyPhase, path: &'a Path) -> Self {
262        Self {
263            phase: watcher_drain_phase_name(phase),
264            path,
265            batch_len: 1,
266            started: Instant::now(),
267        }
268    }
269
270    fn start_batch(phase: WatcherDrainApplyPhase, path: &'a Path, batch_len: usize) -> Self {
271        Self {
272            phase: watcher_drain_phase_name(phase),
273            path,
274            batch_len,
275            started: Instant::now(),
276        }
277    }
278}
279
280impl Drop for WatcherDrainUnitGuard<'_> {
281    fn drop(&mut self) {
282        let elapsed = self.started.elapsed();
283        let (warn_after, final_after) = watcher_drain_unit_thresholds();
284        if elapsed < warn_after {
285            return;
286        }
287        let path = if self.batch_len == 1 {
288            self.path.display().to_string()
289        } else {
290            format!("{} (+{} paths)", self.path.display(), self.batch_len - 1)
291        };
292        emit_watcher_drain_unit_log(format!(
293            "watcher drain unit exceeded 5s: phase={} path={} elapsed={}ms",
294            self.phase,
295            path,
296            elapsed.as_millis()
297        ));
298        if elapsed >= final_after {
299            emit_watcher_drain_unit_log(format!(
300                "watcher drain unit completed after 30s: phase={} path={} elapsed={}ms",
301                self.phase,
302                path,
303                elapsed.as_millis()
304            ));
305        }
306    }
307}
308
309fn watcher_drain_unit_thresholds() -> (Duration, Duration) {
310    #[cfg(test)]
311    if let Some(thresholds) = WATCHER_UNIT_TEST_THRESHOLDS.with(std::cell::Cell::get) {
312        return thresholds;
313    }
314    (
315        WATCHER_DRAIN_UNIT_WARN_AFTER,
316        WATCHER_DRAIN_UNIT_FINAL_AFTER,
317    )
318}
319
320fn emit_watcher_drain_unit_log(line: String) {
321    log::warn!("{line}");
322    #[cfg(test)]
323    WATCHER_UNIT_TEST_LOGS.with(|logs| logs.borrow_mut().push(line));
324}
325
326#[cfg(test)]
327thread_local! {
328    static WATCHER_UNIT_TEST_DELAY: std::cell::Cell<Duration> = const { std::cell::Cell::new(Duration::ZERO) };
329    static WATCHER_UNIT_TEST_THRESHOLDS: std::cell::Cell<Option<(Duration, Duration)>> = const { std::cell::Cell::new(None) };
330    static WATCHER_UNIT_TEST_LOGS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
331}
332
333#[cfg(test)]
334fn delay_watcher_unit_for_test() {
335    let delay = WATCHER_UNIT_TEST_DELAY.with(std::cell::Cell::get);
336    if !delay.is_zero() {
337        thread::sleep(delay);
338    }
339}
340
341#[cfg(not(test))]
342fn delay_watcher_unit_for_test() {}
343
344pub fn drain_deferred_configure_maintenance(ctx: &AppContext) {
345    crate::commands::configure::drain_deferred_configure_maintenance(ctx);
346}
347
348/// Tracks deferred configure work for the standalone NDJSON loop. The attached-
349/// daemon (`subc`) path schedules this work through its executor instead.
350#[derive(Debug)]
351pub struct StandaloneConfigureMaintenance {
352    inner: crate::commands::configure::ConfigureMaintenanceState,
353}
354
355impl Default for StandaloneConfigureMaintenance {
356    fn default() -> Self {
357        Self {
358            inner: crate::commands::configure::ConfigureMaintenanceState::standalone(),
359        }
360    }
361}
362
363impl StandaloneConfigureMaintenance {
364    pub fn has_pending(&mut self, ctx: &AppContext) -> bool {
365        crate::commands::configure::standalone_configure_maintenance_pending(ctx, &mut self.inner)
366    }
367
368    pub fn drain_prefix(&mut self, ctx: &AppContext) -> bool {
369        crate::commands::configure::drain_standalone_configure_prefix(ctx, &mut self.inner)
370    }
371
372    pub fn drain_one(&mut self, ctx: &AppContext) -> bool {
373        crate::commands::configure::drain_deferred_configure_maintenance_unit(ctx, &mut self.inner)
374    }
375}
376
377const STANDALONE_LOG_SWEEP_ATTEMPT_INTERVAL: Duration = Duration::from_secs(60);
378const CONFIGURE_MAINTENANCE_YIELD_LOG_INTERVAL: Duration = Duration::from_secs(5);
379static STANDALONE_LOG_SWEEP_LAST_ATTEMPT: OnceLock<Mutex<Option<Instant>>> = OnceLock::new();
380static STANDALONE_LOG_SWEEP_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
381static CONFIGURE_MAINTENANCE_YIELD_LAST_LOG: OnceLock<Mutex<Option<Instant>>> = OnceLock::new();
382
383/// Keep shared log-retention I/O off the standalone request loop. The logging
384/// module retains its hourly sweep cadence; this coarser gate avoids spawning a
385/// no-op thread on every 250 ms idle tick.
386pub fn spawn_standalone_log_maintenance() {
387    let now = Instant::now();
388    let should_attempt = STANDALONE_LOG_SWEEP_LAST_ATTEMPT
389        .get_or_init(|| Mutex::new(None))
390        .lock()
391        .map(|mut last_attempt| {
392            if last_attempt.is_some_and(|last| {
393                now.duration_since(last) < STANDALONE_LOG_SWEEP_ATTEMPT_INTERVAL
394            }) {
395                false
396            } else {
397                *last_attempt = Some(now);
398                true
399            }
400        })
401        .unwrap_or(false);
402    if !should_attempt || STANDALONE_LOG_SWEEP_IN_FLIGHT.swap(true, Ordering::AcqRel) {
403        return;
404    }
405
406    if let Err(error) = thread::Builder::new()
407        .name("aft-log-retention-sweep".to_string())
408        .spawn(|| {
409            crate::logging::maybe_sweep_logs();
410            STANDALONE_LOG_SWEEP_IN_FLIGHT.store(false, Ordering::Release);
411        })
412    {
413        STANDALONE_LOG_SWEEP_IN_FLIGHT.store(false, Ordering::Release);
414        crate::slog_warn!("failed to spawn log retention maintenance thread: {error}");
415    }
416}
417
418/// Report cooperative configure work displaced by already-buffered requests.
419/// Bursty clients may trigger many unit boundaries, so emit at most once per
420/// five seconds while preserving the queued count from the observed boundary.
421pub fn note_configure_maintenance_yield(queued: usize) {
422    if queued == 0 {
423        return;
424    }
425    let now = Instant::now();
426    let should_log = CONFIGURE_MAINTENANCE_YIELD_LAST_LOG
427        .get_or_init(|| Mutex::new(None))
428        .lock()
429        .map(|mut last_log| {
430            if last_log.is_some_and(|last| {
431                now.duration_since(last) < CONFIGURE_MAINTENANCE_YIELD_LOG_INTERVAL
432            }) {
433                false
434            } else {
435                *last_log = Some(now);
436                true
437            }
438        })
439        .unwrap_or(false);
440    if should_log {
441        crate::slog_info!(
442            "configure maintenance yielded to {} queued request(s)",
443            queued
444        );
445    }
446}
447
448pub fn drain_configure_warning_events(ctx: &AppContext) {
449    for (generation, frame) in ctx.drain_configure_warnings() {
450        if ctx.configure_generation() != generation {
451            aft::slog_info!(
452                "dropping stale configure_warnings for generation {} (current {})",
453                generation,
454                ctx.configure_generation()
455            );
456            continue;
457        }
458
459        if let Some(sender) = ctx.progress_sender_handle() {
460            sender(PushFrame::ConfigureWarnings(frame));
461        }
462    }
463}
464
465pub fn drain_inspect_events(ctx: &AppContext) {
466    drain_inspect_events_for_generation(ctx, ctx.configure_generation());
467}
468
469pub(crate) fn drain_inspect_events_for_generation(ctx: &AppContext, generation: u64) {
470    let Some((drained, reuse_completed)) = ctx.run_if_subc_bound_generation(generation, || {
471        let drained = ctx.inspect_manager().drain_completions();
472        // Watcher-driven Tier-2 scans complete via the reuse path, which bypasses
473        // `result_rx`/`drain_completions`. Poll the manager's reuse counter so a
474        // background scan still refreshes the bar (#3), otherwise the counts and
475        // `~` marker would only update on a manual `aft_inspect`.
476        (drained, ctx.take_new_reuse_completions())
477    }) else {
478        return;
479    };
480    // A completed background Tier-2 scan refreshes the agent status-bar counts
481    // to the freshly-persisted aggregate, and clears the stale marker, so the
482    // bar reflects the new numbers on the next tool result without waiting for
483    // an explicit aft_inspect call.
484    if drained > 0 || reuse_completed {
485        if let Some(project_root) = ctx.config().project_root.clone() {
486            let inspect_dir = ctx.inspect_dir();
487            let (dead_code, unused_exports, duplicates) = ctx
488                .inspect_manager()
489                .latest_tier2_counts(inspect_dir.clone(), project_root.clone());
490            // Don't clear the `~` stale marker until the whole serial Tier-2
491            // cycle has drained. While any category is still in flight the
492            // already-persisted categories may predate the latest edit, so
493            // claiming fresh would be premature. `None` counts preserve the
494            // last-known value rather than fabricating a `0`.
495            let stale = ctx.inspect_manager().tier2_any_in_flight();
496            ctx.update_status_bar_tier2(dead_code, unused_exports, duplicates, None, stale);
497            // Health must distinguish "tier2 still building" from "tier2 complete
498            // except dead_code, which is blocked on the callgraph store". Refresh
499            // the flag from the same latest aggregate the counts came from so the
500            // two never disagree.
501            let blocked = ctx
502                .inspect_manager()
503                .dead_code_blocked_on_callgraph(inspect_dir, project_root);
504            ctx.set_status_bar_tier2_dead_code_blocked_on_callgraph(blocked);
505            ctx.status_emitter().signal(ctx.build_status_snapshot());
506        }
507    }
508}
509
510/// Drain all background build-completion receivers in standalone order.
511///
512/// Search installs first so watcher/pending updates apply to the freshest index,
513/// followed by callgraph store and semantic index completion.
514pub fn drain_build_completions(ctx: &AppContext) {
515    drain_search_index_events(ctx);
516    drain_callgraph_store_events(ctx);
517    drain_semantic_index_events(ctx);
518}
519
520/// Return true when any background build-completion receiver is currently set.
521///
522/// Each receiver is checked under its own short lock; no lock is held while
523/// checking the next subsystem.
524pub fn any_build_in_flight(ctx: &AppContext) -> bool {
525    {
526        let rx = ctx
527            .search_index_rx()
528            .read()
529            .unwrap_or_else(std::sync::PoisonError::into_inner);
530        if rx.is_some() {
531            return true;
532        }
533    }
534
535    {
536        let rx = ctx.callgraph_store_rx().lock();
537        if rx.is_some() {
538            return true;
539        }
540    }
541
542    {
543        let rx = ctx.semantic_index_rx().lock();
544        rx.is_some()
545    }
546}
547
548pub fn watcher_path_is_ignored_by_current_matcher(ctx: &AppContext, path: &Path) -> bool {
549    if watcher_path_is_infra_skip(path) {
550        return true;
551    }
552
553    if let Some(matcher) = ctx.gitignore() {
554        if path.starts_with(matcher.path()) {
555            let is_dir = path.is_dir();
556            return matcher
557                .matched_path_or_any_parents(path, is_dir)
558                .is_ignore();
559        }
560    }
561
562    false
563}
564
565fn replay_search_index_pending_updates(
566    ctx: &AppContext,
567    index: &mut crate::search_index::SearchIndex,
568    pending_paths: Vec<std::path::PathBuf>,
569) {
570    for path in pending_paths {
571        if path.exists() {
572            if watcher_path_is_ignored_by_current_matcher(ctx, &path) {
573                index.remove_file(&path);
574            } else {
575                index.update_file(&path);
576            }
577        } else {
578            index.remove_file(&path);
579        }
580    }
581}
582
583pub fn watcher_path_is_semantic_source(path: &Path) -> bool {
584    crate::semantic_index::is_semantic_indexed_extension(path)
585}
586
587pub fn mark_semantic_corpus_refresh_success(ctx: &AppContext) {
588    ctx.clear_all_semantic_refresh_retry_attempts();
589    ctx.reset_semantic_refresh_circuit_after_success();
590}
591
592pub fn drain_search_index_events(ctx: &AppContext) {
593    let (latest, disconnected, receiver_generation, receiver_epoch) = {
594        let rx_ref = ctx
595            .search_index_rx()
596            .read()
597            .unwrap_or_else(std::sync::PoisonError::into_inner);
598        let Some(rx) = rx_ref.as_ref() else {
599            return;
600        };
601
602        let mut latest = None;
603        let mut disconnected = false;
604        loop {
605            match rx.try_recv() {
606                Ok(index) => latest = Some(index),
607                Err(crossbeam_channel::TryRecvError::Empty) => break,
608                Err(crossbeam_channel::TryRecvError::Disconnected) => {
609                    disconnected = true;
610                    break;
611                }
612            }
613        }
614        (
615            latest,
616            disconnected,
617            ctx.search_index_rx_generation(),
618            ctx.search_index_rx_epoch(),
619        )
620    };
621
622    let mut installed_index = false;
623    if let Some(mut index) = latest {
624        wait_on_artifact_drain_commit_gate_for_test(ctx);
625        installed_index = ctx
626            .with_current_search_index_rx(receiver_generation, receiver_epoch, |receiver| {
627                let pending_paths = ctx.take_pending_search_index_paths();
628                if !pending_paths.is_empty() {
629                    replay_search_index_pending_updates(ctx, &mut index, pending_paths);
630                }
631                *ctx.search_index()
632                    .write()
633                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
634                *receiver = None;
635                true
636            })
637            .unwrap_or(false);
638        if !installed_index {
639            return;
640        }
641        ctx.note_search_index_load_succeeded();
642    } else if disconnected {
643        let cleared = ctx
644            .with_current_search_index_rx(receiver_generation, receiver_epoch, |receiver| {
645                *receiver = None;
646                let mut search_index = ctx
647                    .search_index()
648                    .write()
649                    .unwrap_or_else(std::sync::PoisonError::into_inner);
650                // A build-denied index is a terminal settled state (write access
651                // refused), not an in-progress build, so keep it: clearing it
652                // would flip health back to "building" for a root that can never
653                // produce a real index here.
654                if search_index
655                    .as_ref()
656                    .is_some_and(|index| !index.ready && !index.build_denied)
657                {
658                    *search_index = None;
659                }
660                true
661            })
662            .unwrap_or(false);
663        if !cleared {
664            return;
665        }
666        // The worker dropped its sender without delivering an index (a lost
667        // load). Left alone, the root now has no index and no receiver, so health
668        // would report "building" forever — nothing reschedules the load until a
669        // later search query happens to run. Schedule one automatic replacement
670        // load (capped per configure generation) so the root recovers on its own.
671        crate::commands::configure::restart_search_index_after_load_disconnect(ctx);
672    }
673
674    if installed_index || disconnected {
675        ctx.status_emitter().signal(ctx.build_status_snapshot());
676    }
677}
678
679pub fn drain_callgraph_store_events(ctx: &AppContext) {
680    let (
681        latest,
682        denied,
683        suspended,
684        settled,
685        disconnected,
686        fulfilled_force_token,
687        receiver_generation,
688        receiver_epoch,
689    ) = {
690        let rx_ref = ctx.callgraph_store_rx().lock();
691        let Some(rx) = rx_ref.as_ref() else {
692            return;
693        };
694
695        let mut latest = None;
696        let mut denied = None;
697        let mut suspended = None;
698        let mut settled = false;
699        let mut fulfilled_force_token = None;
700        let mut disconnected = false;
701        loop {
702            match rx.try_recv() {
703                Ok(CallGraphStoreBuildEvent::Ready {
704                    store,
705                    fulfilled_force_token: token,
706                    publication_epoch,
707                }) => {
708                    if ctx.callgraph_persist_epoch_flag().current() == publication_epoch {
709                        latest = Some(store);
710                        fulfilled_force_token = token;
711                    } else {
712                        // A newer configure advanced the persist epoch after this
713                        // build published; its generation is already superseded on
714                        // disk, so treat the event as settled instead of installing
715                        // the stale handle in RAM.
716                        drop(store);
717                        settled = true;
718                    }
719                }
720                Ok(CallGraphStoreBuildEvent::Denied { reason }) => denied = Some(reason),
721                Ok(CallGraphStoreBuildEvent::Suspended { suspension }) => {
722                    suspended = Some(suspension)
723                }
724                Ok(CallGraphStoreBuildEvent::Settled) => settled = true,
725                Err(crossbeam_channel::TryRecvError::Empty) => break,
726                Err(crossbeam_channel::TryRecvError::Disconnected) => {
727                    disconnected = true;
728                    break;
729                }
730            }
731        }
732        (
733            latest,
734            denied,
735            suspended,
736            settled,
737            disconnected,
738            fulfilled_force_token,
739            ctx.callgraph_store_rx_generation(),
740            ctx.callgraph_store_rx_epoch(),
741        )
742    };
743
744    let ready_received = latest.is_some();
745    let terminal =
746        ready_received || denied.is_some() || suspended.is_some() || settled || disconnected;
747    if !terminal {
748        return;
749    }
750    if let Some(project_root) = ctx.callgraph_project_root() {
751        crate::logging::release_index_build_start_waiters(
752            crate::logging::IndexPlane::Callgraph,
753            &project_root,
754        );
755    }
756    wait_on_artifact_drain_commit_gate_for_test(ctx);
757
758    let mut reopened = None;
759    if let Some(store) = latest {
760        // Release the cold-build writer lease before opening the published
761        // generation through its read-only pointer.
762        drop(store);
763        if let Some(project_root) = ctx.callgraph_project_root() {
764            match CallGraphStore::open_readonly(ctx.callgraph_store_dir(), project_root) {
765                Ok(Some(store)) => reopened = Some(Arc::new(store)),
766                Ok(None) => {
767                    crate::slog_warn!(
768                        "callgraph store build completed without a readable published generation"
769                    );
770                }
771                Err(error) => {
772                    crate::slog_warn!("failed to install read-only callgraph store: {}", error);
773                }
774            }
775        }
776    }
777
778    let mut pending = Vec::new();
779    let installed =
780        ctx.with_current_callgraph_store_rx(receiver_generation, receiver_epoch, |receiver| {
781            let installed = if let Some(store) = reopened {
782                ctx.clear_callgraph_store_build_denied();
783                *ctx.callgraph_store()
784                    .write()
785                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(store);
786                // This take and the refresh worker's post-defer re-check form a
787                // check-then-act handoff: one site sees parked paths with a
788                // ready current store, so neither site needs to poll alone.
789                pending = ctx
790                    .take_pending_callgraph_store_paths()
791                    .into_iter()
792                    .filter(|path| {
793                        invalidates_workspace_crate_prefix_cache(path)
794                            || !watcher_path_is_generated_for_callgraph(ctx, path)
795                    })
796                    .collect();
797                true
798            } else {
799                false
800            };
801            if let Some(reason) = denied {
802                ctx.record_callgraph_store_build_denied(receiver_generation, reason);
803            }
804            if let Some(suspension) = suspended {
805                ctx.record_callgraph_store_build_suspension(receiver_generation, suspension);
806            }
807            if terminal {
808                *receiver = None;
809            }
810            if installed {
811                if let Some(force_token) = fulfilled_force_token {
812                    ctx.fulfill_callgraph_store_force_token(force_token);
813                }
814            }
815            installed
816        });
817    let Some(installed) = installed else {
818        return;
819    };
820
821    if installed {
822        if !pending.is_empty() {
823            let _ = ctx.enqueue_callgraph_store_refresh(pending);
824        }
825        let _ = ctx.request_tier2_refresh_pull();
826    }
827    if terminal {
828        ctx.status_emitter().signal(ctx.build_status_snapshot());
829    }
830}
831
832pub fn drain_semantic_index_events(ctx: &AppContext) {
833    let (events, disconnected, receiver_generation, receiver_epoch) = {
834        let rx_ref = ctx.semantic_index_rx().lock();
835        let Some(rx) = rx_ref.as_ref() else {
836            return;
837        };
838
839        let mut events = Vec::new();
840        let mut disconnected = false;
841        loop {
842            match rx.try_recv() {
843                Ok(event) => events.push(event),
844                Err(crossbeam_channel::TryRecvError::Empty) => break,
845                Err(crossbeam_channel::TryRecvError::Disconnected) => {
846                    disconnected = true;
847                    break;
848                }
849            }
850        }
851        (
852            events,
853            disconnected,
854            ctx.semantic_index_rx_generation(),
855            ctx.semantic_index_rx_epoch(),
856        )
857    };
858
859    if events.is_empty() && !disconnected {
860        return;
861    }
862
863    wait_on_artifact_drain_commit_gate_for_test(ctx);
864    let mut terminal = false;
865    let mut status_changed = false;
866    let mut replay_refresh_paths = Vec::new();
867    let mut replay_corpus_refresh = false;
868    let mut cold_seed_resumes = Vec::new();
869
870    for event in events {
871        match event {
872            SemanticIndexEvent::Progress {
873                stage,
874                files,
875                entries_done,
876                entries_total,
877            } => {
878                let committed = ctx
879                    .with_current_semantic_index_rx(
880                        receiver_generation,
881                        receiver_epoch,
882                        |_receiver| {
883                            *ctx.semantic_index_status()
884                                .write()
885                                .unwrap_or_else(std::sync::PoisonError::into_inner) =
886                                SemanticIndexStatus::Building {
887                                    stage,
888                                    files,
889                                    entries_done,
890                                    entries_total,
891                                };
892                            true
893                        },
894                    )
895                    .unwrap_or(false);
896                if !committed {
897                    return;
898                }
899                status_changed = true;
900            }
901            SemanticIndexEvent::ColdSeedGateCleared => {
902                let resume = ctx.with_current_semantic_index_rx(
903                    receiver_generation,
904                    receiver_epoch,
905                    |_receiver| ctx.take_semantic_cold_seed_resume(true),
906                );
907                let Some(resume) = resume else {
908                    return;
909                };
910                cold_seed_resumes.push(resume);
911            }
912            SemanticIndexEvent::Ready(mut index) => {
913                let committed = ctx.with_current_semantic_index_rx(
914                    receiver_generation,
915                    receiver_epoch,
916                    |receiver| {
917                        mark_semantic_corpus_refresh_success(ctx);
918                        let refresh_paths = ctx
919                            .take_pending_semantic_index_paths()
920                            .into_iter()
921                            .filter(|path| watcher_path_is_semantic_source(path))
922                            .collect::<Vec<_>>();
923                        index.invalidate_files(&refresh_paths);
924                        let corpus_refresh = ctx.take_pending_semantic_corpus_refresh()
925                            && !ctx.shared_artifacts_read_only();
926                        if let Some(root) = ctx.canonical_cache_root_opt() {
927                            let semantic_config = ctx.config().semantic.clone();
928                            let _ = index.adopt_frozen_base_for_root(&root, &semantic_config);
929                        }
930                        *ctx.semantic_index()
931                            .write()
932                            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
933                        *ctx.semantic_index_status()
934                            .write()
935                            .unwrap_or_else(std::sync::PoisonError::into_inner) =
936                            SemanticIndexStatus::ready();
937                        *receiver = None;
938                        (
939                            ctx.take_semantic_cold_seed_resume(false),
940                            refresh_paths,
941                            corpus_refresh,
942                        )
943                    },
944                );
945                let Some((resume, refresh_paths, corpus_refresh)) = committed else {
946                    return;
947                };
948                cold_seed_resumes.push(resume);
949                replay_refresh_paths.extend(refresh_paths);
950                replay_corpus_refresh = corpus_refresh;
951                terminal = true;
952                status_changed = true;
953            }
954            SemanticIndexEvent::Failed(error) => {
955                let committed = ctx.with_current_semantic_index_rx(
956                    receiver_generation,
957                    receiver_epoch,
958                    |receiver| {
959                        let _ = ctx.take_pending_semantic_index_paths();
960                        let _ = ctx.take_pending_semantic_corpus_refresh();
961                        *ctx.semantic_index()
962                            .write()
963                            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
964                        ctx.clear_semantic_refresh_worker();
965                        *ctx.semantic_index_status()
966                            .write()
967                            .unwrap_or_else(std::sync::PoisonError::into_inner) =
968                            SemanticIndexStatus::Failed(error);
969                        *receiver = None;
970                        ctx.take_semantic_cold_seed_resume(false)
971                    },
972                );
973                let Some(resume) = committed else {
974                    return;
975                };
976                cold_seed_resumes.push(resume);
977                terminal = true;
978                status_changed = true;
979            }
980        }
981    }
982
983    if terminal
984        && matches!(
985            &*ctx
986                .semantic_index_status()
987                .read()
988                .unwrap_or_else(std::sync::PoisonError::into_inner),
989            SemanticIndexStatus::Ready { .. }
990        )
991    {
992        let _ = crate::commands::configure::ensure_ready_semantic_refresh_worker(ctx);
993    }
994
995    if terminal
996        && ctx.config().views.enabled
997        && matches!(
998            &*ctx
999                .semantic_index_status()
1000                .read()
1001                .unwrap_or_else(std::sync::PoisonError::into_inner),
1002            SemanticIndexStatus::Ready { .. }
1003        )
1004    {
1005        // A refresh completing only matters to the view for the paths whose
1006        // semantic plane was still pending; republishing the whole checkout
1007        // on every refresh re-derived every entry and rescanned the live index
1008        // for nothing (soak finding, 2026-09-12).
1009        if let Some(pending) = semantic_ready_view_publication_paths(
1010            ctx.view_runtime_snapshot()
1011                .map(|snapshot| snapshot.pending_paths),
1012        ) {
1013            if let Err(error) = crate::executor::view_publication::schedule(ctx, pending, true) {
1014                aft::slog_warn!("semantic-ready view publication failed: {}", error);
1015            }
1016        }
1017    }
1018
1019    if disconnected && !terminal {
1020        let committed =
1021            ctx.with_current_semantic_index_rx(receiver_generation, receiver_epoch, |receiver| {
1022                let _ = ctx.take_pending_semantic_index_paths();
1023                let _ = ctx.take_pending_semantic_corpus_refresh();
1024                *ctx.semantic_index()
1025                    .write()
1026                    .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1027                ctx.clear_semantic_refresh_worker();
1028                *ctx.semantic_index_status()
1029                    .write()
1030                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
1031                    SemanticIndexStatus::Failed(
1032                        "semantic index build worker disconnected before reporting completion"
1033                            .to_string(),
1034                    );
1035                *receiver = None;
1036                ctx.take_semantic_cold_seed_resume(false)
1037            });
1038        let Some(resume) = committed else {
1039            return;
1040        };
1041        cold_seed_resumes.push(resume);
1042        status_changed = true;
1043    }
1044
1045    if terminal || disconnected {
1046        ctx.set_semantic_build_progress(None);
1047    }
1048
1049    for resume in cold_seed_resumes {
1050        ctx.apply_semantic_cold_seed_resume(resume);
1051    }
1052
1053    if replay_corpus_refresh {
1054        let replayed = ctx.run_if_subc_bound_generation(receiver_generation, || {
1055            if ctx.semantic_index_rx_epoch() != receiver_epoch
1056                || ctx.canonical_cache_root_opt().is_none()
1057            {
1058                return false;
1059            }
1060            *ctx.semantic_index_status()
1061                .write()
1062                .unwrap_or_else(std::sync::PoisonError::into_inner) =
1063                SemanticIndexStatus::Building {
1064                    stage: "refreshing_corpus".to_string(),
1065                    files: None,
1066                    entries_done: None,
1067                    entries_total: None,
1068                };
1069            let sent = ctx
1070                .semantic_refresh_sender()
1071                .is_some_and(|sender| sender.send(SemanticRefreshRequest::Corpus).is_ok());
1072            if !sent {
1073                *ctx.semantic_index_status()
1074                    .write()
1075                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
1076                    SemanticIndexStatus::Failed(
1077                        "semantic corpus refresh worker unavailable".to_string(),
1078                    );
1079            }
1080            true
1081        });
1082        if replayed != Some(true) {
1083            return;
1084        }
1085        status_changed = true;
1086    } else if !replay_refresh_paths.is_empty() {
1087        let replayed = ctx.run_if_subc_bound_generation(receiver_generation, || {
1088            if ctx.semantic_index_rx_epoch() != receiver_epoch {
1089                return false;
1090            }
1091            {
1092                let mut status = ctx
1093                    .semantic_index_status()
1094                    .write()
1095                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1096                if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
1097                    for path in &replay_refresh_paths {
1098                        status.add_refreshing_file(path.clone());
1099                    }
1100                }
1101            }
1102            let sent = ctx.semantic_refresh_sender().is_some_and(|sender| {
1103                sender
1104                    .send(SemanticRefreshRequest::Files {
1105                        paths: replay_refresh_paths.clone(),
1106                    })
1107                    .is_ok()
1108            });
1109            if !sent {
1110                crate::slog_warn!(
1111                    "semantic refresh worker unavailable; dropping {} replayed file(s)",
1112                    replay_refresh_paths.len()
1113                );
1114                let mut status = ctx
1115                    .semantic_index_status()
1116                    .write()
1117                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1118                for path in &replay_refresh_paths {
1119                    status.cancel_refreshing_file(path);
1120                }
1121            }
1122            true
1123        });
1124        if replayed != Some(true) {
1125            return;
1126        }
1127        status_changed = true;
1128    }
1129
1130    if status_changed {
1131        ctx.status_emitter().signal(ctx.build_status_snapshot());
1132    }
1133}
1134
1135pub const MAX_RETRY_ATTEMPTS: usize = 6;
1136pub const BREAKER_TRIP_THRESHOLD: usize = 3;
1137
1138#[cfg(test)]
1139static SEMANTIC_REFRESH_RETRY_DELAY_OVERRIDE_MS: AtomicU64 = AtomicU64::new(u64::MAX);
1140
1141/// Backoff for live semantic refresh retries after a transient embedding backend
1142/// failure. Mirrors the cold-build retry cadence (15s -> 30s -> 60s capped) so
1143/// a down backend cannot spin the watcher/refresh loop hot while still
1144/// self-healing once the backend returns.
1145fn semantic_refresh_retry_backoff(attempt: usize) -> Duration {
1146    #[cfg(test)]
1147    {
1148        let override_ms = SEMANTIC_REFRESH_RETRY_DELAY_OVERRIDE_MS.load(Ordering::SeqCst);
1149        if override_ms != u64::MAX {
1150            return Duration::from_millis(override_ms);
1151        }
1152    }
1153    // Test seam, intentionally matching the build-level retry override.
1154    if let Ok(raw) = std::env::var("AFT_SEMANTIC_RETRY_BACKOFF_MS") {
1155        if let Ok(ms) = raw.parse::<u64>() {
1156            return Duration::from_millis(ms);
1157        }
1158    }
1159    const SCHEDULE_SECS: [u64; 3] = [15, 30, 60];
1160    let secs = SCHEDULE_SECS
1161        .get(attempt)
1162        .copied()
1163        .unwrap_or(*SCHEDULE_SECS.last().unwrap());
1164    Duration::from_secs(secs)
1165}
1166
1167struct SemanticRefreshRetryPlan {
1168    retry_paths: Vec<std::path::PathBuf>,
1169    capped_paths: Vec<std::path::PathBuf>,
1170    delay: Option<Duration>,
1171}
1172
1173fn next_semantic_refresh_retry_plan(
1174    ctx: &AppContext,
1175    paths: Vec<std::path::PathBuf>,
1176) -> SemanticRefreshRetryPlan {
1177    let mut retry_paths = Vec::new();
1178    let mut capped_paths = Vec::new();
1179    let mut max_attempt = 0usize;
1180
1181    ctx.with_semantic_refresh_retry_attempts_mut(|attempts| {
1182        for path in paths {
1183            let attempt = attempts.get(&path).copied().unwrap_or(0);
1184            if attempt >= MAX_RETRY_ATTEMPTS {
1185                capped_paths.push(path);
1186                continue;
1187            }
1188            max_attempt = max_attempt.max(attempt);
1189            attempts.insert(path.clone(), attempt.saturating_add(1));
1190            retry_paths.push(path);
1191        }
1192    });
1193
1194    let delay = if retry_paths.is_empty() {
1195        None
1196    } else {
1197        Some(semantic_refresh_retry_backoff(max_attempt))
1198    };
1199
1200    SemanticRefreshRetryPlan {
1201        retry_paths,
1202        capped_paths,
1203        delay,
1204    }
1205}
1206
1207fn clear_semantic_refresh_retry_attempts(ctx: &AppContext, paths: &[std::path::PathBuf]) {
1208    ctx.clear_semantic_refresh_retry_attempts(paths);
1209}
1210
1211fn clear_completed_pending_semantic_index_paths(
1212    ctx: &AppContext,
1213    completed_paths: &[std::path::PathBuf],
1214) {
1215    if completed_paths.is_empty() {
1216        return;
1217    }
1218
1219    let completed = completed_paths.iter().cloned().collect::<HashSet<_>>();
1220    let remaining = ctx
1221        .take_pending_semantic_index_paths()
1222        .into_iter()
1223        .filter(|path| !completed.contains(path))
1224        .collect::<Vec<_>>();
1225    if !remaining.is_empty() {
1226        ctx.add_pending_semantic_index_paths(remaining);
1227    }
1228}
1229
1230fn semantic_refresh_probe_delay() -> Duration {
1231    semantic_refresh_retry_backoff(usize::MAX)
1232}
1233
1234pub fn semantic_refresh_circuit_is_open(ctx: &AppContext) -> bool {
1235    ctx.semantic_refresh_circuit_is_open()
1236}
1237
1238pub fn record_semantic_refresh_transient_failure(ctx: &AppContext, error: &str) -> bool {
1239    let reason = aft::semantic_index::strip_transient_embedding_marker(error);
1240    ctx.record_semantic_refresh_transient_failure(BREAKER_TRIP_THRESHOLD, &reason)
1241}
1242
1243fn reset_semantic_refresh_transient_failure_count(ctx: &AppContext) {
1244    ctx.reset_semantic_refresh_transient_failure_count();
1245}
1246
1247fn reset_semantic_refresh_circuit_after_success(ctx: &AppContext) {
1248    ctx.reset_semantic_refresh_circuit_after_success();
1249}
1250
1251fn mark_semantic_refresh_success(ctx: &AppContext, completed_paths: &[std::path::PathBuf]) {
1252    clear_semantic_refresh_retry_attempts(ctx, completed_paths);
1253    clear_completed_pending_semantic_index_paths(ctx, completed_paths);
1254    reset_semantic_refresh_circuit_after_success(ctx);
1255}
1256
1257#[doc(hidden)]
1258pub fn semantic_refresh_transient_failure_count_for_test(ctx: &AppContext) -> usize {
1259    ctx.semantic_refresh_transient_failure_count()
1260}
1261
1262#[doc(hidden)]
1263pub fn semantic_refresh_probe_is_scheduled_for_test(ctx: &AppContext) -> bool {
1264    ctx.semantic_refresh_probe_is_scheduled()
1265}
1266
1267fn ensure_semantic_refresh_probe_scheduled(ctx: &AppContext) {
1268    ctx.ensure_semantic_refresh_probe_scheduled(semantic_refresh_probe_delay());
1269}
1270
1271fn maybe_fire_semantic_refresh_probe(ctx: &AppContext) {
1272    let generation = ctx.semantic_refresh_generation();
1273    let _ = ctx.run_if_subc_bound_generation(generation, || {
1274        if !ctx.take_semantic_refresh_probe_ready() {
1275            return;
1276        }
1277        if !semantic_refresh_circuit_is_open(ctx) {
1278            return;
1279        }
1280
1281        if ctx.take_pending_semantic_corpus_refresh() && !ctx.shared_artifacts_read_only() {
1282            // Stamp the status BEFORE sending: the worker emits CorpusStarted
1283            // only after walking the project, and an unbind cancellation in
1284            // that window preserves corpus intent by reading
1285            // corpus_refresh_in_flight() from the status. A send without the
1286            // stamp would lose the intent entirely.
1287            let previous_status = {
1288                let mut status = ctx
1289                    .semantic_index_status()
1290                    .write()
1291                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1292                let previous = status.clone();
1293                *status = SemanticIndexStatus::Building {
1294                    stage: "refreshing_corpus".to_string(),
1295                    files: None,
1296                    entries_done: None,
1297                    entries_total: None,
1298                };
1299                previous
1300            };
1301            let sent = ctx
1302                .semantic_refresh_sender()
1303                .is_some_and(|sender| sender.send(SemanticRefreshRequest::Corpus).is_ok());
1304            if !sent {
1305                *ctx.semantic_index_status()
1306                    .write()
1307                    .unwrap_or_else(std::sync::PoisonError::into_inner) = previous_status;
1308                ctx.mark_pending_semantic_corpus_refresh();
1309            }
1310            return;
1311        }
1312
1313        let pending_paths = ctx.take_pending_semantic_index_paths();
1314        if pending_paths.is_empty() {
1315            return;
1316        }
1317
1318        let sent = ctx.semantic_refresh_sender().is_some_and(|sender| {
1319            sender
1320                .send(SemanticRefreshRequest::Files {
1321                    paths: pending_paths.clone(),
1322                })
1323                .is_ok()
1324        });
1325        if !sent {
1326            ctx.add_pending_semantic_index_paths(pending_paths);
1327        }
1328    });
1329}
1330
1331pub fn schedule_semantic_refresh_retry(
1332    ctx: &AppContext,
1333    paths: Vec<std::path::PathBuf>,
1334    error: &str,
1335) -> bool {
1336    if paths.is_empty() {
1337        return false;
1338    }
1339    if ctx.semantic_refresh_sender().is_none() {
1340        return false;
1341    };
1342
1343    let SemanticRefreshRetryPlan {
1344        retry_paths,
1345        capped_paths,
1346        delay,
1347    } = next_semantic_refresh_retry_plan(ctx, paths);
1348
1349    if !capped_paths.is_empty() {
1350        aft::slog_warn!(
1351            "semantic refresh retry limit reached for {} file(s); preserving for next watcher/configure refresh",
1352            capped_paths.len(),
1353        );
1354        ctx.add_pending_semantic_index_paths(capped_paths);
1355    }
1356
1357    let Some(delay) = delay else {
1358        return true;
1359    };
1360
1361    let clean = aft::semantic_index::strip_transient_embedding_marker(error);
1362    aft::slog_warn!(
1363        "semantic refresh hit a transient backend error ({}); retrying {} file(s) in {}ms",
1364        clean,
1365        retry_paths.len(),
1366        delay.as_millis(),
1367    );
1368
1369    let session_id = log_ctx::current_session();
1370    let generation = ctx.semantic_refresh_generation();
1371    let generation_flag = ctx.configure_generation_flag();
1372    let lifecycle = ctx.subc_lifecycle_admission();
1373    let (sender_slot, pending_paths_slot) = ctx.semantic_refresh_retry_slots();
1374    thread::spawn(move || {
1375        log_ctx::with_session(session_id, || {
1376            thread::sleep(delay);
1377            let _ = lifecycle.run_if_current(&generation_flag, generation, || {
1378                let sent = sender_slot.lock().as_ref().is_some_and(|sender| {
1379                    sender
1380                        .send(SemanticRefreshRequest::Files {
1381                            paths: retry_paths.clone(),
1382                        })
1383                        .is_ok()
1384                });
1385                if !sent {
1386                    pending_paths_slot.lock().extend(retry_paths);
1387                }
1388            });
1389        });
1390    });
1391    true
1392}
1393
1394pub fn drain_semantic_refresh_events(ctx: &AppContext) {
1395    let (events, disconnected, receiver_generation, receiver_epoch) = {
1396        let rx_ref = ctx.semantic_refresh_event_rx().lock();
1397        let Some(rx) = rx_ref.as_ref() else {
1398            return;
1399        };
1400
1401        let mut events = Vec::new();
1402        let mut disconnected = false;
1403        loop {
1404            match rx.try_recv() {
1405                Ok(event) => events.push(event),
1406                Err(crossbeam_channel::TryRecvError::Empty) => break,
1407                Err(crossbeam_channel::TryRecvError::Disconnected) => {
1408                    disconnected = true;
1409                    break;
1410                }
1411            }
1412        }
1413        (
1414            events,
1415            disconnected,
1416            ctx.semantic_refresh_generation(),
1417            ctx.semantic_refresh_epoch(),
1418        )
1419    };
1420
1421    if events.is_empty() && !disconnected {
1422        maybe_fire_semantic_refresh_probe(ctx);
1423        return;
1424    }
1425
1426    wait_on_artifact_drain_commit_gate_for_test(ctx);
1427    let committed = ctx.with_current_semantic_refresh_rx(
1428        receiver_generation,
1429        receiver_epoch,
1430        || {
1431        let had_events = !events.is_empty();
1432        let mut status_changed = false;
1433        let mut replay_refresh_paths = Vec::new();
1434        let mut schedule_breaker_probe = false;
1435        let mut view_refresh_completed = false;
1436        for event in events {
1437        match event {
1438            SemanticRefreshEvent::Started { paths } => {
1439                let mut status = ctx
1440                    .semantic_index_status()
1441                    .write()
1442                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1443                if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
1444                    for path in paths {
1445                        status.start_refreshing_file(path);
1446                    }
1447                    status_changed = true;
1448                }
1449            }
1450            SemanticRefreshEvent::CorpusStarted { files } => {
1451                *ctx.semantic_index_status()
1452                    .write()
1453                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
1454                    SemanticIndexStatus::Building {
1455                        stage: "refreshing_corpus".to_string(),
1456                        files: Some(files),
1457                        entries_done: None,
1458                        entries_total: None,
1459                    };
1460                status_changed = true;
1461            }
1462            SemanticRefreshEvent::Completed {
1463                added_entries,
1464                updated_metadata,
1465                completed_paths,
1466            } => {
1467                if let Some(index) = ctx
1468                    .semantic_index()
1469                    .write()
1470                    .unwrap_or_else(std::sync::PoisonError::into_inner)
1471                    .as_mut()
1472                {
1473                    index.apply_refresh_update(added_entries, updated_metadata, &completed_paths);
1474                }
1475                mark_semantic_refresh_success(ctx, &completed_paths);
1476                view_refresh_completed = true;
1477                let mut status = ctx
1478                    .semantic_index_status()
1479                    .write()
1480                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1481                if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
1482                    for path in &completed_paths {
1483                        status.complete_refreshing_file(path);
1484                    }
1485                    status_changed = true;
1486                }
1487            }
1488            SemanticRefreshEvent::CorpusCompleted {
1489                mut index,
1490                changed,
1491                added,
1492                deleted,
1493                total_processed,
1494            } => {
1495                aft::runtime_drain::mark_semantic_corpus_refresh_success(ctx);
1496                if changed > 0 || added > 0 || deleted > 0 {
1497                    aft::slog_info!(
1498                        "semantic corpus refresh completed: {} changed, {} new, {} deleted, {} total processed",
1499                        changed,
1500                        added,
1501                        deleted,
1502                        total_processed
1503                    );
1504                }
1505                let pending_paths = ctx.take_pending_semantic_index_paths();
1506                let mut invalidated_paths = Vec::new();
1507                for path in pending_paths {
1508                    if !aft::runtime_drain::watcher_path_is_semantic_source(&path) {
1509                        continue;
1510                    }
1511                    if !aft::runtime_drain::watcher_path_is_ignored_by_current_matcher(ctx, &path) {
1512                        replay_refresh_paths.push(path.clone());
1513                    }
1514                    invalidated_paths.push(path);
1515                }
1516                index.invalidate_files(&invalidated_paths);
1517                *ctx.semantic_index()
1518                    .write()
1519                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
1520                *ctx.semantic_index_status()
1521                    .write()
1522                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
1523                    SemanticIndexStatus::ready();
1524                view_refresh_completed = true;
1525                status_changed = true;
1526            }
1527            SemanticRefreshEvent::Failed { paths, error } => {
1528                if aft::semantic_index::embedding_failure_is_transient(&error) {
1529                    if record_semantic_refresh_transient_failure(ctx, &error) {
1530                        ctx.add_pending_semantic_index_paths(paths);
1531                        schedule_breaker_probe = true;
1532                    } else if !schedule_semantic_refresh_retry(ctx, paths.clone(), &error) {
1533                        aft::slog_warn!(
1534                            "semantic refresh worker unavailable; preserving {} transiently failed file(s) for retry",
1535                            paths.len(),
1536                        );
1537                        ctx.add_pending_semantic_index_paths(paths);
1538                    }
1539                } else {
1540                    aft::slog_warn!("semantic refresh failed: {}", error);
1541                    reset_semantic_refresh_transient_failure_count(ctx);
1542                    clear_semantic_refresh_retry_attempts(ctx, &paths);
1543                    let mut status = ctx
1544                        .semantic_index_status()
1545                        .write()
1546                        .unwrap_or_else(std::sync::PoisonError::into_inner);
1547                    if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
1548                        for path in &paths {
1549                            status.complete_refreshing_file(path);
1550                        }
1551                        status_changed = true;
1552                    }
1553                }
1554            }
1555            SemanticRefreshEvent::CorpusFailed { paths, error } => {
1556                // A transient backend blip during a corpus refresh must NOT
1557                // destroy the working index — the prior index is still valid and
1558                // serving. Keep it Ready and let the next watcher/ignore change
1559                // re-trigger the refresh, rather than nuking everything to
1560                // `Failed` over a connection hiccup (the same park-forever trap
1561                // the initial build now rides out). Permanent errors (dimension
1562                // mismatch, too-many-files) still drop the index and surface the
1563                // real failure.
1564                if aft::semantic_index::embedding_failure_is_transient(&error) {
1565                    let clean = aft::semantic_index::strip_transient_embedding_marker(&error);
1566                    let has_index = ctx
1567                        .semantic_index()
1568                        .read()
1569                        .unwrap_or_else(std::sync::PoisonError::into_inner)
1570                        .is_some();
1571                    // The corpus worker already paid for the project walk and
1572                    // identified the exact changed/new/deleted file set. Retain
1573                    // only those paths so the probe re-extracts them through the
1574                    // file refresh lane instead of collecting the corpus again.
1575                    ctx.add_pending_semantic_index_paths(paths);
1576                    ctx.trip_semantic_refresh_circuit(BREAKER_TRIP_THRESHOLD, &clean);
1577                    schedule_breaker_probe = true;
1578                    if has_index {
1579                        aft::slog_warn!(
1580                            "semantic corpus refresh hit a transient backend error ({}); keeping the existing index",
1581                            clean,
1582                        );
1583                        *ctx.semantic_index_status()
1584                            .write()
1585                            .unwrap_or_else(std::sync::PoisonError::into_inner) =
1586                            SemanticIndexStatus::ready();
1587                    } else {
1588                        // No index to fall back on — surface the clean message.
1589                        aft::slog_warn!("semantic corpus refresh failed: {}", clean);
1590                        *ctx.semantic_index_status()
1591                            .write()
1592                            .unwrap_or_else(std::sync::PoisonError::into_inner) =
1593                            SemanticIndexStatus::Failed(clean);
1594                    }
1595                    status_changed = true;
1596                } else {
1597                    aft::slog_warn!("semantic corpus refresh failed: {}", error);
1598                    let _ = ctx.take_pending_semantic_index_paths();
1599                    *ctx.semantic_index()
1600                        .write()
1601                        .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1602                    *ctx.semantic_index_status()
1603                        .write()
1604                        .unwrap_or_else(std::sync::PoisonError::into_inner) =
1605                        SemanticIndexStatus::Failed(error);
1606                    status_changed = true;
1607                }
1608            }
1609            }
1610        }
1611
1612        if disconnected {
1613        let refreshing_paths = {
1614            let status = ctx
1615                .semantic_index_status()
1616                .read()
1617                .unwrap_or_else(std::sync::PoisonError::into_inner);
1618            match &*status {
1619                SemanticIndexStatus::Ready { refreshing, .. } => refreshing.clone(),
1620                _ => Vec::new(),
1621            }
1622        };
1623        if !refreshing_paths.is_empty() {
1624            let mut status = ctx
1625                .semantic_index_status()
1626                .write()
1627                .unwrap_or_else(std::sync::PoisonError::into_inner);
1628            for path in &refreshing_paths {
1629                status.cancel_refreshing_file(path);
1630            }
1631        }
1632        if !refreshing_paths.is_empty() || had_events {
1633            status_changed = true;
1634        }
1635    }
1636
1637    if !replay_refresh_paths.is_empty() {
1638        {
1639            let mut status = ctx
1640                .semantic_index_status()
1641                .write()
1642                .unwrap_or_else(std::sync::PoisonError::into_inner);
1643            if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
1644                for path in &replay_refresh_paths {
1645                    status.add_refreshing_file(path.clone());
1646                }
1647                status_changed = true;
1648            }
1649        }
1650        let sent = ctx.semantic_refresh_sender().is_some_and(|sender| {
1651            sender
1652                .send(SemanticRefreshRequest::Files {
1653                    paths: replay_refresh_paths.clone(),
1654                })
1655                .is_ok()
1656        });
1657        if !sent {
1658            aft::slog_warn!(
1659                "semantic refresh worker unavailable; dropping {} replayed corpus file(s)",
1660                replay_refresh_paths.len()
1661            );
1662            let mut status = ctx
1663                .semantic_index_status()
1664                .write()
1665                .unwrap_or_else(std::sync::PoisonError::into_inner);
1666            for path in &replay_refresh_paths {
1667                status.cancel_refreshing_file(path);
1668            }
1669            status_changed = true;
1670        }
1671        }
1672
1673        (status_changed, schedule_breaker_probe, view_refresh_completed)
1674    },
1675    );
1676    let Some((mut status_changed, schedule_breaker_probe, view_refresh_completed)) = committed
1677    else {
1678        return;
1679    };
1680    if schedule_breaker_probe && semantic_refresh_circuit_is_open(ctx) {
1681        ensure_semantic_refresh_probe_scheduled(ctx);
1682    }
1683    if disconnected {
1684        if let Some(disconnected_build_epoch) =
1685            ctx.clear_semantic_refresh_worker_if_current(receiver_generation, receiver_epoch)
1686        {
1687            wait_on_semantic_refresh_recovery_gate_for_test(ctx);
1688            let _ = crate::commands::configure::restart_semantic_artifacts_after_refresh_disconnect(
1689                ctx,
1690                disconnected_build_epoch,
1691            );
1692            status_changed = true;
1693        }
1694    }
1695
1696    maybe_fire_semantic_refresh_probe(ctx);
1697
1698    if view_refresh_completed {
1699        publish_semantic_ready_view(ctx);
1700    }
1701
1702    if status_changed {
1703        ctx.status_emitter().signal(ctx.build_status_snapshot());
1704    }
1705}
1706
1707fn publish_semantic_ready_view(ctx: &AppContext) {
1708    let Some(paths) = semantic_ready_view_publication_paths(
1709        ctx.view_runtime_snapshot()
1710            .map(|snapshot| snapshot.pending_paths),
1711    ) else {
1712        return;
1713    };
1714    let Some(root) = ctx.canonical_cache_root_opt() else {
1715        return;
1716    };
1717    match crate::executor::view_publication::schedule(ctx, paths, !ctx.shared_artifacts_read_only())
1718    {
1719        Ok(()) => crate::slog_info!(
1720            "content-addressed view publication scheduled after semantic refresh root={}",
1721            root.display()
1722        ),
1723        Err(error) => crate::slog_warn!(
1724            "content-addressed view publication after semantic refresh failed: {}",
1725            error
1726        ),
1727    }
1728}
1729
1730/// Source file extensions that the call graph supports.
1731const SOURCE_EXTENSIONS: &[&str] = &[
1732    "ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "py", "pyi", "rs", "go",
1733];
1734
1735pub const WATCHER_BATCH_INLINE_CAP: usize = 256;
1736
1737/// A `tsconfig.json` / `jsconfig.json` (including variant names like
1738/// `tsconfig.base.json`). A change to any of these can shift TypeScript build
1739/// membership (which files `tsc` checks), so the status-bar membership cache
1740/// must be invalidated. Deliberately broad on the variant suffix and ignorant
1741/// of `extends` graphs: the cache is cleared wholesale on a match, and base
1742/// configs almost always follow the `tsconfig*.json` naming. Non-standard base
1743/// names are covered on the next `tsconfig.json` change or `configure`.
1744pub fn watcher_path_is_tsconfig(path: &std::path::Path) -> bool {
1745    path.file_name()
1746        .and_then(|n| n.to_str())
1747        .map(|n| {
1748            n == "tsconfig.json"
1749                || n == "jsconfig.json"
1750                || ((n.starts_with("tsconfig.") || n.starts_with("jsconfig."))
1751                    && n.ends_with(".json"))
1752        })
1753        .unwrap_or(false)
1754}
1755
1756pub fn watcher_path_is_source(path: &std::path::Path) -> bool {
1757    path.extension()
1758        .and_then(|ext| ext.to_str())
1759        .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
1760}
1761
1762/// A file the callgraph STORE would have indexed at cold-build time. The store
1763/// indexes every file `walk_project_files` yields (i.e. any detected language),
1764/// not just the trigram `SOURCE_EXTENSIONS` set. Gating the store's watcher
1765/// refresh on the narrower trigram set left edits to Java/C/C++/C#/Kotlin/Ruby/
1766/// PHP/… (all of which the store extracts calls for) serving stale results until
1767/// a full rebuild. Mirror cold-build exactly so refresh coverage == index
1768/// coverage.
1769pub fn watcher_path_is_callgraph_indexed(path: &std::path::Path) -> bool {
1770    aft::parser::detect_language(path).is_some()
1771}
1772
1773pub fn semantic_corpus_refresh_in_progress(ctx: &AppContext) -> bool {
1774    let status = ctx
1775        .semantic_index_status()
1776        .read()
1777        .unwrap_or_else(std::sync::PoisonError::into_inner);
1778    matches!(
1779        &*status,
1780        SemanticIndexStatus::Building { stage, .. } if stage == "refreshing_corpus"
1781    )
1782}
1783
1784struct SearchRebuildPublishGate {
1785    reached_tx: crossbeam_channel::Sender<()>,
1786    release_rx: crossbeam_channel::Receiver<()>,
1787}
1788
1789static SEARCH_REBUILD_PUBLISH_GATE: OnceLock<Mutex<Option<SearchRebuildPublishGate>>> =
1790    OnceLock::new();
1791static SEARCH_REBUILD_SHUTDOWN_WAIT_SIGNAL: OnceLock<Mutex<Option<crossbeam_channel::Sender<()>>>> =
1792    OnceLock::new();
1793
1794#[doc(hidden)]
1795pub fn install_search_rebuild_publish_gate_for_test() -> (
1796    crossbeam_channel::Receiver<()>,
1797    crossbeam_channel::Receiver<()>,
1798    crossbeam_channel::Sender<()>,
1799) {
1800    let (reached_tx, reached_rx) = crossbeam_channel::bounded(1);
1801    let (shutdown_waiting_tx, shutdown_waiting_rx) = crossbeam_channel::bounded(1);
1802    let (release_tx, release_rx) = crossbeam_channel::bounded(1);
1803    *SEARCH_REBUILD_PUBLISH_GATE
1804        .get_or_init(|| Mutex::new(None))
1805        .lock()
1806        .expect("search rebuild publish gate mutex poisoned") = Some(SearchRebuildPublishGate {
1807        reached_tx,
1808        release_rx,
1809    });
1810    *SEARCH_REBUILD_SHUTDOWN_WAIT_SIGNAL
1811        .get_or_init(|| Mutex::new(None))
1812        .lock()
1813        .expect("search rebuild shutdown wait signal mutex poisoned") = Some(shutdown_waiting_tx);
1814    (reached_rx, shutdown_waiting_rx, release_tx)
1815}
1816
1817pub(crate) fn note_search_rebuild_shutdown_wait_for_test() {
1818    let signal = SEARCH_REBUILD_SHUTDOWN_WAIT_SIGNAL
1819        .get_or_init(|| Mutex::new(None))
1820        .lock()
1821        .expect("search rebuild shutdown wait signal mutex poisoned")
1822        .take();
1823    if let Some(signal) = signal {
1824        let _ = signal.send(());
1825    }
1826}
1827
1828fn wait_on_search_rebuild_publish_gate_for_test() {
1829    let gate = SEARCH_REBUILD_PUBLISH_GATE
1830        .get_or_init(|| Mutex::new(None))
1831        .lock()
1832        .expect("search rebuild publish gate mutex poisoned")
1833        .take();
1834    if let Some(gate) = gate {
1835        let _ = gate.reached_tx.send(());
1836        let _ = gate.release_rx.recv_timeout(Duration::from_secs(12));
1837    }
1838}
1839
1840pub fn spawn_search_corpus_refresh(
1841    ctx: &AppContext,
1842    root: std::path::PathBuf,
1843    config: Arc<aft::config::Config>,
1844) {
1845    let generation = ctx.configure_generation();
1846    let _ = ctx.run_if_subc_bound_generation(generation, || {
1847        spawn_search_corpus_refresh_admitted(ctx, root, config, generation);
1848    });
1849}
1850
1851fn spawn_search_corpus_refresh_admitted(
1852    ctx: &AppContext,
1853    root: std::path::PathBuf,
1854    config: Arc<aft::config::Config>,
1855    generation: u64,
1856) {
1857    {
1858        let mut search_index = ctx
1859            .search_index()
1860            .write()
1861            .unwrap_or_else(std::sync::PoisonError::into_inner);
1862        if let Some(index) = search_index.as_mut() {
1863            index.ready = false;
1864        }
1865    }
1866
1867    let (tx, rx): (
1868        crossbeam_channel::Sender<aft::search_index::SearchIndex>,
1869        crossbeam_channel::Receiver<aft::search_index::SearchIndex>,
1870    ) = crossbeam_channel::unbounded();
1871    let receiver_epoch = ctx.install_search_index_rx(rx, generation);
1872    let receiver_terminal_guard = ctx.search_index_rx_terminal_guard(receiver_epoch);
1873    ctx.reset_symbol_cache();
1874
1875    let shared_artifacts_read_only = ctx.shared_artifacts_read_only();
1876    let project_key = ctx.memoized_artifact_cache_key(&root);
1877    let session_id = log_ctx::current_session();
1878    let generation_flag = ctx.configure_generation_flag();
1879    let content_generation = ctx.configure_content_generation();
1880    let content_generation_flag = ctx.configure_content_generation_flag();
1881    let persist_epoch_flag = ctx.search_persist_epoch_flag();
1882    let persist_epoch = ctx.next_search_persist_epoch();
1883    let lifecycle = ctx.subc_lifecycle_admission();
1884    let cold_build_limiter = ctx.cold_build_limiter();
1885    thread::spawn(move || {
1886        let _terminal_guard = receiver_terminal_guard;
1887        log_ctx::with_session(session_id, || {
1888            let Some(_permit) = crate::cold_build_limiter::acquire_blocking_while_with_limiter(
1889                &cold_build_limiter,
1890                "search corpus refresh",
1891                || lifecycle.is_current(&generation_flag, generation),
1892            ) else {
1893                return;
1894            };
1895            if !lifecycle.is_current(&generation_flag, generation)
1896                || persist_epoch_flag.current() != persist_epoch
1897            {
1898                return;
1899            }
1900            let cache_dir = aft::search_index::resolve_cache_dir_with_key(
1901                &project_key,
1902                config.storage_dir.as_deref(),
1903            );
1904            let cache_lock = if shared_artifacts_read_only {
1905                None
1906            } else {
1907                match aft::search_index::CacheLock::acquire(&cache_dir, &root) {
1908                    Ok(lock) => Some(lock),
1909                    Err(error) => {
1910                        aft::slog_warn!(
1911                            "failed to acquire search cache lock for ignore refresh: {}",
1912                            error
1913                        );
1914                        None
1915                    }
1916                }
1917            };
1918            let mut index = aft::search_index::SearchIndex::build_with_limit_to_cache_dir(
1919                &root,
1920                config.search_index_max_file_size,
1921                &cache_dir,
1922            );
1923            wait_on_search_rebuild_publish_gate_for_test();
1924            // cache_lock is None for borrow-only roots, so ignore-refresh
1925            // never writes cache.bin even when ram_overlay is on.
1926            if cache_lock.is_some()
1927                && content_generation_flag.load(std::sync::atomic::Ordering::SeqCst)
1928                    == content_generation
1929            {
1930                let _ = persist_epoch_flag.run_if_current(persist_epoch, || {
1931                    let head = index.stored_git_head().map(str::to_owned);
1932                    index.write_to_disk(&cache_dir, head.as_deref());
1933                });
1934            }
1935            let _ = lifecycle.run_if_current(&generation_flag, generation, || {
1936                let _ = tx.send(index);
1937            });
1938        });
1939    });
1940}
1941
1942pub fn refresh_project_corpus(
1943    ctx: &AppContext,
1944    reason: &str,
1945    _invalidate_ignore_paths: bool,
1946) -> bool {
1947    let generation = ctx.configure_generation();
1948    ctx.run_if_subc_bound_generation(generation, || {
1949        let Some(root) = ctx.canonical_cache_root_opt() else {
1950            return false;
1951        };
1952        let config = ctx.config();
1953        let mut status_changed = false;
1954
1955        if ctx.callgraph_writer() {
1956            // Do NOT cold-build the callgraph store synchronously here. This function
1957            // runs on the single-threaded dispatch loop from `drain_watcher_events`,
1958            // which fires before EVERY request (and on idle ticks). A full O(repo)
1959            // `refresh_corpus` (= `cold_build`: parse all files + resolve refs +
1960            // rewrite SQLite) blocks ALL queued requests — including `configure` and
1961            // `bash` — for its entire duration, which exceeds the 30s transport
1962            // timeout on a large repo. On a long-lived bridge (OpenCode Desktop) an
1963            // FSEvents overflow triggers this drain, so the user sees configure/bash
1964            // time out (regression: the watcher-overflow path that calls this is new
1965            // in 0.39.1; the ignore-rule path that also calls this had the same
1966            // latent inline block, just rarely triggered).
1967            //
1968            // Instead, drop the resident store and force a BACKGROUND rebuild: the
1969            // next `callgraph_store_for_ops()` spawns the cold build off-thread and
1970            // returns `Building` (callgraph ops + dead_code projection already handle
1971            // `Building`/unavailable gracefully). This mirrors the search/semantic
1972            // refreshes below, which are already async. A build already in flight
1973            // keeps running; the resident drop + force flag make the next op converge
1974            // to a fresh full rebuild.
1975            // Mirror the original "act only when the callgraph is actually loaded or
1976            // building" guard, but reschedule instead of inline-building.
1977            let callgraph_store_resident = {
1978                let guard = ctx
1979                    .callgraph_store()
1980                    .read()
1981                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1982                guard.is_some()
1983            };
1984            if callgraph_store_resident || ctx.callgraph_store_rx().lock().is_some() {
1985                *ctx.callgraph_store()
1986                    .write()
1987                    .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1988                ctx.mark_callgraph_store_force_rebuild();
1989                status_changed = true;
1990                aft::slog_info!(
1991                    "callgraph store scheduled for background rebuild after {}",
1992                    reason
1993                );
1994            }
1995        }
1996
1997        if config.search_index && !ctx.shared_artifacts_read_only() {
1998            spawn_search_corpus_refresh_admitted(ctx, root.clone(), config.clone(), generation);
1999            status_changed = true;
2000            aft::slog_info!("started search index refresh after {}", reason);
2001        }
2002
2003        if config.semantic_search && !ctx.shared_artifacts_read_only() {
2004            if let Some(sender) = ctx.semantic_refresh_sender() {
2005                *ctx.semantic_index_status()
2006                    .write()
2007                    .unwrap_or_else(std::sync::PoisonError::into_inner) =
2008                    SemanticIndexStatus::Building {
2009                        stage: "refreshing_corpus".to_string(),
2010                        files: None,
2011                        entries_done: None,
2012                        entries_total: None,
2013                    };
2014                match sender.send(SemanticRefreshRequest::Corpus) {
2015                    Ok(()) => {
2016                        status_changed = true;
2017                    }
2018                    Err(error) => {
2019                        *ctx.semantic_index_status()
2020                            .write()
2021                            .unwrap_or_else(std::sync::PoisonError::into_inner) =
2022                            SemanticIndexStatus::Failed(format!(
2023                                "semantic corpus refresh worker unavailable: {error}"
2024                            ));
2025                        status_changed = true;
2026                    }
2027                }
2028            } else if ctx.semantic_index_rx().lock().is_some() {
2029                ctx.mark_pending_semantic_corpus_refresh();
2030            }
2031        }
2032
2033        status_changed
2034    })
2035    .unwrap_or(false)
2036}
2037
2038pub fn refresh_corpus_after_ignore_change(ctx: &AppContext) -> bool {
2039    refresh_project_corpus(ctx, "ignore-rule change", true)
2040}
2041
2042fn watcher_rescan_rss_bytes() -> Option<u64> {
2043    crate::memory::rss_bytes()
2044}
2045
2046fn watcher_rescan_rss_delta(before: Option<u64>, after: Option<u64>) -> Option<i64> {
2047    let delta = i128::from(after?) - i128::from(before?);
2048    Some(delta.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64)
2049}
2050
2051pub fn refresh_project_after_watcher_rescan(ctx: &AppContext) -> bool {
2052    if ctx.canonical_cache_root_opt().is_none() {
2053        return false;
2054    }
2055    let generation = ctx.configure_generation();
2056    let Some(mut status_changed) = ctx.run_if_subc_bound_generation(generation, || {
2057        if let Some(root) = ctx.canonical_cache_root_opt() {
2058            // A rescan means watcher events were LOST. Same-size,
2059            // preserved-mtime edits are exactly what stat-first verification
2060            // misses, so the memo must downgrade to strict content
2061            // verification, not just to stat-first.
2062            crate::cache_freshness::invalidate_verify_memo_strict(&root);
2063        }
2064        ctx.clear_pending_index_updates();
2065        ctx.reset_symbol_cache();
2066        let _ = ctx.mark_status_bar_tier2_stale();
2067        ctx.clear_tsconfig_membership_cache();
2068        true
2069    }) else {
2070        return false;
2071    };
2072
2073    status_changed |= refresh_project_corpus(ctx, "watcher overflow", false);
2074
2075    // The shared corpus refresh only reconciles what is resident or has a live
2076    // worker. After lost events that is not enough: nothing may be resident
2077    // (evicted root), no refresh worker may exist yet, and read-only roots
2078    // skip watcher path application entirely. Force the reconciliation for
2079    // each lane regardless of residency.
2080    let hardened = ctx.run_if_subc_bound_generation(generation, || {
2081        let config = ctx.config();
2082        if ctx.callgraph_writer()
2083            && config.callgraph_store
2084            && ctx.pending_callgraph_store_force_token().is_none()
2085        {
2086            // The corpus refresh above forces a rebuild only when a store was
2087            // resident or building; lost events invalidate the disk
2088            // generation either way.
2089            ctx.mark_callgraph_store_force_rebuild();
2090        }
2091        if ctx.shared_artifacts_read_only() {
2092            // Read-only roots reconcile by re-opening the shared artifacts:
2093            // drop the resident snapshots so the evicted-reload path fires on
2094            // the next query.
2095            ctx.search_index()
2096                .write()
2097                .unwrap_or_else(std::sync::PoisonError::into_inner)
2098                .take();
2099            if config.semantic_search {
2100                ctx.semantic_index()
2101                    .write()
2102                    .unwrap_or_else(std::sync::PoisonError::into_inner)
2103                    .take();
2104                ctx.clear_semantic_refresh_worker();
2105            }
2106        } else if config.semantic_search
2107            && ctx.semantic_refresh_sender().is_none()
2108            && ctx.semantic_index_rx().lock().is_none()
2109        {
2110            // No worker exists to receive the corpus request and none is
2111            // building; retain the intent so the next worker replays it.
2112            ctx.mark_pending_semantic_corpus_refresh();
2113        }
2114    });
2115    status_changed |= hardened.is_some();
2116    status_changed
2117}
2118
2119fn watcher_path_is_generated_for_callgraph(ctx: &AppContext, path: &Path) -> bool {
2120    ctx.callgraph_project_root()
2121        .is_some_and(|project_root| crate::inspect::is_generated_file(&project_root, path))
2122}
2123
2124pub fn refresh_callgraph_store_for_watcher(
2125    ctx: &AppContext,
2126    changed: &HashSet<std::path::PathBuf>,
2127) {
2128    if !ctx.heavy_root_work_allowed() {
2129        return;
2130    }
2131    let refresh_paths = changed
2132        .iter()
2133        .filter(|path| {
2134            invalidates_workspace_crate_prefix_cache(path)
2135                || (watcher_path_is_callgraph_indexed(path)
2136                    && !watcher_path_is_generated_for_callgraph(ctx, path))
2137        })
2138        .cloned()
2139        .collect::<Vec<_>>();
2140    if refresh_paths.is_empty() {
2141        return;
2142    }
2143    // This is intentionally the only watcher call-site action. Opening and
2144    // mutating SQLite belongs to the process-wide store worker, outside every
2145    // executor lane and its epoch gate.
2146    ctx.enqueue_callgraph_store_refresh(refresh_paths);
2147}
2148
2149/// Drain pre-filtered watcher events and apply cache invalidations on the
2150/// dispatch thread. The watcher filter thread owns notify receive/decode,
2151/// metadata filtering, ignore matching, root-deleted detection, and path
2152/// coalescing; this drain only reacts to compact control events and surviving
2153/// paths because the cache/index state below is not Send.
2154pub fn drain_watcher_events(ctx: &AppContext) {
2155    loop {
2156        let outcome = drain_watcher_events_bounded(ctx, WATCHER_PATH_DRAIN_BATCH_CAP);
2157        if !outcome.has_more {
2158            break;
2159        }
2160    }
2161}
2162
2163fn watcher_drain_phase_name(stage: WatcherDrainApplyPhase) -> &'static str {
2164    match stage {
2165        WatcherDrainApplyPhase::PendingTier2 => "pending_tier2",
2166        WatcherDrainApplyPhase::PendingIndexes => "pending_indexes",
2167        WatcherDrainApplyPhase::SymbolCache => "symbol_cache",
2168        WatcherDrainApplyPhase::Callgraph => "callgraph",
2169        WatcherDrainApplyPhase::SearchIndex => "search_index",
2170        WatcherDrainApplyPhase::SemanticIndex => "semantic_index",
2171        WatcherDrainApplyPhase::LspDiagnostics => "lsp_diagnostics",
2172        WatcherDrainApplyPhase::Complete => "complete",
2173    }
2174}
2175
2176fn apply_watcher_path_phase(
2177    stage: WatcherDrainApplyPhase,
2178    paths: &mut VecDeque<PathBuf>,
2179    remaining: &mut usize,
2180    started: Instant,
2181    budget: Duration,
2182    mut apply: impl FnMut(&Path),
2183) -> bool {
2184    while *remaining > 0 {
2185        let path = paths
2186            .pop_front()
2187            .expect("watcher apply phase tracks its remaining paths");
2188        {
2189            let _watchdog = WatcherDrainUnitGuard::start(stage, &path);
2190            delay_watcher_unit_for_test();
2191            wait_on_watcher_phase_commit_gate_for_test(&path);
2192            apply(&path);
2193        }
2194        paths.push_back(path);
2195        *remaining -= 1;
2196        if started.elapsed() >= budget {
2197            return false;
2198        }
2199    }
2200    true
2201}
2202
2203fn apply_callgraph_watcher_phase(
2204    ctx: &AppContext,
2205    paths: &mut VecDeque<PathBuf>,
2206    remaining: &mut usize,
2207    started: Instant,
2208    budget: Duration,
2209    enabled: bool,
2210    mut refresh: impl FnMut(&AppContext, &HashSet<PathBuf>),
2211) -> bool {
2212    let mut changed = HashSet::new();
2213    if enabled {
2214        // Include manifest invalidation before the budgeted path loop. If this
2215        // phase yields before reaching Cargo.toml, an earlier source sub-batch
2216        // must still discard the root's old workspace map before resolving refs.
2217        changed.extend(
2218            paths
2219                .iter()
2220                .filter(|path| invalidates_workspace_crate_prefix_cache(path))
2221                .cloned(),
2222        );
2223    }
2224    let mut generated_skipped = 0usize;
2225    let completed = apply_watcher_path_phase(
2226        WatcherDrainApplyPhase::Callgraph,
2227        paths,
2228        remaining,
2229        started,
2230        budget,
2231        |path| {
2232            if !enabled {
2233                return;
2234            }
2235            if invalidates_workspace_crate_prefix_cache(path) {
2236                changed.insert(path.to_path_buf());
2237            } else if watcher_path_is_callgraph_indexed(path) {
2238                if watcher_path_is_generated_for_callgraph(ctx, path) {
2239                    generated_skipped += 1;
2240                } else {
2241                    changed.insert(path.to_path_buf());
2242                }
2243            }
2244        },
2245    );
2246    if generated_skipped > 0 {
2247        log::debug!(
2248            "callgraph refresh skipped {} generated file(s)",
2249            generated_skipped
2250        );
2251    }
2252    if !changed.is_empty() {
2253        let first = changed
2254            .iter()
2255            .min()
2256            .expect("non-empty callgraph watcher batch has a first path");
2257        let _watchdog = WatcherDrainUnitGuard::start_batch(
2258            WatcherDrainApplyPhase::Callgraph,
2259            first,
2260            changed.len(),
2261        );
2262        delay_watcher_unit_for_test();
2263        refresh(ctx, &changed);
2264    }
2265    completed
2266}
2267
2268fn next_watcher_apply_phase(stage: WatcherDrainApplyPhase) -> WatcherDrainApplyPhase {
2269    match stage {
2270        WatcherDrainApplyPhase::PendingTier2 => WatcherDrainApplyPhase::PendingIndexes,
2271        WatcherDrainApplyPhase::PendingIndexes => WatcherDrainApplyPhase::SymbolCache,
2272        WatcherDrainApplyPhase::SymbolCache => WatcherDrainApplyPhase::Callgraph,
2273        WatcherDrainApplyPhase::Callgraph => WatcherDrainApplyPhase::SearchIndex,
2274        WatcherDrainApplyPhase::SearchIndex => WatcherDrainApplyPhase::SemanticIndex,
2275        WatcherDrainApplyPhase::SemanticIndex => WatcherDrainApplyPhase::LspDiagnostics,
2276        WatcherDrainApplyPhase::LspDiagnostics => WatcherDrainApplyPhase::Complete,
2277        WatcherDrainApplyPhase::Complete => WatcherDrainApplyPhase::Complete,
2278    }
2279}
2280
2281fn apply_watcher_slice(ctx: &AppContext, state: &mut WatcherDrainSliceState, started: Instant) {
2282    let WatcherDrainPhase::Apply {
2283        mut stage,
2284        mut paths,
2285        mut remaining,
2286        oversized_inline_batch,
2287    } = std::mem::take(&mut state.phase)
2288    else {
2289        return;
2290    };
2291    let lifecycle_generation = ctx.configure_generation();
2292    // Mid-slice unbind: every per-path mutation below is lifecycle-gated, so
2293    // continuing would burn the whole batch as no-ops and DROP the paths at
2294    // Complete. Abort with the phase intact instead — the continuation is
2295    // retained across lifecycle-only generation changes and the rebind
2296    // rebases and replays it.
2297    if ctx
2298        .run_if_subc_bound_generation(lifecycle_generation, || ())
2299        .is_none()
2300    {
2301        state.phase = WatcherDrainPhase::Apply {
2302            stage,
2303            paths,
2304            remaining,
2305            oversized_inline_batch,
2306        };
2307        return;
2308    }
2309    if !paths.is_empty() || remaining > 0 {
2310        let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2311            ctx.invalidate_warm_verify_memo();
2312        });
2313    }
2314    let heavy_root_work_allowed = ctx.heavy_root_work_allowed();
2315    let shared_artifacts_read_only = ctx.shared_artifacts_read_only();
2316    let apply_ram_search_updates = !shared_artifacts_read_only || ctx.ram_overlay_active();
2317    let mut semantic_refresh_paths = std::mem::take(&mut state.semantic_refresh_paths);
2318    let mut status_changed = state.status_changed;
2319
2320    loop {
2321        let completed = match stage {
2322            WatcherDrainApplyPhase::PendingTier2 => apply_watcher_path_phase(
2323                WatcherDrainApplyPhase::PendingTier2,
2324                &mut paths,
2325                &mut remaining,
2326                started,
2327                WATCHER_DRAIN_SLICE_BUDGET,
2328                |path| {
2329                    if heavy_root_work_allowed && ctx.inspect_writer() {
2330                        let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2331                            ctx.add_pending_tier2_paths([path.to_path_buf()]);
2332                        });
2333                    }
2334                },
2335            ),
2336            WatcherDrainApplyPhase::PendingIndexes => {
2337                let search_build_in_progress = ctx
2338                    .search_index_rx()
2339                    .read()
2340                    .unwrap_or_else(std::sync::PoisonError::into_inner)
2341                    .is_some();
2342                let semantic_build_in_progress = ctx.semantic_index_rx().lock().is_some();
2343                let semantic_corpus_refresh_in_progress = semantic_corpus_refresh_in_progress(ctx);
2344                apply_watcher_path_phase(
2345                    WatcherDrainApplyPhase::PendingIndexes,
2346                    &mut paths,
2347                    &mut remaining,
2348                    started,
2349                    WATCHER_DRAIN_SLICE_BUDGET,
2350                    |path| {
2351                        if heavy_root_work_allowed
2352                            && !shared_artifacts_read_only
2353                            && search_build_in_progress
2354                        {
2355                            let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2356                                ctx.add_pending_search_index_paths([path.to_path_buf()])
2357                            });
2358                        }
2359                        if heavy_root_work_allowed
2360                            && (!shared_artifacts_read_only || ctx.ram_overlay_active())
2361                            && (semantic_build_in_progress || semantic_corpus_refresh_in_progress)
2362                            && watcher_path_is_semantic_source(path)
2363                        {
2364                            let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2365                                ctx.add_pending_semantic_index_paths([path.to_path_buf()])
2366                            });
2367                        }
2368                    },
2369                )
2370            }
2371            WatcherDrainApplyPhase::SymbolCache => apply_watcher_path_phase(
2372                WatcherDrainApplyPhase::SymbolCache,
2373                &mut paths,
2374                &mut remaining,
2375                started,
2376                WATCHER_DRAIN_SLICE_BUDGET,
2377                |path| {
2378                    if apply_ram_search_updates {
2379                        let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2380                            if let Ok(mut symbol_cache) = ctx.symbol_cache().write() {
2381                                symbol_cache.invalidate(path);
2382                            }
2383                        });
2384                    }
2385                },
2386            ),
2387            WatcherDrainApplyPhase::Callgraph => apply_callgraph_watcher_phase(
2388                ctx,
2389                &mut paths,
2390                &mut remaining,
2391                started,
2392                WATCHER_DRAIN_SLICE_BUDGET,
2393                heavy_root_work_allowed,
2394                |ctx, changed| {
2395                    let _ = ctx.enqueue_callgraph_store_refresh_for_generation(
2396                        changed.iter().cloned(),
2397                        lifecycle_generation,
2398                    );
2399                },
2400            ),
2401            WatcherDrainApplyPhase::SearchIndex => apply_watcher_path_phase(
2402                WatcherDrainApplyPhase::SearchIndex,
2403                &mut paths,
2404                &mut remaining,
2405                started,
2406                WATCHER_DRAIN_SLICE_BUDGET,
2407                |path| {
2408                    if heavy_root_work_allowed && apply_ram_search_updates {
2409                        let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2410                            let mut index_ref = ctx
2411                                .search_index()
2412                                .write()
2413                                .unwrap_or_else(std::sync::PoisonError::into_inner);
2414                            if let Some(index) = index_ref.as_mut() {
2415                                if path.exists() {
2416                                    index.update_file(path);
2417                                } else {
2418                                    index.remove_file(path);
2419                                }
2420                            }
2421                        });
2422                    }
2423                },
2424            ),
2425            WatcherDrainApplyPhase::SemanticIndex => {
2426                // Every root masks stale shared vectors immediately. A writer or
2427                // an enabled RAM overlay also queues a private replacement.
2428                let mut invalidated_paths = Vec::new();
2429                let completed = apply_watcher_path_phase(
2430                    WatcherDrainApplyPhase::SemanticIndex,
2431                    &mut paths,
2432                    &mut remaining,
2433                    started,
2434                    WATCHER_DRAIN_SLICE_BUDGET,
2435                    |path| {
2436                        if heavy_root_work_allowed && watcher_path_is_semantic_source(path) {
2437                            invalidated_paths.push(path.to_path_buf());
2438                        }
2439                    },
2440                );
2441
2442                if !invalidated_paths.is_empty() {
2443                    // Invalidate all semantic paths processed in this slice under
2444                    // one write lock so a multi-file edit scans the index once.
2445                    let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2446                        let invalidated = {
2447                            let mut semantic_index_ref = ctx
2448                                .semantic_index()
2449                                .write()
2450                                .unwrap_or_else(std::sync::PoisonError::into_inner);
2451                            semantic_index_ref.as_mut().is_some_and(|index| {
2452                                index.invalidate_files(&invalidated_paths);
2453                                true
2454                            })
2455                        };
2456                        if invalidated && ctx.semantic_refresh_sender().is_some() {
2457                            let mut status = ctx
2458                                .semantic_index_status()
2459                                .write()
2460                                .unwrap_or_else(std::sync::PoisonError::into_inner);
2461                            if matches!(&*status, SemanticIndexStatus::Ready { .. }) {
2462                                for path in invalidated_paths {
2463                                    status.add_refreshing_file(path.clone());
2464                                    semantic_refresh_paths.push(path);
2465                                }
2466                                status_changed = true;
2467                            }
2468                        }
2469                    });
2470                }
2471                completed
2472            }
2473            WatcherDrainApplyPhase::LspDiagnostics => apply_watcher_path_phase(
2474                WatcherDrainApplyPhase::LspDiagnostics,
2475                &mut paths,
2476                &mut remaining,
2477                started,
2478                WATCHER_DRAIN_SLICE_BUDGET,
2479                |path| {
2480                    let _ = ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2481                        if !path.exists() {
2482                            status_changed |= ctx.lsp_clear_diagnostics_for_file(path);
2483                            return;
2484                        }
2485                        let stale = ctx.lsp_mark_diagnostics_stale_for_file(path);
2486                        status_changed |= stale.changed;
2487                        if stale.had_entries {
2488                            ctx.lsp_resync_changed_file_for_diagnostics(path);
2489                        }
2490                    });
2491                },
2492            ),
2493            WatcherDrainApplyPhase::Complete => true,
2494        };
2495
2496        // Per-path mutations are lifecycle-gated no-ops once the root unbinds,
2497        // so a stage that overlapped an unbind may have skipped paths (their
2498        // `remaining` was still decremented). Rewind the CURRENT stage (every
2499        // stage action is idempotent) and park the continuation; the rebased
2500        // replay after rebind re-runs it in full. This check must cover the
2501        // budget-exhausted park as well: a mid-stage park that kept the
2502        // decremented `remaining` would permanently skip the gated paths.
2503        if ctx
2504            .run_if_subc_bound_generation(lifecycle_generation, || ())
2505            .is_none()
2506        {
2507            state.status_changed = status_changed;
2508            state.semantic_refresh_paths = semantic_refresh_paths;
2509            remaining = paths.len();
2510            state.phase = WatcherDrainPhase::Apply {
2511                stage,
2512                paths,
2513                remaining,
2514                oversized_inline_batch,
2515            };
2516            return;
2517        }
2518
2519        if !completed {
2520            state.status_changed = status_changed;
2521            state.semantic_refresh_paths = semantic_refresh_paths;
2522            state.phase = WatcherDrainPhase::Apply {
2523                stage,
2524                paths,
2525                remaining,
2526                oversized_inline_batch,
2527            };
2528            return;
2529        }
2530
2531        if stage == WatcherDrainApplyPhase::Complete {
2532            break;
2533        }
2534        stage = next_watcher_apply_phase(stage);
2535        remaining = paths.len();
2536        if started.elapsed() >= WATCHER_DRAIN_SLICE_BUDGET {
2537            state.status_changed = status_changed;
2538            state.semantic_refresh_paths = semantic_refresh_paths;
2539            state.phase = WatcherDrainPhase::Apply {
2540                stage,
2541                paths,
2542                remaining,
2543                oversized_inline_batch,
2544            };
2545            return;
2546        }
2547    }
2548
2549    if !semantic_refresh_paths.is_empty() {
2550        // Distinguish "root unbound" (None) from "worker unavailable"
2551        // (Some(false)): losing admission here must PARK the collected paths
2552        // for the post-rebind replay, not drop them — the per-path
2553        // invalidation already ran, so these paths are the only record that
2554        // the semantic index is stale for them.
2555        match ctx.run_if_subc_bound_generation(lifecycle_generation, || {
2556            ctx.semantic_refresh_sender().is_some_and(|sender| {
2557                sender
2558                    .send(SemanticRefreshRequest::Files {
2559                        paths: semantic_refresh_paths.clone(),
2560                    })
2561                    .is_ok()
2562            })
2563        }) {
2564            Some(true) => {}
2565            Some(false) => {
2566                aft::slog_warn!(
2567                    "semantic refresh worker unavailable; dropping {} refreshing file(s)",
2568                    semantic_refresh_paths.len()
2569                );
2570                let mut status = ctx
2571                    .semantic_index_status()
2572                    .write()
2573                    .unwrap_or_else(std::sync::PoisonError::into_inner);
2574                for path in &semantic_refresh_paths {
2575                    status.cancel_refreshing_file(path);
2576                }
2577                status_changed = true;
2578            }
2579            None => {
2580                state.status_changed = status_changed;
2581                state.semantic_refresh_paths = semantic_refresh_paths;
2582                state.phase = WatcherDrainPhase::Apply {
2583                    stage: WatcherDrainApplyPhase::Complete,
2584                    paths,
2585                    remaining: 0,
2586                    oversized_inline_batch,
2587                };
2588                return;
2589            }
2590        }
2591    }
2592
2593    aft::slog_info!("invalidated {} files", paths.len());
2594    if status_changed {
2595        ctx.status_emitter().signal(ctx.build_status_snapshot());
2596    }
2597    ctx.tick_tier2_refresh_scheduler(state.scheduler_changed_path_count);
2598    state.phase = WatcherDrainPhase::Collect;
2599    state.status_changed = false;
2600    state.scheduler_changed_path_count = 0;
2601    state.semantic_refresh_paths.clear();
2602}
2603
2604/// Paths the view still owes a semantic plane, or `None` when a completed
2605/// refresh has nothing to publish. An absent view runtime (views off, or the
2606/// load failed) and an empty pending set both mean "do nothing": the current
2607/// generation already covers HEAD, and re-deriving it would only mint a
2608/// duplicate.
2609fn semantic_ready_view_publication_paths(
2610    pending: Option<BTreeSet<Vec<u8>>>,
2611) -> Option<BTreeSet<Vec<u8>>> {
2612    pending.filter(|paths| !paths.is_empty())
2613}
2614
2615fn publish_view_if_quiet(ctx: &AppContext, state: &mut WatcherDrainSliceState) {
2616    if !ctx.config().views.enabled
2617        || !matches!(state.phase, WatcherDrainPhase::Collect)
2618        || state
2619            .view_publication_due
2620            .is_none_or(|due| Instant::now() < due)
2621    {
2622        return;
2623    }
2624    let Some(root) = ctx.canonical_cache_root_opt() else {
2625        return;
2626    };
2627    let changed = state
2628        .view_publication_paths
2629        .iter()
2630        .filter_map(|path| path.strip_prefix(&root).ok())
2631        .filter_map(|path| aft::views::RelPath::from_os_path(path).ok())
2632        .map(|path| path.as_bytes().to_vec())
2633        .collect::<BTreeSet<_>>();
2634    match crate::executor::view_publication::schedule(
2635        ctx,
2636        changed,
2637        !ctx.shared_artifacts_read_only(),
2638    ) {
2639        Ok(()) => {
2640            aft::slog_info!(
2641                "content-addressed view publication scheduled root={}",
2642                root.display()
2643            );
2644            state.view_publication_paths.clear();
2645            state.view_publication_due = None;
2646        }
2647        Err(error) => {
2648            aft::slog_warn!(
2649                "content-addressed view publication failed root={} error={}",
2650                root.display(),
2651                error
2652            );
2653            state.view_publication_due = Some(Instant::now() + Duration::from_secs(1));
2654        }
2655    }
2656}
2657
2658pub fn drain_watcher_events_bounded(ctx: &AppContext, max_paths: usize) -> DrainBatchOutcome {
2659    let started = Instant::now();
2660    let configure_generation = ctx.configure_generation();
2661    let content_generation = ctx.configure_content_generation();
2662    let mut outcome = DrainBatchOutcome::default();
2663    // Admission before touching the continuation: an unbound invocation must
2664    // leave the retained state in place for the rebind to rebase, not take
2665    // and drop it.
2666    if ctx
2667        .run_if_subc_bound_generation(configure_generation, || ())
2668        .is_none()
2669    {
2670        return outcome;
2671    }
2672    let mut state = match ctx.watcher_drain_slice().lock().take() {
2673        Some(state) if state.configure_generation == configure_generation => state,
2674        // Lifecycle-only generation change (transient unbind + equivalent
2675        // rebind): the retained paths are still valid for this configuration.
2676        // Rebase onto the current generation and replay — every apply phase
2677        // is idempotent (re-indexing an unchanged file is a no-op).
2678        Some(mut state) if state.configure_content_generation == content_generation => {
2679            state.configure_generation = configure_generation;
2680            state
2681        }
2682        _ => WatcherDrainSliceState::new(configure_generation, content_generation),
2683    };
2684    let mut dispatch_events_received = 0usize;
2685    let mut watcher_failed = None;
2686    let mut root_deleted = false;
2687
2688    {
2689        let rx_ref = ctx.watcher_rx().lock();
2690        let Some(rx) = rx_ref.as_ref() else {
2691            ctx.tick_tier2_refresh_scheduler(0);
2692            return outcome;
2693        };
2694
2695        loop {
2696            match rx.try_recv() {
2697                Ok(WatcherDispatchEvent::Paths(paths)) => {
2698                    dispatch_events_received += 1;
2699                    if !state.rescan_required {
2700                        state.pending_paths.extend(paths);
2701                    }
2702                }
2703                Ok(WatcherDispatchEvent::RescanRequired(reason)) => {
2704                    dispatch_events_received += 1;
2705                    state.rescan_required = true;
2706                    state.rescan_reason = reason;
2707                    state.pending_paths.clear();
2708                    state.phase = WatcherDrainPhase::Collect;
2709                    state.semantic_refresh_paths.clear();
2710                    state.scheduler_changed_path_count = 0;
2711                }
2712                Ok(WatcherDispatchEvent::IgnoreRulesChanged { path }) => {
2713                    dispatch_events_received += 1;
2714                    state.ignore_changed = true;
2715                    log::debug!(
2716                        "watcher: ignore rules changed at {}, rebuilding matcher",
2717                        path.display()
2718                    );
2719                    if !state.rescan_required {
2720                        let heavy_root_work_allowed = ctx.heavy_root_work_allowed();
2721                        let _ = ctx.run_if_subc_bound_generation(configure_generation, || {
2722                            if heavy_root_work_allowed {
2723                                ctx.rebuild_gitignore();
2724                            } else {
2725                                ctx.clear_gitignore();
2726                            }
2727                        });
2728                    }
2729                }
2730                Ok(WatcherDispatchEvent::RootDeleted) => {
2731                    dispatch_events_received += 1;
2732                    root_deleted = true;
2733                    break;
2734                }
2735                Ok(WatcherDispatchEvent::Error(error)) => {
2736                    dispatch_events_received += 1;
2737                    watcher_failed = Some(error);
2738                    break;
2739                }
2740                Err(crossbeam_channel::TryRecvError::Empty) => break,
2741                Err(crossbeam_channel::TryRecvError::Disconnected) => {
2742                    watcher_failed = Some("watcher channel disconnected".to_string());
2743                    break;
2744                }
2745            }
2746            if started.elapsed() >= WATCHER_DRAIN_SLICE_BUDGET {
2747                break;
2748            }
2749        }
2750    }
2751
2752    crate::logging::note_watcher_events(dispatch_events_received);
2753    let receiver_has_more_after_receive = ctx
2754        .watcher_rx()
2755        .lock()
2756        .as_ref()
2757        .is_some_and(|rx| !rx.is_empty());
2758
2759    if root_deleted {
2760        ctx.stop_watcher_runtime_in_background();
2761        let _ = ctx.add_degraded_reason("project_root_deleted".to_string());
2762        aft::slog_warn!(
2763            "project root deleted; dropping watcher to avoid delete-storm: {:?}",
2764            ctx.canonical_cache_root_opt()
2765        );
2766        ctx.status_emitter().signal(ctx.build_status_snapshot());
2767        return outcome;
2768    }
2769    if let Some(error) = watcher_failed {
2770        ctx.stop_watcher_runtime_in_background();
2771        let _ = ctx.add_degraded_reason("watcher_unavailable".to_string());
2772        aft::slog_warn!(
2773            "file watcher unavailable; continuing without live external-change invalidation: {}",
2774            error
2775        );
2776        ctx.status_emitter().signal(ctx.build_status_snapshot());
2777        return outcome;
2778    }
2779
2780    if state.rescan_required && receiver_has_more_after_receive {
2781        outcome.has_more = true;
2782        *ctx.watcher_drain_slice().lock() = Some(state);
2783        return outcome;
2784    }
2785
2786    if state.rescan_required {
2787        crate::logging::note_watcher_overflow();
2788        let root = ctx
2789            .canonical_cache_root_opt()
2790            .or_else(|| ctx.config().project_root.clone())
2791            .unwrap_or_else(|| PathBuf::from("<unconfigured>"));
2792        let watcher_counters = ctx.watcher_counters();
2793        watcher_counters.start_rescan();
2794        let mut rescan_reason = state.rescan_reason;
2795        loop {
2796            let interval = watcher_counters.begin_rescan(rescan_reason);
2797            wait_on_watcher_rescan_gate_for_test(ctx);
2798            let app = ctx.app();
2799            let db = app.db();
2800            crate::watcher_filter::persist_watcher_observations(
2801                &root,
2802                &watcher_counters,
2803                db.as_ref(),
2804            );
2805            if ctx.heavy_root_work_allowed() {
2806                ctx.rebuild_gitignore();
2807            } else {
2808                ctx.clear_gitignore();
2809            }
2810            let rss_before = watcher_rescan_rss_bytes();
2811            let rescan_started = Instant::now();
2812            state.status_changed |= refresh_project_after_watcher_rescan(ctx);
2813            let cost_ms = rescan_started.elapsed().as_millis().min(u64::MAX as u128) as u64;
2814            let rss_delta_bytes = watcher_rescan_rss_delta(rss_before, watcher_rescan_rss_bytes());
2815            watcher_counters.finish_rescan(cost_ms, rss_delta_bytes);
2816            crate::logging::log_watcher_rescan(
2817                &root,
2818                rescan_reason,
2819                cost_ms,
2820                rss_delta_bytes,
2821                interval.raw_events,
2822            );
2823            let Some(next_reason) = watcher_counters.finish_rescan_walk() else {
2824                break;
2825            };
2826            rescan_reason = next_reason;
2827        }
2828        state.scheduler_changed_path_count =
2829            aft::inspect::tier2_scheduler::TIER2_REFRESH_STORM_PATH_THRESHOLD + 1;
2830        if state.status_changed {
2831            ctx.status_emitter().signal(ctx.build_status_snapshot());
2832        }
2833        ctx.tick_tier2_refresh_scheduler(state.scheduler_changed_path_count);
2834        // Acknowledge the rescan only if the whole refresh sequence ran under
2835        // the original lifecycle generation: an unbind advances the
2836        // generation, and the internal admission gates then made parts of the
2837        // sequence no-ops. Keeping the flag parks the rescan for the
2838        // post-rebind replay instead of acknowledging a partial one.
2839        if ctx
2840            .run_if_subc_bound_generation(configure_generation, || ())
2841            .is_some()
2842        {
2843            state.rescan_required = false;
2844            state.ignore_changed = false;
2845            if ctx.config().views.enabled {
2846                state.view_publication_paths.clear();
2847                state.view_publication_due = Some(Instant::now() + VIEW_PUBLICATION_QUIET_WINDOW);
2848            }
2849        }
2850        state.status_changed = false;
2851        state.scheduler_changed_path_count = 0;
2852    } else if matches!(state.phase, WatcherDrainPhase::Collect) {
2853        let ignore_changed = state.ignore_changed;
2854        if ignore_changed {
2855            state.status_changed |= refresh_corpus_after_ignore_change(ctx);
2856            // Same partial-sequence rule as the rescan path: acknowledge only
2857            // when the refresh ran fully under this lifecycle generation.
2858            if ctx
2859                .run_if_subc_bound_generation(configure_generation, || ())
2860                .is_some()
2861            {
2862                state.ignore_changed = false;
2863                if ctx.config().views.enabled {
2864                    state.view_publication_paths.clear();
2865                    state.view_publication_due =
2866                        Some(Instant::now() + VIEW_PUBLICATION_QUIET_WINDOW);
2867                }
2868            }
2869        }
2870
2871        if max_paths > 0 && !state.pending_paths.is_empty() {
2872            let mut unique = HashSet::new();
2873            let mut paths = VecDeque::new();
2874            while outcome.processed < max_paths {
2875                let Some(path) = state.pending_paths.pop_front() else {
2876                    break;
2877                };
2878                outcome.processed += 1;
2879                if unique.insert(path.clone()) {
2880                    paths.push_back(path);
2881                }
2882            }
2883            crate::logging::note_drain_paths(outcome.processed);
2884
2885            if paths.is_empty() {
2886                if state.status_changed {
2887                    ctx.status_emitter().signal(ctx.build_status_snapshot());
2888                }
2889                ctx.tick_tier2_refresh_scheduler(usize::from(ignore_changed));
2890                state.status_changed = false;
2891            } else {
2892                if ctx.config().views.enabled {
2893                    state.view_publication_paths.extend(paths.iter().cloned());
2894                    state.view_publication_due =
2895                        Some(Instant::now() + VIEW_PUBLICATION_QUIET_WINDOW);
2896                }
2897                state.path_slice_count += 1;
2898                state.scheduler_changed_path_count = if ignore_changed {
2899                    paths.len().max(1)
2900                } else {
2901                    paths.len()
2902                };
2903                if ctx
2904                    .run_if_subc_bound_generation(configure_generation, || {
2905                        ctx.mark_status_bar_tier2_stale()
2906                    })
2907                    .unwrap_or(false)
2908                {
2909                    state.status_changed = true;
2910                }
2911                if paths.iter().any(|path| watcher_path_is_tsconfig(path))
2912                    && ctx
2913                        .run_if_subc_bound_generation(configure_generation, || {
2914                            ctx.clear_tsconfig_membership_cache();
2915                        })
2916                        .is_some()
2917                {
2918                    state.status_changed = true;
2919                }
2920
2921                let oversized_inline_batch = paths.len() > WATCHER_BATCH_INLINE_CAP;
2922                if oversized_inline_batch {
2923                    aft::slog_warn!(
2924                        "watcher batch of {} paths exceeds inline cap {}; applying bounded incremental refresh",
2925                        paths.len(),
2926                        WATCHER_BATCH_INLINE_CAP
2927                    );
2928                }
2929                let remaining = paths.len();
2930                state.phase = WatcherDrainPhase::Apply {
2931                    stage: WatcherDrainApplyPhase::PendingTier2,
2932                    paths,
2933                    remaining,
2934                    oversized_inline_batch,
2935                };
2936            }
2937        } else if ignore_changed {
2938            if state.status_changed {
2939                ctx.status_emitter().signal(ctx.build_status_snapshot());
2940            }
2941            ctx.tick_tier2_refresh_scheduler(1);
2942            state.status_changed = false;
2943        }
2944    }
2945
2946    if matches!(state.phase, WatcherDrainPhase::Apply { .. })
2947        && started.elapsed() < WATCHER_DRAIN_SLICE_BUDGET
2948    {
2949        apply_watcher_slice(ctx, &mut state, started);
2950    }
2951
2952    publish_view_if_quiet(ctx, &mut state);
2953
2954    let receiver_has_more = ctx
2955        .watcher_rx()
2956        .lock()
2957        .as_ref()
2958        .is_some_and(|rx| !rx.is_empty());
2959    outcome.has_more = state.has_pending_work() || receiver_has_more;
2960    // Retain the continuation across lifecycle-only generation changes (a
2961    // mid-drain unbind advances the generation; the rebind rebases). Only a
2962    // content change — a real reconfigure — discards it.
2963    if state.configure_content_generation == ctx.configure_content_generation() {
2964        *ctx.watcher_drain_slice().lock() = Some(state);
2965    }
2966    outcome
2967}
2968
2969pub fn drain_lsp_events(ctx: &AppContext) {
2970    let _ = drain_lsp_events_bounded(ctx, usize::MAX);
2971}
2972
2973/// Shut down language servers for a standalone runtime whose last request is
2974/// older than `idle.lsp_ttl_minutes`. Subc uses the same helper with its own
2975/// per-root activity stamp.
2976pub fn shutdown_idle_lsp(ctx: &AppContext) {
2977    shutdown_idle_lsp_at(ctx, Instant::now(), ctx.last_request_at());
2978}
2979
2980pub fn shutdown_idle_lsp_at(ctx: &AppContext, now: Instant, last_activity: Instant) {
2981    let ttl = ctx.config().idle.lsp_ttl();
2982    let idle = now.saturating_duration_since(last_activity);
2983    if idle < ttl {
2984        return;
2985    }
2986    let clients = {
2987        let mut lsp = ctx.lsp();
2988        if lsp.server_count() == 0 {
2989            return;
2990        }
2991        lsp.take_all_clients()
2992    };
2993    let n = clients.len();
2994    if n == 0 {
2995        return;
2996    }
2997    let root = ctx
2998        .config()
2999        .project_root
3000        .as_ref()
3001        .map(|path| path.display().to_string())
3002        .unwrap_or_else(|| "standalone".to_string());
3003    aft::slog_info!(
3004        "idle lsp reap {root}: shut down {n} server(s) after {}m (ttl {}m)",
3005        idle.as_secs() / 60,
3006        ctx.config().idle.lsp_ttl_minutes
3007    );
3008    crate::lsp::manager::LspManager::spawn_idle_lsp_reap(clients);
3009}
3010
3011pub fn drain_lsp_events_bounded(ctx: &AppContext, max_events: usize) -> DrainBatchOutcome {
3012    let drained = {
3013        let mut lsp = ctx.lsp();
3014        lsp.drain_events_bounded(max_events)
3015    };
3016    let outcome = DrainBatchOutcome {
3017        processed: drained.events.len(),
3018        has_more: drained.has_more,
3019    };
3020    let mut status_changed = drained.diagnostics_changed;
3021    for event in drained.events {
3022        match event {
3023            LspEvent::Notification {
3024                server_kind,
3025                root,
3026                method,
3027                params,
3028            } => {
3029                log::debug!(
3030                    "[aft-lsp] notification {:?} {} {} {}",
3031                    server_kind,
3032                    root.display(),
3033                    method,
3034                    params.unwrap_or(serde_json::Value::Null)
3035                );
3036            }
3037            LspEvent::ServerRequest {
3038                server_kind,
3039                root,
3040                id,
3041                method,
3042                params,
3043            } => {
3044                log::debug!(
3045                    "[aft-lsp] request {:?} {} {:?} {} {}",
3046                    server_kind,
3047                    root.display(),
3048                    id,
3049                    method,
3050                    params.unwrap_or(serde_json::Value::Null)
3051                );
3052            }
3053            LspEvent::ServerExited {
3054                server_kind,
3055                root,
3056                reason,
3057            } => {
3058                aft::slog_info!("exited {:?} {} ({reason})", server_kind, root.display());
3059                status_changed = true;
3060            }
3061        }
3062    }
3063    if status_changed {
3064        ctx.status_emitter().signal(ctx.build_status_snapshot());
3065    }
3066    outcome
3067}
3068
3069#[cfg(test)]
3070pub(crate) fn configure_search_order_context_for_test(
3071    root: &Path,
3072    storage: &Path,
3073) -> (AppContext, std::path::PathBuf) {
3074    std::fs::write(root.join(".gitignore"), "ignored.rs\n").unwrap();
3075    std::fs::write(root.join("ignored.rs"), "fn ignored_marker() {}\n").unwrap();
3076
3077    let ctx = AppContext::new(
3078        crate::context::default_language_provider_factory(),
3079        crate::config::Config {
3080            project_root: Some(root.to_path_buf()),
3081            storage_dir: Some(storage.to_path_buf()),
3082            ..crate::config::Config::default()
3083        },
3084    );
3085    let canonical_root = std::fs::canonicalize(root).unwrap();
3086    let ignored_path = canonical_root.join("ignored.rs");
3087    ctx.set_canonical_cache_root(canonical_root.clone());
3088    ctx.set_harness(crate::harness::Harness::Opencode);
3089    ctx.enqueue_configure_maintenance(crate::context::ConfigureMaintenanceJob {
3090        generation: ctx.configure_generation(),
3091        root_path: root.to_path_buf(),
3092        canonical_cache_root: canonical_root,
3093        harness: crate::harness::Harness::Opencode,
3094        storage_root: storage.to_path_buf(),
3095        harness_dir: storage.join("opencode"),
3096        session_id: "order-test".to_string(),
3097        home_match: false,
3098        format_tool_cache_clear_needed: false,
3099        run_bash_replay: false,
3100        refresh_project_runtime: true,
3101        sync_bash_compress_flag: false,
3102        reset_filter_registry: false,
3103        clear_failed_spawns: false,
3104        warm_callgraph_store: false,
3105        supersede_search_artifact_persistence: false,
3106        supersede_callgraph_artifact_persistence: false,
3107        supersede_semantic_artifact_persistence: false,
3108        search_artifact_load_start: None,
3109        semantic_artifact_load_start: None,
3110    })
3111    .expect("test configure maintenance queue has capacity");
3112
3113    let (search_tx, search_rx) = crossbeam_channel::unbounded();
3114    search_tx
3115        .send(crate::search_index::SearchIndex::new())
3116        .unwrap();
3117    drop(search_tx);
3118    *ctx.search_index_rx()
3119        .write()
3120        .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(search_rx);
3121    ctx.add_pending_search_index_paths([ignored_path.clone()]);
3122    (ctx, ignored_path)
3123}
3124
3125#[cfg(test)]
3126mod tests {
3127    use super::*;
3128    use crate::config::Config;
3129    use crate::context::{default_language_provider_factory, AppContext};
3130
3131    #[test]
3132    fn semantic_ready_publication_is_skipped_unless_paths_are_pending() {
3133        assert_eq!(semantic_ready_view_publication_paths(None), None);
3134        assert_eq!(
3135            semantic_ready_view_publication_paths(Some(BTreeSet::new())),
3136            None,
3137            "an up-to-date view must not republish when a refresh completes"
3138        );
3139        let pending = BTreeSet::from([b"src/lib.rs".to_vec()]);
3140        assert_eq!(
3141            semantic_ready_view_publication_paths(Some(pending.clone())),
3142            Some(pending),
3143            "only the paths still owed a semantic plane are published"
3144        );
3145    }
3146
3147    fn watcher_context(
3148        root: &Path,
3149    ) -> (AppContext, crossbeam_channel::Sender<WatcherDispatchEvent>) {
3150        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
3151        ctx.update_config(|config| {
3152            config.project_root = Some(root.to_path_buf());
3153        });
3154        ctx.set_canonical_cache_root(root.to_path_buf());
3155        let (tx, rx) = crossbeam_channel::unbounded();
3156        *ctx.watcher_rx().lock() = Some(rx);
3157        (ctx, tx)
3158    }
3159
3160    #[test]
3161    fn watcher_semantic_phase_batches_invalidation_into_one_retain_pass() {
3162        let root = tempfile::tempdir().unwrap();
3163        let root_path = root.path().canonicalize().unwrap();
3164        let files = (0..4)
3165            .map(|ordinal| {
3166                let file = root_path.join(format!("source_{ordinal}.rs"));
3167                std::fs::write(&file, format!("pub fn source_{ordinal}() {{}}\n")).unwrap();
3168                file
3169            })
3170            .collect::<Vec<_>>();
3171        let mut embed = |texts: Vec<String>| {
3172            Ok::<_, String>(texts.into_iter().map(|_| vec![1.0, 0.5]).collect())
3173        };
3174        let index = crate::semantic_index::SemanticIndex::build(
3175            &root_path,
3176            &files,
3177            &mut embed,
3178            files.len(),
3179        )
3180        .unwrap();
3181        assert!(index.entry_count() >= files.len());
3182
3183        let (ctx, watcher_tx) = watcher_context(&root_path);
3184        ctx.mark_subc_bound();
3185        ctx.set_heavy_root_work_allowed(true);
3186        ctx.set_cache_writer_capabilities(true, true);
3187        *ctx.semantic_index()
3188            .write()
3189            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
3190        *ctx.semantic_index_status()
3191            .write()
3192            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
3193        watcher_tx
3194            .send(WatcherDispatchEvent::Paths(files.clone()))
3195            .unwrap();
3196
3197        let outcome = drain_watcher_events_bounded(&ctx, files.len());
3198
3199        assert_eq!(outcome.processed, files.len());
3200        assert!(!outcome.has_more);
3201        let index = ctx
3202            .semantic_index()
3203            .read()
3204            .unwrap_or_else(std::sync::PoisonError::into_inner);
3205        let index = index.as_ref().unwrap();
3206        assert_eq!(index.entry_count(), 0);
3207        assert_eq!(index.removal_retain_passes_for_test(), 1);
3208    }
3209
3210    #[test]
3211    fn newer_watcher_refresh_prevents_older_configure_build_from_overwriting_disk() {
3212        let root = tempfile::tempdir().unwrap();
3213        let storage = tempfile::tempdir().unwrap();
3214        let root_path = root.path().canonicalize().unwrap();
3215        let source = root_path.join("marker.rs");
3216        std::fs::write(&source, "fn old_generation_marker() {}\n").unwrap();
3217
3218        let ctx = AppContext::new(
3219            default_language_provider_factory(),
3220            Config {
3221                project_root: Some(root_path.clone()),
3222                storage_dir: Some(storage.path().to_path_buf()),
3223                ..Config::default()
3224            },
3225        );
3226        ctx.set_canonical_cache_root(root_path.clone());
3227        ctx.set_harness(crate::harness::Harness::Opencode);
3228
3229        let project_key = ctx.memoized_artifact_cache_key(&root_path);
3230        let cache_dir =
3231            crate::search_index::resolve_cache_dir_with_key(&project_key, Some(storage.path()));
3232        let mut older_index = crate::search_index::SearchIndex::build(&root_path);
3233        let older_epoch = ctx.next_search_persist_epoch();
3234        let persist_epoch = ctx.search_persist_epoch_flag();
3235        let (older_reached_tx, older_reached_rx) = std::sync::mpsc::channel();
3236        let (older_release_tx, older_release_rx) = std::sync::mpsc::channel();
3237        let older_root = root_path.clone();
3238        let older_cache = cache_dir.clone();
3239        let older_writer = std::thread::spawn(move || {
3240            older_reached_tx.send(()).unwrap();
3241            older_release_rx.recv().unwrap();
3242            let _lock = crate::search_index::CacheLock::acquire(&older_cache, &older_root)
3243                .expect("older build should acquire the persistence lock");
3244            let _ = persist_epoch.run_if_current(older_epoch, || {
3245                older_index.write_to_disk(&older_cache, None);
3246            });
3247        });
3248        older_reached_rx
3249            .recv_timeout(Duration::from_secs(2))
3250            .expect("older configure build did not reach its persistence barrier");
3251
3252        std::fs::write(&source, "fn new_watcher_marker() {}\n").unwrap();
3253        spawn_search_corpus_refresh(&ctx, root_path.clone(), ctx.config());
3254        let refresh_rx = ctx
3255            .search_index_rx()
3256            .read()
3257            .unwrap_or_else(std::sync::PoisonError::into_inner)
3258            .as_ref()
3259            .expect("watcher refresh receiver")
3260            .clone();
3261        refresh_rx
3262            .recv_timeout(Duration::from_secs(12))
3263            .expect("watcher refresh did not complete");
3264
3265        older_release_tx.send(()).unwrap();
3266        older_writer.join().unwrap();
3267
3268        let disk = crate::search_index::SearchIndex::read_from_disk(&cache_dir, &root_path)
3269            .expect("persisted search index");
3270        assert_eq!(
3271            disk.grep("new_watcher_marker", true, &[], &[], &root_path, 10)
3272                .matches
3273                .len(),
3274            1,
3275            "newer watcher refresh must remain on disk"
3276        );
3277        assert!(
3278            disk.grep("old_generation_marker", true, &[], &[], &root_path, 10)
3279                .matches
3280                .is_empty(),
3281            "older configure build must not overwrite the newer watcher refresh"
3282        );
3283    }
3284
3285    #[test]
3286    fn watcher_phase_dequeued_before_unbind_cannot_index_after_teardown() {
3287        let temp = tempfile::tempdir().unwrap();
3288        let root = std::fs::canonicalize(temp.path()).unwrap();
3289        let root = root.as_path();
3290        let source = root.join("changed.rs");
3291        std::fs::write(&source, "fn watcher_marker() {}\n").unwrap();
3292        let (ctx, watcher_tx) = watcher_context(root);
3293        *ctx.search_index()
3294            .write()
3295            .unwrap_or_else(std::sync::PoisonError::into_inner) =
3296            Some(crate::search_index::SearchIndex::new());
3297        watcher_tx
3298            .send(WatcherDispatchEvent::Paths(vec![source.clone()]))
3299            .unwrap();
3300
3301        let ctx = Arc::new(ctx);
3302        let (reached_rx, release_tx) = install_watcher_phase_commit_gate_for_test(source.clone());
3303        let drain_ctx = Arc::clone(&ctx);
3304        let drain = std::thread::spawn(move || {
3305            while drain_watcher_events_bounded(&drain_ctx, WATCHER_PATH_DRAIN_BATCH_CAP).has_more {}
3306        });
3307        reached_rx
3308            .recv_timeout(Duration::from_secs(2))
3309            .expect("watcher phase did not reach its commit barrier");
3310        ctx.mark_subc_unbound();
3311        release_tx.send(()).unwrap();
3312        drain.join().unwrap();
3313
3314        {
3315            let search = ctx
3316                .search_index()
3317                .read()
3318                .unwrap_or_else(std::sync::PoisonError::into_inner);
3319            assert!(
3320                search
3321                    .as_ref()
3322                    .expect("search index")
3323                    .grep("watcher_marker", true, &[], &[], root, 10)
3324                    .matches
3325                    .is_empty(),
3326                "watcher work dequeued before teardown must not mutate the index after unbind"
3327            );
3328        }
3329
3330        // The unapplied path survives the unbound window in the retained
3331        // continuation; an equivalent rebind rebases and replays it.
3332        ctx.mark_subc_bound();
3333        let mut guard = 0;
3334        while drain_watcher_events_bounded(&ctx, WATCHER_PATH_DRAIN_BATCH_CAP).has_more {
3335            guard += 1;
3336            assert!(guard < 16, "rebased replay must finish");
3337        }
3338        let search = ctx
3339            .search_index()
3340            .read()
3341            .unwrap_or_else(std::sync::PoisonError::into_inner);
3342        assert_eq!(
3343            search
3344                .as_ref()
3345                .expect("search index")
3346                .grep("watcher_marker", true, &[], &[], root, 10)
3347                .matches
3348                .len(),
3349            1,
3350            "post-rebind replay must apply the retained watcher path"
3351        );
3352    }
3353
3354    #[test]
3355    fn standalone_configure_tail_precedes_completed_search_install() {
3356        let root = tempfile::tempdir().unwrap();
3357        let storage = tempfile::tempdir().unwrap();
3358        let (ctx, ignored_path) =
3359            configure_search_order_context_for_test(root.path(), storage.path());
3360        assert!(!watcher_path_is_ignored_by_current_matcher(
3361            &ctx,
3362            &ignored_path
3363        ));
3364
3365        drain_deferred_configure_maintenance(&ctx);
3366        drain_configure_warning_events(&ctx);
3367        drain_search_index_events(&ctx);
3368
3369        assert!(watcher_path_is_ignored_by_current_matcher(
3370            &ctx,
3371            &ignored_path
3372        ));
3373        assert_eq!(
3374            ctx.search_index()
3375                .read()
3376                .unwrap_or_else(std::sync::PoisonError::into_inner)
3377                .as_ref()
3378                .expect("completed search index installed")
3379                .file_count(),
3380            0,
3381            "configure must install the ignore matcher before pending paths replay"
3382        );
3383        ctx.stop_watcher_runtime();
3384    }
3385
3386    #[test]
3387    fn post_ack_semantic_ready_transition_pushes_status_changed() {
3388        let root = tempfile::tempdir().unwrap();
3389        let config = Config {
3390            project_root: Some(root.path().to_path_buf()),
3391            semantic_search: true,
3392            ..Config::default()
3393        };
3394        let ctx = AppContext::new(default_language_provider_factory(), config);
3395        ctx.set_canonical_cache_root(root.path().to_path_buf());
3396        *ctx.semantic_index_status()
3397            .write()
3398            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
3399            stage: "loading_artifacts".to_string(),
3400            files: None,
3401            entries_done: None,
3402            entries_total: None,
3403        };
3404        let (event_tx, event_rx) = crossbeam_channel::unbounded();
3405        *ctx.semantic_index_rx().lock() = Some(event_rx);
3406        let (push_tx, push_rx) = std::sync::mpsc::channel();
3407        ctx.set_progress_sender(Some(std::sync::Arc::new(Box::new(move |frame| {
3408            let _ = push_tx.send(frame);
3409        }))));
3410
3411        event_tx
3412            .send(SemanticIndexEvent::Ready(
3413                crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
3414            ))
3415            .unwrap();
3416        drain_semantic_index_events(&ctx);
3417
3418        assert!(matches!(
3419            &*ctx
3420                .semantic_index_status()
3421                .read()
3422                .unwrap_or_else(std::sync::PoisonError::into_inner),
3423            SemanticIndexStatus::Ready { .. }
3424        ));
3425        let pushed = push_rx
3426            .recv_timeout(Duration::from_secs(2))
3427            .expect("ready transition should push status_changed");
3428        assert!(matches!(
3429            pushed,
3430            crate::protocol::PushFrame::StatusChanged(_)
3431        ));
3432    }
3433
3434    #[test]
3435    fn watcher_overflow_invalidates_artifact_freshness_memo() {
3436        let root = tempfile::tempdir().unwrap();
3437        let artifact = root.path().join("semantic.bin");
3438        std::fs::write(&artifact, b"artifact").unwrap();
3439        let canonical_root = std::fs::canonicalize(root.path()).unwrap();
3440        let generation = crate::cache_freshness::artifact_generation(&artifact);
3441        let ticket = crate::cache_freshness::capture_verify_ticket(&canonical_root);
3442        assert!(
3443            crate::cache_freshness::record_verify_completed_if_unchanged(
3444                &canonical_root,
3445                crate::cache_freshness::VerifyArtifact::Semantic,
3446                generation,
3447                ticket,
3448            )
3449        );
3450        assert_eq!(
3451            crate::cache_freshness::warm_verify_plan(
3452                &canonical_root,
3453                crate::cache_freshness::VerifyArtifact::Semantic,
3454                generation,
3455            ),
3456            crate::cache_freshness::WarmVerifyPlan::Skip
3457        );
3458
3459        let ctx = AppContext::new(
3460            default_language_provider_factory(),
3461            Config {
3462                project_root: Some(canonical_root.clone()),
3463                ..Config::default()
3464            },
3465        );
3466        ctx.set_canonical_cache_root(canonical_root.clone());
3467        refresh_project_after_watcher_rescan(&ctx);
3468
3469        assert_eq!(
3470            crate::cache_freshness::warm_verify_plan(
3471                &canonical_root,
3472                crate::cache_freshness::VerifyArtifact::Semantic,
3473                generation,
3474            ),
3475            crate::cache_freshness::WarmVerifyPlan::Strict,
3476            "lost watcher events force STRICT verification: stat-first would \
3477             miss same-size, preserved-mtime edits made during the gap"
3478        );
3479    }
3480
3481    #[test]
3482    fn superseded_callgraph_worker_settles_receiver_and_allows_retry() {
3483        let root = tempfile::tempdir().unwrap();
3484        let storage = tempfile::tempdir().unwrap();
3485        std::fs::write(root.path().join("lib.rs"), "pub fn marker() {}\n").unwrap();
3486        let ctx = AppContext::new(
3487            default_language_provider_factory(),
3488            Config {
3489                project_root: Some(root.path().to_path_buf()),
3490                storage_dir: Some(storage.path().to_path_buf()),
3491                callgraph_chunk_size: 1,
3492                ..Config::default()
3493            },
3494        );
3495        let generation = ctx.configure_generation();
3496        let (worker_tx, worker_rx) = crossbeam_channel::unbounded();
3497        ctx.note_callgraph_store_rx_generation(generation);
3498        ctx.next_callgraph_store_rx_epoch();
3499        *ctx.callgraph_store_rx().lock() = Some(worker_rx);
3500
3501        drain_callgraph_store_events(&ctx);
3502        assert!(
3503            ctx.callgraph_store_rx().lock().is_some(),
3504            "an empty running receiver remains in flight"
3505        );
3506        ctx.next_callgraph_persist_epoch();
3507        worker_tx.send(CallGraphStoreBuildEvent::Settled).unwrap();
3508        drain_callgraph_store_events(&ctx);
3509        assert!(
3510            ctx.callgraph_store_rx().lock().is_none(),
3511            "a superseded worker must explicitly retire its receiver"
3512        );
3513
3514        assert!(matches!(
3515            ctx.callgraph_store_for_ops(),
3516            crate::context::CallgraphStoreAccess::Building
3517                | crate::context::CallgraphStoreAccess::Ready(_)
3518        ));
3519        assert!(
3520            ctx.callgraph_store_rx().lock().is_some()
3521                || ctx
3522                    .callgraph_store()
3523                    .read()
3524                    .unwrap_or_else(std::sync::PoisonError::into_inner)
3525                    .is_some(),
3526            "a later operation must be able to retry the callgraph build"
3527        );
3528    }
3529
3530    #[test]
3531    fn failed_forced_callgraph_build_preserves_durable_demand() {
3532        let root = tempfile::tempdir().unwrap();
3533        let ctx = AppContext::new(
3534            default_language_provider_factory(),
3535            Config {
3536                project_root: Some(root.path().to_path_buf()),
3537                ..Config::default()
3538            },
3539        );
3540        let force_token = ctx.mark_callgraph_store_force_rebuild();
3541        assert_eq!(ctx.pending_callgraph_store_force_token(), Some(force_token));
3542
3543        let generation = ctx.configure_generation();
3544        let (tx, rx) = crossbeam_channel::unbounded();
3545        ctx.note_callgraph_store_rx_generation(generation);
3546        ctx.next_callgraph_store_rx_epoch();
3547        *ctx.callgraph_store_rx().lock() = Some(rx);
3548        tx.send(CallGraphStoreBuildEvent::Settled).unwrap();
3549        drain_callgraph_store_events(&ctx);
3550
3551        assert!(
3552            ctx.pending_callgraph_store_force_token().is_some(),
3553            "the current failed forced build must preserve retry demand"
3554        );
3555        assert!(ctx.callgraph_store_rx().lock().is_none());
3556    }
3557
3558    #[test]
3559    fn newer_forced_callgraph_demand_survives_older_publication() {
3560        let root = tempfile::tempdir().unwrap();
3561        let storage = tempfile::tempdir().unwrap();
3562        let source = root.path().join("lib.rs");
3563        std::fs::write(&source, "pub fn marker() {}\n").unwrap();
3564        let project_root = std::fs::canonicalize(root.path()).unwrap();
3565        let ctx = AppContext::new(
3566            default_language_provider_factory(),
3567            Config {
3568                project_root: Some(project_root.clone()),
3569                storage_dir: Some(storage.path().to_path_buf()),
3570                ..Config::default()
3571            },
3572        );
3573        ctx.set_canonical_cache_root(project_root.clone());
3574        let project_key = crate::search_index::artifact_cache_key(&project_root);
3575        crate::root_cache::configure_artifact_access(&project_root, &project_key, false);
3576        let (store, _stats) = CallGraphStore::cold_build_with_lease_chunked(
3577            ctx.callgraph_store_dir(),
3578            project_root,
3579            &[source],
3580            1,
3581        )
3582        .unwrap();
3583        let older = ctx.mark_callgraph_store_force_rebuild();
3584        let generation = ctx.configure_generation();
3585        let (tx, rx) = crossbeam_channel::unbounded();
3586        ctx.note_callgraph_store_rx_generation(generation);
3587        ctx.next_callgraph_store_rx_epoch();
3588        *ctx.callgraph_store_rx().lock() = Some(rx);
3589        tx.send(CallGraphStoreBuildEvent::Ready {
3590            store,
3591            fulfilled_force_token: Some(older),
3592            publication_epoch: ctx.callgraph_persist_epoch_flag().current(),
3593        })
3594        .unwrap();
3595        let newer = ctx.mark_callgraph_store_force_rebuild();
3596
3597        drain_callgraph_store_events(&ctx);
3598
3599        assert!(ctx.callgraph_store().read().unwrap().is_some());
3600        assert_eq!(ctx.pending_callgraph_store_force_token(), Some(newer));
3601        assert!(matches!(
3602            ctx.callgraph_store_for_ops(),
3603            crate::context::CallgraphStoreAccess::Building
3604        ));
3605        assert!(ctx.callgraph_store_rx().lock().is_some());
3606
3607        let deadline = Instant::now() + Duration::from_secs(10);
3608        while ctx.pending_callgraph_store_force_token().is_some() {
3609            drain_callgraph_store_events(&ctx);
3610            assert!(
3611                Instant::now() < deadline,
3612                "newer forced callgraph rebuild did not publish"
3613            );
3614            std::thread::sleep(Duration::from_millis(5));
3615        }
3616        assert!(ctx.callgraph_store().read().unwrap().is_some());
3617    }
3618
3619    #[test]
3620    fn callgraph_ready_without_published_pointer_settles_and_preserves_pending_paths() {
3621        let root = tempfile::tempdir().unwrap();
3622        let storage = tempfile::tempdir().unwrap();
3623        let source = root.path().join("lib.rs");
3624        std::fs::write(&source, "pub fn marker() {}\n").unwrap();
3625        let project_root = std::fs::canonicalize(root.path()).unwrap();
3626        let ctx = AppContext::new(
3627            default_language_provider_factory(),
3628            Config {
3629                project_root: Some(project_root.clone()),
3630                storage_dir: Some(storage.path().to_path_buf()),
3631                callgraph_chunk_size: 1,
3632                ..Config::default()
3633            },
3634        );
3635        ctx.set_canonical_cache_root(project_root.clone());
3636        let project_key = crate::search_index::artifact_cache_key(&project_root);
3637        crate::root_cache::configure_artifact_access(&project_root, &project_key, false);
3638        let callgraph_dir = ctx.callgraph_store_dir();
3639        let (store, _stats) = CallGraphStore::cold_build_with_lease_chunked(
3640            callgraph_dir.clone(),
3641            project_root,
3642            &[source],
3643            1,
3644        )
3645        .unwrap();
3646        let pointer = callgraph_dir.join(format!("{}.current", store.project_key()));
3647        std::fs::remove_file(pointer).unwrap();
3648
3649        let pending = root.path().join("pending.rs");
3650        ctx.add_pending_callgraph_store_paths([pending.clone()]);
3651        let generation = ctx.configure_generation();
3652        let (tx, rx) = crossbeam_channel::unbounded();
3653        {
3654            let mut receiver = ctx.callgraph_store_rx().lock();
3655            ctx.note_callgraph_store_rx_generation(generation);
3656            ctx.next_callgraph_store_rx_epoch();
3657            *receiver = Some(rx);
3658        }
3659        tx.send(CallGraphStoreBuildEvent::Ready {
3660            store,
3661            fulfilled_force_token: None,
3662            publication_epoch: ctx.callgraph_persist_epoch_flag().current(),
3663        })
3664        .unwrap();
3665        drop(tx);
3666
3667        drain_callgraph_store_events(&ctx);
3668
3669        assert!(
3670            ctx.callgraph_store_rx().lock().is_none(),
3671            "Ready is terminal even when reopening the pointer fails"
3672        );
3673        assert_eq!(
3674            ctx.take_pending_callgraph_store_paths(),
3675            vec![pending],
3676            "failed reopen must preserve pending watcher paths for the retry"
3677        );
3678    }
3679
3680    #[test]
3681    fn callgraph_ready_transition_schedules_tier2_dead_code_rescan() {
3682        // When the callgraph store transitions to ready, the drain must request a
3683        // tier2 refresh pull. dead_code is suppressed (callgraph_available:false)
3684        // while no store is ready; this pull is what eventually re-runs dead_code
3685        // against the now-ready store and replaces that aggregate, flipping the
3686        // root to genuinely complete instead of "building" forever.
3687        let root = tempfile::tempdir().unwrap();
3688        let storage = tempfile::tempdir().unwrap();
3689        let source = root.path().join("lib.rs");
3690        std::fs::write(&source, "pub fn marker() {}\n").unwrap();
3691        let project_root = std::fs::canonicalize(root.path()).unwrap();
3692        let ctx = AppContext::new(
3693            default_language_provider_factory(),
3694            Config {
3695                project_root: Some(project_root.clone()),
3696                storage_dir: Some(storage.path().to_path_buf()),
3697                callgraph_chunk_size: 1,
3698                ..Config::default()
3699            },
3700        );
3701        ctx.set_canonical_cache_root(project_root.clone());
3702        let project_key = crate::search_index::artifact_cache_key(&project_root);
3703        crate::root_cache::configure_artifact_access(&project_root, &project_key, false);
3704        let (store, _stats) = CallGraphStore::cold_build_with_lease_chunked(
3705            ctx.callgraph_store_dir(),
3706            project_root,
3707            &[source],
3708            1,
3709        )
3710        .unwrap();
3711
3712        assert!(
3713            !ctx.tier2_pull_demand_pending(),
3714            "no tier2 pull demand before the callgraph store is ready"
3715        );
3716
3717        let generation = ctx.configure_generation();
3718        let (tx, rx) = crossbeam_channel::unbounded();
3719        ctx.note_callgraph_store_rx_generation(generation);
3720        ctx.next_callgraph_store_rx_epoch();
3721        *ctx.callgraph_store_rx().lock() = Some(rx);
3722        tx.send(CallGraphStoreBuildEvent::Ready {
3723            store,
3724            fulfilled_force_token: None,
3725            publication_epoch: ctx.callgraph_persist_epoch_flag().current(),
3726        })
3727        .unwrap();
3728        drop(tx);
3729
3730        drain_callgraph_store_events(&ctx);
3731
3732        assert!(
3733            ctx.callgraph_store().read().unwrap().is_some(),
3734            "the ready callgraph store must install"
3735        );
3736        assert!(
3737            ctx.tier2_pull_demand_pending(),
3738            "the callgraph-ready transition must schedule a tier2 refresh pull so dead_code is rescanned against the ready store"
3739        );
3740    }
3741
3742    #[test]
3743    fn stale_callgraph_receiver_cannot_clear_newer_same_generation_receiver() {
3744        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
3745        let root = tempfile::tempdir().unwrap();
3746        let ctx = Arc::new(AppContext::new(
3747            default_language_provider_factory(),
3748            Config {
3749                project_root: Some(root.path().to_path_buf()),
3750                ..Config::default()
3751            },
3752        ));
3753        let generation = ctx.configure_generation();
3754        let (old_tx, old_rx) = crossbeam_channel::unbounded();
3755        ctx.note_callgraph_store_rx_generation(generation);
3756        ctx.next_callgraph_store_rx_epoch();
3757        *ctx.callgraph_store_rx().lock() = Some(old_rx);
3758        old_tx.send(CallGraphStoreBuildEvent::Settled).unwrap();
3759        let (reached, release) = install_artifact_drain_commit_gate_for_test(&ctx);
3760
3761        let drain_ctx = Arc::clone(&ctx);
3762        let drain = std::thread::spawn(move || drain_callgraph_store_events(&drain_ctx));
3763        reached
3764            .recv_timeout(Duration::from_secs(2))
3765            .expect("stale callgraph receiver was not dequeued");
3766
3767        let (_new_tx, new_rx) = crossbeam_channel::unbounded();
3768        ctx.note_callgraph_store_rx_generation(generation);
3769        ctx.next_callgraph_store_rx_epoch();
3770        *ctx.callgraph_store_rx().lock() = Some(new_rx);
3771        release.send(()).unwrap();
3772        drain.join().unwrap();
3773
3774        assert!(
3775            ctx.callgraph_store_rx().lock().is_some(),
3776            "a stale callgraph drain must not clear the replacement receiver"
3777        );
3778        assert!(
3779            ctx.pending_callgraph_store_force_token().is_none(),
3780            "a stale terminal event must not create force demand for its replacement"
3781        );
3782    }
3783
3784    #[test]
3785    fn dequeued_search_completion_cannot_clear_newer_same_generation_receiver() {
3786        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
3787        let root = tempfile::tempdir().unwrap();
3788        let ctx = Arc::new(AppContext::new(
3789            default_language_provider_factory(),
3790            Config {
3791                project_root: Some(root.path().to_path_buf()),
3792                ..Config::default()
3793            },
3794        ));
3795        let generation = ctx.configure_generation();
3796        let (old_tx, old_rx) = crossbeam_channel::unbounded();
3797        old_tx
3798            .send(crate::search_index::SearchIndex::new())
3799            .unwrap();
3800        ctx.note_search_index_rx_generation(generation);
3801        ctx.next_search_index_rx_epoch();
3802        *ctx.search_index_rx()
3803            .write()
3804            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(old_rx);
3805        let (reached, release) = install_artifact_drain_commit_gate_for_test(&ctx);
3806
3807        let drain_ctx = Arc::clone(&ctx);
3808        let drain = std::thread::spawn(move || drain_search_index_events(&drain_ctx));
3809        reached
3810            .recv_timeout(Duration::from_secs(2))
3811            .expect("old search completion was not dequeued");
3812
3813        let (_new_tx, new_rx) = crossbeam_channel::unbounded();
3814        ctx.note_search_index_rx_generation(generation);
3815        ctx.next_search_index_rx_epoch();
3816        *ctx.search_index_rx()
3817            .write()
3818            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(new_rx);
3819        release.send(()).unwrap();
3820        drain.join().unwrap();
3821
3822        assert!(
3823            ctx.search_index()
3824                .read()
3825                .unwrap_or_else(std::sync::PoisonError::into_inner)
3826                .is_none(),
3827            "an older same-generation receiver must not publish after replacement"
3828        );
3829        assert!(
3830            ctx.search_index_rx()
3831                .read()
3832                .unwrap_or_else(std::sync::PoisonError::into_inner)
3833                .is_some(),
3834            "an older same-generation drain must not clear the newer receiver"
3835        );
3836    }
3837
3838    #[test]
3839    fn rescan_arriving_while_unbound_executes_fully_after_rebind() {
3840        let temp = tempfile::tempdir().unwrap();
3841        let root = std::fs::canonicalize(temp.path()).unwrap();
3842        let (ctx, watcher_tx) = watcher_context(&root);
3843        // Warm Skip memo: the rescan's strict invalidation is its observable
3844        // effect, so the memo downgrade proves the refresh actually ran.
3845        let artifact = root.join("artifact.bin");
3846        std::fs::write(&artifact, b"artifact").unwrap();
3847        let generation = crate::cache_freshness::artifact_generation(&artifact);
3848        let ticket = crate::cache_freshness::capture_verify_ticket(&root);
3849        assert!(
3850            crate::cache_freshness::record_verify_completed_if_unchanged(
3851                &root,
3852                crate::cache_freshness::VerifyArtifact::Search,
3853                generation,
3854                ticket,
3855            )
3856        );
3857        watcher_tx
3858            .send(WatcherDispatchEvent::RescanRequired(RescanReason::Unknown))
3859            .unwrap();
3860
3861        // While unbound the drain must not consume (and then lose) the
3862        // rescan: nothing may execute, so the memo stays warm.
3863        ctx.mark_subc_unbound();
3864        drain_watcher_events_bounded(&ctx, WATCHER_PATH_DRAIN_BATCH_CAP);
3865        assert_eq!(
3866            crate::cache_freshness::warm_verify_plan(
3867                &root,
3868                crate::cache_freshness::VerifyArtifact::Search,
3869                generation,
3870            ),
3871            crate::cache_freshness::WarmVerifyPlan::Skip,
3872            "an unbound drain must not run (or half-run) the rescan"
3873        );
3874
3875        // After rebind the retained rescan executes in full: strict memo and
3876        // acknowledged flag.
3877        ctx.mark_subc_bound();
3878        let mut guard = 0;
3879        while drain_watcher_events_bounded(&ctx, WATCHER_PATH_DRAIN_BATCH_CAP).has_more {
3880            guard += 1;
3881            assert!(guard < 16, "rescan replay must finish");
3882        }
3883        assert_eq!(
3884            crate::cache_freshness::warm_verify_plan(
3885                &root,
3886                crate::cache_freshness::VerifyArtifact::Search,
3887                generation,
3888            ),
3889            crate::cache_freshness::WarmVerifyPlan::Strict,
3890            "the post-rebind drain must execute the retained rescan strictly"
3891        );
3892        assert!(
3893            !ctx.watcher_drain_slice()
3894                .lock()
3895                .as_ref()
3896                .is_some_and(|state| state.rescan_required),
3897            "a fully-bound rescan must be acknowledged"
3898        );
3899    }
3900
3901    #[test]
3902    fn budget_interrupted_stage_rewinds_when_unbind_lands_mid_stage() {
3903        // An unbind mid-stage makes the remaining per-path actions gated
3904        // no-ops while `remaining` still decrements. The park must rewind the
3905        // stage so the post-rebind replay re-runs it in full — for BOTH park
3906        // shapes (budget-exhausted and stage-complete).
3907        let temp = tempfile::tempdir().unwrap();
3908        let root = std::fs::canonicalize(temp.path()).unwrap();
3909        let first = root.join("first.rs");
3910        let second = root.join("second.rs");
3911        std::fs::write(&first, "fn first_marker() {}\n").unwrap();
3912        std::fs::write(&second, "fn second_marker() {}\n").unwrap();
3913        let (ctx, watcher_tx) = watcher_context(&root);
3914        *ctx.search_index()
3915            .write()
3916            .unwrap_or_else(std::sync::PoisonError::into_inner) =
3917            Some(crate::search_index::SearchIndex::new());
3918        watcher_tx
3919            .send(WatcherDispatchEvent::Paths(vec![
3920                first.clone(),
3921                second.clone(),
3922            ]))
3923            .unwrap();
3924
3925        // Gate on the SECOND path so the unbind lands after `first` was
3926        // already applied within the same stage pass.
3927        let ctx = Arc::new(ctx);
3928        let (reached_rx, release_tx) = install_watcher_phase_commit_gate_for_test(second.clone());
3929        let drain_ctx = Arc::clone(&ctx);
3930        let drain = std::thread::spawn(move || {
3931            while drain_watcher_events_bounded(&drain_ctx, WATCHER_PATH_DRAIN_BATCH_CAP).has_more {}
3932        });
3933        reached_rx
3934            .recv_timeout(Duration::from_secs(2))
3935            .expect("watcher phase did not reach the second path");
3936        ctx.mark_subc_unbound();
3937        release_tx.send(()).unwrap();
3938        drain.join().unwrap();
3939
3940        ctx.mark_subc_bound();
3941        let mut guard = 0;
3942        while drain_watcher_events_bounded(&ctx, WATCHER_PATH_DRAIN_BATCH_CAP).has_more {
3943            guard += 1;
3944            assert!(guard < 32, "rebased replay must finish");
3945        }
3946        let search = ctx
3947            .search_index()
3948            .read()
3949            .unwrap_or_else(std::sync::PoisonError::into_inner);
3950        let index = search.as_ref().expect("search index");
3951        for (marker, path) in [("first_marker", &first), ("second_marker", &second)] {
3952            assert_eq!(
3953                index.grep(marker, true, &[], &[], &root, 10).matches.len(),
3954                1,
3955                "post-rebind replay must apply {} ({})",
3956                marker,
3957                path.display()
3958            );
3959        }
3960    }
3961
3962    #[test]
3963    fn pending_paths_retained_across_transient_unbind_repair_next_installed_index() {
3964        let temp = tempfile::tempdir().unwrap();
3965        let root = std::fs::canonicalize(temp.path()).unwrap();
3966        let source = root.join("edited-during-unbind.rs");
3967        std::fs::write(&source, "fn repaired_marker() {}\n").unwrap();
3968        let ctx = AppContext::new(
3969            default_language_provider_factory(),
3970            Config {
3971                project_root: Some(root.clone()),
3972                ..Config::default()
3973            },
3974        );
3975        ctx.set_canonical_cache_root(root.clone());
3976
3977        // Watcher recorded the edit while a build was in flight, then the
3978        // route unbound. The transient-unbind cleanup retires the receiver but
3979        // must keep the pending path: it is the only record that any artifact
3980        // a pre-unbind worker persisted is content-stale.
3981        ctx.add_pending_search_index_paths([source.clone()]);
3982        ctx.mark_subc_unbound();
3983        ctx.cancel_unbound_artifact_work();
3984        assert!(ctx.search_index_rx().read().unwrap().is_none());
3985
3986        // Equivalent rebind starts a replacement build; its install must
3987        // replay the retained path into the fresh index.
3988        ctx.mark_subc_bound();
3989        let (tx, rx) = crossbeam_channel::unbounded();
3990        let mut stale_index = crate::search_index::SearchIndex::build(&root);
3991        // Simulate the pre-unbind worker's artifact predating the edit.
3992        stale_index.remove_file(&source);
3993        tx.send(stale_index).unwrap();
3994        ctx.install_search_index_rx(rx, ctx.configure_generation());
3995
3996        drain_search_index_events(&ctx);
3997
3998        let search = ctx
3999            .search_index()
4000            .read()
4001            .unwrap_or_else(std::sync::PoisonError::into_inner);
4002        assert_eq!(
4003            search
4004                .as_ref()
4005                .expect("installed search index")
4006                .grep("repaired_marker", true, &[], &[], &root, 10)
4007                .matches
4008                .len(),
4009            1,
4010            "retained pending path must repair the stale artifact on install"
4011        );
4012    }
4013
4014    #[test]
4015    fn disconnected_search_refresh_clears_nonready_index_and_preserves_pending_paths() {
4016        let root = tempfile::tempdir().unwrap();
4017        let ctx = AppContext::new(
4018            default_language_provider_factory(),
4019            Config {
4020                project_root: Some(root.path().to_path_buf()),
4021                ..Config::default()
4022            },
4023        );
4024        let mut index = crate::search_index::SearchIndex::new();
4025        index.ready = false;
4026        *ctx.search_index()
4027            .write()
4028            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
4029        let pending = root.path().join("pending.rs");
4030        ctx.add_pending_search_index_paths([pending.clone()]);
4031        let generation = ctx.configure_generation();
4032        let (tx, rx) = crossbeam_channel::unbounded();
4033        drop(tx);
4034        ctx.install_search_index_rx(rx, generation);
4035
4036        drain_search_index_events(&ctx);
4037
4038        assert!(
4039            ctx.search_index()
4040                .read()
4041                .unwrap_or_else(std::sync::PoisonError::into_inner)
4042                .is_none(),
4043            "a disconnected refresh must not leave a permanently non-ready index"
4044        );
4045        assert!(ctx.search_index_rx().read().unwrap().is_none());
4046        assert_eq!(ctx.take_pending_search_index_paths(), vec![pending]);
4047    }
4048
4049    #[test]
4050    fn search_index_disconnect_reschedule_caps_at_one_per_generation() {
4051        // The drain path replaces a lost search-index load at most once per
4052        // configure generation; after that the query-triggered reload is the
4053        // recovery path, so a persistently failing worker cannot be relaunched in
4054        // a loop on the drain thread.
4055        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
4056        assert!(
4057            ctx.allow_search_index_disconnect_reschedule(),
4058            "the first automatic replacement in a generation must be allowed"
4059        );
4060        assert!(
4061            !ctx.allow_search_index_disconnect_reschedule(),
4062            "a second automatic replacement in the same generation must be denied"
4063        );
4064        assert!(
4065            !ctx.search_index_query_reload_allowed(),
4066            "queued queries must use fallback during the retry cooldown"
4067        );
4068        ctx.advance_configure_generation();
4069        assert!(
4070            ctx.search_index_query_reload_allowed(),
4071            "a new configure generation must clear the retry cooldown"
4072        );
4073        assert!(
4074            ctx.allow_search_index_disconnect_reschedule(),
4075            "advancing the configure generation must reset the replacement cap"
4076        );
4077    }
4078
4079    #[test]
4080    fn lost_search_load_disconnect_schedules_one_replacement_that_installs() {
4081        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4082        let temp = tempfile::tempdir().unwrap();
4083        let root = std::fs::canonicalize(temp.path()).unwrap();
4084        let storage = temp.path().join("storage");
4085        std::fs::create_dir_all(&storage).unwrap();
4086        std::fs::write(
4087            root.join("lib.rs"),
4088            "pub fn LostLoadNeedle() -> bool { true }\n",
4089        )
4090        .unwrap();
4091        let ctx = AppContext::new(
4092            default_language_provider_factory(),
4093            Config {
4094                project_root: Some(root.clone()),
4095                storage_dir: Some(storage),
4096                search_index: true,
4097                semantic_search: false,
4098                callgraph_store: false,
4099                ..Config::default()
4100            },
4101        );
4102        ctx.set_canonical_cache_root(root.clone());
4103
4104        // Simulate a lost post-configure load: the build worker dropped its
4105        // sender without delivering an index, leaving a not-ready index and a
4106        // disconnected receiver. Before the fix nothing rescheduled this, so
4107        // health reported "building" forever.
4108        let mut stranded = crate::search_index::SearchIndex::new();
4109        stranded.ready = false;
4110        *ctx.search_index()
4111            .write()
4112            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stranded);
4113        let generation = ctx.configure_generation();
4114        let (tx, rx) = crossbeam_channel::unbounded::<crate::search_index::SearchIndex>();
4115        drop(tx); // disconnect without sending
4116        ctx.install_search_index_rx(rx, generation);
4117
4118        drain_search_index_events(&ctx);
4119
4120        assert!(
4121            ctx.search_index_rx().read().unwrap().is_some(),
4122            "a lost load must schedule a replacement search-index load (fresh receiver installed)"
4123        );
4124
4125        // The replacement worker builds and publishes; draining installs it.
4126        // Poll until ready (bounded so a regression fails instead of hanging).
4127        let deadline = std::time::Instant::now() + Duration::from_secs(20);
4128        loop {
4129            drain_search_index_events(&ctx);
4130            let ready = ctx
4131                .search_index()
4132                .read()
4133                .unwrap_or_else(std::sync::PoisonError::into_inner)
4134                .as_ref()
4135                .is_some_and(|index| index.ready);
4136            if ready {
4137                break;
4138            }
4139            assert!(
4140                std::time::Instant::now() < deadline,
4141                "replacement search-index load did not install before the deadline"
4142            );
4143            std::thread::sleep(Duration::from_millis(20));
4144        }
4145
4146        let matches = ctx
4147            .search_index()
4148            .read()
4149            .unwrap_or_else(std::sync::PoisonError::into_inner)
4150            .as_ref()
4151            .expect("installed replacement index")
4152            .grep("LostLoadNeedle", true, &[], &[], &root, 10)
4153            .matches
4154            .len();
4155        assert_eq!(
4156            matches, 1,
4157            "the replacement index must actually serve queries"
4158        );
4159
4160        // The one-per-generation cap is now consumed. A second lost load in the
4161        // same generation must NOT schedule another replacement (no loop); the
4162        // query-triggered reload remains the recovery path.
4163        let mut stranded_again = crate::search_index::SearchIndex::new();
4164        stranded_again.ready = false;
4165        *ctx.search_index()
4166            .write()
4167            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stranded_again);
4168        let (tx2, rx2) = crossbeam_channel::unbounded::<crate::search_index::SearchIndex>();
4169        drop(tx2);
4170        ctx.install_search_index_rx(rx2, ctx.configure_generation());
4171
4172        drain_search_index_events(&ctx);
4173
4174        assert!(
4175            ctx.search_index_rx().read().unwrap().is_none(),
4176            "the cap must prevent a second automatic replacement in the same generation"
4177        );
4178    }
4179
4180    #[test]
4181    fn dequeued_search_completion_cannot_publish_after_unbind() {
4182        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4183        let root = tempfile::tempdir().unwrap();
4184        let ctx = Arc::new(AppContext::new(
4185            default_language_provider_factory(),
4186            Config {
4187                project_root: Some(root.path().to_path_buf()),
4188                ..Config::default()
4189            },
4190        ));
4191        ctx.set_canonical_cache_root(root.path().to_path_buf());
4192        let generation = ctx.configure_generation();
4193        let (tx, rx) = crossbeam_channel::unbounded();
4194        tx.send(crate::search_index::SearchIndex::new()).unwrap();
4195        ctx.note_search_index_rx_generation(generation);
4196        *ctx.search_index_rx()
4197            .write()
4198            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(rx);
4199        let (reached, release) = install_artifact_drain_commit_gate_for_test(&ctx);
4200
4201        let drain_ctx = Arc::clone(&ctx);
4202        let drain = std::thread::spawn(move || drain_search_index_events(&drain_ctx));
4203        reached
4204            .recv_timeout(Duration::from_secs(2))
4205            .expect("search completion was not dequeued");
4206        ctx.mark_subc_unbound();
4207        release.send(()).unwrap();
4208        drain.join().unwrap();
4209
4210        assert!(
4211            ctx.search_index()
4212                .read()
4213                .unwrap_or_else(std::sync::PoisonError::into_inner)
4214                .is_none(),
4215            "a dequeued completion must re-check lifecycle admission at commit"
4216        );
4217    }
4218
4219    #[test]
4220    fn dequeued_semantic_completion_cannot_publish_after_unbind() {
4221        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4222        let root = tempfile::tempdir().unwrap();
4223        let ctx = Arc::new(AppContext::new(
4224            default_language_provider_factory(),
4225            Config {
4226                project_root: Some(root.path().to_path_buf()),
4227                semantic_search: true,
4228                ..Config::default()
4229            },
4230        ));
4231        ctx.set_canonical_cache_root(root.path().to_path_buf());
4232        let generation = ctx.configure_generation();
4233        let (tx, rx) = crossbeam_channel::unbounded();
4234        tx.send(SemanticIndexEvent::Ready(
4235            crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
4236        ))
4237        .unwrap();
4238        ctx.note_semantic_index_rx_generation(generation);
4239        *ctx.semantic_index_rx().lock() = Some(rx);
4240        let (reached, release) = install_artifact_drain_commit_gate_for_test(&ctx);
4241
4242        let drain_ctx = Arc::clone(&ctx);
4243        let drain = std::thread::spawn(move || drain_semantic_index_events(&drain_ctx));
4244        reached
4245            .recv_timeout(Duration::from_secs(2))
4246            .expect("semantic completion was not dequeued");
4247        ctx.mark_subc_unbound();
4248        release.send(()).unwrap();
4249        drain.join().unwrap();
4250
4251        assert!(
4252            ctx.semantic_index()
4253                .read()
4254                .unwrap_or_else(std::sync::PoisonError::into_inner)
4255                .is_none(),
4256            "a dequeued completion must re-check lifecycle admission at commit"
4257        );
4258    }
4259
4260    #[test]
4261    fn dequeued_semantic_refresh_cannot_publish_after_unbind() {
4262        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4263        let root = tempfile::tempdir().unwrap();
4264        let ctx = Arc::new(AppContext::new(
4265            default_language_provider_factory(),
4266            Config {
4267                project_root: Some(root.path().to_path_buf()),
4268                semantic_search: true,
4269                ..Config::default()
4270            },
4271        ));
4272        ctx.set_canonical_cache_root(root.path().to_path_buf());
4273        let (request_tx, _request_rx) = crossbeam_channel::unbounded();
4274        let (event_tx, event_rx) = crossbeam_channel::unbounded();
4275        ctx.install_semantic_refresh_worker_for_build_epoch(
4276            request_tx,
4277            event_rx,
4278            Arc::new(Mutex::new(None)),
4279            ctx.semantic_index_rx_epoch(),
4280        );
4281        event_tx
4282            .send(SemanticRefreshEvent::CorpusCompleted {
4283                index: crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
4284                changed: 0,
4285                added: 0,
4286                deleted: 0,
4287                total_processed: 0,
4288            })
4289            .unwrap();
4290        let (reached, release) = install_artifact_drain_commit_gate_for_test(&ctx);
4291
4292        let drain_ctx = Arc::clone(&ctx);
4293        let drain = std::thread::spawn(move || drain_semantic_refresh_events(&drain_ctx));
4294        reached
4295            .recv_timeout(Duration::from_secs(2))
4296            .expect("semantic refresh completion was not dequeued");
4297        ctx.mark_subc_unbound();
4298        release.send(()).unwrap();
4299        drain.join().unwrap();
4300
4301        assert!(
4302            ctx.semantic_index()
4303                .read()
4304                .unwrap_or_else(std::sync::PoisonError::into_inner)
4305                .is_none(),
4306            "a dequeued refresh must re-check lifecycle admission at commit"
4307        );
4308    }
4309
4310    #[test]
4311    fn dequeued_semantic_refresh_cannot_publish_after_bound_replacement() {
4312        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4313        let root = tempfile::tempdir().unwrap();
4314        let ctx = Arc::new(AppContext::new(
4315            default_language_provider_factory(),
4316            Config {
4317                project_root: Some(root.path().to_path_buf()),
4318                semantic_search: true,
4319                ..Config::default()
4320            },
4321        ));
4322        ctx.set_canonical_cache_root(root.path().to_path_buf());
4323        let (old_request_tx, _old_request_rx) = crossbeam_channel::unbounded();
4324        let (old_event_tx, old_event_rx) = crossbeam_channel::unbounded();
4325        ctx.install_semantic_refresh_worker_for_build_epoch(
4326            old_request_tx,
4327            old_event_rx,
4328            Arc::new(Mutex::new(None)),
4329            ctx.semantic_index_rx_epoch(),
4330        );
4331        old_event_tx
4332            .send(SemanticRefreshEvent::CorpusCompleted {
4333                index: crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
4334                changed: 0,
4335                added: 0,
4336                deleted: 0,
4337                total_processed: 0,
4338            })
4339            .unwrap();
4340        let (reached, release) = install_artifact_drain_commit_gate_for_test(&ctx);
4341
4342        let drain_ctx = Arc::clone(&ctx);
4343        let drain = std::thread::spawn(move || drain_semantic_refresh_events(&drain_ctx));
4344        reached
4345            .recv_timeout(Duration::from_secs(2))
4346            .expect("old semantic refresh completion was not dequeued");
4347
4348        let (new_request_tx, _new_request_rx) = crossbeam_channel::unbounded();
4349        let (_new_event_tx, new_event_rx) = crossbeam_channel::unbounded();
4350        ctx.install_semantic_refresh_worker_for_build_epoch(
4351            new_request_tx,
4352            new_event_rx,
4353            Arc::new(Mutex::new(None)),
4354            ctx.semantic_index_rx_epoch(),
4355        );
4356        release.send(()).unwrap();
4357        drain.join().unwrap();
4358
4359        assert!(
4360            ctx.semantic_index()
4361                .read()
4362                .unwrap_or_else(std::sync::PoisonError::into_inner)
4363                .is_none(),
4364            "an old refresh event must not be relabeled as the replacement worker"
4365        );
4366        assert!(
4367            ctx.semantic_refresh_event_rx().lock().is_some(),
4368            "the stale drain must not clear the replacement refresh receiver"
4369        );
4370    }
4371
4372    #[test]
4373    fn current_semantic_refresh_disconnect_requests_full_reload() {
4374        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4375        crate::commands::configure::set_semantic_refresh_restart_result_for_test(Some(true));
4376        struct RestartOverrideReset;
4377        impl Drop for RestartOverrideReset {
4378            fn drop(&mut self) {
4379                crate::commands::configure::set_semantic_refresh_restart_result_for_test(None);
4380            }
4381        }
4382        let _reset = RestartOverrideReset;
4383
4384        let root = tempfile::tempdir().unwrap();
4385        let ctx = AppContext::new(
4386            default_language_provider_factory(),
4387            Config {
4388                project_root: Some(root.path().to_path_buf()),
4389                semantic_search: true,
4390                ..Config::default()
4391            },
4392        );
4393        ctx.set_canonical_cache_root(root.path().to_path_buf());
4394        *ctx.semantic_index()
4395            .write()
4396            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(
4397            crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
4398        );
4399        *ctx.semantic_index_status()
4400            .write()
4401            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
4402        let (_build_tx, build_rx) = crossbeam_channel::unbounded();
4403        let disconnected_build_epoch =
4404            ctx.install_semantic_index_rx(build_rx, ctx.configure_generation());
4405        let (request_tx, _request_rx) = crossbeam_channel::unbounded();
4406        let (event_tx, event_rx) = crossbeam_channel::unbounded();
4407        ctx.install_semantic_refresh_worker_for_build_epoch(
4408            request_tx,
4409            event_rx,
4410            Arc::new(Mutex::new(None)),
4411            disconnected_build_epoch,
4412        );
4413        drop(event_tx);
4414
4415        drain_semantic_refresh_events(&ctx);
4416
4417        assert_eq!(
4418            crate::commands::configure::semantic_refresh_restart_attempts_for_test(),
4419            1
4420        );
4421        assert!(
4422            ctx.semantic_index()
4423                .read()
4424                .unwrap_or_else(std::sync::PoisonError::into_inner)
4425                .is_none(),
4426            "recovery must force a full reload rather than retain an index without a refresh worker"
4427        );
4428        assert!(ctx.semantic_refresh_event_rx().lock().is_none());
4429        assert!(
4430            ctx.semantic_index_rx().lock().is_none(),
4431            "a build receiver from the disconnected refresh generation must not be adopted"
4432        );
4433        assert!(matches!(
4434            &*ctx
4435                .semantic_index_status()
4436                .read()
4437                .unwrap_or_else(std::sync::PoisonError::into_inner),
4438            SemanticIndexStatus::Building { stage, .. } if stage == "restarting_refresh_worker"
4439        ));
4440    }
4441
4442    #[test]
4443    fn finished_refresh_worker_wakes_maintenance_after_last_event_is_drained() {
4444        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4445        crate::commands::configure::set_semantic_refresh_restart_result_for_test(Some(true));
4446        struct RestartOverrideReset;
4447        impl Drop for RestartOverrideReset {
4448            fn drop(&mut self) {
4449                crate::commands::configure::set_semantic_refresh_restart_result_for_test(None);
4450            }
4451        }
4452        let _reset = RestartOverrideReset;
4453
4454        let root = tempfile::tempdir().unwrap();
4455        let ctx = AppContext::new(
4456            default_language_provider_factory(),
4457            Config {
4458                project_root: Some(root.path().to_path_buf()),
4459                semantic_search: true,
4460                ..Config::default()
4461            },
4462        );
4463        ctx.set_canonical_cache_root(root.path().to_path_buf());
4464        *ctx.semantic_index()
4465            .write()
4466            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(
4467            crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
4468        );
4469        *ctx.semantic_index_status()
4470            .write()
4471            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
4472
4473        let (request_tx, _request_rx) = crossbeam_channel::unbounded();
4474        let (event_tx, event_rx) = crossbeam_channel::unbounded();
4475        let (event_sent_tx, event_sent_rx) = crossbeam_channel::bounded(1);
4476        let (finish_tx, finish_rx) = crossbeam_channel::bounded(1);
4477        let worker = std::thread::spawn(move || {
4478            event_tx
4479                .send(SemanticRefreshEvent::Started { paths: Vec::new() })
4480                .unwrap();
4481            event_sent_tx.send(()).unwrap();
4482            finish_rx.recv().unwrap();
4483        });
4484        let worker_slot = Arc::new(Mutex::new(Some(worker)));
4485        ctx.install_semantic_refresh_worker_for_build_epoch(
4486            request_tx,
4487            event_rx,
4488            Arc::clone(&worker_slot),
4489            ctx.semantic_index_rx_epoch(),
4490        );
4491        event_sent_rx.recv_timeout(Duration::from_secs(2)).unwrap();
4492        drain_semantic_refresh_events(&ctx);
4493        assert!(
4494            !ctx.completion_drains_have_work(),
4495            "a live worker with an empty event queue should not cause maintenance churn"
4496        );
4497
4498        finish_tx.send(()).unwrap();
4499        let deadline = Instant::now() + Duration::from_secs(2);
4500        while !ctx.completion_drains_have_work() {
4501            assert!(
4502                Instant::now() < deadline,
4503                "finished refresh worker did not wake maintenance"
4504            );
4505            std::thread::yield_now();
4506        }
4507        drain_semantic_refresh_events(&ctx);
4508
4509        assert_eq!(
4510            crate::commands::configure::semantic_refresh_restart_attempts_for_test(),
4511            1
4512        );
4513        assert!(ctx.semantic_refresh_event_rx().lock().is_none());
4514    }
4515
4516    #[test]
4517    fn semantic_disconnect_does_not_overwrite_replacement_loader_state() {
4518        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4519        let root = tempfile::tempdir().unwrap();
4520        let ctx = Arc::new(AppContext::new(
4521            default_language_provider_factory(),
4522            Config {
4523                project_root: Some(root.path().to_path_buf()),
4524                semantic_search: true,
4525                ..Config::default()
4526            },
4527        ));
4528        *ctx.semantic_index()
4529            .write()
4530            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(
4531            crate::semantic_index::SemanticIndex::new(root.path().to_path_buf(), 3),
4532        );
4533        *ctx.semantic_index_status()
4534            .write()
4535            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
4536        let (old_request_tx, _old_request_rx) = crossbeam_channel::unbounded();
4537        let (old_event_tx, old_event_rx) = crossbeam_channel::unbounded();
4538        ctx.install_semantic_refresh_worker_for_build_epoch(
4539            old_request_tx,
4540            old_event_rx,
4541            Arc::new(Mutex::new(None)),
4542            ctx.semantic_index_rx_epoch(),
4543        );
4544        drop(old_event_tx);
4545        let (reached, release) = install_semantic_refresh_recovery_gate_for_test(&ctx);
4546
4547        let drain_ctx = Arc::clone(&ctx);
4548        let drain = std::thread::spawn(move || drain_semantic_refresh_events(&drain_ctx));
4549        reached
4550            .recv_timeout(Duration::from_secs(2))
4551            .expect("old worker was not cleared before recovery");
4552
4553        let (build_tx, build_rx) = crossbeam_channel::unbounded::<SemanticIndexEvent>();
4554        ctx.install_semantic_index_rx(build_rx, ctx.configure_generation());
4555        let (new_request_tx, _new_request_rx) = crossbeam_channel::unbounded();
4556        let (new_event_tx, new_event_rx) = crossbeam_channel::unbounded();
4557        ctx.install_semantic_refresh_worker_for_build_epoch(
4558            new_request_tx,
4559            new_event_rx,
4560            Arc::new(Mutex::new(None)),
4561            ctx.semantic_index_rx_epoch(),
4562        );
4563        *ctx.semantic_index_status()
4564            .write()
4565            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
4566            stage: "replacement_loader".to_string(),
4567            files: None,
4568            entries_done: None,
4569            entries_total: None,
4570        };
4571        release.send(()).unwrap();
4572        drain.join().unwrap();
4573
4574        assert!(ctx.semantic_index_rx().lock().is_some());
4575        assert!(ctx.semantic_refresh_event_rx().lock().is_some());
4576        assert!(matches!(
4577            &*ctx
4578                .semantic_index_status()
4579                .read()
4580                .unwrap_or_else(std::sync::PoisonError::into_inner),
4581            SemanticIndexStatus::Building { stage, .. } if stage == "replacement_loader"
4582        ));
4583        drop(build_tx);
4584        drop(new_event_tx);
4585    }
4586
4587    #[test]
4588    fn semantic_disconnect_preserves_newer_build_receiver_before_refresh_install() {
4589        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4590        let root = tempfile::tempdir().unwrap();
4591        let ctx = Arc::new(AppContext::new(
4592            default_language_provider_factory(),
4593            Config {
4594                project_root: Some(root.path().to_path_buf()),
4595                semantic_search: true,
4596                ..Config::default()
4597            },
4598        ));
4599        ctx.set_canonical_cache_root(root.path().to_path_buf());
4600        let (old_request_tx, _old_request_rx) = crossbeam_channel::unbounded();
4601        let (old_event_tx, old_event_rx) = crossbeam_channel::unbounded();
4602        ctx.install_semantic_refresh_worker_for_build_epoch(
4603            old_request_tx,
4604            old_event_rx,
4605            Arc::new(Mutex::new(None)),
4606            ctx.semantic_index_rx_epoch(),
4607        );
4608        drop(old_event_tx);
4609        let (reached, release) = install_semantic_refresh_recovery_gate_for_test(&ctx);
4610
4611        let drain_ctx = Arc::clone(&ctx);
4612        let drain = std::thread::spawn(move || drain_semantic_refresh_events(&drain_ctx));
4613        reached
4614            .recv_timeout(Duration::from_secs(2))
4615            .expect("semantic refresh recovery did not reach the post-clear gate");
4616
4617        let (_replacement_tx, replacement_rx) = crossbeam_channel::unbounded();
4618        ctx.install_semantic_index_rx(replacement_rx, ctx.configure_generation());
4619        *ctx.semantic_index_status()
4620            .write()
4621            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::Building {
4622            stage: "replacement_loader".to_string(),
4623            files: None,
4624            entries_done: None,
4625            entries_total: None,
4626        };
4627        release.send(()).unwrap();
4628        drain.join().unwrap();
4629
4630        assert!(
4631            ctx.semantic_index_rx().lock().is_some(),
4632            "the old disconnect must not retire a newer build receiver while its refresh worker is being installed"
4633        );
4634        assert!(matches!(
4635            &*ctx
4636                .semantic_index_status()
4637                .read()
4638                .unwrap_or_else(std::sync::PoisonError::into_inner),
4639            SemanticIndexStatus::Building { stage, .. } if stage == "replacement_loader"
4640        ));
4641    }
4642
4643    #[test]
4644    fn delayed_semantic_retry_targets_same_generation_replacement_worker() {
4645        let _guard = ARTIFACT_DRAIN_TEST_MUTEX.lock().unwrap();
4646        SEMANTIC_REFRESH_RETRY_DELAY_OVERRIDE_MS.store(20, Ordering::SeqCst);
4647        struct RetryDelayReset;
4648        impl Drop for RetryDelayReset {
4649            fn drop(&mut self) {
4650                SEMANTIC_REFRESH_RETRY_DELAY_OVERRIDE_MS.store(u64::MAX, Ordering::SeqCst);
4651            }
4652        }
4653        let _delay_reset = RetryDelayReset;
4654
4655        let root = tempfile::tempdir().unwrap();
4656        let ctx = AppContext::new(
4657            default_language_provider_factory(),
4658            Config {
4659                project_root: Some(root.path().to_path_buf()),
4660                semantic_search: true,
4661                ..Config::default()
4662            },
4663        );
4664        let (old_request_tx, _old_request_rx) = crossbeam_channel::unbounded();
4665        let (_old_event_tx, old_event_rx) = crossbeam_channel::unbounded();
4666        ctx.install_semantic_refresh_worker_for_build_epoch(
4667            old_request_tx,
4668            old_event_rx,
4669            Arc::new(Mutex::new(None)),
4670            ctx.semantic_index_rx_epoch(),
4671        );
4672        let retry_path = root.path().join("retry.rs");
4673        assert!(schedule_semantic_refresh_retry(
4674            &ctx,
4675            vec![retry_path.clone()],
4676            "transient embedding failure",
4677        ));
4678
4679        let (new_request_tx, new_request_rx) = crossbeam_channel::unbounded();
4680        let (_new_event_tx, new_event_rx) = crossbeam_channel::unbounded();
4681        ctx.install_semantic_refresh_worker_for_build_epoch(
4682            new_request_tx,
4683            new_event_rx,
4684            Arc::new(Mutex::new(None)),
4685            ctx.semantic_index_rx_epoch(),
4686        );
4687
4688        let request = new_request_rx
4689            .recv_timeout(Duration::from_secs(2))
4690            .expect("retry should resolve the replacement sender when it fires");
4691        assert!(matches!(
4692            request,
4693            SemanticRefreshRequest::Files { paths } if paths == vec![retry_path]
4694        ));
4695    }
4696
4697    #[test]
4698    fn watcher_drain_batch_cap_yields_with_events_remaining() {
4699        let temp = tempfile::tempdir().unwrap();
4700        let (ctx, tx) = watcher_context(temp.path());
4701        let cap = 3;
4702        for index in 0..(cap * 2 + 1) {
4703            tx.send(WatcherDispatchEvent::Paths(vec![temp
4704                .path()
4705                .join(format!("file-{index}.rs"))]))
4706                .unwrap();
4707        }
4708
4709        let first = drain_watcher_events_bounded(&ctx, cap);
4710
4711        assert_eq!(first.processed, cap);
4712        assert!(first.has_more);
4713        assert_eq!(ctx.pending_tier2_paths().len(), cap);
4714    }
4715
4716    #[test]
4717    fn watcher_drain_requeues_until_all_events_are_applied() {
4718        let temp = tempfile::tempdir().unwrap();
4719        let (ctx, tx) = watcher_context(temp.path());
4720        let cap = 4;
4721        let total = cap * 2 + 3;
4722        for index in 0..total {
4723            tx.send(WatcherDispatchEvent::Paths(vec![temp
4724                .path()
4725                .join(format!("file-{index}.rs"))]))
4726                .unwrap();
4727        }
4728
4729        let mut processed = 0;
4730        loop {
4731            let outcome = drain_watcher_events_bounded(&ctx, cap);
4732            assert!(outcome.processed <= cap);
4733            processed += outcome.processed;
4734            if !outcome.has_more {
4735                break;
4736            }
4737        }
4738
4739        assert_eq!(processed, total);
4740        assert_eq!(ctx.pending_tier2_paths().len(), total);
4741    }
4742}
4743
4744#[cfg(test)]
4745mod watcher_slice_tests {
4746    use super::*;
4747    use crate::config::Config;
4748    use crate::context::{default_language_provider_factory, AppContext};
4749
4750    fn context_with_watcher(
4751        root: &Path,
4752    ) -> (AppContext, crossbeam_channel::Sender<WatcherDispatchEvent>) {
4753        let ctx = AppContext::new(default_language_provider_factory(), Config::default());
4754        ctx.update_config(|config| config.project_root = Some(root.to_path_buf()));
4755        ctx.set_canonical_cache_root(root.to_path_buf());
4756        let (tx, rx) = crossbeam_channel::unbounded();
4757        *ctx.watcher_rx().lock() = Some(rx);
4758        (ctx, tx)
4759    }
4760
4761    #[test]
4762    fn watcher_batch_schedules_view_publication_within_its_own_quiet_window() {
4763        let temp = tempfile::tempdir().unwrap();
4764        let (ctx, tx) = context_with_watcher(temp.path());
4765        ctx.update_config(|config| config.views.enabled = true);
4766        tx.send(WatcherDispatchEvent::Paths(vec![temp
4767            .path()
4768            .join("changed.rs")]))
4769            .unwrap();
4770        let applied_at = Instant::now();
4771
4772        let outcome = drain_watcher_events_bounded(&ctx, 1);
4773
4774        assert_eq!(outcome.processed, 1);
4775        let due = ctx
4776            .watcher_drain_slice()
4777            .lock()
4778            .as_ref()
4779            .and_then(|state| state.view_publication_due)
4780            .expect("watcher batch should schedule view publication");
4781        assert!(due > applied_at);
4782        assert!(
4783            due <= applied_at + VIEW_PUBLICATION_QUIET_WINDOW + Duration::from_millis(250),
4784            "view publication due {due:?} exceeded its own quiet window from {applied_at:?}"
4785        );
4786        assert!(
4787            due < applied_at + crate::commands::configure::semantic_refresh_quiet_window(),
4788            "view publication inherited the semantic refresh quiet window"
4789        );
4790    }
4791
4792    fn set_watcher_unit_test_seam(delay: Duration, thresholds: Option<(Duration, Duration)>) {
4793        WATCHER_UNIT_TEST_DELAY.with(|value| value.set(delay));
4794        WATCHER_UNIT_TEST_THRESHOLDS.with(|value| value.set(thresholds));
4795        WATCHER_UNIT_TEST_LOGS.with(|logs| logs.borrow_mut().clear());
4796    }
4797
4798    fn clear_watcher_unit_test_seam() {
4799        set_watcher_unit_test_seam(Duration::ZERO, None);
4800    }
4801
4802    #[test]
4803    fn callgraph_phase_batches_all_indexed_paths_into_one_refresh() {
4804        let temp = tempfile::tempdir().unwrap();
4805        let (ctx, _) = context_with_watcher(temp.path());
4806        let generated = temp.path().join("compiled.ts");
4807        std::fs::write(&generated, "// @generated\nexport const compiled = true;\n").unwrap();
4808        let mut paths = VecDeque::from([
4809            temp.path().join("a.rs"),
4810            temp.path().join("b.ts"),
4811            generated,
4812            temp.path().join("ignored.txt"),
4813            temp.path().join("Cargo.toml"),
4814        ]);
4815        let mut remaining = paths.len();
4816        let mut refreshed = Vec::new();
4817
4818        let completed = apply_callgraph_watcher_phase(
4819            &ctx,
4820            &mut paths,
4821            &mut remaining,
4822            Instant::now(),
4823            WATCHER_DRAIN_SLICE_BUDGET,
4824            true,
4825            |_, changed| refreshed.push(changed.clone()),
4826        );
4827
4828        assert!(completed);
4829        assert_eq!(remaining, 0);
4830        assert_eq!(refreshed.len(), 1);
4831        assert_eq!(refreshed[0].len(), 3);
4832        assert!(refreshed[0].contains(&temp.path().join("Cargo.toml")));
4833    }
4834
4835    #[test]
4836    fn callgraph_phase_includes_manifest_before_budget_yield() {
4837        let temp = tempfile::tempdir().unwrap();
4838        let (ctx, _) = context_with_watcher(temp.path());
4839        let source = temp.path().join("first.rs");
4840        let manifest = temp.path().join("Cargo.toml");
4841        let mut paths = VecDeque::from([source.clone(), manifest.clone()]);
4842        let mut remaining = paths.len();
4843        let mut refreshed = Vec::new();
4844        set_watcher_unit_test_seam(Duration::from_millis(2), None);
4845
4846        let completed = apply_callgraph_watcher_phase(
4847            &ctx,
4848            &mut paths,
4849            &mut remaining,
4850            Instant::now(),
4851            Duration::from_millis(1),
4852            true,
4853            |_, changed| refreshed.push(changed.clone()),
4854        );
4855        clear_watcher_unit_test_seam();
4856
4857        assert!(!completed);
4858        assert_eq!(remaining, 1);
4859        assert_eq!(refreshed.len(), 1);
4860        assert_eq!(refreshed[0], HashSet::from([source, manifest]));
4861    }
4862
4863    #[test]
4864    fn callgraph_phase_flushes_once_per_slice_before_requeue() {
4865        let temp = tempfile::tempdir().unwrap();
4866        let (ctx, _) = context_with_watcher(temp.path());
4867        let mut paths =
4868            VecDeque::from([temp.path().join("first.rs"), temp.path().join("second.rs")]);
4869        let mut remaining = paths.len();
4870        let mut refreshed = Vec::new();
4871        set_watcher_unit_test_seam(Duration::from_millis(2), None);
4872
4873        let first_completed = apply_callgraph_watcher_phase(
4874            &ctx,
4875            &mut paths,
4876            &mut remaining,
4877            Instant::now(),
4878            Duration::from_millis(1),
4879            true,
4880            |_, changed| refreshed.push(changed.clone()),
4881        );
4882        assert!(!first_completed);
4883        assert_eq!(remaining, 1);
4884        assert_eq!(refreshed.len(), 1, "the yielded slice must flush its batch");
4885
4886        let second_completed = apply_callgraph_watcher_phase(
4887            &ctx,
4888            &mut paths,
4889            &mut remaining,
4890            Instant::now(),
4891            Duration::from_millis(1),
4892            true,
4893            |_, changed| refreshed.push(changed.clone()),
4894        );
4895        clear_watcher_unit_test_seam();
4896
4897        assert!(!second_completed);
4898        assert_eq!(remaining, 0);
4899        assert_eq!(refreshed.len(), 2);
4900        assert!(refreshed.iter().all(|batch| batch.len() == 1));
4901    }
4902
4903    #[test]
4904    fn watcher_unit_watchdog_names_slow_phase_and_path() {
4905        let temp = tempfile::tempdir().unwrap();
4906        let slow_path = temp.path().join("slow.rs");
4907        let mut paths = VecDeque::from([slow_path.clone()]);
4908        let mut remaining = 1;
4909        set_watcher_unit_test_seam(
4910            Duration::from_millis(5),
4911            Some((Duration::from_millis(1), Duration::from_secs(1))),
4912        );
4913
4914        let completed = apply_watcher_path_phase(
4915            WatcherDrainApplyPhase::SemanticIndex,
4916            &mut paths,
4917            &mut remaining,
4918            Instant::now(),
4919            WATCHER_DRAIN_SLICE_BUDGET,
4920            |_| {},
4921        );
4922        let logs = WATCHER_UNIT_TEST_LOGS.with(|logs| logs.borrow().clone());
4923        clear_watcher_unit_test_seam();
4924
4925        assert!(completed);
4926        assert_eq!(logs.len(), 1);
4927        assert!(logs[0].contains("watcher drain unit exceeded 5s"));
4928        assert!(logs[0].contains("phase=semantic_index"));
4929        assert!(logs[0].contains(&format!("path={}", slow_path.display())));
4930    }
4931
4932    #[test]
4933    fn watcher_callgraph_refresh_defers_when_ready_store_is_unavailable() {
4934        let temp = tempfile::tempdir().unwrap();
4935        let (ctx, _) = context_with_watcher(temp.path());
4936        ctx.update_config(|config| config.callgraph_store = true);
4937        ctx.set_cache_role(false, None);
4938        let source = temp.path().join("pending.rs");
4939        let generated = temp.path().join("compiled.ts");
4940        std::fs::write(&generated, "// @generated\nexport const compiled = true;\n").unwrap();
4941
4942        refresh_callgraph_store_for_watcher(&ctx, &HashSet::from([source.clone(), generated]));
4943
4944        let deadline = Instant::now() + Duration::from_secs(12);
4945        loop {
4946            let pending = ctx.take_pending_callgraph_store_paths();
4947            if !pending.is_empty() {
4948                assert_eq!(pending, vec![source]);
4949                break;
4950            }
4951            assert!(
4952                Instant::now() < deadline,
4953                "refresh worker did not defer the unavailable store batch"
4954            );
4955            std::thread::sleep(Duration::from_millis(5));
4956        }
4957    }
4958
4959    #[test]
4960    fn watcher_callgraph_refresh_keeps_worktree_paths_pending() {
4961        let temp = tempfile::tempdir().unwrap();
4962        let (ctx, _) = context_with_watcher(temp.path());
4963        ctx.update_config(|config| config.callgraph_store = true);
4964        ctx.set_cache_role(true, None);
4965        let source = temp.path().join("worktree.rs");
4966
4967        refresh_callgraph_store_for_watcher(&ctx, &HashSet::from([source.clone()]));
4968
4969        assert_eq!(ctx.take_pending_callgraph_store_paths(), vec![source]);
4970    }
4971
4972    #[test]
4973    fn watcher_single_dispatch_event_is_sliced_by_path_count() {
4974        let temp = tempfile::tempdir().unwrap();
4975        let (ctx, tx) = context_with_watcher(temp.path());
4976        let path_count = 1_024;
4977        let path_cap = 256;
4978        tx.send(WatcherDispatchEvent::Paths(
4979            (0..path_count)
4980                .map(|index| temp.path().join(format!("single-event-{index}.txt")))
4981                .collect(),
4982        ))
4983        .unwrap();
4984
4985        let mut slices = 0;
4986        let mut processed = 0;
4987        loop {
4988            let outcome = drain_watcher_events_bounded(&ctx, path_cap);
4989            slices += 1;
4990            processed += outcome.processed;
4991            assert!(outcome.processed <= path_cap);
4992            if !outcome.has_more {
4993                break;
4994            }
4995            // Convergence guard only: the 250 ms time budget can end a slice
4996            // after a handful of paths on a loaded box, so the slice count is
4997            // bounded by the mechanism (every slice makes progress), not by a
4998            // number that depends on the host's speed.
4999            assert!(
5000                slices <= path_count,
5001                "single dispatch event did not converge"
5002            );
5003        }
5004
5005        assert_eq!(processed, path_count);
5006        // At least ceil(1024/256) slices from the path budget; the time budget
5007        // may only add slices, never remove them.
5008        assert!(
5009            slices >= 4,
5010            "expected at least 4 path-budgeted slices, got {slices}"
5011        );
5012        assert_eq!(ctx.pending_tier2_paths().len(), path_count);
5013    }
5014
5015    #[test]
5016    fn watcher_overflows_during_rescan_coalesce_one_followup_without_filter_stall() {
5017        let temp = tempfile::tempdir().unwrap();
5018        let root = std::fs::canonicalize(temp.path()).unwrap();
5019        let ctx = Arc::new(AppContext::new(
5020            default_language_provider_factory(),
5021            Config::default(),
5022        ));
5023        ctx.update_config(|config| config.project_root = Some(root.clone()));
5024        ctx.set_canonical_cache_root(root.clone());
5025        let counters = ctx.watcher_counters();
5026        let matcher = Arc::new(std::sync::RwLock::new(None));
5027        let matcher_generation = Arc::new(AtomicU64::new(0));
5028        let shutdown = Arc::new(AtomicBool::new(false));
5029        let (dispatch_tx, dispatch_rx) = crossbeam_channel::bounded(8);
5030        *ctx.watcher_rx().lock() = Some(dispatch_rx);
5031        let (raw_sender_tx, raw_sender_rx) = crossbeam_channel::bounded(1);
5032        let filter_shutdown = Arc::clone(&shutdown);
5033        let filter_root = root.clone();
5034        let filter = std::thread::spawn(move || {
5035            crate::watcher_filter::run_watcher_thread(
5036                crate::watcher_filter::WatcherFilterConfig::new(filter_root, None),
5037                Vec::new(),
5038                matcher,
5039                matcher_generation,
5040                dispatch_tx,
5041                filter_shutdown,
5042                move |_root, _extra, raw_tx| {
5043                    raw_sender_tx.send(raw_tx).unwrap();
5044                    Ok::<(), std::io::Error>(())
5045                },
5046            );
5047        });
5048        let raw_tx = raw_sender_rx.recv_timeout(Duration::from_secs(2)).unwrap();
5049        let overflow =
5050            || notify::Event::new(notify::EventKind::Other).set_flag(notify::event::Flag::Rescan);
5051        raw_tx.send(Ok(overflow())).unwrap();
5052        let deadline = Instant::now() + Duration::from_secs(2);
5053        while (counters.snapshot().overflows_total != 1
5054            || ctx
5055                .watcher_rx()
5056                .lock()
5057                .as_ref()
5058                .is_none_or(crossbeam_channel::Receiver::is_empty))
5059            && Instant::now() < deadline
5060        {
5061            std::thread::yield_now();
5062        }
5063        assert_eq!(counters.snapshot().overflows_total, 1);
5064        assert!(ctx
5065            .watcher_rx()
5066            .lock()
5067            .as_ref()
5068            .is_some_and(|receiver| !receiver.is_empty()));
5069        let _ = crate::watcher_filter::take_watcher_overflow_logs_for_test();
5070
5071        let (rescan_reached, release_rescan) = install_watcher_rescan_gate_for_test(&ctx);
5072        let drain_ctx = Arc::clone(&ctx);
5073        let drain = std::thread::spawn(move || drain_watcher_events(&drain_ctx));
5074        rescan_reached
5075            .recv_timeout(Duration::from_secs(2))
5076            .expect("first rescan did not reach the test gate");
5077
5078        raw_tx.send(Ok(overflow())).unwrap();
5079        raw_tx.send(Ok(overflow())).unwrap();
5080        let callback_deadline = Instant::now() + Duration::from_millis(50);
5081        let mut overflow_lines = Vec::new();
5082        while overflow_lines
5083            .iter()
5084            .filter(|line: &&String| {
5085                line.contains(&format!("root={}", root.display()))
5086                    && line.contains("rescan_in_progress=true")
5087            })
5088            .count()
5089            != 2
5090            && Instant::now() < callback_deadline
5091        {
5092            overflow_lines.extend(crate::watcher_filter::take_watcher_overflow_logs_for_test());
5093            std::thread::yield_now();
5094        }
5095        assert_eq!(
5096            overflow_lines
5097                .iter()
5098                .filter(|line| {
5099                    line.contains(&format!("root={}", root.display()))
5100                        && line.contains("rescan_in_progress=true")
5101                })
5102                .count(),
5103            2,
5104            "the filter must finish attributing drops without waiting for the rescan: {overflow_lines:?}"
5105        );
5106        assert_eq!(counters.snapshot().overflows_total, 3);
5107        assert!(ctx
5108            .watcher_rx()
5109            .lock()
5110            .as_ref()
5111            .is_some_and(crossbeam_channel::Receiver::is_empty));
5112        release_rescan.send(()).unwrap();
5113        drain.join().unwrap();
5114
5115        let snapshot = counters.snapshot();
5116        assert_eq!(snapshot.overflows_during_rescan, 2);
5117        assert_eq!(snapshot.rescans_unknown_total, 2);
5118        assert!(!counters.rescan_in_progress());
5119        assert!(ctx
5120            .watcher_rx()
5121            .lock()
5122            .as_ref()
5123            .is_some_and(crossbeam_channel::Receiver::is_empty));
5124
5125        shutdown.store(true, Ordering::SeqCst);
5126        drop(raw_tx);
5127        filter.join().unwrap();
5128    }
5129
5130    #[test]
5131    fn watcher_rescan_supersedes_pending_paths() {
5132        let temp = tempfile::tempdir().unwrap();
5133        let (ctx, tx) = context_with_watcher(temp.path());
5134        tx.send(WatcherDispatchEvent::Paths(
5135            (0..5)
5136                .map(|index| temp.path().join(format!("before-rescan-{index}.txt")))
5137                .collect(),
5138        ))
5139        .unwrap();
5140        let first = drain_watcher_events_bounded(&ctx, 2);
5141        assert_eq!(first.processed, 2);
5142        assert!(first.has_more);
5143        assert_eq!(ctx.watcher_drain_pending_path_count(), 3);
5144
5145        let counters = ctx.watcher_counters();
5146        counters.note_raw_event();
5147        counters.note_raw_event();
5148        tx.send(WatcherDispatchEvent::RescanRequired(
5149            RescanReason::KernelDropped,
5150        ))
5151        .unwrap();
5152        let (second, lines) =
5153            crate::logging::capture_index_events(|| drain_watcher_events_bounded(&ctx, 2));
5154
5155        assert_eq!(second.processed, 0);
5156        assert!(!second.has_more);
5157        assert_eq!(ctx.watcher_drain_pending_path_count(), 0);
5158        let snapshot = counters.snapshot();
5159        assert_eq!(snapshot.raw_events_total, 2);
5160        assert_eq!(snapshot.raw_events_since_last_rescan, 0);
5161        assert_eq!(snapshot.rescans_kernel_dropped_total, 1);
5162        assert_eq!(snapshot.rescans_user_dropped_total, 0);
5163        assert_eq!(snapshot.rescans_unknown_total, 0);
5164        assert!(snapshot.last_rescan_at_ms.is_some());
5165        assert!(snapshot.last_rescan_cost_ms.is_some());
5166        // Other tests on this thread pool emit index_event lines too; the
5167        // capture is process-wide, so select this test's line by content —
5168        // kind and this test's own root (matched by the temp dir's unique
5169        // name, since the line may spell the root canonically or not).
5170        let root_name = temp
5171            .path()
5172            .file_name()
5173            .unwrap()
5174            .to_string_lossy()
5175            .into_owned();
5176        let rescan_lines = lines
5177            .iter()
5178            .filter(|line| {
5179                line.contains("kind=watcher_rescan plane=watcher") && line.contains(&root_name)
5180            })
5181            .collect::<Vec<_>>();
5182        assert_eq!(rescan_lines.len(), 1, "lines: {lines:?}");
5183        assert!(rescan_lines[0].contains("reason=kernel_dropped"));
5184        assert!(rescan_lines[0].contains("raw_events_since_last=2"));
5185    }
5186
5187    #[test]
5188    fn watcher_lifecycle_generation_change_rebases_continuation() {
5189        let temp = tempfile::tempdir().unwrap();
5190        let (ctx, tx) = context_with_watcher(temp.path());
5191        tx.send(WatcherDispatchEvent::Paths(
5192            (0..5)
5193                .map(|index| temp.path().join(format!("old-generation-{index}.txt")))
5194                .collect(),
5195        ))
5196        .unwrap();
5197        let first = drain_watcher_events_bounded(&ctx, 2);
5198        assert_eq!(first.processed, 2);
5199        assert!(first.has_more);
5200
5201        // A lifecycle-only generation change (transient unbind + equivalent
5202        // rebind) must NOT lose the in-flight paths: the continuation rebases
5203        // onto the new generation and keeps draining.
5204        ctx.advance_configure_generation();
5205        let second = drain_watcher_events_bounded(&ctx, 2);
5206        assert_eq!(second.processed, 2);
5207        assert!(second.has_more);
5208
5209        let mut guard = 0;
5210        while drain_watcher_events_bounded(&ctx, 2).has_more {
5211            guard += 1;
5212            assert!(guard < 16, "rebased continuation must finish draining");
5213        }
5214        assert_eq!(ctx.watcher_drain_pending_path_count(), 0);
5215        assert_eq!(
5216            ctx.pending_tier2_paths().len(),
5217            5,
5218            "every path survives the lifecycle-only generation change"
5219        );
5220    }
5221
5222    #[test]
5223    fn watcher_content_generation_change_discards_continuation() {
5224        let temp = tempfile::tempdir().unwrap();
5225        let (ctx, tx) = context_with_watcher(temp.path());
5226        tx.send(WatcherDispatchEvent::Paths(
5227            (0..5)
5228                .map(|index| temp.path().join(format!("old-content-{index}.txt")))
5229                .collect(),
5230        ))
5231        .unwrap();
5232        let first = drain_watcher_events_bounded(&ctx, 2);
5233        assert_eq!(first.processed, 2);
5234        assert!(first.has_more);
5235
5236        // A real reconfigure (content change) rebuilds artifacts wholesale;
5237        // the stale continuation is discarded, not replayed.
5238        ctx.configure_content_generation_flag()
5239            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5240        ctx.advance_configure_generation();
5241        let second = drain_watcher_events_bounded(&ctx, 2);
5242
5243        assert_eq!(second.processed, 0);
5244        assert!(!second.has_more);
5245        assert_eq!(ctx.watcher_drain_pending_path_count(), 0);
5246        assert_eq!(ctx.pending_tier2_paths().len(), 2);
5247    }
5248
5249    fn install_search_index(
5250        ctx: &AppContext,
5251        root: &Path,
5252        storage: &Path,
5253        file: &Path,
5254        contents: &[u8],
5255    ) -> (PathBuf, PathBuf) {
5256        let canonical = std::fs::canonicalize(root).expect("canonical root");
5257        ctx.update_config(|config| {
5258            config.storage_dir = Some(storage.to_path_buf());
5259            config.search_index = true;
5260        });
5261        ctx.set_canonical_cache_root(canonical.clone());
5262        let cache_dir = crate::search_index::resolve_cache_dir(&canonical, Some(storage));
5263        let mut index = crate::search_index::SearchIndex::build(&canonical);
5264        index.index_file(file, contents);
5265        let git_head = index.stored_git_head().map(str::to_owned);
5266        assert!(
5267            index.write_to_disk(&cache_dir, git_head.as_deref()),
5268            "owner write of cache.bin must succeed before borrow-only setup"
5269        );
5270        *ctx.search_index()
5271            .write()
5272            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
5273        (canonical, cache_dir)
5274    }
5275
5276    fn grep_count(ctx: &AppContext, pattern: &str, root: &Path) -> usize {
5277        let index = ctx
5278            .search_index()
5279            .read()
5280            .unwrap_or_else(std::sync::PoisonError::into_inner);
5281        index
5282            .as_ref()
5283            .expect("resident search index")
5284            .grep(pattern, true, &[], &[], root, 10)
5285            .matches
5286            .len()
5287    }
5288
5289    fn cache_bin_hash(path: &Path) -> blake3::Hash {
5290        blake3::hash(&std::fs::read(path).expect("read cache.bin"))
5291    }
5292
5293    fn search_index_has_pending_disk_changes(ctx: &AppContext) -> bool {
5294        ctx.search_index()
5295            .read()
5296            .unwrap_or_else(std::sync::PoisonError::into_inner)
5297            .as_ref()
5298            .is_some_and(|index| index.has_pending_disk_changes())
5299    }
5300
5301    fn mark_borrow_only(ctx: &AppContext, canonical: &Path, cache_dir: &Path) {
5302        let project_key = cache_dir
5303            .file_name()
5304            .and_then(|name| name.to_str())
5305            .expect("cache key")
5306            .to_string();
5307        crate::root_cache::configure_artifact_access(canonical, &project_key, true);
5308        ctx.set_cache_role(true, None);
5309    }
5310
5311    #[test]
5312    fn ram_overlay_is_off_by_default_and_transport_independent() {
5313        let temp = tempfile::tempdir().unwrap();
5314        let (ctx, _) = context_with_watcher(temp.path());
5315        ctx.set_cache_role(true, None);
5316        assert!(
5317            ctx.shared_artifacts_read_only(),
5318            "worktree role is borrow-only"
5319        );
5320        assert!(
5321            !ctx.ram_overlay_active(),
5322            "worktree.ram_overlay defaults off"
5323        );
5324
5325        ctx.update_config(|config| config.worktree.ram_overlay = true);
5326        assert!(
5327            ctx.ram_overlay_active(),
5328            "overlay follows config + borrow-only, not bind/transport identity"
5329        );
5330
5331        ctx.set_cache_role(false, None);
5332        assert!(
5333            !ctx.ram_overlay_active(),
5334            "owner roots stay on the normal writer path even when the gate is on"
5335        );
5336    }
5337
5338    #[test]
5339    fn borrow_only_watcher_arms_are_noops_until_overlay_flips_on() {
5340        let root = tempfile::tempdir().unwrap();
5341        let storage = tempfile::tempdir().unwrap();
5342        let (ctx, tx) = context_with_watcher(root.path());
5343        let file = std::fs::canonicalize(root.path())
5344            .expect("canonical root")
5345            .join("overlay.rs");
5346        std::fs::write(&file, "old overlay token\n").expect("write source");
5347        let (canonical, cache_dir) = install_search_index(
5348            &ctx,
5349            root.path(),
5350            storage.path(),
5351            &file,
5352            b"old overlay token\n",
5353        );
5354        mark_borrow_only(&ctx, &canonical, &cache_dir);
5355        assert!(ctx.shared_artifacts_read_only());
5356        assert!(!ctx.ram_overlay_active());
5357        assert!(
5358            !search_index_has_pending_disk_changes(&ctx),
5359            "precondition: compacted owner write leaves no RAM delta"
5360        );
5361
5362        std::fs::write(&file, "new overlay token\n").expect("edit source");
5363        tx.send(WatcherDispatchEvent::Paths(vec![file.clone()]))
5364            .unwrap();
5365        drain_watcher_events(&ctx);
5366        assert!(
5367            !search_index_has_pending_disk_changes(&ctx),
5368            "gate off: borrow-only watcher must not mutate the RAM delta"
5369        );
5370
5371        ctx.update_config(|config| config.worktree.ram_overlay = true);
5372        assert!(ctx.ram_overlay_active());
5373        tx.send(WatcherDispatchEvent::Paths(vec![file])).unwrap();
5374        drain_watcher_events(&ctx);
5375        assert!(
5376            search_index_has_pending_disk_changes(&ctx),
5377            "flipping the predicate on must let the same watcher arm apply"
5378        );
5379        assert_eq!(
5380            grep_count(&ctx, "new overlay token", &canonical),
5381            1,
5382            "overlay-on drain must make the disk edit searchable from RAM"
5383        );
5384    }
5385
5386    #[test]
5387    fn ram_overlay_search_reflects_edits_without_writing_cache_bin() {
5388        let root = tempfile::tempdir().unwrap();
5389        let storage = tempfile::tempdir().unwrap();
5390        let (ctx, tx) = context_with_watcher(root.path());
5391        let file = std::fs::canonicalize(root.path())
5392            .expect("canonical root")
5393            .join("overlay.rs");
5394        std::fs::write(&file, "old overlay token\n").expect("write source");
5395        let (canonical, cache_dir) = install_search_index(
5396            &ctx,
5397            root.path(),
5398            storage.path(),
5399            &file,
5400            b"old overlay token\n",
5401        );
5402        mark_borrow_only(&ctx, &canonical, &cache_dir);
5403        ctx.update_config(|config| config.worktree.ram_overlay = true);
5404
5405        *ctx.semantic_index()
5406            .write()
5407            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(
5408            crate::semantic_index::SemanticIndex::new(canonical.clone(), 8),
5409        );
5410        *ctx.semantic_index_status()
5411            .write()
5412            .unwrap_or_else(std::sync::PoisonError::into_inner) = SemanticIndexStatus::ready();
5413
5414        let cache_path = cache_dir.join("cache.bin");
5415        let before = cache_bin_hash(&cache_path);
5416
5417        std::fs::write(&file, "new overlay token\n").expect("edit source");
5418        tx.send(WatcherDispatchEvent::Paths(vec![file.clone()]))
5419            .unwrap();
5420        drain_watcher_events(&ctx);
5421
5422        assert_eq!(
5423            grep_count(&ctx, "new overlay token", &canonical),
5424            1,
5425            "overlay must serve the edit from the RAM delta"
5426        );
5427        assert_eq!(grep_count(&ctx, "old overlay token", &canonical), 0);
5428        assert_eq!(
5429            cache_bin_hash(&cache_path),
5430            before,
5431            "overlay must not change on-disk cache.bin bytes"
5432        );
5433
5434        {
5435            // Family key != this checkout's path identity, matching a linked
5436            // worktree that borrows the owner's shared artifact key.
5437            let family_key = "shared-family-overlay-key";
5438            crate::root_cache::configure_artifact_access(&canonical, family_key, true);
5439            let family_dir = storage.path().join(family_key);
5440            let mut index = ctx
5441                .search_index()
5442                .write()
5443                .unwrap_or_else(std::sync::PoisonError::into_inner);
5444            assert!(
5445                !index
5446                    .as_mut()
5447                    .expect("resident search index")
5448                    .write_to_disk(&family_dir, None),
5449                "direct persist must fail-closed for a borrow-only shared key"
5450            );
5451        }
5452        assert!(
5453            !ctx.flush_search_index_on_graceful_shutdown(),
5454            "shutdown must not flush a borrow-only overlay root"
5455        );
5456        assert_eq!(
5457            cache_bin_hash(&cache_path),
5458            before,
5459            "shutdown and persist must leave cache.bin byte-identical"
5460        );
5461
5462        let refreshing = match &*ctx
5463            .semantic_index_status()
5464            .read()
5465            .unwrap_or_else(std::sync::PoisonError::into_inner)
5466        {
5467            SemanticIndexStatus::Ready { refreshing, .. } => refreshing.clone(),
5468            other => panic!("expected Ready semantic status, got {other:?}"),
5469        };
5470        assert!(
5471            refreshing.is_empty(),
5472            "a context without a semantic refresh worker must not report refresh work"
5473        );
5474    }
5475
5476    #[test]
5477    fn owner_root_still_uses_normal_path_when_overlay_gate_is_on() {
5478        let root = tempfile::tempdir().unwrap();
5479        let storage = tempfile::tempdir().unwrap();
5480        let (ctx, tx) = context_with_watcher(root.path());
5481        let file = std::fs::canonicalize(root.path())
5482            .expect("canonical root")
5483            .join("owner.rs");
5484        std::fs::write(&file, "old owner token\n").expect("write source");
5485        let (canonical, cache_dir) = install_search_index(
5486            &ctx,
5487            root.path(),
5488            storage.path(),
5489            &file,
5490            b"old owner token\n",
5491        );
5492        ctx.update_config(|config| config.worktree.ram_overlay = true);
5493        assert!(!ctx.shared_artifacts_read_only(), "owner root is a writer");
5494        assert!(!ctx.ram_overlay_active());
5495
5496        std::fs::write(&file, "new owner token\n").expect("edit source");
5497        tx.send(WatcherDispatchEvent::Paths(vec![file])).unwrap();
5498        drain_watcher_events(&ctx);
5499        assert_eq!(grep_count(&ctx, "new owner token", &canonical), 1);
5500        assert_eq!(grep_count(&ctx, "old owner token", &canonical), 0);
5501
5502        assert!(
5503            ctx.flush_search_index_on_graceful_shutdown(),
5504            "owner persist path must still flush when the overlay gate is on"
5505        );
5506        let mut restored = crate::search_index::SearchIndex::read_from_disk(&cache_dir, &canonical)
5507            .expect("reload owner cache.bin");
5508        restored.set_ready(true);
5509        assert_eq!(
5510            restored
5511                .grep("new owner token", true, &[], &[], &canonical, 10)
5512                .matches
5513                .len(),
5514            1,
5515            "owner shutdown flush must persist the RAM delta"
5516        );
5517    }
5518}
5519
5520// Every test here spawns a real child process, so the module is Unix-only.
5521#[cfg(all(test, unix))]
5522mod idle_lsp_tests {
5523    use super::shutdown_idle_lsp_at;
5524    use crate::config::Config;
5525    use crate::context::AppContext;
5526    use crate::lsp::child_registry::LspChildRegistry;
5527    use crate::lsp::client::LspClient;
5528    use crate::lsp::registry::ServerKind;
5529    use std::collections::HashMap;
5530    use std::path::Path;
5531    use std::time::{Duration, Instant};
5532
5533    #[cfg(unix)]
5534    fn spawn_sleep_client(ctx: &AppContext, root: std::path::PathBuf) -> u32 {
5535        let registry = LspChildRegistry::new();
5536        ctx.lsp().set_child_registry(registry.clone());
5537        let client = LspClient::spawn(
5538            ServerKind::TypeScript,
5539            root,
5540            Path::new("sh"),
5541            &["-c".to_string(), "exec sleep 60".to_string()],
5542            &HashMap::new(),
5543            ctx.lsp().event_sender_for_test(),
5544            registry,
5545        )
5546        .expect("spawn idle-lsp stand-in");
5547        let pid = client.child_pid();
5548        ctx.lsp().insert_client_for_test(client);
5549        pid
5550    }
5551
5552    #[cfg(unix)]
5553    fn wait_until_dead(pid: u32, timeout: Duration) -> bool {
5554        let started = Instant::now();
5555        while started.elapsed() < timeout {
5556            if !crate::bash_background::process::is_process_alive(pid) {
5557                return true;
5558            }
5559            std::thread::sleep(Duration::from_millis(20));
5560        }
5561        false
5562    }
5563
5564    #[cfg(unix)]
5565    #[test]
5566    fn idle_lsp_ttl_shuts_down_stale_client_and_keeps_recent() {
5567        let tmp = tempfile::tempdir().unwrap();
5568        let mut stale_config = Config::default();
5569        stale_config.idle.lsp_ttl_minutes = 1;
5570        let stale = AppContext::new(
5571            crate::context::default_language_provider_factory(),
5572            stale_config,
5573        );
5574        let pid = spawn_sleep_client(&stale, tmp.path().to_path_buf());
5575        assert_eq!(stale.lsp().server_count(), 1);
5576        let now = Instant::now();
5577        stale.set_last_request_at_for_test(now - Duration::from_secs(61));
5578        let started = Instant::now();
5579        shutdown_idle_lsp_at(&stale, now, stale.last_request_at());
5580        assert!(
5581            started.elapsed() < Duration::from_millis(250),
5582            "idle lsp reap must return without waiting on Shutdown, took {:?}",
5583            started.elapsed()
5584        );
5585        assert_eq!(
5586            stale.lsp().server_count(),
5587            0,
5588            "a root idle longer than lsp_ttl_minutes must shut down its language servers"
5589        );
5590        assert!(
5591            wait_until_dead(pid, Duration::from_secs(6)),
5592            "idle-reaped child must die within SHUTDOWN_TIMEOUT + 1s"
5593        );
5594
5595        let mut fresh_config = Config::default();
5596        fresh_config.idle.lsp_ttl_minutes = 1;
5597        let fresh = AppContext::new(
5598            crate::context::default_language_provider_factory(),
5599            fresh_config,
5600        );
5601        spawn_sleep_client(&fresh, tmp.path().to_path_buf());
5602        assert_eq!(fresh.lsp().server_count(), 1);
5603        shutdown_idle_lsp_at(&fresh, Instant::now(), fresh.last_request_at());
5604        assert_eq!(
5605            fresh.lsp().server_count(),
5606            1,
5607            "a root with recent activity must keep its language servers"
5608        );
5609    }
5610}