Skip to main content

winx_code_agent/daemon/
exit.rs

1//! Why a daemon process stopped, recorded beside its socket.
2//!
3//! A control daemon or guardian that disappears without a trace forces a
4//! post-mortem from process lists alone. Every planned exit path (the
5//! `winx.shutdown` RPC, a termination signal, a listener failure) records a
6//! small JSON file next to the socket and logs the reason once, so `doctor`
7//! and the control daemon's reaper can tell a planned restart from a crash.
8//! A hard kill still leaves no record, which is itself the signal: a stale
9//! socket with no exit record means the process did not get to say goodbye.
10
11use std::fmt;
12use std::path::{Path, PathBuf};
13use std::sync::OnceLock;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use serde::{Deserialize, Serialize};
17
18/// Why the process is exiting.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum ExitReason {
21    /// A client sent `winx.shutdown` (planned control restart, session kill).
22    ShutdownRequest,
23    /// The operating system delivered a termination signal.
24    Signal(&'static str),
25    /// The listener stopped accepting connections.
26    ListenerFailure(String),
27}
28
29impl ExitReason {
30    fn kind(&self) -> &'static str {
31        match self {
32            Self::ShutdownRequest => "shutdown_request",
33            Self::Signal(_) => "signal",
34            Self::ListenerFailure(_) => "listener_failure",
35        }
36    }
37
38    fn detail(&self) -> Option<String> {
39        match self {
40            Self::ShutdownRequest => None,
41            Self::Signal(name) => Some((*name).to_string()),
42            Self::ListenerFailure(error) => Some(error.clone()),
43        }
44    }
45}
46
47impl fmt::Display for ExitReason {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            Self::ShutdownRequest => f.write_str("winx.shutdown request"),
51            Self::Signal(name) => write!(f, "signal {name}"),
52            Self::ListenerFailure(error) => write!(f, "listener failure: {error}"),
53        }
54    }
55}
56
57/// What a daemon wrote when it last exited on purpose.
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59pub struct ExitRecord {
60    pub role: String,
61    pub pid: u32,
62    pub epoch: String,
63    pub reason: String,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub detail: Option<String>,
66    pub exited_at_unix_ms: u64,
67}
68
69/// Per-process identity captured when the daemon binds its socket.
70#[derive(Debug, Clone)]
71struct ExitContext {
72    role: &'static str,
73    socket: PathBuf,
74    epoch: String,
75}
76
77static CONTEXT: OnceLock<ExitContext> = OnceLock::new();
78
79/// Path of the exit record kept beside `socket`.
80pub fn record_path(socket: &Path) -> PathBuf {
81    socket.with_extension("exit.json")
82}
83
84/// Remember who this process is and arm the signal handlers that turn a
85/// termination signal into a recorded exit. Later calls are ignored: one
86/// process binds one socket.
87pub fn install(role: &'static str, socket: &Path, epoch: &str) {
88    let context = ExitContext { role, socket: socket.to_path_buf(), epoch: epoch.to_string() };
89    if CONTEXT.set(context).is_err() {
90        return;
91    }
92    tokio::spawn(wait_for_termination_signal());
93}
94
95/// Record `reason` for this process and exit with status 0. Callers use it
96/// only after the in-flight response has been written.
97pub fn leave(reason: &ExitReason) -> ! {
98    record_current(reason);
99    std::process::exit(0)
100}
101
102/// Record `reason` for this process without exiting (listener failures that
103/// propagate an error to `main`).
104pub fn record_current(reason: &ExitReason) {
105    let Some(context) = CONTEXT.get() else {
106        tracing::info!(%reason, "daemon exiting before its socket was bound");
107        return;
108    };
109    tracing::info!(role = context.role, %reason, epoch = %context.epoch, "daemon exiting");
110    if let Err(error) = write_record(&context.socket, context.role, &context.epoch, reason) {
111        tracing::warn!(%error, "could not write the exit record");
112    }
113}
114
115/// Write the exit record for `socket` atomically (temp file plus rename).
116pub fn write_record(
117    socket: &Path,
118    role: &str,
119    epoch: &str,
120    reason: &ExitReason,
121) -> std::io::Result<()> {
122    let record = ExitRecord {
123        role: role.to_string(),
124        pid: std::process::id(),
125        epoch: epoch.to_string(),
126        reason: reason.kind().to_string(),
127        detail: reason.detail(),
128        exited_at_unix_ms: unix_ms(),
129    };
130    let path = record_path(socket);
131    let temp = path.with_extension(format!("exit.json.tmp-{:016x}", rand::random::<u64>()));
132    let data = serde_json::to_vec_pretty(&record).map_err(std::io::Error::other)?;
133    std::fs::write(&temp, data)?;
134    std::fs::rename(&temp, &path).inspect_err(|_| {
135        let _ = std::fs::remove_file(&temp);
136    })
137}
138
139/// Read the exit record beside `socket`, if the daemon left one.
140pub fn read_record(socket: &Path) -> Option<ExitRecord> {
141    let data = std::fs::read(record_path(socket)).ok()?;
142    serde_json::from_slice(&data).ok()
143}
144
145/// Remove the exit record beside `socket`, returning what it said.
146pub async fn take_record(socket: &Path) -> Option<ExitRecord> {
147    let record = read_record(socket);
148    let _ = tokio::fs::remove_file(record_path(socket)).await;
149    record
150}
151
152fn unix_ms() -> u64 {
153    SystemTime::now()
154        .duration_since(UNIX_EPOCH)
155        .map_or(0, |elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX))
156}
157
158#[cfg(unix)]
159async fn wait_for_termination_signal() {
160    use tokio::signal::unix::{signal, SignalKind};
161
162    let mut terminate = match signal(SignalKind::terminate()) {
163        Ok(stream) => stream,
164        Err(error) => {
165            tracing::warn!(%error, "could not install the SIGTERM handler");
166            return;
167        }
168    };
169    let mut interrupt = match signal(SignalKind::interrupt()) {
170        Ok(stream) => stream,
171        Err(error) => {
172            tracing::warn!(%error, "could not install the SIGINT handler");
173            return;
174        }
175    };
176    let name = tokio::select! {
177        _ = terminate.recv() => "SIGTERM",
178        _ = interrupt.recv() => "SIGINT",
179    };
180    leave(&ExitReason::Signal(name));
181}
182
183#[cfg(not(unix))]
184async fn wait_for_termination_signal() {
185    // Detached daemons have no console, so the handler may fail to install;
186    // that only means a console Ctrl-C can never reach them anyway.
187    if tokio::signal::ctrl_c().await.is_ok() {
188        leave(&ExitReason::Signal("CTRL_C"));
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    #![allow(clippy::expect_used)]
195    use super::*;
196
197    #[test]
198    fn record_round_trips_beside_the_socket() {
199        let dir = tempfile::tempdir().expect("tempdir");
200        let socket = dir.path().join("winxd.sock");
201        write_record(&socket, "winxd", "abc", &ExitReason::Signal("SIGTERM")).expect("write");
202        let record = read_record(&socket).expect("record");
203        assert_eq!(record.role, "winxd");
204        assert_eq!(record.epoch, "abc");
205        assert_eq!(record.reason, "signal");
206        assert_eq!(record.detail.as_deref(), Some("SIGTERM"));
207        assert_eq!(record.pid, std::process::id());
208        assert!(record.exited_at_unix_ms > 0);
209        assert_eq!(record_path(&socket), dir.path().join("winxd.exit.json"));
210    }
211
212    #[test]
213    fn shutdown_request_has_no_detail_and_overwrites_the_previous_record() {
214        let dir = tempfile::tempdir().expect("tempdir");
215        let socket = dir.path().join("g.sock");
216        write_record(&socket, "winx-guardian", "1", &ExitReason::ListenerFailure("boom".into()))
217            .expect("write");
218        write_record(&socket, "winx-guardian", "2", &ExitReason::ShutdownRequest).expect("write");
219        let record = read_record(&socket).expect("record");
220        assert_eq!(record.epoch, "2");
221        assert_eq!(record.reason, "shutdown_request");
222        assert_eq!(record.detail, None);
223        let leftovers: Vec<_> = std::fs::read_dir(dir.path())
224            .expect("read dir")
225            .filter_map(Result::ok)
226            .map(|entry| entry.file_name().to_string_lossy().into_owned())
227            .collect();
228        assert_eq!(leftovers, vec!["g.exit.json".to_string()], "no temp files left behind");
229    }
230
231    #[tokio::test]
232    async fn take_record_removes_the_file() {
233        let dir = tempfile::tempdir().expect("tempdir");
234        let socket = dir.path().join("g.sock");
235        write_record(&socket, "winx-guardian", "1", &ExitReason::ShutdownRequest).expect("write");
236        assert!(take_record(&socket).await.is_some(), "expected a record to take");
237        assert!(take_record(&socket).await.is_none(), "record should be gone");
238    }
239
240    #[test]
241    fn missing_or_corrupt_records_read_as_none() {
242        let dir = tempfile::tempdir().expect("tempdir");
243        let socket = dir.path().join("g.sock");
244        assert!(read_record(&socket).is_none(), "nothing written yet");
245        std::fs::write(record_path(&socket), b"{not json").expect("write");
246        assert!(read_record(&socket).is_none(), "corrupt record is ignored");
247    }
248}