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