Skip to main content

tree_space/
ipc.rs

1//! Single-instance IPC for tree-space.
2//!
3//! Launching `tree-space` while an instance is already running must hand the
4//! new invocation's intent to that instance (toggle visibility, open a pane,
5//! choose a dock side) rather than starting a second panel. GApplication's own
6//! single-instance machinery can't carry our `--side` flag or directory
7//! arguments through the freshly-launched process cleanly, so we use a small
8//! Unix domain socket under the runtime directory:
9//!
10//! * The first instance binds the socket and runs an accept thread that
11//!   decodes each incoming line into a [`Command`] and hands it to the app.
12//! * Later instances try to connect; on success they write their serialized
13//!   [`Command`] and exit without ever touching the GUI.
14//! * A stale socket (crashed instance) is detected by a failed connect and
15//!   removed before re-binding.
16
17use std::io::{BufRead, BufReader, Write};
18use std::os::unix::net::{UnixListener, UnixStream};
19use std::path::{Path, PathBuf};
20
21use crate::cmd::Command;
22
23/// The socket file name below the runtime directory.
24const SOCKET_NAME: &str = "tree-space.sock";
25
26/// Path of the instance socket for the current environment.
27///
28/// `$XDG_RUNTIME_DIR` is present for any user session (in particular any
29/// Wayland session); `/tmp` is only a fallback for bare environments.
30pub fn socket_path() -> PathBuf {
31    let dir = std::env::var_os("XDG_RUNTIME_DIR")
32        .map(PathBuf::from)
33        .filter(|p| !p.as_os_str().is_empty())
34        .unwrap_or_else(|| PathBuf::from("/tmp"));
35    dir.join(SOCKET_NAME)
36}
37
38/// Try to deliver `cmd` to a running instance. Returns `true` when a server
39/// accepted the request; `false` when no server is listening.
40pub fn deliver(cmd: &Command) -> bool {
41    deliver_at(&socket_path(), cmd)
42}
43
44/// Deliver `cmd` to the server listening on `path`.
45pub fn deliver_at(path: &Path, cmd: &Command) -> bool {
46    let Ok(mut stream) = UnixStream::connect(path) else {
47        return false;
48    };
49    let line = format!("{}\n", cmd.encode());
50    if stream.write_all(line.as_bytes()).is_err() || stream.flush().is_err() {
51        return false;
52    }
53    true
54}
55
56/// Bind the instance socket as the (single) server.
57///
58/// If the path is already in use, a connect is attempted to distinguish a live
59/// server (in which case we must not become a second instance — the socket is
60/// left alone and the error returned) from a stale socket left by a crashed
61/// instance (which is removed and the bind retried).
62pub fn bind() -> std::io::Result<UnixListener> {
63    bind_at(&socket_path())
64}
65
66/// Bind the instance socket at an explicit path (see [`bind`]).
67pub fn bind_at(path: &Path) -> std::io::Result<UnixListener> {
68    if let Some(parent) = path.parent() {
69        let _ = std::fs::create_dir_all(parent);
70    }
71    match UnixListener::bind(path) {
72        Ok(listener) => Ok(listener),
73        Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => {
74            if UnixStream::connect(path).is_ok() {
75                // A live server holds the socket.
76                return Err(err);
77            }
78            // Stale socket from a crashed instance: clean up and retry.
79            let _ = std::fs::remove_file(path);
80            UnixListener::bind(path)
81        }
82        Err(err) => Err(err),
83    }
84}
85
86/// Spawn a thread that accepts instance connections and forwards every decoded
87/// [`Command`] to `deliver` (which must hop onto the UI thread itself, e.g. via
88/// `MainContext::invoke`).
89pub fn spawn_listener(listener: UnixListener, deliver: impl Fn(Command) + Send + 'static) {
90    std::thread::spawn(move || {
91        for connection in listener.incoming() {
92            let Ok(stream) = connection else { continue };
93            let mut reader = BufReader::new(stream);
94            let mut line = String::new();
95            if reader.read_line(&mut line).is_err() {
96                continue;
97            }
98            if let Some(cmd) = Command::decode(line.trim_end()) {
99                deliver(cmd);
100            }
101        }
102    });
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use std::sync::mpsc;
109    use std::time::Duration;
110
111    #[test]
112    fn deliver_and_listener_round_trip() {
113        let dir = tempfile::tempdir().unwrap();
114        let path = dir.path().join(SOCKET_NAME);
115        let listener = bind_at(&path).unwrap();
116
117        let (tx, rx) = mpsc::channel();
118        spawn_listener(listener, move |cmd| tx.send(cmd).unwrap());
119
120        let expected = Command {
121            side: Some(crate::config::PanelSide::Right),
122            roots: vec![PathBuf::from("/home/eolu/x")],
123            ..Command::default()
124        };
125        assert!(deliver_at(&path, &expected));
126        assert_eq!(rx.recv_timeout(Duration::from_secs(2)), Ok(expected));
127    }
128
129    #[test]
130    fn deliver_fails_without_a_server() {
131        let dir = tempfile::tempdir().unwrap();
132        let path = dir.path().join(SOCKET_NAME);
133        assert!(!deliver_at(&path, &Command::default()));
134    }
135
136    #[test]
137    fn deliver_toggle_round_trip() {
138        let dir = tempfile::tempdir().unwrap();
139        let path = dir.path().join(SOCKET_NAME);
140        let listener = bind_at(&path).unwrap();
141        let (tx, rx) = mpsc::channel();
142        spawn_listener(listener, move |cmd| tx.send(cmd).unwrap());
143        assert!(deliver_at(&path, &Command::default()));
144        assert_eq!(rx.recv_timeout(Duration::from_secs(2)), Ok(Command::default()));
145    }
146
147    #[test]
148    fn bind_replaces_a_stale_socket() {
149        let dir = tempfile::tempdir().unwrap();
150        let path = dir.path().join(SOCKET_NAME);
151        // A leftover socket from a crashed instance (no one listening).
152        std::fs::write(&path, b"stale").unwrap();
153        let listener = bind_at(&path).unwrap();
154        assert!(listener.local_addr().is_ok());
155    }
156
157    #[test]
158    fn bind_refuses_when_a_live_server_holds_the_socket() {
159        let dir = tempfile::tempdir().unwrap();
160        let path = dir.path().join(SOCKET_NAME);
161        let _server = bind_at(&path).unwrap();
162        // Connecting succeeds because the server is listening, so bind must not
163        // clobber the socket.
164        assert!(bind_at(&path).is_err());
165    }
166}