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}