Skip to main content

running_process/broker/
backend_handle.rs

1//! Public handle for a verified backend daemon.
2//!
3//! `BackendHandle` is the shared probe-and-verify abstraction for broker-managed
4//! daemons and direct-daemon consumers. A cache manifest records where a daemon
5//! is listening and which process identity it claimed when the manifest was
6//! written. Probing turns that persisted identity into an owned handle only
7//! after the endpoint tuple, active IPC response, current boot ID, process
8//! liveness, executable path, and executable digest still match.
9//!
10//! Consumers should use this module at the boundary where they would otherwise
11//! trust a manifest, PID file, socket path, or named-pipe path from disk.
12//!
13//! ```
14//! use running_process::broker::backend_handle::BackendHandle;
15//! use running_process::broker::protocol::CacheManifest;
16//!
17//! fn existing_backend(manifest: &CacheManifest) -> Option<BackendHandle> {
18//!     let handle = BackendHandle::probe_manifest(manifest)?;
19//!     handle.is_alive().then_some(handle)
20//! }
21//! ```
22//!
23//! Direct-daemon consumers that just spawned a backend can persist
24//! [`DaemonProcess`] and later probe it without duplicating the liveness and
25//! executable-hash checks:
26//!
27//! ```no_run
28//! use running_process::broker::backend_handle::{BackendHandle, DaemonProcess};
29//! use running_process::broker::protocol::Endpoint;
30//!
31//! # fn example() -> running_process::broker::backend_handle::Result<()> {
32//! let endpoint = Endpoint {
33//!     namespace_id: "local-dev".to_owned(),
34//!     path: "running-process-example.sock".to_owned(),
35//! };
36//! let daemon = DaemonProcess::current_process(endpoint.clone(), Some(300))?;
37//!
38//! let handle =
39//!     BackendHandle::probe_with_service("soldr", "1.2.3", &endpoint, &daemon)?;
40//! assert_eq!(handle.service_name, "soldr");
41//! # Ok(())
42//! # }
43//! ```
44
45#[cfg(feature = "client")]
46use std::io;
47#[cfg(feature = "client")]
48use std::time::{Duration, Instant};
49
50#[cfg(feature = "client")]
51use crate::broker::backend_lifecycle::identity::IdentityError;
52use crate::broker::backend_lifecycle::probe;
53#[cfg(feature = "client")]
54use crate::broker::backend_lifecycle::probe::ProbeError;
55use crate::broker::backend_lifecycle::verify_pid::ProcessHandle;
56#[cfg(feature = "client")]
57use crate::broker::backend_lifecycle::verify_pid::{self, VerifyPidError};
58#[cfg(feature = "client")]
59use crate::broker::protocol::CacheManifest;
60use crate::broker::protocol::Endpoint;
61
62pub use crate::broker::backend_lifecycle::DaemonProcess;
63
64/// Result type returned by backend-handle operations.
65#[cfg(feature = "client")]
66pub type Result<T> = std::result::Result<T, BackendHandleError>;
67
68/// A verified handle to a running backend daemon.
69///
70/// The handle carries the daemon identity needed to defend against stale
71/// manifests and PID recycling before consumers connect to the IPC endpoint.
72///
73/// A handle is created only through one of the `probe*` constructors. The
74/// constructor performs all identity checks first; successful callers may then
75/// use [`Self::is_alive`] for a cheap liveness check or [`Self::connect`] to
76/// open a fresh local-socket connection.
77pub struct BackendHandle {
78    /// Logical service name from the manifest or direct probe caller.
79    pub service_name: String,
80    /// Service version from the manifest or direct probe caller.
81    pub service_version: String,
82    /// Verified daemon process identity.
83    pub daemon_process: DaemonProcess,
84    /// The OS reference proving this backend is the process we verified.
85    ///
86    /// This used to be two fields under two names -- `pid_handle` on Unix,
87    /// `process_handle` on Windows -- holding the same type on both. The
88    /// hosts differ in what the handle *is* (a pidfd, a kqueue subscription,
89    /// an open process handle), and `platform::process` owns that difference;
90    /// nothing about it reaches this struct, which only ever asks whether the
91    /// process is still alive.
92    pub(crate) process_handle: Option<ProcessHandle>,
93}
94
95impl BackendHandle {
96    /// Connect to an existing backend by endpoint and verify process identity.
97    ///
98    /// This probe verifies the endpoint identity tuple, requires the endpoint
99    /// to answer the nonce-based IPC identity probe, then verifies current boot
100    /// ID, process liveness, executable path, and executable BLAKE3 hash. It
101    /// returns `None` for stale manifests, dead PIDs, mismatched daemon
102    /// binaries, or endpoints that do not answer as the expected backend.
103    ///
104    /// Use this when the caller already has service metadata elsewhere and only
105    /// needs to know whether the daemon identity is still valid.
106    ///
107    /// **BLOCKING.** Performs synchronous IPC up to
108    /// [`probe::DEFAULT_ENDPOINT_PROBE_TIMEOUT`]
109    /// (500 ms). From a tokio task, call from `spawn_blocking` or
110    /// switch to `Self::probe_async` (requires the `client-async`
111    /// feature).
112    ///
113    /// ```no_run
114    /// use running_process::broker::backend_handle::{BackendHandle, DaemonProcess};
115    /// use running_process::broker::protocol::Endpoint;
116    ///
117    /// # fn example(endpoint: Endpoint, expected: DaemonProcess) {
118    /// if let Some(handle) = BackendHandle::probe(&endpoint, &expected) {
119    ///     assert!(handle.is_alive());
120    /// }
121    /// # }
122    /// ```
123    pub fn probe(endpoint: &Endpoint, expected: &DaemonProcess) -> Option<Self> {
124        let process_handle = probe::probe_endpoint(endpoint, expected).ok()?;
125        Some(Self::from_verified(
126            String::new(),
127            String::new(),
128            expected.clone(),
129            process_handle,
130        ))
131    }
132
133    /// Async counterpart of [`Self::probe`] (#414).
134    ///
135    /// Performs the same identity checks but all I/O runs on the
136    /// current tokio runtime, so tokio daemons (zccache, soldr, clud)
137    /// can call this directly instead of wrapping in `spawn_blocking`.
138    ///
139    /// Available when the `client-async` cargo feature is enabled.
140    #[cfg(feature = "client-async")]
141    pub async fn probe_async(endpoint: &Endpoint, expected: &DaemonProcess) -> Option<Self> {
142        Self::probe_with_service_async("", "", endpoint, expected)
143            .await
144            .ok()
145    }
146
147    /// Probe an existing backend and attach service metadata to the handle.
148    ///
149    /// This is the preferred constructor for direct-daemon consumers because it
150    /// preserves the logical service tuple alongside the verified process
151    /// identity.
152    ///
153    /// **BLOCKING.** Performs synchronous IPC up to
154    /// [`probe::DEFAULT_ENDPOINT_PROBE_TIMEOUT`]
155    /// (500 ms). From a tokio task, call from `spawn_blocking` or use
156    /// `Self::probe_with_service_async` (requires the
157    /// `client-async` feature) instead — calling this directly from
158    /// an async context will block the runtime worker thread.
159    ///
160    /// ```no_run
161    /// use running_process::broker::backend_handle::{BackendHandle, DaemonProcess};
162    /// use running_process::broker::protocol::Endpoint;
163    ///
164    /// # fn example(endpoint: Endpoint, expected: DaemonProcess)
165    /// #     -> running_process::broker::backend_handle::Result<BackendHandle>
166    /// # {
167    /// BackendHandle::probe_with_service("zccache", "0.8.0", &endpoint, &expected)
168    /// # }
169    /// ```
170    #[cfg(feature = "client")]
171    pub fn probe_with_service(
172        service_name: impl Into<String>,
173        service_version: impl Into<String>,
174        endpoint: &Endpoint,
175        expected: &DaemonProcess,
176    ) -> Result<Self> {
177        Self::probe_with_service_and_timeout(
178            service_name,
179            service_version,
180            endpoint,
181            expected,
182            probe::DEFAULT_ENDPOINT_PROBE_TIMEOUT,
183        )
184    }
185
186    /// [`Self::probe_with_service`] with a caller-chosen probe deadline.
187    ///
188    /// The default budget assumes a backend running at normal speed. A caller
189    /// that knows its backend is slower for a reason unrelated to health --
190    /// coverage instrumentation (#1114) -- asks for more here rather than the
191    /// default being raised for every consumer.
192    ///
193    /// **BLOCKING.** Performs synchronous IPC up to `timeout`.
194    #[cfg(feature = "client")]
195    pub fn probe_with_service_and_timeout(
196        service_name: impl Into<String>,
197        service_version: impl Into<String>,
198        endpoint: &Endpoint,
199        expected: &DaemonProcess,
200        timeout: std::time::Duration,
201    ) -> Result<Self> {
202        let process_handle = probe::probe_endpoint_with_timeout(endpoint, expected, timeout)?;
203        Ok(Self::from_verified(
204            service_name.into(),
205            service_version.into(),
206            expected.clone(),
207            process_handle,
208        ))
209    }
210
211    /// Async counterpart of [`Self::probe_with_service`] (#414).
212    ///
213    /// Performs the same identity checks (endpoint tuple, PID, exe
214    /// path, executable BLAKE3 hash, boot ID, and the live nonce probe) but all
215    /// I/O runs on the current tokio runtime. This is the preferred
216    /// entry point for tokio daemons (zccache, soldr, clud) — calling
217    /// the blocking [`Self::probe_with_service`] from an async
218    /// context blocks the runtime worker thread.
219    ///
220    /// Available when the `client-async` cargo feature is enabled.
221    ///
222    /// ```no_run
223    /// # #[cfg(feature = "client-async")]
224    /// # async fn example(
225    /// #     endpoint: running_process::broker::protocol::Endpoint,
226    /// #     expected: running_process::broker::backend_handle::DaemonProcess,
227    /// # ) -> running_process::broker::backend_handle::Result<()> {
228    /// use running_process::broker::backend_handle::BackendHandle;
229    ///
230    /// let handle = BackendHandle::probe_with_service_async(
231    ///     "zccache", "0.8.0", &endpoint, &expected,
232    /// ).await?;
233    /// assert!(handle.is_alive());
234    /// # Ok(()) }
235    /// ```
236    #[cfg(feature = "client-async")]
237    pub async fn probe_with_service_async(
238        service_name: impl Into<String>,
239        service_version: impl Into<String>,
240        endpoint: &Endpoint,
241        expected: &DaemonProcess,
242    ) -> Result<Self> {
243        let process_handle =
244            crate::broker::backend_lifecycle::probe_async::probe_endpoint_async(endpoint, expected)
245                .await?;
246        Ok(Self::from_verified(
247            service_name.into(),
248            service_version.into(),
249            expected.clone(),
250            process_handle,
251        ))
252    }
253
254    /// Probe the `current_daemon` recorded in a cache manifest.
255    ///
256    /// Returns `None` when the manifest has no daemon entry or when the daemon
257    /// entry no longer matches a live process on the current boot.
258    ///
259    /// ```
260    /// use running_process::broker::backend_handle::BackendHandle;
261    /// use running_process::broker::protocol::CacheManifest;
262    ///
263    /// # fn example(manifest: &CacheManifest) {
264    /// match BackendHandle::probe_manifest(manifest) {
265    ///     Some(handle) if handle.is_alive() => {
266    ///         // Reuse the verified backend.
267    ///     }
268    ///     _ => {
269    ///         // Spawn or discover a replacement backend.
270    ///     }
271    /// }
272    /// # }
273    /// ```
274    #[cfg(feature = "client")]
275    pub fn probe_manifest(manifest: &CacheManifest) -> Option<Self> {
276        Self::try_from_manifest(manifest).ok().flatten()
277    }
278
279    /// Fallible variant of [`Self::probe_manifest`] that preserves parse errors.
280    ///
281    /// Use this in maintenance tools and diagnostics where malformed manifest
282    /// identities should be reported separately from a normal cache miss.
283    #[cfg(feature = "client")]
284    pub fn try_from_manifest(manifest: &CacheManifest) -> Result<Option<Self>> {
285        let Some(daemon_process) = DaemonProcess::from_manifest_current_daemon(manifest)? else {
286            return Ok(None);
287        };
288        let handle = Self::probe_with_service(
289            manifest.service_name.clone(),
290            manifest.service_version.clone(),
291            &daemon_process.ipc_endpoint,
292            &daemon_process,
293        )?;
294        Ok(Some(handle))
295    }
296
297    /// Check liveness without opening a new IPC connection.
298    ///
299    /// On platforms with an owned process-handle primitive, this checks the
300    /// handle captured during probing. Otherwise it falls back to opening the
301    /// process ID again.
302    #[cfg(feature = "client")]
303    pub fn is_alive(&self) -> bool {
304        self.platform_handle()
305            .map(|handle| handle.is_alive())
306            .unwrap_or_else(|| verify_pid::process_is_alive(self.daemon_process.pid))
307    }
308
309    /// Open a fresh IPC connection to this backend.
310    ///
311    /// The process identity is verified when the handle is created. Callers that
312    /// cache handles for a long time should call [`Self::is_alive`] or reprobe
313    /// from the latest manifest before opening a connection.
314    ///
315    /// ```no_run
316    /// use running_process::broker::backend_handle::BackendHandle;
317    ///
318    /// async fn connect_to_verified_backend(
319    ///     handle: &BackendHandle,
320    /// ) -> running_process::broker::backend_handle::Result<()> {
321    ///     let connection = handle.connect().await?;
322    ///     let _stream = connection.into_inner();
323    ///     Ok(())
324    /// }
325    /// ```
326    #[cfg(feature = "client")]
327    pub async fn connect(&self) -> Result<Connection> {
328        Connection::connect(&self.daemon_process.ipc_endpoint).map_err(BackendHandleError::Connect)
329    }
330
331    /// Duplicate a broker-owned pipe handle into this verified backend process.
332    ///
333    /// This is the Windows bridge between `BackendHandle` identity verification
334    /// and the optional Phase 6 `DuplicateHandle` transport. The caller still
335    /// owns delivery of the paired handoff token to the backend and must wait
336    /// for backend acknowledgement before reporting handoff success.
337    #[cfg(feature = "client")]
338    pub fn try_duplicate_windows_handoff_handle(
339        &self,
340        pipe_handle: crate::broker::server::handoff::WindowsHandleValue,
341        handoff_token: crate::broker::server::handoff::HandoffToken,
342    ) -> crate::broker::server::handoff::DuplicateHandleResult {
343        let attempt = crate::broker::server::handoff::DuplicateHandleAttempt::new(
344            pipe_handle,
345            self.daemon_process.pid,
346            handoff_token,
347        );
348        crate::broker::server::handoff::try_duplicate_handle(&attempt)
349    }
350
351    /// Send a graceful shutdown signal and wait until the process exits.
352    ///
353    /// On Windows this foundation returns `GracefulTerminateUnsupported` until
354    /// the broker shutdown request protocol lands.
355    ///
356    /// Dropping the handle without calling this method leaves the backend
357    /// running.
358    #[cfg(feature = "client")]
359    pub async fn shutdown(self, timeout: Duration) -> Result<()> {
360        verify_pid::signal_terminate(self.daemon_process.pid)?;
361        let deadline = Instant::now() + timeout;
362        while Instant::now() < deadline {
363            if !self.is_alive() {
364                // The broker asked for this daemon to stop and watched it
365                // stop, so the endpoint it was serving is now a dead name.
366                // Nothing else will remove it: a daemon that is signalled
367                // does not run its own cleanup, and under broker-owned bind
368                // the adopted listener carries no reclaim guard at all.
369                //
370                // Stale sockets are not inert here — #519 recorded them
371                // masking real failures as `EADDRINUSE` on bind or
372                // `ECONNREFUSED` on connect.
373                remove_endpoint_socket(&self.daemon_process.ipc_endpoint);
374                return Ok(());
375            }
376            std::thread::sleep(Duration::from_millis(20));
377        }
378        Err(BackendHandleError::ShutdownTimeout {
379            pid: self.daemon_process.pid,
380        })
381    }
382
383    /// Force-kill the daemon process.
384    ///
385    /// This is the last-resort teardown path for a daemon that ignored graceful
386    /// shutdown or whose IPC protocol is unavailable.
387    #[cfg(feature = "client")]
388    pub fn force_kill(self) -> Result<()> {
389        verify_pid::force_kill_pid(self.daemon_process.pid)?;
390        Ok(())
391    }
392
393    fn from_verified(
394        service_name: String,
395        service_version: String,
396        daemon_process: DaemonProcess,
397        process_handle: ProcessHandle,
398    ) -> Self {
399        Self {
400            service_name,
401            service_version,
402            daemon_process,
403            process_handle: Some(process_handle),
404        }
405    }
406
407    #[cfg(feature = "client")]
408    fn platform_handle(&self) -> Option<&ProcessHandle> {
409        self.process_handle.as_ref()
410    }
411}
412
413/// A fresh IPC connection to a verified backend daemon.
414///
415/// `Connection` is intentionally thin: `BackendHandle` owns identity and
416/// liveness, while this type owns a single local-socket stream opened from the
417/// verified endpoint.
418#[cfg(feature = "client")]
419pub struct Connection {
420    stream: crate::platform::ipc::Stream,
421}
422
423#[cfg(feature = "client")]
424impl Connection {
425    /// Connect to a backend endpoint using the platform local-socket name type.
426    pub fn connect(endpoint: &Endpoint) -> io::Result<Self> {
427        if endpoint.path.is_empty() {
428            return Err(io::Error::new(
429                io::ErrorKind::InvalidInput,
430                "backend endpoint path is empty",
431            ));
432        }
433        let endpoint = crate::platform::ipc::Endpoint::new(endpoint.path.clone())?;
434        let stream = crate::platform::ipc::Stream::connect(&endpoint)?;
435        Ok(Self { stream })
436    }
437
438    /// Return the underlying platform stream.
439    pub fn into_inner(self) -> crate::platform::ipc::Stream {
440        self.stream
441    }
442}
443
444/// Errors returned by `BackendHandle`.
445#[cfg(feature = "client")]
446#[derive(Debug, thiserror::Error)]
447pub enum BackendHandleError {
448    /// Daemon identity normalization failed.
449    #[error(transparent)]
450    Identity(#[from] IdentityError),
451    /// Endpoint/process probing failed.
452    #[error(transparent)]
453    Probe(#[from] ProbeError),
454    /// Opening an IPC connection failed.
455    #[error("backend IPC connection failed: {0}")]
456    Connect(io::Error),
457    /// Process verification or signalling failed.
458    #[error(transparent)]
459    VerifyPid(#[from] VerifyPidError),
460    /// Graceful shutdown timed out.
461    #[error("backend shutdown timed out for pid {pid}")]
462    ShutdownTimeout {
463        /// Process ID that did not exit before the timeout.
464        pid: u32,
465    },
466}
467
468/// Remove the socket file backing `endpoint`, if there is one.
469///
470/// Absence is success: a daemon that exited cleanly on its own may already
471/// have reclaimed the name, and racing it is not an error.
472///
473/// Deliberately not called from [`BackendHandle::force_kill`]. That path
474/// signals and returns without confirming the process is gone, so removing
475/// the name there could unlink the socket of a daemon that is still serving —
476/// turning a failed kill into an unreachable-but-live backend, which is worse
477/// than a stale file.
478///
479/// # Why this asks rather than branches on the host
480///
481/// The question is not "am I on Unix", it is "does this endpoint have a name
482/// in the filesystem". A Windows named pipe has no directory entry -- it
483/// disappears with its last handle -- so there is nothing to unlink, and
484/// `platform::ipc` already answers that for whichever transport the host
485/// uses. Branching on the host restates that answer and can disagree with it.
486#[cfg(feature = "client")]
487fn remove_endpoint_socket(endpoint: &Endpoint) {
488    if crate::platform::ipc::endpoint_is_filesystem_backed() {
489        let _ = std::fs::remove_file(&endpoint.path);
490    }
491}
492
493#[cfg(all(test, feature = "client"))]
494mod endpoint_socket_tests {
495    use super::*;
496
497    /// Whether this host names endpoints in the filesystem.
498    ///
499    /// These tests used to be `#[cfg(unix)]`, which said the same thing
500    /// in host terms and so said nothing on a host that changed its
501    /// transport. Asking the facade means the no-unlink case is asserted
502    /// too, rather than the tests simply not existing there.
503    fn endpoints_are_files() -> bool {
504        crate::platform::ipc::endpoint_is_filesystem_backed()
505    }
506
507    fn endpoint_at(path: &std::path::Path) -> Endpoint {
508        Endpoint {
509            namespace_id: "shared".into(),
510            path: path.display().to_string(),
511        }
512    }
513
514    #[test]
515    fn the_socket_file_is_removed() {
516        // The property `shutdown` depends on: once the daemon is confirmed
517        // gone, its endpoint name goes too. Stale sockets are not inert —
518        // #519 recorded them masking real failures as EADDRINUSE on bind and
519        // ECONNREFUSED on connect.
520        let dir = tempfile::tempdir().expect("tempdir");
521        let path = dir.path().join("endpoint.sock");
522        std::fs::write(&path, b"").expect("create the stand-in socket");
523        assert!(path.exists(), "precondition: the file exists");
524
525        remove_endpoint_socket(&endpoint_at(&path));
526
527        if endpoints_are_files() {
528            assert!(!path.exists(), "the endpoint name outlived its daemon");
529        } else {
530            assert!(
531                path.exists(),
532                "a host whose endpoints are not files must not unlink one",
533            );
534        }
535    }
536
537    #[test]
538    fn an_already_removed_socket_is_not_an_error() {
539        // A daemon that exited cleanly may have reclaimed the name first.
540        // Racing it is normal, not a failure — and this function has no way
541        // to report one, so the test exists to pin that it does not panic.
542        let dir = tempfile::tempdir().expect("tempdir");
543        let path = dir.path().join("never-existed.sock");
544        assert!(!path.exists(), "precondition: nothing to remove");
545
546        remove_endpoint_socket(&endpoint_at(&path));
547    }
548
549    #[test]
550    fn a_directory_at_the_endpoint_path_is_left_alone() {
551        // `remove_file` will not remove a directory, which is the behaviour
552        // wanted: an endpoint path that is somehow a directory is a broken
553        // assumption elsewhere, and quietly deleting a tree to satisfy
554        // cleanup would turn that into data loss.
555        let dir = tempfile::tempdir().expect("tempdir");
556        let path = dir.path().join("surprise-directory");
557        std::fs::create_dir(&path).expect("create the directory");
558
559        remove_endpoint_socket(&endpoint_at(&path));
560
561        assert!(path.is_dir(), "cleanup removed a directory");
562    }
563}