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}