Skip to main content

fno_agents/
drift.rs

1//! Daemon binary-version drift detection (ab-1891cdff).
2//!
3//! The `fno-agents` daemon is a long-lived process. A `cargo install` (or any
4//! rebuild) replaces the on-disk binary, but the *running* daemon keeps
5//! executing its old code until it idle-exits or is killed, silently stranding
6//! new features. This module is the drift *signal*: a fingerprint of the
7//! executable a process is running, compared against the binary a client would
8//! launch right now.
9//!
10//! The signal is a running-exe fingerprint (canonical path + mtime + size), NOT
11//! `CARGO_PKG_VERSION` (Locked Decision #1): the package version rarely bumps in
12//! development, where many features land at the same `0.1.0`. The fingerprint
13//! catches any reinstall/rebuild, including a same-version dev build.
14//!
15//! This file holds only the *pure* pieces (fingerprint + classification) so the
16//! `DriftState` matrix is unit-testable without a live daemon. The async
17//! daemon-status probe that feeds it lives in [`crate::client::check_daemon_drift`].
18
19use std::path::{Path, PathBuf};
20use std::time::UNIX_EPOCH;
21
22/// A running-or-on-disk executable's identity, for drift comparison. The path is
23/// canonicalized (so symlink vs target, `~/.cargo/bin` vs `target/debug`, are
24/// compared apples-to-apples); `mtime_nanos`/`size` are compared only for
25/// equality against a fingerprint of the SAME logical binary, so coarse clocks
26/// only ever cost an advisory false verdict, never a crash.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct ExeFingerprint {
29    /// Canonicalized absolute path of the executable.
30    pub path: PathBuf,
31    /// File mtime as nanoseconds since the Unix epoch (i64 holds ~year 2262).
32    pub mtime_nanos: i64,
33    /// File size in bytes.
34    pub size: u64,
35}
36
37impl ExeFingerprint {
38    /// Stat `path` (canonicalizing it) into a fingerprint. Returns `None` on any
39    /// error -- a missing file, a stat failure, or an mtime that does not fit an
40    /// `i64` of nanoseconds. The caller treats `None` as `Unknown` (silent,
41    /// never a false alarm); a drift check must never crash a `status`/`list`.
42    pub fn of(path: &Path) -> Option<ExeFingerprint> {
43        let canon = std::fs::canonicalize(path).ok()?;
44        let meta = std::fs::metadata(&canon).ok()?;
45        let mtime = meta.modified().ok()?;
46        let nanos = mtime.duration_since(UNIX_EPOCH).ok()?.as_nanos();
47        let mtime_nanos = i64::try_from(nanos).ok()?;
48        Some(ExeFingerprint {
49            path: canon,
50            mtime_nanos,
51            size: meta.len(),
52        })
53    }
54
55    /// Fingerprint the current process's own executable. The daemon calls this
56    /// once at startup to record what it is running; `None` if `current_exe()`
57    /// or the stat fails (the daemon then reports no fingerprint, and every
58    /// client check fails safe to `Unknown`).
59    pub fn current() -> Option<ExeFingerprint> {
60        ExeFingerprint::of(&std::env::current_exe().ok()?)
61    }
62}
63
64/// The verdict of a drift check.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum DriftState {
67    /// The running binary matches the binary a client would launch now.
68    Fresh,
69    /// The running binary differs (path OR content) from the on-disk launch
70    /// target -- the daemon is stale and should be restarted.
71    Drifted {
72        running: ExeFingerprint,
73        on_disk: ExeFingerprint,
74    },
75    /// No daemon is running; nothing can be stale. (Decided by the async wrapper
76    /// before [`classify`] is called.)
77    DaemonDown,
78    /// The check could not be completed: a stat/`current_exe` error, or the
79    /// daemon reported no fingerprint. Silent by design -- never a warning, so a
80    /// drift-check failure never cries wolf.
81    Unknown,
82}
83
84/// Pure classification: compare the daemon's reported `running` fingerprint to
85/// the client's fresh `on_disk` fingerprint of the binary it would launch now.
86///
87/// A `None` on either side yields [`DriftState::Unknown`] (fail-safe). Otherwise
88/// any difference in canonical path, mtime, or size is [`DriftState::Drifted`].
89/// Path drift and content drift are both "drifted" -- the operator's remedy
90/// (`fno agents restart`) is the same either way. [`DriftState::DaemonDown`] is
91/// never produced here; the async wrapper decides it from the status probe.
92pub fn classify(running: Option<&ExeFingerprint>, on_disk: Option<&ExeFingerprint>) -> DriftState {
93    match (running, on_disk) {
94        (Some(r), Some(d)) => {
95            if r.path != d.path || r.mtime_nanos != d.mtime_nanos || r.size != d.size {
96                DriftState::Drifted {
97                    running: r.clone(),
98                    on_disk: d.clone(),
99                }
100            } else {
101                DriftState::Fresh
102            }
103        }
104        // Daemon reported no fingerprint, or the on-disk stat failed: no basis to
105        // prove drift, so stay silent rather than warn on a guess.
106        _ => DriftState::Unknown,
107    }
108}
109
110/// Format the operator-facing drift warning, or `None` when there is nothing to
111/// warn about (`Fresh`/`DaemonDown`/`Unknown`). The message is advisory and
112/// names the exact remedy verb. The caller routes it to **stderr** only, so a
113/// `--json` stdout consumer is never contaminated (Locked Decision #5).
114///
115/// `pid` is the running daemon's pid when the caller has it (the `status`
116/// surface does); it is woven into the message for a more actionable warning and
117/// omitted otherwise.
118pub fn drift_warning(state: &DriftState, pid: Option<u32>) -> Option<String> {
119    match state {
120        DriftState::Drifted { .. } => {
121            let who = match pid {
122                Some(p) => format!("the running daemon (pid {p})"),
123                None => "the running daemon".to_string(),
124            };
125            Some(format!(
126                "fno agents: {who} is an older build than the installed binary; \
127                 run `fno agents restart` to pick up the new build."
128            ))
129        }
130        DriftState::Fresh | DriftState::DaemonDown | DriftState::Unknown => None,
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use std::fs;
138    use std::io::Write;
139
140    fn tmp_path(tag: &str) -> PathBuf {
141        let mut p = std::env::temp_dir();
142        p.push(format!("fno_drift_{}_{}_{tag}", std::process::id(), {
143            use std::sync::atomic::{AtomicU32, Ordering};
144            static C: AtomicU32 = AtomicU32::new(0);
145            C.fetch_add(1, Ordering::Relaxed)
146        }));
147        p
148    }
149
150    fn write_file(path: &Path, bytes: &[u8]) {
151        let mut f = fs::File::create(path).unwrap();
152        f.write_all(bytes).unwrap();
153        f.flush().unwrap();
154    }
155
156    #[test]
157    fn of_missing_path_is_none() {
158        // AC1-ERR: a stat that cannot resolve the file fails safe to None
159        // (mapped to Unknown by classify), never a panic.
160        let p = tmp_path("missing");
161        assert!(ExeFingerprint::of(&p).is_none());
162    }
163
164    #[test]
165    fn of_roundtrips_and_tracks_size_change() {
166        let p = tmp_path("rt");
167        write_file(&p, b"hello");
168        let a = ExeFingerprint::of(&p).expect("fingerprint");
169        let b = ExeFingerprint::of(&p).expect("fingerprint again");
170        assert_eq!(a, b, "same file fingerprints equal");
171        assert_eq!(a.size, 5);
172
173        // A larger rewrite changes the size -> a distinct fingerprint, even if
174        // the coarse mtime did not advance.
175        write_file(&p, b"hello world!!");
176        let c = ExeFingerprint::of(&p).expect("fingerprint after grow");
177        assert_ne!(a, c, "size change yields a different fingerprint");
178        assert_eq!(c.size, 13);
179        fs::remove_file(&p).ok();
180    }
181
182    #[test]
183    fn classify_fresh_when_equal() {
184        // AC1-FR: identical running/on-disk fingerprint -> Fresh, no warning.
185        let p = tmp_path("fresh");
186        write_file(&p, b"bin");
187        let fp = ExeFingerprint::of(&p).unwrap();
188        assert_eq!(classify(Some(&fp), Some(&fp)), DriftState::Fresh);
189        assert_eq!(drift_warning(&DriftState::Fresh, Some(1)), None);
190        fs::remove_file(&p).ok();
191    }
192
193    #[test]
194    fn classify_content_drift_when_size_differs() {
195        // AC1-HP (classification half): same path, different content -> Drifted.
196        let p = tmp_path("content");
197        write_file(&p, b"old");
198        let running = ExeFingerprint::of(&p).unwrap();
199        let on_disk = ExeFingerprint {
200            size: running.size + 7,
201            ..running.clone()
202        };
203        match classify(Some(&running), Some(&on_disk)) {
204            DriftState::Drifted { .. } => {}
205            other => panic!("expected Drifted, got {other:?}"),
206        }
207        fs::remove_file(&p).ok();
208    }
209
210    #[test]
211    fn classify_path_drift_when_path_differs() {
212        // AC1-EDGE: running from a different path than we would launch -> Drifted.
213        let a = ExeFingerprint {
214            path: PathBuf::from("/opt/a/fno-agents-daemon"),
215            mtime_nanos: 100,
216            size: 10,
217        };
218        let b = ExeFingerprint {
219            path: PathBuf::from("/home/u/.cargo/bin/fno-agents-daemon"),
220            mtime_nanos: 100,
221            size: 10,
222        };
223        match classify(Some(&a), Some(&b)) {
224            DriftState::Drifted { .. } => {}
225            other => panic!("expected Drifted, got {other:?}"),
226        }
227    }
228
229    #[test]
230    fn classify_unknown_when_either_missing() {
231        // AC1-ERR: a None on either side is Unknown (silent), never Drifted.
232        let fp = ExeFingerprint {
233            path: PathBuf::from("/x"),
234            mtime_nanos: 1,
235            size: 1,
236        };
237        assert_eq!(classify(None, Some(&fp)), DriftState::Unknown);
238        assert_eq!(classify(Some(&fp), None), DriftState::Unknown);
239        assert_eq!(classify(None, None), DriftState::Unknown);
240        // And Unknown never warns.
241        assert_eq!(drift_warning(&DriftState::Unknown, None), None);
242        assert_eq!(drift_warning(&DriftState::DaemonDown, None), None);
243    }
244
245    #[test]
246    fn drift_warning_names_restart_verb() {
247        // AC1-HP (message half): a Drifted state warns, names the restart verb,
248        // and weaves in the pid when present.
249        let fp = ExeFingerprint {
250            path: PathBuf::from("/x"),
251            mtime_nanos: 1,
252            size: 1,
253        };
254        let state = DriftState::Drifted {
255            running: fp.clone(),
256            on_disk: fp,
257        };
258        let msg = drift_warning(&state, Some(91627)).expect("warns on drift");
259        assert!(msg.contains("fno agents restart"), "names the remedy verb");
260        assert!(msg.contains("build"), "describes a build mismatch");
261        assert!(msg.contains("91627"), "names the pid when known");
262    }
263}