1use std::collections::BTreeSet;
10use std::fs::OpenOptions;
11use std::io::Write;
12use std::path::{Path, PathBuf};
13use std::process::{Command, Stdio};
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::mpsc;
16use std::time::{Duration, Instant, SystemTime};
17
18use notify::{RecursiveMode, Watcher};
19use serde_json::{Value, json};
20
21use crate::indexing::{self, Plan, ScanMetrics};
22use crate::okfindex::{Index, UpdateReport};
23use crate::{okfindex, okfroots};
24
25const RUNTIME_DIR: &str = "runtime";
26const STATUS_FILE: &str = "watch-status.json";
27const START_CLAIM: &str = "start.claim";
28const START_FAILURE: &str = "start.failed";
29const LIFECYCLE_LOG: &str = "daemon.log";
30const LIFECYCLE_LOG_MAX_BYTES: u64 = 32 * 1024;
31const LIFECYCLE_LOG_ROTATIONS: usize = 2;
32const UPDATE_LOCK: &str = "update.lock";
33const DAEMON_LOCK: &str = "daemon.lock";
34const STOP_FILE: &str = "stop.request";
35const REQUESTS_DIR: &str = "requests";
36const HEALTHY_MS: u64 = 3_000;
37const BARRIER_MS: u64 = 750;
38const START_CLAIM_STALE_SECONDS: u64 = 10;
39
40static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false);
41
42#[cfg(unix)]
43extern "C" fn shutdown_signal(_signal: libc::c_int) {
44 SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst);
45}
46
47#[cfg(unix)]
48fn install_shutdown_handlers() -> Result<(), String> {
49 SHUTDOWN_REQUESTED.store(false, Ordering::SeqCst);
50 unsafe {
53 let mut action: libc::sigaction = std::mem::zeroed();
54 action.sa_sigaction = shutdown_signal as usize;
55 libc::sigemptyset(&mut action.sa_mask);
56 for signal in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP] {
57 if libc::sigaction(signal, &action, std::ptr::null_mut()) != 0 {
58 return Err(format!(
59 "install signal handler {signal}: {}",
60 std::io::Error::last_os_error()
61 ));
62 }
63 }
64 }
65 Ok(())
66}
67
68#[cfg(windows)]
69unsafe extern "system" fn shutdown_console_event(event: u32) -> i32 {
70 use windows_sys::Win32::System::Console::{
71 CTRL_BREAK_EVENT, CTRL_C_EVENT, CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT,
72 };
73
74 if matches!(
75 event,
76 CTRL_C_EVENT
77 | CTRL_BREAK_EVENT
78 | CTRL_CLOSE_EVENT
79 | CTRL_LOGOFF_EVENT
80 | CTRL_SHUTDOWN_EVENT
81 ) {
82 SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst);
83 1
84 } else {
85 0
86 }
87}
88
89#[cfg(windows)]
90fn install_shutdown_handlers() -> Result<(), String> {
91 use windows_sys::Win32::System::Console::SetConsoleCtrlHandler;
92
93 SHUTDOWN_REQUESTED.store(false, Ordering::SeqCst);
94 if unsafe { SetConsoleCtrlHandler(Some(shutdown_console_event), 1) } == 0 {
97 Err(format!(
98 "install console control handler: {}",
99 std::io::Error::last_os_error()
100 ))
101 } else {
102 Ok(())
103 }
104}
105
106#[cfg(not(any(unix, windows)))]
107fn install_shutdown_handlers() -> Result<(), String> {
108 SHUTDOWN_REQUESTED.store(false, Ordering::SeqCst);
109 Ok(())
110}
111
112pub fn runtime_dir(project: &Path) -> PathBuf {
113 okfroots::index_dir(project).join(RUNTIME_DIR)
114}
115
116fn status_path(project: &Path) -> PathBuf {
117 runtime_dir(project).join(STATUS_FILE)
118}
119
120fn requests_dir(project: &Path) -> PathBuf {
121 runtime_dir(project).join(REQUESTS_DIR)
122}
123
124fn lifecycle_log(project: &Path, event: &str) {
127 let dir = runtime_dir(project);
128 if std::fs::create_dir_all(&dir).is_err() {
129 return;
130 }
131 let path = dir.join(LIFECYCLE_LOG);
132 let mut event = event.replace(['\r', '\n'], " ");
133 if event.len() > 2048 {
134 let mut end = 2048;
135 while !event.is_char_boundary(end) {
136 end -= 1;
137 }
138 event.truncate(end);
139 event.push_str("...");
140 }
141 let line = format!(
142 "{} pid={} {event}\n",
143 indexing::unix_millis(),
144 std::process::id()
145 );
146 let rotate = std::fs::metadata(&path).is_ok_and(|metadata| {
147 metadata.len().saturating_add(line.len() as u64) > LIFECYCLE_LOG_MAX_BYTES
148 });
149 if rotate {
150 let oldest = dir.join(format!("{LIFECYCLE_LOG}.{LIFECYCLE_LOG_ROTATIONS}"));
151 let _ = std::fs::remove_file(oldest);
152 for generation in (1..LIFECYCLE_LOG_ROTATIONS).rev() {
153 let from = dir.join(format!("{LIFECYCLE_LOG}.{generation}"));
154 let to = dir.join(format!("{LIFECYCLE_LOG}.{}", generation + 1));
155 let _ = std::fs::rename(from, to);
156 }
157 let _ = std::fs::rename(&path, dir.join(format!("{LIFECYCLE_LOG}.1")));
158 }
159 if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
160 let _ = file.write_all(line.as_bytes());
161 }
162}
163
164fn atomic_json(path: &Path, value: &Value) -> Result<(), String> {
165 crate::atomicfile::write(path, format!("{}\n", value))
166}
167
168#[derive(Debug, Clone)]
169pub struct Status {
170 pub pid: u64,
171 pub heartbeat_ms: u64,
172 pub lane: String,
173 pub backend: String,
174 pub generation: u64,
175 pub dirty_paths: usize,
176 pub last_reconcile_ms: u64,
177 pub entries_visited: usize,
178 pub last_batch_ms: u64,
179 pub last_batch_paths: usize,
180 pub last_event_latency_ms: u64,
181 pub source_bytes: u64,
182 pub index_bytes: u64,
183 pub documents: usize,
184 pub segments: usize,
185 pub tombstones: usize,
186 pub memory_rss_bytes: u64,
187 pub memory_limit_bytes: u64,
188 pub system_memory_bytes: u64,
189 pub started_ms: u64,
190}
191
192impl Default for Status {
193 fn default() -> Self {
194 Status {
195 pid: std::process::id() as u64,
196 heartbeat_ms: indexing::unix_millis(),
197 lane: "unavailable".to_string(),
198 backend: "none".to_string(),
199 generation: 0,
200 dirty_paths: 0,
201 last_reconcile_ms: 0,
202 entries_visited: 0,
203 last_batch_ms: 0,
204 last_batch_paths: 0,
205 last_event_latency_ms: 0,
206 source_bytes: 0,
207 index_bytes: 0,
208 documents: 0,
209 segments: 0,
210 tombstones: 0,
211 memory_rss_bytes: 0,
212 memory_limit_bytes: 0,
213 system_memory_bytes: 0,
214 started_ms: indexing::unix_millis(),
215 }
216 }
217}
218
219impl Status {
220 pub fn to_json(&self) -> Value {
221 json!({
222 "pid": self.pid,
223 "heartbeat_ms": self.heartbeat_ms,
224 "lane": self.lane,
225 "backend": self.backend,
226 "generation": self.generation,
227 "dirty_paths": self.dirty_paths,
228 "last_reconcile_ms": self.last_reconcile_ms,
229 "entries_visited": self.entries_visited,
230 "last_batch_ms": self.last_batch_ms,
231 "last_batch_paths": self.last_batch_paths,
232 "last_event_latency_ms": self.last_event_latency_ms,
233 "source_bytes": self.source_bytes,
234 "index_bytes": self.index_bytes,
235 "documents": self.documents,
236 "segments": self.segments,
237 "tombstones": self.tombstones,
238 "memory_rss_bytes": self.memory_rss_bytes,
239 "memory_limit_bytes": self.memory_limit_bytes,
240 "system_memory_bytes": self.system_memory_bytes,
241 "started_ms": self.started_ms,
242 })
243 }
244
245 fn from_json(v: &Value) -> Option<Status> {
246 let u = |name: &str| v.get(name).and_then(Value::as_u64).unwrap_or(0);
247 Some(Status {
248 pid: u("pid"),
249 heartbeat_ms: u("heartbeat_ms"),
250 lane: v.get("lane")?.as_str()?.to_string(),
251 backend: v
252 .get("backend")
253 .and_then(Value::as_str)
254 .unwrap_or("unknown")
255 .to_string(),
256 generation: u("generation"),
257 dirty_paths: u("dirty_paths") as usize,
258 last_reconcile_ms: u("last_reconcile_ms"),
259 entries_visited: u("entries_visited") as usize,
260 last_batch_ms: u("last_batch_ms"),
261 last_batch_paths: u("last_batch_paths") as usize,
262 last_event_latency_ms: u("last_event_latency_ms"),
263 source_bytes: u("source_bytes"),
264 index_bytes: u("index_bytes"),
265 documents: u("documents") as usize,
266 segments: u("segments") as usize,
267 tombstones: u("tombstones") as usize,
268 memory_rss_bytes: u("memory_rss_bytes"),
269 memory_limit_bytes: u("memory_limit_bytes"),
270 system_memory_bytes: u("system_memory_bytes"),
271 started_ms: u("started_ms"),
272 })
273 }
274
275 pub fn healthy(&self) -> bool {
276 self.lane != "unavailable"
277 && indexing::unix_millis().saturating_sub(self.heartbeat_ms) <= HEALTHY_MS
278 }
279}
280
281pub fn read_status(project: &Path) -> Option<Status> {
282 let text = std::fs::read_to_string(status_path(project)).ok()?;
283 let mut status = Status::from_json(&serde_json::from_str(&text).ok()?)?;
284 if status.lane != "unavailable" && !daemon_running(project).unwrap_or(false) {
285 status.lane = "unavailable".to_string();
286 }
287 Some(status)
288}
289
290pub fn start_failure(project: &Path) -> Option<String> {
291 std::fs::read_to_string(runtime_dir(project).join(START_FAILURE))
292 .ok()
293 .map(|s| s.trim().to_string())
294 .filter(|s| !s.is_empty())
295}
296
297fn write_status(project: &Path, status: &mut Status) -> Result<(), String> {
298 status.heartbeat_ms = indexing::unix_millis();
299 atomic_json(&status_path(project), &status.to_json())
300}
301
302struct UpdateGuard {
305 file: std::fs::File,
306}
307
308struct DaemonGuard {
311 file: std::fs::File,
312}
313
314struct StartClaimGuard(PathBuf);
315
316impl Drop for StartClaimGuard {
317 fn drop(&mut self) {
318 let _ = std::fs::remove_file(&self.0);
319 }
320}
321
322impl UpdateGuard {
323 fn acquire(project: &Path, timeout: Duration) -> Result<UpdateGuard, String> {
324 let dir = runtime_dir(project);
325 std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
326 let path = dir.join(UPDATE_LOCK);
327 let file = OpenOptions::new()
328 .read(true)
329 .write(true)
330 .create(true)
331 .truncate(false)
332 .open(&path)
333 .map_err(|e| format!("{}: {e}", path.display()))?;
334 let start = Instant::now();
335 loop {
336 match try_lock(&file) {
337 Ok(true) => return Ok(UpdateGuard { file }),
338 Ok(false) => {
339 if start.elapsed() >= timeout {
340 return Err(format!(
341 "timed out waiting for index update lock {}",
342 path.display()
343 ));
344 }
345 std::thread::sleep(Duration::from_millis(10));
346 }
347 Err(e) => return Err(format!("{}: {e}", path.display())),
348 }
349 }
350 }
351}
352
353impl DaemonGuard {
354 fn try_acquire(project: &Path) -> Result<Option<DaemonGuard>, String> {
355 let dir = runtime_dir(project);
356 std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
357 let path = dir.join(DAEMON_LOCK);
358 let file = OpenOptions::new()
359 .read(true)
360 .write(true)
361 .create(true)
362 .truncate(false)
363 .open(&path)
364 .map_err(|e| format!("{}: {e}", path.display()))?;
365 try_lock(&file)
366 .map(|acquired| acquired.then_some(DaemonGuard { file }))
367 .map_err(|e| format!("{}: {e}", path.display()))
368 }
369}
370
371impl Drop for UpdateGuard {
372 fn drop(&mut self) {
373 unlock(&self.file);
374 }
375}
376
377impl Drop for DaemonGuard {
378 fn drop(&mut self) {
379 unlock(&self.file);
380 }
381}
382
383fn daemon_running(project: &Path) -> Result<bool, String> {
384 Ok(DaemonGuard::try_acquire(project)?.is_none())
385}
386
387#[cfg(windows)]
388fn try_lock(file: &std::fs::File) -> std::io::Result<bool> {
389 use std::os::windows::io::AsRawHandle;
390 use windows_sys::Win32::Storage::FileSystem::LockFile;
391
392 if unsafe { LockFile(file.as_raw_handle() as _, 0, 0, u32::MAX, u32::MAX) } != 0 {
395 return Ok(true);
396 }
397 let error = std::io::Error::last_os_error();
398 if error.raw_os_error() == Some(33) {
399 Ok(false)
401 } else {
402 Err(error)
403 }
404}
405
406#[cfg(windows)]
407fn unlock(file: &std::fs::File) {
408 use std::os::windows::io::AsRawHandle;
409 use windows_sys::Win32::Storage::FileSystem::UnlockFile;
410
411 let _ = unsafe { UnlockFile(file.as_raw_handle() as _, 0, 0, u32::MAX, u32::MAX) };
413}
414
415#[cfg(unix)]
416fn try_lock(file: &std::fs::File) -> std::io::Result<bool> {
417 use std::os::fd::AsRawFd;
418
419 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
421 return Ok(true);
422 }
423 let error = std::io::Error::last_os_error();
424 if error.kind() == std::io::ErrorKind::WouldBlock {
425 Ok(false)
426 } else {
427 Err(error)
428 }
429}
430
431#[cfg(unix)]
432fn unlock(file: &std::fs::File) {
433 use std::os::fd::AsRawFd;
434
435 let _ = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) };
437}
438
439#[cfg(not(any(unix, windows)))]
440fn try_lock(_file: &std::fs::File) -> std::io::Result<bool> {
441 Ok(true)
442}
443
444#[cfg(not(any(unix, windows)))]
445fn unlock(_file: &std::fs::File) {}
446
447pub fn reconcile(
450 project: &Path,
451 plan: &Plan,
452) -> Result<(Index, UpdateReport, ScanMetrics), String> {
453 let _guard = UpdateGuard::acquire(project, Duration::from_secs(30))?;
454 let mut idx = okfindex::Index::open(&okfroots::index_dir(project))?;
455 let (files, metrics) = indexing::scan(plan);
456 let report = idx.update(&files, |f| okfroots::load_doc(&f.path))?;
457 if !report.is_empty() {
458 idx.save()?;
459 }
460 Ok((idx, report, metrics))
461}
462
463fn apply_dirty(
467 project: &Path,
468 plan: &Plan,
469 dirty: &BTreeSet<PathBuf>,
470) -> Result<Option<(Index, UpdateReport, ScanMetrics)>, String> {
471 let started = Instant::now();
472 let mut upserts = Vec::new();
473 let mut removed = BTreeSet::new();
474 let mut metrics = ScanMetrics {
475 entries_visited: dirty.len(),
476 ..ScanMetrics::default()
477 };
478 for path in dirty {
479 let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
480 if matches!(name, ".gitignore" | ".ignore") {
481 return Ok(None);
482 }
483 let key = path
484 .strip_prefix(&plan.project)
485 .map(indexing::path_key)
486 .unwrap_or_else(|_| indexing::path_key(path));
487 match std::fs::metadata(path) {
488 Ok(meta) if meta.is_dir() => return Ok(None),
489 Ok(meta) if meta.is_file() => {
490 metrics.files_considered += 1;
491 if plan.decide(path, Some(&meta)).included {
492 metrics.files_included += 1;
493 metrics.logical_bytes += meta.len();
494 upserts.push(indexing::file_stat(plan, path, &meta));
495 } else {
496 removed.insert(key);
497 }
498 }
499 Ok(_) => {
500 removed.insert(key);
501 }
502 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
503 removed.insert(key);
506 }
507 Err(_) => return Ok(None),
508 }
509 }
510 let _guard = UpdateGuard::acquire(project, Duration::from_secs(30))?;
511 let mut idx = okfindex::Index::open(&okfroots::index_dir(project))?;
512 let removed: Vec<String> = removed.into_iter().collect();
513 let report = idx.update_delta(&upserts, &removed, |f| okfroots::load_doc(&f.path))?;
514 if !report.is_empty() {
515 idx.save()?;
516 }
517 metrics.elapsed_ms = started.elapsed().as_millis() as u64;
518 Ok(Some((idx, report, metrics)))
519}
520
521pub fn condense(project: &Path) -> Result<(Index, bool), String> {
522 let _guard = UpdateGuard::acquire(project, Duration::from_secs(30))?;
523 let mut idx = okfindex::Index::open(&okfroots::index_dir(project))?;
524 let changed = idx.condense()?;
525 if changed {
526 idx.save()?;
527 }
528 Ok((idx, changed))
529}
530
531pub fn rebuild(project: &Path, plan: &Plan) -> Result<(Index, UpdateReport, ScanMetrics), String> {
532 let _guard = UpdateGuard::acquire(project, Duration::from_secs(30))?;
533 let mut idx = okfindex::Index::open(&okfroots::index_dir(project))?;
534 idx.reset();
535 let (files, metrics) = indexing::scan(plan);
536 let report = idx.update(&files, |f| okfroots::load_doc(&f.path))?;
537 idx.save()?;
538 Ok((idx, report, metrics))
539}
540
541fn refresh_metrics(project: &Path, status: &mut Status, idx: &Index) {
542 status.generation = idx.generation();
543 status.source_bytes = idx.source_bytes();
544 status.index_bytes = indexing::directory_bytes(&okfroots::index_dir(project));
545 status.documents = idx.doc_count();
546 status.segments = idx.segment_count();
547 status.tombstones = idx.tombstone_count();
548}
549
550struct MemoryMonitor {
551 system: sysinfo::System,
552 pid: sysinfo::Pid,
553}
554
555impl MemoryMonitor {
556 fn new() -> Result<MemoryMonitor, String> {
557 let pid = sysinfo::get_current_pid().map_err(|e| format!("current process id: {e}"))?;
558 Ok(MemoryMonitor {
559 system: sysinfo::System::new(),
560 pid,
561 })
562 }
563
564 fn rss_bytes(&mut self) -> u64 {
565 self.system
566 .refresh_processes(sysinfo::ProcessesToUpdate::Some(&[self.pid]), true);
567 self.system
568 .process(self.pid)
569 .map(sysinfo::Process::memory)
570 .unwrap_or(0)
571 }
572}
573
574pub fn barrier(project: &Path) -> bool {
577 let Some(status) = read_status(project) else {
578 return false;
579 };
580 if !status.healthy() {
581 return false;
582 }
583 let dir = requests_dir(project);
584 if std::fs::create_dir_all(&dir).is_err() {
585 return false;
586 }
587 let id = format!("{}-{}", std::process::id(), indexing::unix_millis());
588 let req = dir.join(format!("{id}.req"));
589 let ack = dir.join(format!("{id}.ack"));
590 if std::fs::write(&req, b"flush\n").is_err() {
591 return false;
592 }
593 let start = Instant::now();
594 while start.elapsed() < Duration::from_millis(BARRIER_MS) {
595 if ack.is_file() {
596 let _ = std::fs::remove_file(&ack);
597 return true;
598 }
599 std::thread::sleep(Duration::from_millis(10));
600 }
601 let _ = std::fs::remove_file(req);
602 false
603}
604
605fn env_disabled() -> bool {
606 matches!(
607 std::env::var("CT_INDEX_WATCH").ok().as_deref(),
608 Some("never" | "off" | "false" | "0")
609 )
610}
611
612pub fn ensure_started(exe: &Path, project: &Path, plan: &Plan) -> Result<bool, String> {
616 if !plan.watch || env_disabled() {
617 return Ok(false);
618 }
619 let dir = runtime_dir(project);
620 std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
621 if daemon_running(project)? {
622 return Ok(false);
623 }
624 let failed = dir.join(START_FAILURE);
625 let recent_failure = std::fs::metadata(&failed)
626 .and_then(|m| m.modified())
627 .ok()
628 .and_then(|t| SystemTime::now().duration_since(t).ok())
629 .is_some_and(|age| age < Duration::from_secs(60));
630 if recent_failure {
631 return Ok(false);
632 }
633 let claim = dir.join(START_CLAIM);
634 let mut recovered = false;
635 let mut claim_file = loop {
636 match OpenOptions::new().write(true).create_new(true).open(&claim) {
637 Ok(f) => break f,
638 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && !recovered => {
639 let stale = std::fs::metadata(&claim)
640 .and_then(|m| m.modified())
641 .ok()
642 .and_then(|t| SystemTime::now().duration_since(t).ok())
643 .is_some_and(|age| age > Duration::from_secs(START_CLAIM_STALE_SECONDS));
644 if !stale {
645 return Ok(false);
646 }
647 std::fs::remove_file(&claim)
648 .map_err(|e| format!("remove stale {}: {e}", claim.display()))?;
649 recovered = true;
650 }
651 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Ok(false),
652 Err(e) => return Err(format!("{}: {e}", claim.display())),
653 }
654 };
655 if daemon_running(project)? {
657 let _ = std::fs::remove_file(&claim);
658 return Ok(false);
659 }
660 writeln!(claim_file, "{}", indexing::unix_millis())
661 .map_err(|e| format!("{}: {e}", claim.display()))?;
662 let mut cmd = Command::new(exe);
663 cmd.args([
664 "--base",
665 &project.to_string_lossy(),
666 "index",
667 "watch",
668 "run",
669 ])
670 .stdin(Stdio::null())
671 .stdout(Stdio::null())
672 .stderr(Stdio::null());
673 #[cfg(windows)]
674 {
675 use std::os::windows::process::CommandExt;
676 const DETACHED_PROCESS: u32 = 0x0000_0008;
677 const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
678 const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000;
679 cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB);
680 }
681 #[cfg(unix)]
682 {
683 use std::os::unix::process::CommandExt;
684
685 unsafe {
688 cmd.pre_exec(|| {
689 if libc::setsid() == -1 {
690 Err(std::io::Error::last_os_error())
691 } else {
692 Ok(())
693 }
694 });
695 }
696 }
697 match cmd.spawn() {
698 Ok(_) => {
699 let _ = std::fs::remove_file(failed);
700 Ok(true)
701 }
702 Err(e) => {
703 let _ = std::fs::remove_file(claim);
704 let _ = std::fs::write(&failed, format!("{} {e}\n", indexing::unix_millis()));
705 Err(format!("start index watcher: {e}"))
706 }
707 }
708}
709
710pub fn request_stop(project: &Path) -> Result<bool, String> {
711 if !daemon_running(project)? {
712 return Ok(false);
713 }
714 let path = runtime_dir(project).join(STOP_FILE);
715 std::fs::write(&path, b"stop\n").map_err(|e| format!("{}: {e}", path.display()))?;
716 Ok(true)
717}
718
719fn pending_requests(project: &Path, settle: Duration) -> Vec<PathBuf> {
720 let Ok(items) = std::fs::read_dir(requests_dir(project)) else {
721 return Vec::new();
722 };
723 items
724 .filter_map(Result::ok)
725 .map(|e| e.path())
726 .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("req"))
727 .filter(|p| {
730 std::fs::metadata(p)
731 .and_then(|m| m.modified())
732 .ok()
733 .and_then(|t| SystemTime::now().duration_since(t).ok())
734 .is_some_and(|age| age >= settle)
735 })
736 .collect()
737}
738
739fn acknowledge(requests: Vec<PathBuf>) {
740 for req in requests {
741 let ack = req.with_extension("ack");
742 let _ = std::fs::rename(req, ack);
743 }
744}
745
746pub fn run_daemon(project: &Path, plan: Plan) -> Result<(), String> {
749 install_shutdown_handlers()?;
750 run_daemon_logged(project, plan)
751}
752
753fn run_daemon_logged(project: &Path, plan: Plan) -> Result<(), String> {
754 lifecycle_log(project, "start");
755 let result = run_daemon_body(project, plan);
756 if !matches!(&result, Ok(reason) if reason == "duplicate") {
757 let mut status = read_status(project).unwrap_or_default();
758 status.lane = "unavailable".to_string();
759 let _ = write_status(project, &mut status);
760 }
761 match &result {
762 Ok(reason) => lifecycle_log(project, &format!("stop reason={reason}")),
763 Err(error) => {
764 lifecycle_log(project, &format!("stop reason=error detail={error}"));
765 let failed = runtime_dir(project).join(START_FAILURE);
766 let _ = std::fs::write(&failed, format!("{} {error}\n", indexing::unix_millis()));
767 }
768 }
769 result.map(|_| ())
770}
771
772fn run_daemon_body(project: &Path, plan: Plan) -> Result<String, String> {
773 let dir = runtime_dir(project);
774 let start_claim = StartClaimGuard(dir.join(START_CLAIM));
775 let Some(_daemon_guard) = DaemonGuard::try_acquire(project)? else {
776 return Ok("duplicate".to_string());
777 };
778 drop(start_claim);
779 if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) {
780 return Ok("signal".to_string());
781 }
782 let _ = std::fs::remove_file(dir.join(START_FAILURE));
783 std::fs::create_dir_all(requests_dir(project))
784 .map_err(|e| format!("{}: {e}", dir.display()))?;
785 let _ = std::fs::remove_file(dir.join(STOP_FILE));
786
787 let (tx, rx) = mpsc::channel();
788 let mut watcher = notify::recommended_watcher(tx).map_err(|e| format!("watcher: {e}"))?;
789 for scope in &plan.scopes {
790 watcher
791 .watch(&scope.root, RecursiveMode::Recursive)
792 .map_err(|e| format!("watch {}: {e}", scope.root.display()))?;
793 }
794 let ct_dir = project.join(".ct");
795 if ct_dir.is_dir() {
796 watcher
797 .watch(&ct_dir, RecursiveMode::NonRecursive)
798 .map_err(|e| format!("watch {}: {e}", ct_dir.display()))?;
799 }
800 if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) {
801 return Ok("signal".to_string());
802 }
803
804 let mut status = Status {
805 lane: "reconcile".to_string(),
806 backend: format!("{:?}", notify::RecommendedWatcher::kind()).to_ascii_lowercase(),
807 memory_limit_bytes: plan.daemon_memory_limit_bytes,
808 system_memory_bytes: plan.system_memory_bytes,
809 ..Status::default()
810 };
811 let mut memory_monitor = MemoryMonitor::new()?;
812 let (idx, _, initial) = reconcile(project, &plan)?;
815 status.last_reconcile_ms = initial.elapsed_ms;
816 status.entries_visited = initial.entries_visited;
817 status.lane = "clean".to_string();
818 refresh_metrics(project, &mut status, &idx);
819 status.memory_rss_bytes = memory_monitor.rss_bytes();
820 write_status(project, &mut status)?;
821 if status.memory_rss_bytes > status.memory_limit_bytes {
822 status.lane = "unavailable".to_string();
823 write_status(project, &mut status)?;
824 return Ok(format!(
825 "memory-limit used={} limit={}",
826 status.memory_rss_bytes, status.memory_limit_bytes
827 ));
828 }
829
830 let mut dirty = BTreeSet::<PathBuf>::new();
831 let mut first_dirty: Option<Instant> = None;
832 let mut last_activity = Instant::now();
833 let mut last_audit = Instant::now();
834 let mut last_heartbeat = Instant::now();
835 let mut plan_changed = false;
836 let mut needs_reconcile = false;
837 let stop_reason = loop {
838 if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) {
839 status.lane = "unavailable".to_string();
840 write_status(project, &mut status)?;
841 break "signal".to_string();
842 }
843 match rx.recv_timeout(Duration::from_millis(25)) {
844 Ok(Ok(event)) => {
845 for path in event.paths {
846 if path == plan.config_path || path == project.join(".ct/okf.jsonc") {
847 plan_changed = true;
848 continue;
849 }
850 if !path.starts_with(okfroots::index_dir(project)) {
852 dirty.insert(path);
853 }
854 }
855 if !dirty.is_empty() {
856 first_dirty.get_or_insert_with(Instant::now);
857 status.lane = "dirty".to_string();
858 status.dirty_paths = dirty.len();
859 }
860 }
861 Ok(Err(_)) => {
862 status.lane = "reconcile".to_string();
863 needs_reconcile = true;
864 first_dirty.get_or_insert_with(Instant::now);
865 }
866 Err(mpsc::RecvTimeoutError::Timeout) => {}
867 Err(mpsc::RecvTimeoutError::Disconnected) => {
868 status.lane = "unavailable".to_string();
869 write_status(project, &mut status)?;
870 break "watcher-disconnected".to_string();
871 }
872 }
873
874 if plan_changed {
875 status.lane = "unavailable".to_string();
878 write_status(project, &mut status)?;
879 break "configuration-changed".to_string();
880 }
881
882 let requests = pending_requests(project, Duration::from_millis(plan.debounce_ms));
883 let debounce_due =
884 first_dirty.is_some_and(|t| t.elapsed() >= Duration::from_millis(plan.debounce_ms));
885 let audit_due = last_audit.elapsed() >= Duration::from_secs(plan.audit_seconds);
886 if debounce_due || audit_due || !requests.is_empty() && !dirty.is_empty() {
887 let started = Instant::now();
888 let batch_paths = dirty.len();
889 let event_age = first_dirty
890 .map(|t| t.elapsed().as_millis() as u64)
891 .unwrap_or(0);
892 status.lane = "reconcile".to_string();
893 let delta = if audit_due || needs_reconcile {
894 None
895 } else {
896 apply_dirty(project, &plan, &dirty)?
897 };
898 let (idx, _, scan, did_full_reconcile) = match delta {
899 Some((idx, report, metrics)) => (idx, report, metrics, false),
900 None => {
901 let (idx, report, metrics) = reconcile(project, &plan)?;
902 (idx, report, metrics, true)
903 }
904 };
905 status.last_batch_ms = started.elapsed().as_millis() as u64;
906 status.last_batch_paths = batch_paths;
907 status.last_event_latency_ms = event_age;
908 if did_full_reconcile {
909 status.last_reconcile_ms = scan.elapsed_ms;
910 status.entries_visited = scan.entries_visited;
911 }
912 status.lane = "clean".to_string();
913 status.dirty_paths = 0;
914 refresh_metrics(project, &mut status, &idx);
915 dirty.clear();
916 first_dirty = None;
917 needs_reconcile = false;
918 last_audit = Instant::now();
919 last_activity = Instant::now();
920 }
921 if !requests.is_empty() {
922 acknowledge(requests);
925 last_activity = Instant::now();
926 }
927
928 if last_heartbeat.elapsed() >= Duration::from_secs(1) {
929 status.memory_rss_bytes = memory_monitor.rss_bytes();
930 if status.memory_rss_bytes > status.memory_limit_bytes {
931 status.lane = "unavailable".to_string();
932 write_status(project, &mut status)?;
933 break format!(
934 "memory-limit used={} limit={}",
935 status.memory_rss_bytes, status.memory_limit_bytes
936 );
937 }
938 write_status(project, &mut status)?;
939 last_heartbeat = Instant::now();
940 }
941 if dir.join(STOP_FILE).is_file() {
942 status.lane = "unavailable".to_string();
943 write_status(project, &mut status)?;
944 break "requested".to_string();
945 }
946 if last_activity.elapsed() >= Duration::from_secs(plan.idle_seconds) {
947 status.lane = "unavailable".to_string();
948 write_status(project, &mut status)?;
949 break "idle".to_string();
950 }
951 };
952 let _ = std::fs::remove_file(dir.join(STOP_FILE));
953 Ok(stop_reason)
954}
955
956#[cfg(test)]
957mod tests {
958 use super::*;
959
960 static DAEMON_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
961
962 #[test]
963 fn stale_status_is_not_healthy() {
964 let status = Status {
965 lane: "clean".into(),
966 heartbeat_ms: indexing::unix_millis().saturating_sub(HEALTHY_MS + 1),
967 ..Status::default()
968 };
969 assert!(!status.healthy());
970 }
971
972 #[test]
973 fn watcher_barrier_reflects_create_modify_and_remove() {
974 let _serial = DAEMON_TEST_LOCK.lock().unwrap();
975 let project = std::env::temp_dir().join(format!(
976 "ct-indexwatch-{}-{}",
977 std::process::id(),
978 indexing::unix_millis()
979 ));
980 let root = project.join("kb");
981 std::fs::create_dir_all(project.join(".ct")).unwrap();
982 std::fs::create_dir_all(&root).unwrap();
983 let concept = root.join("a.md");
984 std::fs::write(&concept, "---\ntype: Note\ntitle: A\n---\nalpha\n").unwrap();
985 let plan = Plan {
986 project: project.clone(),
987 scopes: vec![crate::indexing::Scope {
988 root: root.clone(),
989 provider: crate::indexing::PROVIDER_OKF.to_string(),
990 include: vec!["**/*.md".to_string()],
991 exclude: vec![],
992 origin: crate::indexing::Origin::Derived,
993 }],
994 exclude: vec![],
995 watch: true,
996 debounce_ms: 50,
997 audit_seconds: 60,
998 idle_seconds: 10,
999 max_file_bytes: 1024 * 1024,
1000 system_memory_bytes: 8 * 1024 * 1024 * 1024,
1001 daemon_memory_limit_bytes: 2 * 1024 * 1024 * 1024,
1002 daemon_memory_limit_automatic: true,
1003 config_path: project.join(".ct/index.jsonc"),
1004 };
1005 let daemon_project = project.clone();
1006 let daemon = std::thread::spawn(move || run_daemon_logged(&daemon_project, plan).unwrap());
1007 let start = Instant::now();
1008 while !read_status(&project).is_some_and(|s| s.healthy()) {
1009 assert!(
1010 start.elapsed() < Duration::from_secs(5),
1011 "watcher did not become healthy"
1012 );
1013 std::thread::sleep(Duration::from_millis(20));
1014 }
1015 assert!(daemon_running(&project).unwrap());
1016 assert!(!runtime_dir(&project).join(START_CLAIM).exists());
1017 assert!(
1018 Index::open(&okfroots::index_dir(&project))
1019 .unwrap()
1020 .search("alpha", 10)
1021 .unwrap()
1022 .len()
1023 == 1
1024 );
1025
1026 std::fs::write(&concept, "---\ntype: Note\ntitle: A\n---\nbeta\n").unwrap();
1027 assert!(barrier(&project), "modify barrier timed out");
1028 let idx = Index::open(&okfroots::index_dir(&project)).unwrap();
1029 assert!(idx.search("alpha", 10).unwrap().is_empty());
1030 assert_eq!(idx.search("beta", 10).unwrap().len(), 1);
1031
1032 std::fs::remove_file(&concept).unwrap();
1033 assert!(barrier(&project), "remove barrier timed out");
1034 assert!(
1035 Index::open(&okfroots::index_dir(&project))
1036 .unwrap()
1037 .search("beta", 10)
1038 .unwrap()
1039 .is_empty()
1040 );
1041 request_stop(&project).unwrap();
1042 daemon.join().unwrap();
1043 assert!(!daemon_running(&project).unwrap());
1044 let log = std::fs::read_to_string(runtime_dir(&project).join(LIFECYCLE_LOG)).unwrap();
1045 assert!(log.contains(" start\n"), "{log}");
1046 assert!(log.contains("stop reason=requested"), "{log}");
1047 }
1048
1049 #[test]
1050 fn lifecycle_log_is_bounded_and_rotates() {
1051 let project = std::env::temp_dir().join(format!(
1052 "ct-indexwatch-log-{}-{}",
1053 std::process::id(),
1054 indexing::unix_millis()
1055 ));
1056 for _ in 0..180 {
1057 lifecycle_log(&project, &format!("exception {}", "x".repeat(700)));
1058 }
1059 let dir = runtime_dir(&project);
1060 for name in [LIFECYCLE_LOG.to_string(), format!("{LIFECYCLE_LOG}.1")] {
1061 let size = std::fs::metadata(dir.join(name)).unwrap().len();
1062 assert!(size <= LIFECYCLE_LOG_MAX_BYTES, "log grew to {size}");
1063 }
1064 assert!(dir.join(format!("{LIFECYCLE_LOG}.2")).is_file());
1065 let _ = std::fs::remove_dir_all(project);
1066 }
1067
1068 #[test]
1069 fn daemon_stops_quietly_after_idle_timeout() {
1070 let _serial = DAEMON_TEST_LOCK.lock().unwrap();
1071 let project = std::env::temp_dir().join(format!(
1072 "ct-indexwatch-idle-{}-{}",
1073 std::process::id(),
1074 indexing::unix_millis()
1075 ));
1076 let root = project.join("kb");
1077 std::fs::create_dir_all(&root).unwrap();
1078 let plan = Plan {
1079 project: project.clone(),
1080 scopes: vec![crate::indexing::Scope {
1081 root,
1082 provider: crate::indexing::PROVIDER_OKF.to_string(),
1083 include: vec!["**/*.md".to_string()],
1084 exclude: vec![],
1085 origin: crate::indexing::Origin::Derived,
1086 }],
1087 exclude: vec![],
1088 watch: true,
1089 debounce_ms: 25,
1090 audit_seconds: 60,
1091 idle_seconds: 1,
1092 max_file_bytes: 1024 * 1024,
1093 system_memory_bytes: 8 * 1024 * 1024 * 1024,
1094 daemon_memory_limit_bytes: 2 * 1024 * 1024 * 1024,
1095 daemon_memory_limit_automatic: true,
1096 config_path: project.join(".ct/index.jsonc"),
1097 };
1098 let started = Instant::now();
1099 run_daemon_logged(&project, plan).unwrap();
1100 assert!(started.elapsed() >= Duration::from_secs(1));
1101 assert!(started.elapsed() < Duration::from_secs(5));
1102 let log = std::fs::read_to_string(runtime_dir(&project).join(LIFECYCLE_LOG)).unwrap();
1103 assert!(log.contains("stop reason=idle"), "{log}");
1104 let _ = std::fs::remove_dir_all(project);
1105 }
1106
1107 #[test]
1108 fn daemon_self_terminates_above_memory_limit() {
1109 let _serial = DAEMON_TEST_LOCK.lock().unwrap();
1110 let project = std::env::temp_dir().join(format!(
1111 "ct-indexwatch-memory-{}-{}",
1112 std::process::id(),
1113 indexing::unix_millis()
1114 ));
1115 let root = project.join("kb");
1116 std::fs::create_dir_all(&root).unwrap();
1117 let plan = Plan {
1118 project: project.clone(),
1119 scopes: vec![crate::indexing::Scope {
1120 root,
1121 provider: crate::indexing::PROVIDER_OKF.to_string(),
1122 include: vec!["**/*.md".to_string()],
1123 exclude: vec![],
1124 origin: crate::indexing::Origin::Derived,
1125 }],
1126 exclude: vec![],
1127 watch: true,
1128 debounce_ms: 25,
1129 audit_seconds: 60,
1130 idle_seconds: 60,
1131 max_file_bytes: 1024 * 1024,
1132 system_memory_bytes: 8 * 1024 * 1024 * 1024,
1133 daemon_memory_limit_bytes: 1,
1134 daemon_memory_limit_automatic: false,
1135 config_path: project.join(".ct/index.jsonc"),
1136 };
1137 run_daemon_logged(&project, plan).unwrap();
1138 let status = read_status(&project).unwrap();
1139 assert_eq!(status.lane, "unavailable");
1140 assert!(status.memory_rss_bytes > status.memory_limit_bytes);
1141 let log = std::fs::read_to_string(runtime_dir(&project).join(LIFECYCLE_LOG)).unwrap();
1142 assert!(log.contains("stop reason=memory-limit"), "{log}");
1143 let _ = std::fs::remove_dir_all(project);
1144 }
1145
1146 #[test]
1147 fn shutdown_flag_marks_exit_and_releases_singleton() {
1148 let _serial = DAEMON_TEST_LOCK.lock().unwrap();
1149 let project = std::env::temp_dir().join(format!(
1150 "ct-indexwatch-signal-{}-{}",
1151 std::process::id(),
1152 indexing::unix_millis()
1153 ));
1154 let plan = Plan {
1155 project: project.clone(),
1156 scopes: vec![],
1157 exclude: vec![],
1158 watch: true,
1159 debounce_ms: 25,
1160 audit_seconds: 60,
1161 idle_seconds: 60,
1162 max_file_bytes: 1024 * 1024,
1163 system_memory_bytes: 8 * 1024 * 1024 * 1024,
1164 daemon_memory_limit_bytes: 2 * 1024 * 1024 * 1024,
1165 daemon_memory_limit_automatic: true,
1166 config_path: project.join(".ct/index.jsonc"),
1167 };
1168 SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst);
1169 run_daemon_logged(&project, plan).unwrap();
1170 SHUTDOWN_REQUESTED.store(false, Ordering::SeqCst);
1171 assert_eq!(read_status(&project).unwrap().lane, "unavailable");
1172 assert!(!daemon_running(&project).unwrap());
1173 let log = std::fs::read_to_string(runtime_dir(&project).join(LIFECYCLE_LOG)).unwrap();
1174 assert!(log.contains("stop reason=signal"), "{log}");
1175 let _ = std::fs::remove_dir_all(project);
1176 }
1177}