1use crate::drift::{classify, DriftState, ExeFingerprint};
6use crate::paths::AgentsHome;
7use crate::protocol::{read_response, write_request, ProtocolError, Request, Response};
8use serde_json::{json, Value};
9use std::path::{Path, PathBuf};
10use std::time::{Duration, Instant};
11use tokio::net::UnixStream;
12
13#[derive(Debug, thiserror::Error)]
14pub enum ClientError {
15 #[error("protocol: {0}")]
16 Protocol(#[from] ProtocolError),
17 #[error("io: {0}")]
18 Io(#[from] std::io::Error),
19 #[error("daemon did not come up within {0:?}")]
20 DaemonStartTimeout(Duration),
21 #[error(
22 "daemon binary not found: {0} - the fno-agents triad (client/daemon/worker) \
23 is split here. Run `fno update` to redeploy the pair, or set \
24 FNO_AGENTS_DAEMON_BIN to a coherent same-build daemon."
25 )]
26 DaemonBinMissing(PathBuf),
27 #[error("daemon is not running")]
28 DaemonNotRunning,
29}
30
31pub fn resolve_daemon_bin() -> PathBuf {
34 if let Some(v) = std::env::var_os("FNO_AGENTS_DAEMON_BIN") {
35 return PathBuf::from(v);
36 }
37 std::env::current_exe()
38 .ok()
39 .and_then(|p| p.parent().map(|d| d.join("fno-agents-daemon")))
40 .unwrap_or_else(|| PathBuf::from("fno-agents-daemon"))
41}
42
43pub async fn ensure_daemon(
47 home: &AgentsHome,
48 daemon_bin: &std::path::Path,
49) -> Result<(), ClientError> {
50 let sock = home.supervisor_sock();
51 if UnixStream::connect(&sock).await.is_ok() {
52 return Ok(());
53 }
54 if !daemon_bin.exists() {
55 return Err(ClientError::DaemonBinMissing(daemon_bin.to_path_buf()));
56 }
57
58 eprintln!("(lazy-starting daemon)");
59 let mut cmd = tokio::process::Command::new(daemon_bin);
61 cmd.process_group(0);
62 cmd.env("FNO_AGENTS_HOME", home.root());
63 if let Some(v) = std::env::var_os("FNO_AGENTS_WORKER_BIN") {
65 cmd.env("FNO_AGENTS_WORKER_BIN", v);
66 }
67 let child = cmd.spawn()?;
68 drop(child); let start = Instant::now();
71 let budget = Duration::from_secs(10);
72 while start.elapsed() < budget {
73 if UnixStream::connect(&sock).await.is_ok() {
74 return Ok(());
75 }
76 tokio::time::sleep(Duration::from_millis(25)).await;
77 }
78 Err(ClientError::DaemonStartTimeout(budget))
79}
80
81pub async fn call(
84 home: &AgentsHome,
85 daemon_bin: &std::path::Path,
86 req: &Request,
87) -> Result<Response, ClientError> {
88 ensure_daemon(home, daemon_bin).await?;
89 let mut conn = UnixStream::connect(home.supervisor_sock()).await?;
90 write_request(&mut conn, req).await?;
91 Ok(read_response(&mut conn).await?)
92}
93
94pub async fn call_if_running(home: &AgentsHome, req: &Request) -> Result<Response, ClientError> {
98 let mut conn = match UnixStream::connect(home.supervisor_sock()).await {
99 Ok(c) => c,
100 Err(e)
104 if matches!(
105 e.kind(),
106 std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused
107 ) =>
108 {
109 return Err(ClientError::DaemonNotRunning)
110 }
111 Err(e) => return Err(ClientError::Io(e)),
112 };
113 write_request(&mut conn, req).await?;
114 Ok(read_response(&mut conn).await?)
115}
116
117fn running_fingerprint(status: &Value) -> Option<ExeFingerprint> {
125 let d = status.get("daemon")?;
126 let path = d.get("exe_path")?.as_str()?;
127 let mtime_nanos = d.get("exe_mtime")?.as_i64()?;
128 let size = d.get("exe_size")?.as_u64()?;
129 Some(ExeFingerprint {
130 path: PathBuf::from(path),
131 mtime_nanos,
132 size,
133 })
134}
135
136pub fn drift_from_status(status: &Value) -> DriftState {
142 let running = running_fingerprint(status);
143 let on_disk = ExeFingerprint::of(&resolve_daemon_bin());
144 classify(running.as_ref(), on_disk.as_ref())
145}
146
147pub async fn check_daemon_drift(home: &AgentsHome) -> DriftState {
153 let req = Request::new(1, "agent.status", json!({}));
154 match call_if_running(home, &req).await {
155 Ok(resp) => match resp.result() {
156 Some(result) => drift_from_status(result),
157 None => DriftState::Unknown,
158 },
159 Err(ClientError::DaemonNotRunning) => DriftState::DaemonDown,
160 Err(_) => DriftState::Unknown,
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct RestartOutcome {
167 pub old_pid: Option<u32>,
170 pub new_pid: u32,
172}
173
174#[derive(Debug, thiserror::Error)]
178pub enum RestartError {
179 #[error("SIGTERM to daemon pid {pid} failed: {reason}")]
180 SigtermFailed { pid: u32, reason: String },
181 #[error("daemon pid {pid} did not exit after SIGTERM within {secs}s; check it manually")]
182 DidNotExit { pid: u32, secs: u64 },
183 #[error("daemon status response missing daemon.pid")]
184 StatusMissingPid,
185 #[error(transparent)]
186 Client(#[from] ClientError),
187}
188
189const RESTART_SOCKET_TIMEOUT: Duration = Duration::from_secs(5);
192
193enum SigtermResult {
194 Sent,
196 AlreadyGone,
198 Failed(String),
200}
201
202fn send_sigterm(pid: u32) -> SigtermResult {
207 let rc = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
210 if rc == 0 {
211 return SigtermResult::Sent;
212 }
213 let err = std::io::Error::last_os_error();
214 match err.raw_os_error() {
215 Some(e) if e == libc::ESRCH => SigtermResult::AlreadyGone,
216 _ => SigtermResult::Failed(err.to_string()),
217 }
218}
219
220async fn read_daemon_pid(home: &AgentsHome) -> Result<u32, RestartError> {
222 let req = Request::new(1, "agent.status", json!({}));
223 let resp = call_if_running(home, &req).await?;
224 resp.result()
225 .and_then(|r| r.get("daemon"))
226 .and_then(|d| d.get("pid"))
227 .and_then(Value::as_u64)
228 .map(|p| p as u32)
229 .ok_or(RestartError::StatusMissingPid)
230}
231
232async fn await_socket_clear(home: &AgentsHome) -> bool {
235 let sock = home.supervisor_sock();
236 let start = Instant::now();
237 while start.elapsed() < RESTART_SOCKET_TIMEOUT {
238 if UnixStream::connect(&sock).await.is_err() {
239 return true;
240 }
241 tokio::time::sleep(Duration::from_millis(25)).await;
242 }
243 UnixStream::connect(&sock).await.is_err()
244}
245
246async fn start_fresh(home: &AgentsHome, daemon_bin: &Path) -> Result<u32, RestartError> {
248 ensure_daemon(home, daemon_bin).await?;
249 read_daemon_pid(home).await
250}
251
252pub async fn restart_daemon(
264 home: &AgentsHome,
265 daemon_bin: &Path,
266) -> Result<RestartOutcome, RestartError> {
267 let status = Request::new(1, "agent.status", json!({}));
270 let result = match call_if_running(home, &status).await {
271 Ok(resp) => resp.result().cloned(),
272 Err(ClientError::DaemonNotRunning) => None,
273 Err(e) => return Err(RestartError::Client(e)),
274 };
275
276 let Some(result) = result else {
277 let new_pid = start_fresh(home, daemon_bin).await?;
278 return Ok(RestartOutcome {
279 old_pid: None,
280 new_pid,
281 });
282 };
283
284 let daemon = result.get("daemon");
285 let old_pid = daemon
286 .and_then(|d| d.get("pid"))
287 .and_then(Value::as_u64)
288 .ok_or(RestartError::StatusMissingPid)? as u32;
289 let recorded_start = daemon
290 .and_then(|d| d.get("pid_start_time"))
291 .and_then(Value::as_u64);
292
293 if !crate::daemon::pid_is_ours(old_pid, recorded_start) {
300 let new_pid = start_fresh(home, daemon_bin).await?;
301 return Ok(RestartOutcome {
302 old_pid: Some(old_pid),
303 new_pid,
304 });
305 }
306
307 match send_sigterm(old_pid) {
308 SigtermResult::Sent | SigtermResult::AlreadyGone => {}
309 SigtermResult::Failed(reason) => {
310 return Err(RestartError::SigtermFailed {
311 pid: old_pid,
312 reason,
313 })
314 }
315 }
316
317 if !await_socket_clear(home).await {
318 return Err(RestartError::DidNotExit {
319 pid: old_pid,
320 secs: RESTART_SOCKET_TIMEOUT.as_secs(),
321 });
322 }
323 let new_pid = start_fresh(home, daemon_bin).await?;
332 Ok(RestartOutcome {
333 old_pid: Some(old_pid),
334 new_pid,
335 })
336}
337
338#[cfg(test)]
339mod drift_restart_tests {
340 use super::*;
341
342 #[test]
343 fn drift_from_status_unknown_when_no_fingerprint() {
344 let status = json!({"daemon": {"pid": 1, "state": "serving"}});
348 assert_eq!(drift_from_status(&status), DriftState::Unknown);
349 }
350
351 #[test]
352 fn running_fingerprint_parses_and_rejects() {
353 let status = json!({"daemon": {
357 "exe_path": "/opt/fno-agents-daemon",
358 "exe_mtime": 1_700_000_000_000_000_000_i64,
359 "exe_size": 4242_u64,
360 }});
361 let fp = running_fingerprint(&status).expect("parses a full fingerprint");
362 assert_eq!(fp.path, PathBuf::from("/opt/fno-agents-daemon"));
363 assert_eq!(fp.mtime_nanos, 1_700_000_000_000_000_000);
364 assert_eq!(fp.size, 4242);
365
366 let partial = json!({"daemon": {"exe_path": "/opt/x", "exe_size": 1_u64}});
368 assert!(running_fingerprint(&partial).is_none());
369 assert!(running_fingerprint(&json!({})).is_none());
370 }
371
372 #[test]
373 fn send_sigterm_reports_already_gone_for_dead_pid() {
374 let child = std::process::Command::new("true")
377 .spawn()
378 .expect("spawn true");
379 let pid = child.id();
380 let mut child = child;
381 let _ = child.wait(); std::thread::sleep(Duration::from_millis(50));
383 match send_sigterm(pid) {
384 SigtermResult::AlreadyGone | SigtermResult::Sent => {}
387 SigtermResult::Failed(e) => panic!("unexpected Failed: {e}"),
388 }
389 }
390}