web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
src/instance.rs (5.9K)
1 //! Single-instance forwarding over the CCE socket convention.
2 //!
3 //! Every external open (`xdg-open` via the desktop entry's `%u`) spawns a
4 //! fresh `cce-browser <url>` process. A second full instance is not just
5 //! clutter: the profile dir holds a plaintext cookie jar two engines must
6 //! not share. So the first instance listens on
7 //! `/tmp/cce-browser-<WAYLAND_DISPLAY>.sock` (keyed by display, which keeps
8 //! shadow sessions isolated for free), and every later launch hands its
9 //! argument to it and exits before any Wayland or engine work happens.
10 //!
11 //! The order in [`forward_or_claim`] is what closes the startup race: try to
12 //! connect, and only bind after a connect has failed. A refused connection
13 //! means the socket file outlived a crashed instance and is removed before
14 //! binding; losing the bind to a simultaneous launch falls back to one more
15 //! connect. If that also fails the launch proceeds un-listened rather than
16 //! not at all.
17 //!
18 //! The claimed listener has to survive from `main()` (before the engine
19 //! starts) to `BrowserApp::new` (where the calloop sender first exists), so
20 //! it parks in a static until [`spawn_listener`] adopts it.
21
22 use std::io::{BufRead, BufReader, Write};
23 use std::os::unix::net::{UnixListener, UnixStream};
24 use std::sync::Mutex;
25
26 use crate::Message;
27
28 /// Socket prefix; `cce_ui::ipc::socket_path` appends `-<WAYLAND_DISPLAY>`.
29 const PREFIX: &str = "cce-browser";
30
31 /// The listener claimed by `forward_or_claim`, waiting for `spawn_listener`.
32 static CLAIMED: Mutex<Option<UnixListener>> = Mutex::new(None);
33 /// The socket path this process bound (and must unlink on exit), if any.
34 static OWNED_PATH: Mutex<Option<String>> = Mutex::new(None);
35
36 /// Hand `arg` to a running instance, or claim the instance socket.
37 ///
38 /// Returns `true` when a running instance took the launch (the caller should
39 /// exit without starting the engine). Returns `false` when this process is
40 /// the instance — with the listener parked for [`spawn_listener`] — or when
41 /// single-instance handling failed entirely and the launch should proceed
42 /// standalone.
43 pub fn forward_or_claim(arg: Option<&str>) -> bool {
44 let path = cce_ui::ipc::socket_path(PREFIX);
45
46 if try_forward(&path, arg) {
47 return true;
48 }
49
50 // Nothing answered. A socket file that still exists is a leftover from a
51 // crashed instance; binding needs it gone.
52 if std::path::Path::new(&path).exists() {
53 let _ = std::fs::remove_file(&path);
54 }
55 match UnixListener::bind(&path) {
56 Ok(listener) => {
57 *CLAIMED.lock().unwrap() = Some(listener);
58 *OWNED_PATH.lock().unwrap() = Some(path);
59 false
60 }
61 // Lost the bind race to a simultaneous launch: it is the instance.
62 Err(_) => try_forward(&path, arg),
63 }
64 }
65
66 /// One forwarding attempt. False on any failure — there is no retry inside.
67 fn try_forward(path: &str, arg: Option<&str>) -> bool {
68 let Ok(mut stream) = UnixStream::connect(path) else {
69 return false;
70 };
71 // A relative file path is resolved against *this* process's cwd — the
72 // instance's differs, so it must travel absolute.
73 let command = match arg {
74 Some(a) => {
75 let p = std::path::Path::new(a);
76 let abs = if p.exists() {
77 std::fs::canonicalize(p)
78 .ok()
79 .and_then(|c| c.to_str().map(String::from))
80 } else {
81 None
82 };
83 format!("open {}\n", abs.as_deref().unwrap_or(a))
84 }
85 None => "new-tab\n".to_string(),
86 };
87 if stream.write_all(command.as_bytes()).is_err() {
88 return false;
89 }
90 // Wait for the ack: returning (and exiting) on write alone races the
91 // instance actually reading the line.
92 let mut reply = String::new();
93 BufReader::new(stream).read_line(&mut reply).is_ok()
94 }
95
96 /// Adopt the listener claimed in `main()` and serve it on a thread, pushing
97 /// each received launch into the app's calloop channel. No-op when this
98 /// process runs standalone.
99 pub fn spawn_listener(sender: calloop::channel::Sender<Message>) {
100 let Some(listener) = CLAIMED.lock().unwrap().take() else {
101 return;
102 };
103 std::thread::spawn(move || {
104 for conn in listener.incoming() {
105 let Ok(conn) = conn else { continue };
106 let mut reader = BufReader::new(conn);
107 let mut line = String::new();
108 if reader.read_line(&mut line).is_err() {
109 continue;
110 }
111 let msg = match parse_command(line.trim()) {
112 Some(m) => m,
113 None => continue,
114 };
115 if sender.send(msg).is_err() {
116 return; // channel gone: the app is shutting down
117 }
118 let _ = reader.get_mut().write_all(b"ok\n");
119 }
120 });
121 }
122
123 /// `open <arg>` / `new-tab` → the message the app loop handles.
124 fn parse_command(line: &str) -> Option<Message> {
125 if let Some(arg) = line.strip_prefix("open ") {
126 let arg = arg.trim();
127 (!arg.is_empty()).then(|| Message::OpenExternal(Some(arg.to_string())))
128 } else if line == "new-tab" {
129 Some(Message::OpenExternal(None))
130 } else {
131 None
132 }
133 }
134
135 /// Unlink the socket if this process bound it. Called after the engine loop
136 /// returns; a crash skips it, which is what the stale-socket removal in
137 /// [`forward_or_claim`] exists for.
138 pub fn cleanup() {
139 if let Some(path) = OWNED_PATH.lock().unwrap().take() {
140 let _ = std::fs::remove_file(path);
141 }
142 }
143
144 #[cfg(test)]
145 mod tests {
146 use super::*;
147
148 #[test]
149 fn commands_parse() {
150 assert!(matches!(
151 parse_command("open https://example.com"),
152 Some(Message::OpenExternal(Some(u))) if u == "https://example.com"
153 ));
154 assert!(matches!(
155 parse_command("new-tab"),
156 Some(Message::OpenExternal(None))
157 ));
158 assert!(parse_command("open ").is_none());
159 assert!(parse_command("bogus").is_none());
160 }
161 }