1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum SessionWarmOutcome {
25 AlreadyLive,
27 Restored,
29 Building,
31 SpawnFailed,
33}
34
35#[derive(Debug)]
36struct WarmState {
37 in_flight_root: Option<PathBuf>,
39 generation: u64,
41}
42
43#[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
62struct 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 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 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 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 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 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
226pub(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 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 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 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 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}