1use std::fs::{File, OpenOptions};
8use std::io;
9use std::path::Path;
10
11use fs4::fs_std::FileExt;
12use serde::{Deserialize, Serialize};
13
14pub use crate::events::registry::{daemon_paths, daemon_paths_in, DaemonPaths};
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub struct DaemonInfo {
19 pub pid: u32,
20 pub protocol_version: u32,
21 pub daemon_version: String,
22 pub profile: Option<String>,
23 pub started_at: chrono::DateTime<chrono::Utc>,
24 pub socket: String,
25 #[serde(default)]
28 pub exe: Option<std::path::PathBuf>,
29 #[serde(default = "one")]
31 pub generation: u64,
32}
33
34fn one() -> u64 {
35 1
36}
37
38#[cfg(unix)]
39fn chmod_600(path: &Path) {
40 use std::os::unix::fs::PermissionsExt;
41 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
42}
43#[cfg(not(unix))]
44fn chmod_600(_path: &Path) {}
45
46pub fn write_daemon_json(paths: &DaemonPaths, info: &DaemonInfo) -> io::Result<()> {
48 let body = serde_json::to_vec_pretty(info).map_err(io::Error::other)?;
49 write_atomic(&paths.json, &body)?;
50 write_atomic(&paths.pid, format!("{}\n", info.pid).as_bytes())
51}
52
53fn write_atomic(path: &Path, body: &[u8]) -> io::Result<()> {
54 let tmp = path.with_extension("tmp");
55 std::fs::write(&tmp, body)?;
56 chmod_600(&tmp);
57 std::fs::rename(&tmp, path)
58}
59
60pub fn write_private_atomic(path: &Path, body: &[u8]) -> io::Result<()> {
62 write_atomic(path, body)
63}
64
65pub fn read_daemon_json(paths: &DaemonPaths) -> Option<DaemonInfo> {
66 let s = std::fs::read_to_string(&paths.json).ok()?;
67 serde_json::from_str(&s).ok()
68}
69
70pub struct DaemonLock {
72 file: File,
73}
74
75impl DaemonLock {
76 pub fn try_acquire(paths: &DaemonPaths) -> io::Result<Option<Self>> {
79 let file = OpenOptions::new()
80 .create(true)
81 .truncate(false)
82 .read(true)
83 .write(true)
84 .open(&paths.lock)?;
85 chmod_600(&paths.lock);
86 if FileExt::try_lock_exclusive(&file)? {
87 Ok(Some(Self { file }))
88 } else {
89 Ok(None)
90 }
91 }
92}
93
94impl DaemonLock {
95 #[cfg(unix)]
99 pub fn adopt(fd: i32) -> io::Result<Self> {
100 use std::os::unix::io::FromRawFd;
101 if fd < 0 {
102 return Err(io::Error::new(io::ErrorKind::InvalidInput, "negative lock fd"));
103 }
104 let file = unsafe { File::from_raw_fd(fd) };
107 unsafe {
110 let flags = libc::fcntl(fd, libc::F_GETFD);
111 if flags >= 0 {
112 libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC);
113 }
114 }
115 Ok(Self { file })
116 }
117
118 #[cfg(unix)]
119 pub fn raw_fd(&self) -> i32 {
120 use std::os::unix::io::AsRawFd;
121 self.file.as_raw_fd()
122 }
123}
124
125impl Drop for DaemonLock {
126 fn drop(&mut self) {
127 let _ = FileExt::unlock(&self.file);
128 }
129}
130
131impl DaemonPaths {
132 pub fn spawn_lock(&self) -> std::path::PathBuf {
135 self.lock.with_extension("spawn.lock")
136 }
137}
138
139#[derive(Debug)]
141pub struct SpawnLock {
142 file: File,
143}
144
145impl SpawnLock {
146 pub fn acquire(paths: &DaemonPaths, wait: std::time::Duration) -> io::Result<Self> {
150 let path = paths.spawn_lock();
151 let file = OpenOptions::new()
152 .create(true)
153 .truncate(false)
154 .read(true)
155 .write(true)
156 .open(&path)?;
157 chmod_600(&path);
158 let deadline = std::time::Instant::now() + wait;
159 loop {
160 if FileExt::try_lock_exclusive(&file)? {
161 return Ok(Self { file });
162 }
163 if std::time::Instant::now() >= deadline {
164 return Err(io::Error::new(io::ErrorKind::TimedOut, "another client is still spawning the daemon"));
165 }
166 std::thread::sleep(std::time::Duration::from_millis(50));
167 }
168 }
169}
170
171impl Drop for SpawnLock {
172 fn drop(&mut self) {
173 let _ = FileExt::unlock(&self.file);
174 }
175}
176
177pub fn is_alive(paths: &DaemonPaths) -> bool {
180 if !paths.lock.exists() {
181 return false;
182 }
183 match DaemonLock::try_acquire(paths) {
184 Ok(Some(_guard)) => false,
185 Ok(None) => true,
186 Err(_) => false,
187 }
188}
189
190pub(super) fn cleanup_metadata(paths: &DaemonPaths, _lock: &DaemonLock) -> bool {
194 let mut removed = false;
195 for path in [&paths.json, &paths.pid] {
196 if std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_file()) {
197 match std::fs::remove_file(path) {
198 Ok(()) => removed = true,
199 Err(e) if e.kind() == io::ErrorKind::NotFound => {}
200 Err(e) => tracing::warn!(path = %path.display(), error = %e, "daemon: metadata cleanup failed"),
201 }
202 }
203 }
204 removed
205}
206
207pub fn reap_stale(paths: &DaemonPaths) -> bool {
210 let Ok(Some(lock)) = DaemonLock::try_acquire(paths) else {
212 return false;
213 };
214 let had_socket = paths.sock.exists();
215 crate::events::socket::cleanup_socket(&paths.sock.to_string_lossy());
216 let reaped_socket = had_socket && !paths.sock.exists();
217 cleanup_metadata(paths, &lock) || reaped_socket
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 fn paths() -> (tempfile::TempDir, DaemonPaths) {
225 let d = tempfile::tempdir().unwrap();
226 let p = daemon_paths_in(d.path(), None);
227 (d, p)
228 }
229
230 #[test]
231 fn lock_is_the_liveness_oracle() {
232 let (_d, p) = paths();
233 assert!(!is_alive(&p));
234 let guard = DaemonLock::try_acquire(&p).unwrap().expect("first holder");
235 assert!(is_alive(&p));
236 assert!(DaemonLock::try_acquire(&p).unwrap().is_none(), "second holder refused");
237 drop(guard);
238 assert!(!is_alive(&p));
239 }
240
241 #[test]
242 fn spawn_lock_is_exclusive_and_waits() {
243 let (_d, p) = paths();
244 let a = SpawnLock::acquire(&p, std::time::Duration::from_millis(10)).unwrap();
245 let err = SpawnLock::acquire(&p, std::time::Duration::from_millis(120)).unwrap_err();
246 assert_eq!(err.kind(), io::ErrorKind::TimedOut);
247 drop(a);
248 SpawnLock::acquire(&p, std::time::Duration::from_millis(10)).unwrap();
249 assert!(!is_alive(&p), "the spawn lock is not the liveness oracle");
250 }
251
252 #[test]
253 fn metadata_cleanup_is_exact_and_requires_the_held_lock() {
254 let (d, p) = paths();
255 let other = daemon_paths_in(d.path(), Some("other"));
256 for path in [&p.json, &p.pid, &other.json, &other.pid] {
257 std::fs::write(path, b"metadata").unwrap();
258 }
259 let unrelated = d.path().join("session.json");
260 let unrelated_pid = d.path().join("session.pid");
261 std::fs::write(&unrelated, b"keep").unwrap();
262 std::fs::write(&unrelated_pid, b"keep").unwrap();
263 std::fs::write(&p.sock, b"not a socket").unwrap();
265 let lock = DaemonLock::try_acquire(&p).unwrap().unwrap();
266 assert!(cleanup_metadata(&p, &lock));
267 assert!(!p.json.exists() && !p.pid.exists());
268 assert!(is_alive(&p), "cleanup must not release or unlink the lock");
269 assert!(!cleanup_metadata(&p, &lock), "idempotent");
270 drop(lock);
271 assert!(!reap_stale(&p), "regular socket impostor is preserved");
272 for path in [&p.sock, &p.lock, &other.json, &other.pid, &unrelated, &unrelated_pid] {
273 assert!(path.exists(), "preserve {}", path.display());
274 }
275 }
276
277 #[cfg(unix)]
278 #[test]
279 fn metadata_cleanup_preserves_symlinks_and_special_files() {
280 use std::os::unix::{fs::symlink, net::UnixListener};
281 let (d, p) = paths();
282 let target = d.path().join("target");
283 std::fs::write(&target, b"keep").unwrap();
284 symlink(&target, &p.json).unwrap();
285 symlink(d.path().join("missing"), &p.pid).unwrap();
286 let lock = DaemonLock::try_acquire(&p).unwrap().unwrap();
287 assert!(!cleanup_metadata(&p, &lock));
288 drop(lock);
289 assert!(!reap_stale(&p));
290 for path in [&p.json, &p.pid] {
291 assert!(std::fs::symlink_metadata(path).unwrap().file_type().is_symlink());
292 std::fs::remove_file(path).unwrap();
293 }
294 assert_eq!(std::fs::read(&target).unwrap(), b"keep");
295 std::fs::create_dir(&p.json).unwrap();
296 let _listener = UnixListener::bind(&p.pid).unwrap();
297 assert!(!reap_stale(&p));
298 assert!(p.json.is_dir() && p.pid.exists());
299 }
300
301 #[test]
302 fn stale_files_reaped_only_without_holder() {
303 let (_d, p) = paths();
304 #[cfg(unix)]
305 let _listener = std::os::unix::net::UnixListener::bind(&p.sock).unwrap();
306 let info = DaemonInfo {
307 pid: 4_000_000,
308 protocol_version: 1,
309 daemon_version: "t".into(),
310 profile: None,
311 started_at: chrono::Utc::now(),
312 socket: p.sock.to_string_lossy().into_owned(),
313 exe: None,
314 generation: 1,
315 };
316 write_daemon_json(&p, &info).unwrap();
317 assert_eq!(read_daemon_json(&p).unwrap(), info);
318 #[cfg(unix)]
319 {
320 use std::os::unix::fs::PermissionsExt;
321 assert_eq!(std::fs::metadata(&p.json).unwrap().permissions().mode() & 0o777, 0o600);
322 }
323 let guard = DaemonLock::try_acquire(&p).unwrap().unwrap();
324 assert!(!reap_stale(&p), "live holder: nothing reaped");
325 #[cfg(unix)]
326 assert!(p.sock.exists());
327 assert!(p.json.exists() && p.pid.exists());
328 drop(guard);
329 assert!(reap_stale(&p));
330 assert!(!p.sock.exists() && !p.json.exists() && !p.pid.exists());
331 }
332}