1use std::fmt;
12use std::path::{Path, PathBuf};
13use std::sync::OnceLock;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum ExitReason {
21 ShutdownRequest,
23 Signal(&'static str),
25 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#[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#[derive(Debug, Clone)]
71struct ExitContext {
72 role: &'static str,
73 socket: PathBuf,
74 epoch: String,
75}
76
77static CONTEXT: OnceLock<ExitContext> = OnceLock::new();
78
79pub fn record_path(socket: &Path) -> PathBuf {
81 socket.with_extension("exit.json")
82}
83
84pub 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
95pub fn leave(reason: &ExitReason) -> ! {
98 record_current(reason);
99 std::process::exit(0)
100}
101
102pub 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
115pub 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
139pub 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
145pub 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 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}