1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum SessionWarmOutcome {
26 AlreadyLive,
28 Restored,
30 Building,
32 SpawnFailed,
34}
35
36#[derive(Debug)]
37struct WarmState {
38 in_flight_root: Option<PathBuf>,
40 generation: u64,
42}
43
44#[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
63struct 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 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 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 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 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 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
227pub(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 for _ in 0..3 {
263 let Some(flight) = warm.try_begin(&canonical) else {
264 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 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 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}