Skip to main content

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}