Skip to main content

zwire_host/
watch.rs

1//! Streaming filesystem observers: `fs_watch` and `fs_tail`.
2//!
3//! `fs_watch` polls a file or directory and streams `{"ev":"fs","kind":…,"path":…}`
4//! frames on create / modify / remove. `fs_tail` streams `{"ev":"line","data":…}`
5//! as lines are appended to a file — `tail -f`, surviving truncation and log
6//! rotation. Each observer runs on a background thread keyed by the request `id`
7//! (so one connection can watch many paths), stops on `watch_stop`, and is torn
8//! down when the connection closes. Poll-based, so no new dependencies.
9use crate::proto::{send_msg, Out};
10use crate::store::expand;
11use serde_json::{json, Value};
12use std::collections::HashMap;
13use std::fs::File;
14use std::io::{Read, Seek, SeekFrom};
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::sync::Arc;
18use std::thread::JoinHandle;
19use std::time::{Duration, UNIX_EPOCH};
20
21/// Cap on entries scanned per `fs_watch` tick (guards watching a huge tree).
22const MAX_ENTRIES: usize = 20_000;
23/// Flush a `fs_tail` partial line once it grows past this without a newline.
24const MAX_LINE: usize = 1 << 20;
25
26/// A running observer. Dropping it stops the thread and joins it.
27pub struct Watcher {
28    stop: Arc<AtomicBool>,
29    handle: Option<JoinHandle<()>>,
30}
31
32impl Drop for Watcher {
33    fn drop(&mut self) {
34        self.stop.store(true, Ordering::Relaxed);
35        if let Some(h) = self.handle.take() {
36            let _ = h.join();
37        }
38    }
39}
40
41impl Watcher {
42    /// Watch `path` (file or directory) for changes; `recursive` descends
43    /// subdirectories, `interval_ms` sets the poll cadence (default 1000).
44    pub fn fs_watch(out: &Out, req: &Value, id: String) -> Watcher {
45        let path = expand(req["path"].as_str().unwrap_or("."));
46        let recursive = req["recursive"].as_bool().unwrap_or(false);
47        let interval = Duration::from_millis(req["interval_ms"].as_u64().unwrap_or(1000).max(100));
48        Self::spawn(out, move |out, stop| {
49            watch_loop(out, stop, &path, recursive, interval, &id)
50        })
51    }
52
53    /// Tail `path`, streaming appended lines. `from` = `"start"` replays the
54    /// whole file first; otherwise (default) only new lines are streamed.
55    pub fn fs_tail(out: &Out, req: &Value, id: String) -> Watcher {
56        let path = expand(req["path"].as_str().unwrap_or("."));
57        let from_end = req["from"].as_str() != Some("start");
58        let interval = Duration::from_millis(req["interval_ms"].as_u64().unwrap_or(300).max(50));
59        Self::spawn(out, move |out, stop| {
60            tail_loop(out, stop, &path, from_end, interval, &id)
61        })
62    }
63
64    fn spawn(out: &Out, body: impl FnOnce(&Out, &AtomicBool) + Send + 'static) -> Watcher {
65        let stop = Arc::new(AtomicBool::new(false));
66        let (s2, o) = (stop.clone(), out.clone());
67        let handle = std::thread::spawn(move || body(&o, &s2));
68        Watcher {
69            stop,
70            handle: Some(handle),
71        }
72    }
73}
74
75/// Sleep `dur`, waking early (every 100 ms) to notice a stop request.
76fn sleep_sliced(stop: &AtomicBool, dur: Duration) {
77    let mut slept = Duration::ZERO;
78    while slept < dur && !stop.load(Ordering::Relaxed) {
79        let slice = Duration::from_millis(100).min(dur - slept);
80        std::thread::sleep(slice);
81        slept += slice;
82    }
83}
84
85fn with_id(mut ev: Value, id: &str) -> Value {
86    if !id.is_empty() {
87        ev["id"] = json!(id);
88    }
89    ev
90}
91
92fn mtime_ms(p: &Path) -> Option<u64> {
93    std::fs::metadata(p)
94        .ok()?
95        .modified()
96        .ok()?
97        .duration_since(UNIX_EPOCH)
98        .ok()
99        .map(|d| d.as_millis() as u64)
100}
101
102/// A `path -> mtime(ms)` snapshot of everything under `root`.
103fn snapshot(root: &Path, recursive: bool) -> HashMap<PathBuf, u64> {
104    let mut out = HashMap::new();
105    if root.is_file() {
106        if let Some(m) = mtime_ms(root) {
107            out.insert(root.to_path_buf(), m);
108        }
109        return out;
110    }
111    let mut stack = vec![(root.to_path_buf(), 0usize)];
112    while let Some((dir, depth)) = stack.pop() {
113        let Ok(rd) = std::fs::read_dir(&dir) else {
114            continue;
115        };
116        for e in rd.flatten() {
117            let p = e.path();
118            let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
119            if let Some(m) = mtime_ms(&p) {
120                out.insert(p.clone(), m);
121            }
122            if is_dir && recursive && depth < 32 {
123                stack.push((p, depth + 1));
124            }
125            if out.len() >= MAX_ENTRIES {
126                return out;
127            }
128        }
129    }
130    out
131}
132
133fn watch_loop(
134    out: &Out,
135    stop: &AtomicBool,
136    root: &Path,
137    recursive: bool,
138    interval: Duration,
139    id: &str,
140) {
141    let emit = |kind: &str, p: &Path| -> bool {
142        let frame = with_id(
143            json!({"ev": "fs", "kind": kind, "path": p.to_string_lossy()}),
144            id,
145        );
146        send_msg(out, &frame).is_ok()
147    };
148    let mut prev = snapshot(root, recursive);
149    while !stop.load(Ordering::Relaxed) {
150        sleep_sliced(stop, interval);
151        if stop.load(Ordering::Relaxed) {
152            break;
153        }
154        let cur = snapshot(root, recursive);
155        for (p, m) in &cur {
156            let changed = match prev.get(p) {
157                None => emit("created", p),
158                Some(pm) if pm != m => emit("modified", p),
159                _ => true,
160            };
161            if !changed {
162                return; // port closed
163            }
164        }
165        for p in prev.keys() {
166            if !cur.contains_key(p) && !emit("removed", p) {
167                return;
168            }
169        }
170        prev = cur;
171    }
172}
173
174fn tail_loop(
175    out: &Out,
176    stop: &AtomicBool,
177    path: &Path,
178    from_end: bool,
179    interval: Duration,
180    id: &str,
181) {
182    let mut pos: u64 = if from_end {
183        std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
184    } else {
185        0
186    };
187    let mut carry = String::new();
188    while !stop.load(Ordering::Relaxed) {
189        if let Ok(mut f) = File::open(path) {
190            let len = f.metadata().map(|m| m.len()).unwrap_or(0);
191            if len < pos {
192                // Truncated or rotated: start over from the top.
193                pos = 0;
194                carry.clear();
195            }
196            if len > pos && f.seek(SeekFrom::Start(pos)).is_ok() {
197                let mut buf = Vec::new();
198                if f.take(len - pos).read_to_end(&mut buf).is_ok() {
199                    pos += buf.len() as u64;
200                    carry.push_str(&String::from_utf8_lossy(&buf));
201                    if !flush_lines(out, &mut carry, id) {
202                        return; // port closed
203                    }
204                }
205            }
206        }
207        sleep_sliced(stop, interval);
208    }
209}
210
211/// Emit each complete line in `carry`; returns false if the port closed.
212fn flush_lines(out: &Out, carry: &mut String, id: &str) -> bool {
213    while let Some(idx) = carry.find('\n') {
214        let line: String = carry.drain(..=idx).collect();
215        let line = line.trim_end_matches(['\r', '\n']);
216        if !send_line(out, line, id) {
217            return false;
218        }
219    }
220    if carry.len() > MAX_LINE {
221        let line = std::mem::take(carry);
222        return send_line(out, &line, id);
223    }
224    true
225}
226
227fn send_line(out: &Out, line: &str, id: &str) -> bool {
228    send_msg(out, &with_id(json!({"ev": "line", "data": line}), id)).is_ok()
229}