Skip to main content

coding_tools/
indexwatch.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! Opportunistic filesystem watcher for the OKF index.
5//!
6//! The daemon is deliberately an optimization: clients use a bounded file-based
7//! barrier when it is healthy and run synchronous reconciliation otherwise.
8
9use 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    // SAFETY: the handler only stores to a lock-free atomic, and `sigaction`
51    // copies the action before this stack frame ends.
52    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    // SAFETY: the callback has the required ABI, is process-static, and only
95    // touches a lock-free atomic flag.
96    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
124/// Append one sparse lifecycle record, rotating before the active log exceeds
125/// 32 KiB. Logging is diagnostic only and must never affect daemon behavior.
126fn 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
302/// An OS-backed cross-process lock. It prevents the daemon and a synchronous
303/// fallback from publishing competing manifests, and is released on crash.
304struct UpdateGuard {
305    file: std::fs::File,
306}
307
308/// The per-project process authority. Unlike status or PID files, this lock is
309/// released by the operating system even after an ungraceful process exit.
310struct 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    // SAFETY: the handle remains valid for the call and the byte range is a
393    // conventional whole-file advisory lock shared by all ct writers.
394    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        // ERROR_LOCK_VIOLATION means another process owns the range.
400        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    // SAFETY: this uses the same live handle and range passed to `LockFile`.
412    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    // SAFETY: `file` owns a valid descriptor for the duration of this call.
420    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    // SAFETY: `file` owns a valid descriptor for the duration of this call.
436    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
447/// The authoritative synchronous reconciliation path shared by normal commands
448/// and the watcher. Events only decide when this needs to run.
449pub 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
463/// Apply a coalesced native-event dirty set without walking unchanged scopes.
464/// `None` requests a full reconciliation because a directory or ignore policy
465/// changed and a path-local delta cannot prove completeness.
466fn 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                // The path may have been a file or a whole directory. Prefix
504                // removal is safe for both and is scoped to existing manifest keys.
505                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
574/// Ask a healthy daemon to drain observed events. Returns false quickly when no
575/// trustworthy daemon is present so the caller can reconcile synchronously.
576pub 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
612/// Start one detached watcher if the per-project OS lock proves that no daemon
613/// exists. The short-lived file claim closes the parent/child lock-handoff race
614/// and is age-recovered if a child dies before entering the daemon body.
615pub 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    // Another launcher may have won immediately before this claim was created.
656    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        // SAFETY: `setsid` is async-signal-safe and the closure does not touch
686        // shared Rust state between fork and exec.
687        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        // Give the native backend one debounce window to deliver filesystem
728        // events that happened immediately before the freshness request.
729        .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
746/// Foreground body of the detached watcher process. Standard streams remain
747/// detached; only sparse lifecycle events are written to the bounded log.
748pub 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    // Watcher is installed before the initial full comparison, closing the
813    // usual scan-then-watch race. Events received during it remain queued.
814    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                    // Index storage is hard-excluded; avoid self-generated loops.
851                    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            // A client will load the new plan, reconcile synchronously, and
876            // start a replacement watcher on its next indexed operation.
877            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            // Any dirty work was drained above. If there was none, the current
923            // clean generation itself satisfies the barrier.
924            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}