running_process/broker/server/singleton_bind.rs
1//! Reusable per-user-session singleton bind for a v2 local-socket listener.
2//!
3//! Extracted from `running-process-broker-v2`'s `main()` (soldr#2361 Phase 2
4//! prep) so any consumer that wants to run v2 broker-server logic — the
5//! scaffold binary in this crate, or soldr's own embedded broker role —
6//! shares one tested implementation of "bind a name exactly once per user
7//! session, refuse a second bind instead of racing it" rather than each
8//! reimplementing the bind/singleton/stale-socket-cleanup dance.
9//!
10//! The stale-socket-cleanup subtlety here is load-bearing: see
11//! [`bind_singleton`]'s docs and running-process#899 for the concurrency bug
12//! this module's shape specifically avoids.
13
14use std::io;
15
16use interprocess::local_socket::{Listener, ListenerOptions};
17
18/// Resolve the bare pipe/socket name into a full, platform-specific bind
19/// path: `\\.\pipe\<bare_name>` on Windows, or a file under a per-user
20/// runtime directory on Unix (macOS additionally hashes the leaf to fit
21/// `sun_path`'s 104-byte limit).
22pub fn resolve_socket_path(bare_name: &str) -> Result<String, String> {
23 #[cfg(windows)]
24 {
25 Ok(format!(r"\\.\pipe\{bare_name}"))
26 }
27 #[cfg(unix)]
28 {
29 let dir = unix_socket_dir();
30 let leaf = if cfg!(target_os = "macos") {
31 let mut hash = blake3::Hasher::new();
32 hash.update(bare_name.as_bytes());
33 let bytes = hash.finalize();
34 let mut hex = String::with_capacity(16);
35 for b in bytes.as_bytes().iter().take(8) {
36 use std::fmt::Write as _;
37 let _ = write!(hex, "{b:02x}");
38 }
39 format!("{hex}.sock")
40 } else {
41 format!("{bare_name}.sock")
42 };
43 Ok(dir.join(leaf).to_string_lossy().into_owned())
44 }
45}
46
47/// Resolve an install-path-scoped broker name without adding user identity.
48///
49/// Unlike [`resolve_socket_path`], this endpoint must remain identical across
50/// users and runtime environments: a user-local install is already unique by
51/// its canonical path hash, while one machine-wide install intentionally has
52/// one machine-wide endpoint. Windows named pipes are already machine-global.
53/// Unix uses the machine-global temporary root and a compact hash to stay
54/// within every platform's `sun_path` limit.
55pub fn resolve_path_scoped_socket_path(bare_name: &str) -> Result<String, String> {
56 #[cfg(windows)]
57 {
58 Ok(format!(r"\\.\pipe\{bare_name}"))
59 }
60 #[cfg(unix)]
61 {
62 let mut hash = blake3::Hasher::new();
63 hash.update(b"running-process:path-scoped-socket:v1\0");
64 hash.update(bare_name.as_bytes());
65 let digest = hash.finalize();
66 let mut leaf_hash = String::with_capacity(32);
67 for byte in digest.as_bytes().iter().take(16) {
68 use std::fmt::Write as _;
69 let _ = write!(leaf_hash, "{byte:02x}");
70 }
71 Ok(std::path::Path::new("/tmp")
72 .join(format!(".rp-path-{leaf_hash}.sock"))
73 .to_string_lossy()
74 .into_owned())
75 }
76}
77
78#[cfg(unix)]
79fn unix_socket_dir() -> std::path::PathBuf {
80 use std::path::PathBuf;
81 #[cfg(target_os = "macos")]
82 {
83 let uid = unsafe { libc::getuid() };
84 let tmp = std::env::var_os("TMPDIR")
85 .map(PathBuf::from)
86 .unwrap_or_else(|| PathBuf::from("/tmp"));
87 tmp.join(format!(".rp-{uid}-broker-v2"))
88 }
89 #[cfg(not(target_os = "macos"))]
90 {
91 if let Some(d) = std::env::var_os("XDG_RUNTIME_DIR") {
92 PathBuf::from(d).join("running-process").join("broker-v2")
93 } else {
94 let uid = unsafe { libc::getuid() };
95 PathBuf::from(format!("/tmp/running-process-{uid}/broker-v2"))
96 }
97 }
98}
99
100/// Classify a [`ListenerOptions::create_sync`] error as "another process is
101/// already bound at this name" vs any other bind failure.
102///
103/// `AddrInUse` / `WouldBlock` are the canonical "another listener already
104/// owns this name" signals on Unix-style transports. **Windows named-pipe
105/// bind reports the same condition as `PermissionDenied`**
106/// (ERROR_ACCESS_DENIED, raw os error 5) because the existing pipe
107/// instance's ACL blocks the second bind. Treat that case as already-bound
108/// too — a "true" permission problem on a per-user runtime-dir socket path
109/// is extremely rare in practice (the path lives under `XDG_RUNTIME_DIR` /
110/// `TMPDIR`, always writable by the current user).
111pub fn is_already_bound_error(err: &io::Error) -> bool {
112 matches!(
113 err.kind(),
114 io::ErrorKind::AddrInUse | io::ErrorKind::WouldBlock | io::ErrorKind::PermissionDenied,
115 )
116}
117
118/// Unix-only: tell a genuinely orphaned unix-socket path (left behind by a
119/// process that exited without cleaning up) apart from a path where a live
120/// peer is listening right now — the two look identical to `bind`
121/// (`AddrInUse` either way). A connect probe distinguishes them: nothing is
122/// listening if the connect itself fails to even reach a peer
123/// (`ConnectionRefused` — the classic "orphaned socket file, no listener"
124/// signal — or `NotFound`); any other outcome, including a successful
125/// connect, means treat the path as live and leave it alone.
126#[cfg(unix)]
127pub fn unix_socket_path_is_stale(socket_path: &str) -> bool {
128 use interprocess::local_socket::traits::Stream as _;
129 use interprocess::local_socket::Stream;
130 let Ok(name) = wrap_socket_name(socket_path) else {
131 return false; // can't even build the name -- don't touch the file
132 };
133 match Stream::connect(name) {
134 Ok(_stream) => false,
135 Err(err) => matches!(
136 err.kind(),
137 io::ErrorKind::ConnectionRefused | io::ErrorKind::NotFound
138 ),
139 }
140}
141
142/// Build an `interprocess` [`Name`](interprocess::local_socket::Name) from a
143/// resolved socket path (see [`resolve_socket_path`]).
144pub fn wrap_socket_name(socket_path: &str) -> Result<interprocess::local_socket::Name<'_>, String> {
145 use interprocess::local_socket::prelude::*;
146 #[cfg(windows)]
147 {
148 use interprocess::local_socket::GenericNamespaced;
149 let bare = socket_path
150 .strip_prefix(r"\\.\pipe\")
151 .unwrap_or(socket_path);
152 bare.to_ns_name::<GenericNamespaced>()
153 .map_err(|e| format!("to_ns_name: {e}"))
154 }
155 #[cfg(unix)]
156 {
157 use interprocess::local_socket::GenericFilePath;
158 socket_path
159 .to_fs_name::<GenericFilePath>()
160 .map_err(|e| format!("to_fs_name: {e}"))
161 }
162}
163
164/// Why [`bind_singleton`] refused to bind.
165#[derive(Debug)]
166pub enum BindSingletonError {
167 /// Building the platform [`Name`](interprocess::local_socket::Name)
168 /// from `socket_path` failed.
169 InvalidName(String),
170 /// Another process already holds this name — the singleton refusal
171 /// path. Callers typically map this to an actionable "already running"
172 /// message and a supervisor-retryable exit code.
173 AlreadyBound(io::Error),
174 /// Any other bind failure (permissions, missing directory, etc.).
175 Other(io::Error),
176}
177
178/// Bind `socket_path` as a v2 local-socket listener, enforcing
179/// exactly-one-bind-per-name (the per-user-session singleton property).
180///
181/// **Never unlinks the path up front.** An earlier version of this logic
182/// (duplicated in `running-process-broker-v2::main` before this
183/// extraction) unconditionally ran `remove_file` before every bind
184/// attempt on Unix. Under a real concurrent-start race that let every one
185/// of N racing starters delete the current winner's *live* socket and
186/// rebind over the freed path — so all N starters observed a successful
187/// bind instead of exactly one (running-process#899, soldr#2361/#2363's
188/// singleton testing invariant). This function instead: attempts the bind
189/// first with no cleanup; on an already-bound failure, Unix-only,
190/// connect-probes the path via `unix_socket_path_is_stale` (Unix-only, so
191/// not linked here — a doc build on a non-Unix target has no such item in
192/// scope to resolve against) to tell a
193/// genuinely orphaned socket file apart from a live peer, and only then
194/// removes + retries once. Windows needs no cleanup step at all — the
195/// named pipe namespace is kernel-managed and a prior binding vanishes
196/// when that process exits.
197///
198/// On Unix, the parent directory of `socket_path` is created if missing
199/// before the first bind attempt.
200pub fn bind_singleton(socket_path: &str) -> Result<Listener, BindSingletonError> {
201 #[cfg(unix)]
202 {
203 let path = std::path::Path::new(socket_path);
204 if let Some(parent) = path.parent() {
205 std::fs::create_dir_all(parent).map_err(BindSingletonError::Other)?;
206 }
207 }
208
209 let name = wrap_socket_name(socket_path).map_err(BindSingletonError::InvalidName)?;
210 #[cfg_attr(not(unix), allow(unused_mut))]
211 let mut listener_result = ListenerOptions::new().name(name).create_sync();
212
213 #[cfg(unix)]
214 if let Err(err) = &listener_result {
215 if is_already_bound_error(err) && unix_socket_path_is_stale(socket_path) {
216 let _ = std::fs::remove_file(socket_path);
217 listener_result = match wrap_socket_name(socket_path) {
218 Ok(retry_name) => ListenerOptions::new().name(retry_name).create_sync(),
219 Err(_) => listener_result,
220 };
221 }
222 }
223
224 listener_result.map_err(|err| {
225 if is_already_bound_error(&err) {
226 BindSingletonError::AlreadyBound(err)
227 } else {
228 BindSingletonError::Other(err)
229 }
230 })
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn resolve_socket_path_produces_a_nonempty_path() {
239 let path = resolve_socket_path("rpb-v2-test-singleton-bind").expect("resolve");
240 assert!(!path.is_empty());
241 }
242
243 #[test]
244 fn path_scoped_socket_does_not_add_a_user_runtime_directory() {
245 let first = resolve_path_scoped_socket_path("rpb-v2-program-0123456789abcdef-0")
246 .expect("resolve path-scoped endpoint");
247 let again = resolve_path_scoped_socket_path("rpb-v2-program-0123456789abcdef-0")
248 .expect("resolve stable endpoint");
249 assert_eq!(first, again);
250 #[cfg(unix)]
251 assert_eq!(
252 std::path::Path::new(&first).parent(),
253 Some(std::path::Path::new("/tmp"))
254 );
255 }
256
257 #[test]
258 fn is_already_bound_error_classifies_expected_kinds() {
259 assert!(is_already_bound_error(&io::Error::from(
260 io::ErrorKind::AddrInUse
261 )));
262 assert!(is_already_bound_error(&io::Error::from(
263 io::ErrorKind::WouldBlock
264 )));
265 // PR #536 deliberately added `PermissionDenied` to this matcher:
266 // on Windows, a double-bind surfaces as `ERROR_ACCESS_DENIED`
267 // (raw os error 5) because the existing pipe instance's ACL
268 // blocks the second bind -- not as `AddrInUse`. An earlier
269 // version of this test (PR #534, before the classification was
270 // widened) expected the negation; PR #536 updated the impl but
271 // forgot the test, which then cascade-failed every CI run until
272 // fixed.
273 assert!(is_already_bound_error(&io::Error::from(
274 io::ErrorKind::PermissionDenied
275 )));
276 assert!(!is_already_bound_error(&io::Error::from(
277 io::ErrorKind::NotFound
278 )));
279 }
280
281 #[test]
282 fn bind_singleton_binds_once_and_refuses_a_second_bind() {
283 let nonce = std::time::SystemTime::now()
284 .duration_since(std::time::UNIX_EPOCH)
285 .map(|d| d.as_nanos())
286 .unwrap_or(0);
287 let socket_path = resolve_socket_path(&format!(
288 "rpb-v2-test-singleton-bind-{:010x}",
289 nonce & 0xFF_FFFF_FFFF
290 ))
291 .expect("resolve");
292
293 let _first = bind_singleton(&socket_path).expect("first bind must succeed");
294 let second = bind_singleton(&socket_path);
295 assert!(
296 matches!(second, Err(BindSingletonError::AlreadyBound(_))),
297 "second bind at the same path must be refused as AlreadyBound, got {second:?}"
298 );
299 }
300}