Skip to main content

harn_hostlib/code_index/
warm.rs

1//! Non-blocking session warm for the code index.
2//!
3//! Embedders call [`CodeIndexCapability::warm_session`] at session start so a
4//! cold workspace can restore a snapshot or begin a background rebuild without
5//! stalling the model's first turn. Sync [`hostlib_code_index_rebuild`] joins
6//! the same single-flight gate so `ensure_initialised` does not start a second
7//! full walk while the warm is still running.
8
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, Condvar, Mutex};
11use std::thread;
12use std::time::Instant;
13
14use harn_vm::VmValue;
15
16use super::builtins::SharedIndex;
17use super::state::{canonicalize, IndexState};
18use super::CodeIndexCapability;
19use crate::error::HostlibError;
20use crate::tools::args::{build_dict, dict_arg, optional_bool, optional_string};
21
22/// Outcome of [`CodeIndexCapability::warm_session`].
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum SessionWarmOutcome {
25    /// In-memory index was already populated.
26    AlreadyLive,
27    /// Snapshot restore succeeded; the index is live now.
28    Restored,
29    /// A background rebuild is in flight (started now or already running).
30    Building,
31    /// The background thread could not be spawned; callers may sync-rebuild.
32    SpawnFailed,
33}
34
35#[derive(Debug)]
36struct WarmState {
37    /// Canonical root of the in-flight build, when any.
38    in_flight_root: Option<PathBuf>,
39    /// Generation bumped whenever an in-flight build finishes.
40    generation: u64,
41}
42
43/// Single-flight coordinator shared by session warm and sync rebuild.
44#[derive(Debug)]
45pub(super) struct WarmCoordinator {
46    state: Mutex<WarmState>,
47    cv: Condvar,
48}
49
50impl Default for WarmCoordinator {
51    fn default() -> Self {
52        Self {
53            state: Mutex::new(WarmState {
54                in_flight_root: None,
55                generation: 0,
56            }),
57            cv: Condvar::new(),
58        }
59    }
60}
61
62/// RAII guard that clears the in-flight warm marker on drop (including panic).
63struct WarmFlight {
64    warm: Arc<WarmCoordinator>,
65    root: PathBuf,
66}
67
68impl Drop for WarmFlight {
69    fn drop(&mut self) {
70        self.warm.end(&self.root);
71    }
72}
73
74impl WarmCoordinator {
75    fn wait_if_building(&self, root: &Path) {
76        let canonical = canonicalize(root);
77        let mut guard = self.state.lock().expect("warm coordinator poisoned");
78        while guard
79            .in_flight_root
80            .as_ref()
81            .is_some_and(|inflight| inflight == &canonical)
82        {
83            guard = self.cv.wait(guard).expect("warm coordinator poisoned");
84        }
85    }
86
87    /// Mark `root` as building. Returns `None` when another builder already
88    /// owns this root (caller should wait). Returns a drop-guard when this
89    /// caller owns the flight.
90    fn try_begin(self: &Arc<Self>, root: &Path) -> Option<WarmFlight> {
91        let canonical = canonicalize(root);
92        let mut guard = self.state.lock().expect("warm coordinator poisoned");
93        if guard
94            .in_flight_root
95            .as_ref()
96            .is_some_and(|inflight| inflight == &canonical)
97        {
98            return None;
99        }
100        // A different root in flight is rare (one capability per workspace).
101        // Wait for it to finish so two full walks never race the shared slot.
102        while guard.in_flight_root.is_some() {
103            guard = self.cv.wait(guard).expect("warm coordinator poisoned");
104        }
105        guard.in_flight_root = Some(canonical.clone());
106        Some(WarmFlight {
107            warm: Arc::clone(self),
108            root: canonical,
109        })
110    }
111
112    fn end(&self, root: &Path) {
113        let canonical = canonicalize(root);
114        let mut guard = self.state.lock().expect("warm coordinator poisoned");
115        if guard.in_flight_root.as_ref() == Some(&canonical) {
116            guard.in_flight_root = None;
117            guard.generation = guard.generation.wrapping_add(1);
118            self.cv.notify_all();
119        }
120    }
121}
122
123impl CodeIndexCapability {
124    /// Warm the shared index for `workspace_root` without blocking on a full
125    /// cold rebuild.
126    ///
127    /// Order:
128    /// 1. If the in-memory slot is already populated, return
129    ///    [`SessionWarmOutcome::AlreadyLive`].
130    /// 2. Try [`Self::restore_from_disk`].
131    /// 3. Otherwise start (or join) a single-flight background
132    ///    [`IndexState::build_from_root`], install it into the shared slot, and
133    ///    [`Self::persist_to_disk`].
134    ///
135    /// Sync `hostlib_code_index_rebuild` joins the same gate, so a reader that
136    /// calls `ensure_initialised` while the warm is running waits for the
137    /// in-flight build instead of starting a second walk.
138    pub fn warm_session(&self, workspace_root: impl AsRef<Path>) -> SessionWarmOutcome {
139        let root = canonicalize(workspace_root.as_ref());
140        {
141            let guard = self.index.lock().expect("code_index mutex poisoned");
142            if guard.is_some() {
143                return SessionWarmOutcome::AlreadyLive;
144            }
145        }
146
147        match self.restore_from_disk(&root) {
148            Ok(true) => return SessionWarmOutcome::Restored,
149            Ok(false) => {}
150            Err(error) => {
151                tracing::debug!(
152                    target: "harn_hostlib::code_index",
153                    %error,
154                    root = %root.display(),
155                    "code-index snapshot restore failed; falling back to background rebuild",
156                );
157            }
158        }
159
160        let Some(flight) = self.warm.try_begin(&root) else {
161            return SessionWarmOutcome::Building;
162        };
163
164        let index = self.index.clone();
165        let capability = self.clone();
166        let thread_root = root.clone();
167        match thread::Builder::new()
168            .name("harn-code-index-warm".to_string())
169            .spawn(move || {
170                let _flight = flight;
171                let started = Instant::now();
172                let (state, outcome) = IndexState::build_from_root(&thread_root);
173                {
174                    let mut guard = index.lock().expect("code_index mutex poisoned");
175                    // Prefer an already-installed index (e.g. a finished sync
176                    // rebuild that raced us after we began) over clobbering.
177                    if guard.is_none() {
178                        *guard = Some(state);
179                    }
180                }
181                if let Err(error) = capability.persist_to_disk() {
182                    tracing::debug!(
183                        target: "harn_hostlib::code_index",
184                        %error,
185                        root = %thread_root.display(),
186                        "code-index warm persist failed",
187                    );
188                }
189                tracing::debug!(
190                    target: "harn_hostlib::code_index",
191                    root = %thread_root.display(),
192                    files_indexed = outcome.files_indexed,
193                    files_skipped = outcome.files_skipped,
194                    elapsed_ms = started.elapsed().as_millis() as u64,
195                    "code-index background warm complete",
196                );
197            }) {
198            Ok(_) => SessionWarmOutcome::Building,
199            Err(error) => {
200                // `spawn` drops the closure (and thus `flight`) on failure.
201                tracing::debug!(
202                    target: "harn_hostlib::code_index",
203                    %error,
204                    root = %root.display(),
205                    "code-index background warm spawn failed",
206                );
207                SessionWarmOutcome::SpawnFailed
208            }
209        }
210    }
211}
212
213fn live_stats_for_root(index: &SharedIndex, canonical: &Path) -> Option<VmValue> {
214    let guard = index.lock().expect("code_index mutex poisoned");
215    let state = guard.as_ref()?;
216    if state.root != *canonical {
217        return None;
218    }
219    Some(build_dict([
220        ("files_indexed", VmValue::Int(state.files.len() as i64)),
221        ("files_skipped", VmValue::Int(0)),
222        ("elapsed_ms", VmValue::Int(0)),
223    ]))
224}
225
226/// Rebuild that joins any in-flight session warm for the same root.
227pub(super) fn run_rebuild_single_flight(
228    index: &SharedIndex,
229    warm: &Arc<WarmCoordinator>,
230    args: &[VmValue],
231) -> Result<VmValue, HostlibError> {
232    use super::builtins::BUILTIN_REBUILD;
233
234    let raw = dict_arg(BUILTIN_REBUILD, args)?;
235    let dict = raw.as_ref();
236    let _force = optional_bool(BUILTIN_REBUILD, dict, "force", false)?;
237    let root = optional_string(BUILTIN_REBUILD, dict, "root")?
238        .map(PathBuf::from)
239        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
240    if !root.exists() {
241        return Err(HostlibError::InvalidParameter {
242            builtin: BUILTIN_REBUILD,
243            param: "root",
244            message: format!("path `{}` does not exist", root.display()),
245        });
246    }
247    if !root.is_dir() {
248        return Err(HostlibError::InvalidParameter {
249            builtin: BUILTIN_REBUILD,
250            param: "root",
251            message: format!("path `{}` is not a directory", root.display()),
252        });
253    }
254
255    let canonical = canonicalize(&root);
256
257    // Join an in-flight warm/rebuild for this root so callers never pay a
258    // second full walk.
259    for _ in 0..3 {
260        warm.wait_if_building(&canonical);
261        if let Some(stats) = live_stats_for_root(index, &canonical) {
262            return Ok(stats);
263        }
264        let Some(flight) = warm.try_begin(&canonical) else {
265            // Lost the race to another builder; loop and join it.
266            continue;
267        };
268
269        let started = Instant::now();
270        let (state, outcome) = IndexState::build_from_root(&canonical);
271        let elapsed_ms = started.elapsed().as_millis() as i64;
272        {
273            let mut guard = index.lock().expect("code_index mutex poisoned");
274            *guard = Some(state);
275        }
276        drop(flight);
277        return Ok(build_dict([
278            ("files_indexed", VmValue::Int(outcome.files_indexed as i64)),
279            ("files_skipped", VmValue::Int(outcome.files_skipped as i64)),
280            ("elapsed_ms", VmValue::Int(elapsed_ms)),
281        ]));
282    }
283
284    // Exhausted join attempts; return whatever is live (possibly empty).
285    Ok(live_stats_for_root(index, &canonical).unwrap_or_else(|| {
286        build_dict([
287            ("files_indexed", VmValue::Int(0)),
288            ("files_skipped", VmValue::Int(0)),
289            ("elapsed_ms", VmValue::Int(0)),
290        ])
291    }))
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use std::fs;
298    use std::time::Duration;
299
300    use super::super::snapshot::CodeIndexSnapshot;
301
302    fn fixture_tree() -> tempfile::TempDir {
303        let dir = tempfile::tempdir().expect("tempdir");
304        fs::create_dir_all(dir.path().join("src")).unwrap();
305        fs::write(
306            dir.path().join("src/alpha.rs"),
307            "pub fn alpha() -> i32 { 1 }\n",
308        )
309        .unwrap();
310        fs::write(
311            dir.path().join("src/beta.py"),
312            "def beta():\n    return 2\n",
313        )
314        .unwrap();
315        dir
316    }
317
318    fn root_arg(root: &Path) -> VmValue {
319        let mut map: harn_vm::value::DictMap = Default::default();
320        map.insert(
321            harn_vm::value::intern_key("root"),
322            VmValue::String(arcstr::ArcStr::from(root.to_string_lossy().as_ref())),
323        );
324        VmValue::dict(map)
325    }
326
327    #[test]
328    fn warm_session_restores_existing_snapshot() {
329        let dir = fixture_tree();
330        let seed = CodeIndexCapability::new();
331        let (state, _) = IndexState::build_from_root(dir.path());
332        {
333            let shared = seed.shared();
334            let mut guard = shared.lock().unwrap();
335            *guard = Some(state);
336        }
337        seed.persist_to_disk().unwrap();
338
339        let cold = CodeIndexCapability::new();
340        assert_eq!(cold.warm_session(dir.path()), SessionWarmOutcome::Restored);
341        let shared = cold.shared();
342        let guard = shared.lock().unwrap();
343        assert_eq!(guard.as_ref().map(|s| s.files.len()), Some(2));
344    }
345
346    #[test]
347    fn warm_session_builds_in_background_without_blocking() {
348        let dir = fixture_tree();
349        let cap = CodeIndexCapability::new();
350        assert_eq!(cap.warm_session(dir.path()), SessionWarmOutcome::Building);
351        // Immediately after kickoff the slot may still be empty — that is the
352        // non-blocking contract. Wait for the background thread.
353        let deadline = Instant::now() + Duration::from_secs(30);
354        loop {
355            {
356                let shared = cap.shared();
357                let guard = shared.lock().unwrap();
358                if guard.as_ref().is_some_and(|s| s.files.len() == 2) {
359                    break;
360                }
361            }
362            assert!(
363                Instant::now() < deadline,
364                "background warm did not populate the index in time"
365            );
366            thread::sleep(Duration::from_millis(20));
367        }
368        assert!(
369            CodeIndexSnapshot::path_for(dir.path()).exists(),
370            "warm should persist a snapshot for the next session"
371        );
372    }
373
374    #[test]
375    fn sync_rebuild_joins_in_flight_warm() {
376        let dir = fixture_tree();
377        let cap = CodeIndexCapability::new();
378        assert_eq!(cap.warm_session(dir.path()), SessionWarmOutcome::Building);
379
380        let args = [root_arg(dir.path())];
381        let started = Instant::now();
382        let result = run_rebuild_single_flight(&cap.shared(), &cap.warm, &args).expect("rebuild");
383        let elapsed = started.elapsed();
384        let dict = match result {
385            VmValue::Dict(d) => d,
386            other => panic!("expected dict, got {other:?}"),
387        };
388        let files = match dict
389            .get(&harn_vm::value::intern_key("files_indexed"))
390            .unwrap()
391        {
392            VmValue::Int(n) => *n,
393            other => panic!("expected int, got {other:?}"),
394        };
395        assert_eq!(files, 2);
396        assert!(elapsed < Duration::from_secs(30));
397    }
398}