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