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::fs::File;
15use std::io;
16use std::path::PathBuf;
17use std::time::{Duration, Instant};
18
19use crate::platform::ipc::Listener;
20
21const STALE_RECOVERY_LOCK_TIMEOUT: Duration = Duration::from_secs(1);
22const STALE_RECOVERY_LOCK_POLL: Duration = Duration::from_millis(10);
23
24/// Resolve the bare pipe/socket name into a full, platform-specific bind
25/// path: `\\.\pipe\<bare_name>` on Windows, or a file under a per-user
26/// runtime directory on Unix (macOS additionally hashes the leaf to fit
27/// `sun_path`'s 104-byte limit).
28pub fn resolve_socket_path(bare_name: &str) -> Result<String, String> {
29 crate::platform::ipc::broker_endpoint_name(bare_name, false).map_err(|error| error.to_string())
30}
31
32/// Resolve an install-path-scoped broker name without adding user identity.
33///
34/// Unlike [`resolve_socket_path`], this endpoint must remain identical across
35/// users and runtime environments: a user-local install is already unique by
36/// its canonical path hash, while one machine-wide install intentionally has
37/// one machine-wide endpoint. Windows named pipes are already machine-global.
38/// Unix uses the machine-global temporary root and a compact hash to stay
39/// within every platform's `sun_path` limit.
40pub fn resolve_path_scoped_socket_path(bare_name: &str) -> Result<String, String> {
41 crate::platform::ipc::broker_endpoint_name(bare_name, true).map_err(|error| error.to_string())
42}
43
44/// Classify a [`Listener::bind`] error as "another process is already bound at
45/// this name" vs any other bind failure.
46///
47/// `AddrInUse` / `WouldBlock` are the canonical "another listener already
48/// owns this name" signals on Unix-style transports. **Windows named-pipe
49/// bind reports the same condition as `PermissionDenied`**
50/// (ERROR_ACCESS_DENIED, raw os error 5) because the existing pipe
51/// instance's ACL blocks the second bind. Treat that case as already-bound
52/// too — a "true" permission problem on a per-user runtime-dir socket path
53/// is extremely rare in practice (the path lives under `XDG_RUNTIME_DIR` /
54/// `TMPDIR`, always writable by the current user).
55pub fn is_already_bound_error(err: &io::Error) -> bool {
56 matches!(
57 err.kind(),
58 io::ErrorKind::AddrInUse | io::ErrorKind::WouldBlock | io::ErrorKind::PermissionDenied,
59 )
60}
61
62/// Source-compatible stale-endpoint helper for filesystem-backed transports.
63/// Tells a genuinely orphaned socket path (left behind by a process that
64/// exited without cleaning up) apart from a path where a live
65/// peer is listening right now — the two look identical to `bind`
66/// (`AddrInUse` either way). A connect probe distinguishes them: nothing is
67/// listening if the connect itself fails to even reach a peer
68/// (`ConnectionRefused` — the classic "orphaned socket file, no listener"
69/// signal — or `NotFound`); any other outcome, including a successful
70/// connect, means treat the path as live and leave it alone. Non-filesystem
71/// transports return `false` because they leave no endpoint file to retire.
72pub fn unix_socket_path_is_stale(socket_path: &str) -> bool {
73 crate::platform::ipc::Endpoint::new(socket_path.to_owned())
74 .map(|endpoint| endpoint.is_stale())
75 .unwrap_or(false)
76}
77
78/// Build an `interprocess` [`Name`](interprocess::local_socket::Name) from a
79/// resolved socket path (see [`resolve_socket_path`]).
80pub fn wrap_socket_name(socket_path: &str) -> Result<interprocess::local_socket::Name<'_>, String> {
81 running_process_platform_internal::legacy_ipc_name(socket_path)
82}
83
84/// Why [`bind_singleton`] refused to bind.
85#[derive(Debug)]
86pub enum BindSingletonError {
87 /// Resolving `socket_path` into a platform endpoint failed.
88 InvalidName(String),
89 /// Another process already holds this name — the singleton refusal
90 /// path. Callers typically map this to an actionable "already running"
91 /// message and a supervisor-retryable exit code.
92 AlreadyBound(io::Error),
93 /// Any other bind failure (permissions, missing directory, etc.).
94 Other(io::Error),
95}
96
97/// Bind `socket_path` as a v2 local-socket listener, enforcing
98/// exactly-one-bind-per-name (the per-user-session singleton property).
99///
100/// **Never unlinks the path up front.** An earlier version of this logic
101/// (duplicated in `running-process-broker-v2::main` before this
102/// extraction) unconditionally ran `remove_file` before every bind
103/// attempt on Unix. Under a real concurrent-start race that let every one
104/// of N racing starters delete the current winner's *live* socket and
105/// rebind over the freed path — so all N starters observed a successful
106/// bind instead of exactly one (running-process#899, soldr#2361/#2363's
107/// singleton testing invariant). This function instead attempts the bind
108/// first with no cleanup. On an already-bound stale result, it serializes
109/// contenders with a sidecar lock, retries the bind under that lock, and
110/// only lets the holder that still sees a stale endpoint remove and reclaim
111/// it. Windows needs no cleanup step at all — the named pipe namespace is
112/// kernel-managed and a prior binding vanishes when that process exits.
113///
114/// On Unix, the parent directory of `socket_path` is created if missing
115/// before the first bind attempt.
116pub fn bind_singleton(socket_path: &str) -> Result<Listener, BindSingletonError> {
117 let endpoint = crate::platform::ipc::Endpoint::new(socket_path.to_owned())
118 .map_err(|error| BindSingletonError::InvalidName(error.to_string()))?;
119 bind_singleton_with_endpoint(&endpoint, || Listener::bind(&endpoint))
120}
121
122/// Bind a caller-owned listener with the same singleton and serialized stale
123/// recovery contract as [`bind_singleton`].
124///
125/// This is for consumers that need listener options the default synchronous
126/// [`Listener`] does not expose (for example an async listener or a custom
127/// accept backlog). `bind` must attempt to claim `socket_path` without
128/// unlinking or reclaiming it first. It may be called up to three times: the
129/// ordinary bind, a serialized recheck after another contender may have
130/// recovered the path, and one final bind after this contender retires a
131/// still-stale endpoint.
132pub fn bind_singleton_with<T, F>(socket_path: &str, bind: F) -> Result<T, BindSingletonError>
133where
134 F: FnMut() -> io::Result<T>,
135{
136 let endpoint = crate::platform::ipc::Endpoint::new(socket_path.to_owned())
137 .map_err(|error| BindSingletonError::InvalidName(error.to_string()))?;
138 bind_singleton_with_endpoint(&endpoint, bind)
139}
140
141fn bind_singleton_with_endpoint<T, F>(
142 endpoint: &crate::platform::ipc::Endpoint,
143 mut bind: F,
144) -> Result<T, BindSingletonError>
145where
146 F: FnMut() -> io::Result<T>,
147{
148 endpoint
149 .ensure_parent_exists()
150 .map_err(BindSingletonError::Other)?;
151 let mut listener_result = bind();
152
153 if let Err(err) = &listener_result {
154 if is_already_bound_error(err) && endpoint.is_stale() {
155 listener_result = recover_stale_endpoint(endpoint, &mut bind);
156 }
157 }
158
159 listener_result.map_err(|err| {
160 if is_already_bound_error(&err) {
161 BindSingletonError::AlreadyBound(err)
162 } else {
163 BindSingletonError::Other(err)
164 }
165 })
166}
167
168/// Serialize the destructive part of stale-endpoint recovery.
169///
170/// Every contender first failed the ordinary bind and observed an orphaned
171/// filesystem socket. Without a separate lock they can all make that decision,
172/// then take turns unlinking whichever endpoint currently occupies the path --
173/// including a live listener installed by an earlier contender. Once the lock
174/// is held, bind again before retiring anything: a prior recovery winner is now
175/// reported as already bound, while a still-stale endpoint is retired exactly
176/// once and rebound by this holder.
177fn recover_stale_endpoint<T, F>(
178 endpoint: &crate::platform::ipc::Endpoint,
179 bind: &mut F,
180) -> io::Result<T>
181where
182 F: FnMut() -> io::Result<T>,
183{
184 let _guard = StaleRecoveryLock::acquire(endpoint.display())?;
185
186 match bind() {
187 Ok(listener) => Ok(listener),
188 Err(error) if is_already_bound_error(&error) && endpoint.is_stale() => {
189 endpoint.retire()?;
190 bind()
191 }
192 Err(error) => Err(error),
193 }
194}
195
196struct StaleRecoveryLock(File);
197
198impl StaleRecoveryLock {
199 fn acquire(socket_path: &str) -> io::Result<Self> {
200 let lock_path = stale_recovery_lock_path(socket_path);
201 let file = crate::platform::fs::open_lock_file(&lock_path)?;
202 let deadline = Instant::now() + STALE_RECOVERY_LOCK_TIMEOUT;
203 loop {
204 match crate::platform::fs::try_lock_exclusive(&file) {
205 Ok(()) => return Ok(Self(file)),
206 Err(error)
207 if crate::platform::fs::is_lock_conflict(&error)
208 && Instant::now() < deadline =>
209 {
210 std::thread::sleep(STALE_RECOVERY_LOCK_POLL);
211 }
212 Err(error) => return Err(error),
213 }
214 }
215 }
216}
217
218impl Drop for StaleRecoveryLock {
219 fn drop(&mut self) {
220 let _ = crate::platform::fs::unlock(&self.0);
221 }
222}
223
224fn stale_recovery_lock_path(socket_path: &str) -> PathBuf {
225 let mut lock_path = std::ffi::OsString::from(socket_path);
226 lock_path.push(".bind.lock");
227 PathBuf::from(lock_path)
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn resolve_socket_path_produces_a_nonempty_path() {
236 let path = resolve_socket_path("rpb-v2-test-singleton-bind").expect("resolve");
237 assert!(!path.is_empty());
238 }
239
240 #[test]
241 fn path_scoped_socket_does_not_add_a_user_runtime_directory() {
242 let first = resolve_path_scoped_socket_path("rpb-v2-program-0123456789abcdef-0")
243 .expect("resolve path-scoped endpoint");
244 let again = resolve_path_scoped_socket_path("rpb-v2-program-0123456789abcdef-0")
245 .expect("resolve stable endpoint");
246 assert_eq!(first, again);
247 if crate::platform::ipc::endpoint_is_filesystem_backed() {
248 assert_eq!(
249 std::path::Path::new(&first).parent(),
250 Some(std::path::Path::new("/tmp"))
251 );
252 }
253 }
254
255 #[test]
256 fn is_already_bound_error_classifies_expected_kinds() {
257 assert!(is_already_bound_error(&io::Error::from(
258 io::ErrorKind::AddrInUse
259 )));
260 assert!(is_already_bound_error(&io::Error::from(
261 io::ErrorKind::WouldBlock
262 )));
263 // PR #536 deliberately added `PermissionDenied` to this matcher:
264 // on Windows, a double-bind surfaces as `ERROR_ACCESS_DENIED`
265 // (raw os error 5) because the existing pipe instance's ACL
266 // blocks the second bind -- not as `AddrInUse`. An earlier
267 // version of this test (PR #534, before the classification was
268 // widened) expected the negation; PR #536 updated the impl but
269 // forgot the test, which then cascade-failed every CI run until
270 // fixed.
271 assert!(is_already_bound_error(&io::Error::from(
272 io::ErrorKind::PermissionDenied
273 )));
274 assert!(!is_already_bound_error(&io::Error::from(
275 io::ErrorKind::NotFound
276 )));
277 }
278
279 #[test]
280 fn bind_singleton_binds_once_and_refuses_a_second_bind() {
281 let nonce = std::time::SystemTime::now()
282 .duration_since(std::time::UNIX_EPOCH)
283 .map(|d| d.as_nanos())
284 .unwrap_or(0);
285 let socket_path = resolve_socket_path(&format!(
286 "rpb-v2-test-singleton-bind-{:010x}",
287 nonce & 0xFF_FFFF_FFFF
288 ))
289 .expect("resolve");
290
291 let _first = bind_singleton(&socket_path).expect("first bind must succeed");
292 let second = bind_singleton(&socket_path);
293 assert!(
294 matches!(second, Err(BindSingletonError::AlreadyBound(_))),
295 "second bind at the same path must be refused as AlreadyBound, got {second:?}"
296 );
297 }
298
299 #[test]
300 fn stale_endpoint_n_way_recovery_has_exactly_one_winner() {
301 use std::sync::{mpsc, Arc, Barrier};
302
303 const CONTENDERS: usize = 16;
304
305 if !crate::platform::ipc::endpoint_is_filesystem_backed() {
306 return;
307 }
308
309 let temp = tempfile::tempdir().expect("tempdir");
310 let socket_path = temp.path().join("stale.sock");
311 let socket_path = socket_path.to_string_lossy().into_owned();
312 let endpoint = crate::platform::ipc::Endpoint::new(socket_path.clone())
313 .expect("construct stale endpoint");
314 let mut stale_listener = Listener::bind(&endpoint).expect("seed stale endpoint");
315 stale_listener.do_not_reclaim_name_on_drop();
316 drop(stale_listener);
317 assert!(endpoint.is_stale(), "seeded endpoint must be stale");
318
319 let start = Arc::new(Barrier::new(CONTENDERS));
320 let release = Arc::new(Barrier::new(CONTENDERS + 1));
321 let (send, receive) = mpsc::channel();
322 let threads: Vec<_> = (0..CONTENDERS)
323 .map(|_| {
324 let socket_path = socket_path.clone();
325 let start = Arc::clone(&start);
326 let release = Arc::clone(&release);
327 let send = send.clone();
328 std::thread::spawn(move || {
329 start.wait();
330 let listener = bind_singleton_with(&socket_path, || {
331 let endpoint = crate::platform::ipc::Endpoint::new(socket_path.clone())?;
332 Listener::bind(&endpoint)
333 });
334 send.send(listener.is_ok()).expect("send bind result");
335 release.wait();
336 drop(listener);
337 })
338 })
339 .collect();
340 drop(send);
341
342 let results: Vec<_> = receive.iter().take(CONTENDERS).collect();
343 assert_eq!(
344 results.iter().filter(|won| **won).count(),
345 1,
346 "stale recovery must not unlink a newly bound winner: {results:?}"
347 );
348
349 release.wait();
350 for thread in threads {
351 thread.join().expect("bind contender");
352 }
353 }
354}