Skip to main content

modde_core/
ipc.rs

1//! Best-effort, fire-and-forget IPC from the CLI to running GUI(s).
2//!
3//! Goal: when the CLI mutates profile state (install, update, uninstall,
4//! profile create/delete, import, …), every GUI process running against
5//! the same data dir should refresh immediately — without polling,
6//! watchers, or any CPU cost when no GUI is running.
7//!
8//! ## Mechanism
9//!
10//! Each GUI process binds a Unix domain socket at a *per-process* path
11//! ([`gui_socket_path`]: `$XDG_RUNTIME_DIR/modde-${euid}-${pid}.sock`).
12//! The CLI calls [`notify_refresh`], which enumerates the directory for
13//! every socket matching the current user's prefix and pushes a one-line
14//! payload to each. Sockets that don't accept (peer crashed without
15//! unlinking, kernel returned ECONNREFUSED) are GC'd in the same pass.
16//!
17//! There is no protocol: the existence of any byte-stream connection is
18//! the signal. Each GUI re-reads the DB on receipt.
19//!
20//! ## Multi-window
21//!
22//! Per-process sockets give us natural fan-out — N GUIs ⇒ N sockets ⇒
23//! N notifications, all from one CLI call. Each GUI is responsible for
24//! unlinking its own socket on shutdown via [`cleanup_socket`]; the
25//! CLI's GC pass handles the case where a GUI crashed without doing so.
26//!
27//! ## Costs
28//!
29//! - GUI idle: 0. `accept().await` is a blocked syscall.
30//! - GUI active, no CLI: 0.
31//! - CLI op, no GUI: one `read_dir` + zero connects (no socket files).
32//! - CLI op, N GUIs: one `read_dir` + N short Unix-socket round trips.
33
34use std::io::Write as _;
35use std::os::unix::net::UnixStream;
36use std::path::{Path, PathBuf};
37use std::time::Duration;
38
39const REFRESH_PAYLOAD: &[u8] = b"refresh\n";
40const SOCKET_EXTENSION: &str = "sock";
41/// Connect/write timeout for the CLI side. Kept short so a stale
42/// socket file (GUI crashed without unlinking) can't hang the CLI.
43const CONNECT_TIMEOUT: Duration = Duration::from_millis(50);
44
45/// Directory we drop sockets into. `$XDG_RUNTIME_DIR` when present (the
46/// systemd-managed per-user tmpfs, cleaned at logout); falls back to
47/// `/tmp`. Callers should not assume the directory is private.
48#[must_use]
49pub fn socket_dir() -> PathBuf {
50    std::env::var_os("XDG_RUNTIME_DIR")
51        .map(PathBuf::from)
52        .unwrap_or_else(|| PathBuf::from("/tmp"))
53}
54
55fn euid() -> u32 {
56    // SAFETY: `geteuid()` takes no arguments, never fails, and cannot cause
57    // undefined behaviour — it just reads the calling process's effective UID.
58    unsafe { libc::geteuid() }
59}
60
61/// Per-user prefix used to filter sockets owned by other users on
62/// shared hosts.
63fn socket_prefix() -> String {
64    format!("modde-{}-", euid())
65}
66
67/// Path each GUI process binds. Includes the pid so multiple GUIs run
68/// side-by-side without colliding.
69#[must_use]
70pub fn gui_socket_path() -> PathBuf {
71    let pid = std::process::id();
72    socket_dir().join(format!("{}{pid}.{SOCKET_EXTENSION}", socket_prefix()))
73}
74
75/// Best-effort cleanup: unlink the socket file the current process
76/// bound at startup. Safe to call from drop / signal handlers / atexit.
77pub fn cleanup_socket(path: &Path) {
78    let _ = std::fs::remove_file(path);
79}
80
81/// Notify every running GUI (if any) that profile state has changed.
82///
83/// Returns the number of listeners that accepted the notification —
84/// `0` is the normal case when no GUI is running. Errors are
85/// swallowed; this function is safe to call from any CLI exit path.
86///
87/// As a side-effect, sockets that fail to connect with ECONNREFUSED or
88/// ENOENT are unlinked: this keeps `$XDG_RUNTIME_DIR` from accumulating
89/// dead socket files when GUIs crash.
90pub fn notify_refresh() -> usize {
91    notify_refresh_in(&socket_dir())
92}
93
94/// [`notify_refresh`] scoped to an explicit directory. Useful for
95/// tests that don't want to mutate `XDG_RUNTIME_DIR`, and for
96/// instance-isolated CLI invocations that pass a custom data dir.
97pub fn notify_refresh_in(dir: &Path) -> usize {
98    let prefix = socket_prefix();
99    let suffix = format!(".{SOCKET_EXTENSION}");
100    let Ok(entries) = std::fs::read_dir(dir) else {
101        return 0;
102    };
103
104    let mut delivered = 0usize;
105    for entry in entries.flatten() {
106        let name = entry.file_name();
107        let Some(name_str) = name.to_str() else {
108            continue;
109        };
110        if !name_str.starts_with(&prefix) || !name_str.ends_with(&suffix) {
111            continue;
112        }
113        let path = entry.path();
114        if notify_refresh_at(&path) {
115            delivered += 1;
116        } else {
117            // Connect refused or path vanished — almost certainly a
118            // dead GUI's leftovers. GC.
119            let _ = std::fs::remove_file(&path);
120        }
121    }
122    delivered
123}
124
125/// [`notify_refresh`] for a single explicit socket path. Exposed for
126/// tests. Returns `true` iff the payload was written successfully.
127pub fn notify_refresh_at(path: &Path) -> bool {
128    let Ok(mut stream) = UnixStream::connect(path) else {
129        return false;
130    };
131    let _ = stream.set_write_timeout(Some(CONNECT_TIMEOUT));
132    stream.write_all(REFRESH_PAYLOAD).is_ok()
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use std::io::Read as _;
139    use std::os::unix::net::UnixListener;
140    use std::sync::atomic::{AtomicUsize, Ordering};
141    use std::thread;
142    use std::time::Duration;
143    use tempfile::TempDir;
144
145    /// Bump on every socket path we hand out so concurrent tests
146    /// inside the same temp dir can't collide.
147    static FAKE_PID: AtomicUsize = AtomicUsize::new(1);
148
149    fn fake_socket_path(dir: &Path) -> PathBuf {
150        let pid = FAKE_PID.fetch_add(1, Ordering::Relaxed);
151        dir.join(format!("{}test{pid}.{SOCKET_EXTENSION}", socket_prefix()))
152    }
153
154    /// Spawn a thread that accepts one connection on `listener` and
155    /// returns whatever payload the peer sent.
156    fn spawn_drain(listener: UnixListener) -> thread::JoinHandle<Vec<u8>> {
157        thread::spawn(move || {
158            let (mut stream, _) = listener.accept().unwrap();
159            let mut buf = Vec::new();
160            stream.read_to_end(&mut buf).unwrap();
161            buf
162        })
163    }
164
165    // ── path-scoped helper ────────────────────────────────────────
166
167    #[test]
168    fn notify_at_returns_false_when_no_listener() {
169        let tmp = TempDir::new().unwrap();
170        let path = fake_socket_path(tmp.path());
171        assert!(!notify_refresh_at(&path));
172    }
173
174    #[test]
175    fn notify_at_delivers_to_listener() {
176        let tmp = TempDir::new().unwrap();
177        let path = fake_socket_path(tmp.path());
178
179        let listener = UnixListener::bind(&path).unwrap();
180        let handle = spawn_drain(listener);
181
182        thread::sleep(Duration::from_millis(50));
183        assert!(notify_refresh_at(&path));
184        assert_eq!(handle.join().unwrap(), REFRESH_PAYLOAD);
185    }
186
187    // ── directory-scoped enumeration ──────────────────────────────
188
189    #[test]
190    fn notify_in_returns_zero_for_empty_dir() {
191        let tmp = TempDir::new().unwrap();
192        assert_eq!(notify_refresh_in(tmp.path()), 0);
193    }
194
195    #[test]
196    fn notify_in_returns_zero_for_missing_dir() {
197        // No `read_dir` blow-up: the runtime dir might not exist on
198        // headless containers / first boot.
199        let tmp = TempDir::new().unwrap();
200        let missing = tmp.path().join("does-not-exist");
201        assert_eq!(notify_refresh_in(&missing), 0);
202    }
203
204    #[test]
205    fn notify_in_delivers_to_every_listener() {
206        // Three concurrent GUIs in the same runtime dir → one notify
207        // pass → all three drain the payload.
208        let tmp = TempDir::new().unwrap();
209        let mut handles = Vec::new();
210        for _ in 0..3 {
211            let path = fake_socket_path(tmp.path());
212            let listener = UnixListener::bind(&path).unwrap();
213            handles.push(spawn_drain(listener));
214        }
215
216        thread::sleep(Duration::from_millis(50));
217        assert_eq!(notify_refresh_in(tmp.path()), 3);
218        for h in handles {
219            assert_eq!(h.join().unwrap(), REFRESH_PAYLOAD);
220        }
221    }
222
223    #[test]
224    fn notify_in_garbage_collects_stale_sockets_and_keeps_live_ones() {
225        // Mix one live listener with two stale socket files (regular
226        // files at the socket path, mimicking a crashed GUI on a
227        // filesystem that doesn't auto-clean). After the pass the
228        // stale files should be gone and the live one untouched.
229        let tmp = TempDir::new().unwrap();
230
231        let stale_a = fake_socket_path(tmp.path());
232        let stale_b = fake_socket_path(tmp.path());
233        std::fs::write(&stale_a, b"").unwrap();
234        std::fs::write(&stale_b, b"").unwrap();
235
236        let live_path = fake_socket_path(tmp.path());
237        let listener = UnixListener::bind(&live_path).unwrap();
238        let handle = spawn_drain(listener);
239
240        thread::sleep(Duration::from_millis(50));
241        let delivered = notify_refresh_in(tmp.path());
242        assert_eq!(delivered, 1, "only the live listener should receive");
243        assert!(!stale_a.exists(), "stale socket A should be GC'd");
244        assert!(!stale_b.exists(), "stale socket B should be GC'd");
245        assert!(live_path.exists(), "live socket must not be GC'd");
246        assert_eq!(handle.join().unwrap(), REFRESH_PAYLOAD);
247    }
248
249    #[test]
250    fn notify_in_skips_files_outside_the_user_prefix() {
251        // A file owned by a different (fake) user must not be touched
252        // — neither connected to nor unlinked. This guards against
253        // cross-user interference on shared hosts.
254        let tmp = TempDir::new().unwrap();
255        let other_user = tmp.path().join("modde-99999-pid42.sock");
256        std::fs::write(&other_user, b"").unwrap();
257
258        let delivered = notify_refresh_in(tmp.path());
259        assert_eq!(delivered, 0);
260        assert!(other_user.exists(), "other-user file must be left alone");
261    }
262
263    #[test]
264    fn notify_in_skips_files_with_other_extensions() {
265        // A `.lock` or `.tmp` file with our prefix shouldn't be
266        // mistaken for a socket. (Same prefix, wrong suffix.)
267        let tmp = TempDir::new().unwrap();
268        let lock = tmp.path().join(format!("{}pid1.lock", socket_prefix()));
269        std::fs::write(&lock, b"").unwrap();
270
271        assert_eq!(notify_refresh_in(tmp.path()), 0);
272        assert!(lock.exists(), "non-socket files must be left alone");
273    }
274
275    // ── helpers ───────────────────────────────────────────────────
276
277    #[test]
278    fn cleanup_socket_removes_file() {
279        let tmp = TempDir::new().unwrap();
280        let path = fake_socket_path(tmp.path());
281        std::fs::write(&path, b"").unwrap();
282        assert!(path.exists());
283        cleanup_socket(&path);
284        assert!(!path.exists());
285    }
286
287    #[test]
288    fn cleanup_socket_is_idempotent() {
289        // Calling on a non-existent path must not panic — drop guards
290        // can fire after explicit cleanup.
291        let tmp = TempDir::new().unwrap();
292        let path = tmp.path().join("never-existed.sock");
293        cleanup_socket(&path);
294        cleanup_socket(&path); // second call: still fine
295    }
296
297    #[test]
298    fn gui_socket_path_includes_pid() {
299        // Path layout is load-bearing — change it and you break
300        // every running GUI's socket. Lock it in.
301        let path = gui_socket_path();
302        let name = path.file_name().unwrap().to_string_lossy().to_string();
303        let prefix = socket_prefix();
304        assert!(
305            name.starts_with(&prefix),
306            "expected prefix {prefix} in {name}"
307        );
308        assert!(name.ends_with(".sock"), "expected .sock suffix in {name}");
309        let pid = std::process::id().to_string();
310        assert!(name.contains(&pid), "expected pid {pid} in {name}");
311    }
312}