cli/client/local_daemon.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Local-daemon auto-detection.
3//!
4//! Every gRPC-using verb in the CLI checks this first. When the per-repo
5//! `.heddle/sockets/grpc.sock` exists and the pidfile points at a live
6//! process, callers can route their RPC over the UDS instead of opening
7//! an in-process [`GrpcLocalService`]. The latency win matters for tight
8//! agent loops.
9//!
10//! Three layers:
11//!
12//! 1. [`detect_local_daemon`] — file-stat probe (pidfile + liveness via
13//! `kill(pid, 0)` + same-executable identity). Cheap, syscall-only,
14//! used as the cheap negative case ("no daemon, fall through to
15//! in-process").
16//! 2. [`detect_local_daemon_with_connect_probe`] — same as (1) but
17//! actually opens a `UnixStream` and checks kernel-reported peer
18//! credentials to confirm the listener is owned by our uid. Catches
19//! the "stale socket file with a live unrelated PID" race.
20//! 3. [`connect_local_daemon_channel`] — full path: build a tonic
21//! [`tonic::transport::Channel`] over the UDS, run the gRPC
22//! `Health.Check` handshake, and cache the working channel for the
23//! rest of the process. This is what the read-shaped CLI verbs
24//! route through.
25//!
26//! All three caches are keyed by canonical heddle-dir path, so a CLI
27//! invocation that touches one repo pays the probe cost exactly once.
28
29use std::{
30 io,
31 path::{Path, PathBuf},
32 sync::{
33 Arc,
34 atomic::{AtomicU64, Ordering},
35 },
36 time::Duration,
37};
38
39#[cfg(unix)]
40use daemon::local_daemon::{PidFileContents, is_heddle_process};
41
42use crate::util::OnceMap;
43
44/// A reachable local daemon — the path of the UDS socket the caller
45/// can connect to. Returned by [`detect_local_daemon`] when the probe
46/// reports `Running`.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct UdsTarget {
49 pub socket_path: PathBuf,
50 pub pid: u32,
51}
52
53/// Cache key — canonical heddle-dir path so two probes from different
54/// CWDs against the same repo share a result.
55type ProbeCacheKey = PathBuf;
56
57/// Process-wide probe cache. Each heddle dir is probed at most once
58/// per process lifetime, after which subsequent calls return the
59/// cached `Option<UdsTarget>` without touching the filesystem.
60///
61/// Keyed by canonical heddle-dir path so a process that touches more
62/// than one repo (test binaries, agent dispatch loops) caches each
63/// repo independently.
64static DETECT_CACHE: OnceMap<ProbeCacheKey, Option<UdsTarget>> = OnceMap::new();
65
66/// Run the probe and, when the daemon is `Running`, return the UDS
67/// target a tonic client can dial. Cached for the process lifetime so
68/// hot agent loops don't pay two stat-syscalls per RPC.
69///
70/// Probing failure (`Absent`, `Stale`) returns `None` — the caller
71/// should fall through to its in-process or remote fallback. The
72/// full `Health.Check` + version handshake over a tonic UDS Channel
73/// layers on top of [`detect_local_daemon_with_connect_probe`] and
74/// [`connect_local_daemon_channel`].
75pub fn detect_local_daemon(heddle_dir: &Path) -> Option<UdsTarget> {
76 let key: ProbeCacheKey = heddle_dir.to_path_buf();
77 DETECT_CACHE.get_or_init_with(&key, || {
78 let probe = probe(heddle_dir);
79 match probe.status {
80 LocalDaemonStatus::Running { pid } => Some(UdsTarget {
81 socket_path: probe.socket_path,
82 pid,
83 }),
84 LocalDaemonStatus::Stale { .. } | LocalDaemonStatus::Absent => None,
85 }
86 })
87}
88
89/// Stronger variant of [`detect_local_daemon`] — runs the file-stat
90/// probe, then attempts a UDS connect to confirm the daemon is
91/// actually accepting connections (not just a stale pidfile that
92/// happens to point at a live unrelated process).
93///
94/// The connect step is intentionally bounded by `timeout`. Default
95/// callers should pass something tight (50ms is plenty for a local
96/// socket) so a hung daemon doesn't stall every CLI invocation.
97///
98/// Returns `None` if either the file-stat probe says `Absent`/`Stale`
99/// or the UDS connect fails / times out. Caches the *first* outcome
100/// for the given heddle dir; subsequent calls are O(1).
101#[cfg(unix)]
102pub async fn detect_local_daemon_with_connect_probe(
103 heddle_dir: &Path,
104 timeout: Duration,
105) -> Option<UdsTarget> {
106 // The file-stat probe handles the cache and the obvious negative
107 // cases; only the positive path needs the live connect.
108 let target = detect_local_daemon(heddle_dir)?;
109 match tokio::time::timeout(
110 timeout,
111 tokio::net::UnixStream::connect(&target.socket_path),
112 )
113 .await
114 {
115 Ok(Ok(stream)) => match check_peer_uid_matches_self(&stream) {
116 Ok(()) => Some(target),
117 Err(_) => None,
118 },
119 // Either the connect errored (socket present but listener
120 // dead — rare but possible during a graceful shutdown) or
121 // the connect timed out (daemon hung). Either way it's not
122 // safe to route RPCs through it. The next probe will retry.
123 Ok(Err(_)) | Err(_) => None,
124 }
125}
126
127/// Process-wide cache of working tonic [`tonic::transport::Channel`]s
128/// keyed by canonical heddle-dir path. Once we've successfully passed
129/// the Health.Check handshake, every subsequent caller in the same
130/// process gets the same channel (which is itself internally pooled
131/// by tonic / hyper).
132///
133/// `Channel` is cheap to clone (it's a handle to the underlying
134/// connection pool), so handing out clones is the fastest way to
135/// amortize the connect cost across a hot agent loop. Keyed by
136/// canonical heddle-dir path so a process touching multiple repos
137/// (test binaries, multi-repo agents) keeps a per-repo channel.
138#[cfg(unix)]
139static CHANNEL_CACHE: OnceMap<ProbeCacheKey, tonic::transport::Channel> = OnceMap::new();
140
141/// Per-heddle-dir count of how many times [`build_channel`] has
142/// actually opened a UDS, constructed a tonic channel, and run the
143/// `Health.Check` handshake. A cache hit in
144/// [`connect_local_daemon_channel`] returns a clone of the cached
145/// channel without entering `build_channel`, so it does not bump this
146/// counter.
147///
148/// Keyed per heddle dir (not process-global) so the count is
149/// deterministic regardless of which other tests run concurrently.
150///
151/// Test-support only — lets the serve_local test assert that the second
152/// `connect_local_daemon_channel` is a true O(1) cache hit (no rebuild)
153/// instead of comparing wall-clock time, which flakes on shared CI
154/// runners (issue #722).
155#[cfg(unix)]
156#[doc(hidden)]
157static CHANNEL_BUILD_COUNT: OnceMap<ProbeCacheKey, Arc<AtomicU64>> = OnceMap::new();
158
159/// Read the [`build_channel`] run count for `heddle_dir`. See
160/// [`CHANNEL_BUILD_COUNT`]. A dir whose channel was never built reads 0.
161#[cfg(unix)]
162#[doc(hidden)]
163pub fn channel_build_count(heddle_dir: &Path) -> u64 {
164 CHANNEL_BUILD_COUNT
165 .get(&heddle_dir.to_path_buf())
166 .map(|c| c.load(Ordering::Relaxed))
167 .unwrap_or(0)
168}
169
170/// Connect-and-handshake outcome for [`connect_local_daemon_channel`].
171///
172/// `target` is repeated for the convenience of callers that only need
173/// to know whether a daemon is reachable; the live `channel` is what
174/// actually issues RPCs.
175#[cfg(unix)]
176#[derive(Debug, Clone)]
177pub struct LocalDaemonChannel {
178 pub target: UdsTarget,
179 pub channel: tonic::transport::Channel,
180}
181
182/// Build a tonic [`tonic::transport::Channel`] over the per-repo UDS,
183/// perform the gRPC `Health.Check` handshake, and return both
184/// alongside the [`UdsTarget`].
185///
186/// On the happy path the channel is cached in [`CHANNEL_CACHE`] for
187/// the lifetime of this process and subsequent calls return clones of
188/// it in O(1). This is the path agent loops should use.
189///
190/// `connect_timeout` bounds the UDS connect *and* the Health.Check —
191/// 50–250ms is appropriate for a same-host socket; anything longer
192/// implies a hung daemon. Returns `None` on any failure mode (no
193/// daemon, connect failed, health refused) — the caller falls through
194/// to its in-process or remote fallback.
195///
196/// # First consumer (TODO)
197///
198/// `cmd_status` is the natural first consumer — its read-shaped output
199/// is built from a handful of `Repository` lookups that the
200/// `OperationLogQueryService` already covers. A future patch should
201/// branch in `crates/cli/src/cli/commands/status.rs::cmd_status` like:
202///
203/// ```ignore
204/// if let Some(LocalDaemonChannel { channel, .. }) =
205/// connect_local_daemon_channel(repo.heddle_dir(), Duration::from_millis(150)).await
206/// {
207/// let mut client = OperationLogQueryServiceClient::new(channel);
208/// // Build StatusOutput from RPCs instead of direct Repository reads.
209/// } else {
210/// // Existing in-process path.
211/// }
212/// ```
213///
214/// Held back from this patch because (a) the query surface doesn't
215/// yet cover every field in `StatusOutput`, and (b) the brief calls
216/// out the channel-construction primitive as the deliverable.
217#[cfg(unix)]
218pub async fn connect_local_daemon_channel(
219 heddle_dir: &Path,
220 connect_timeout: Duration,
221) -> Option<LocalDaemonChannel> {
222 let key: ProbeCacheKey = heddle_dir.to_path_buf();
223 if let Some(channel) = CHANNEL_CACHE.get(&key) {
224 // The detect cache holds the matching target — pull it back
225 // out so the returned struct stays self-contained.
226 let target = detect_local_daemon(heddle_dir)?;
227 return Some(LocalDaemonChannel { target, channel });
228 }
229
230 match build_channel(heddle_dir, connect_timeout).await {
231 Ok(LocalDaemonChannel { target, channel }) => {
232 CHANNEL_CACHE.insert(key, channel.clone());
233 Some(LocalDaemonChannel { target, channel })
234 }
235 Err(_) => None,
236 }
237}
238
239#[cfg(unix)]
240async fn build_channel(
241 heddle_dir: &Path,
242 connect_timeout: Duration,
243) -> std::result::Result<LocalDaemonChannel, ChannelError> {
244 CHANNEL_BUILD_COUNT
245 .get_or_init_with(&heddle_dir.to_path_buf(), || Arc::new(AtomicU64::new(0)))
246 .fetch_add(1, Ordering::Relaxed);
247 let target = detect_local_daemon(heddle_dir).ok_or(ChannelError::NoDaemon)?;
248 // `unix:` URIs aren't usable as the *origin* on a HTTP/2 channel
249 // (the authority pseudo-header has to be a plausible host). The
250 // standard tonic UDS recipe is to give the endpoint an opaque
251 // `http://heddle-uds` URI for routing and override the connector
252 // with a service that returns a `UnixStream` regardless of what
253 // URI it's asked for.
254 let endpoint = tonic::transport::Endpoint::try_from("http://heddle-uds")
255 .map_err(ChannelError::EndpointBuild)?
256 .connect_timeout(connect_timeout);
257
258 let socket_path = target.socket_path.clone();
259 let connector = tower::service_fn(move |_uri: tonic::transport::Uri| {
260 let socket_path = socket_path.clone();
261 async move {
262 let stream = tokio::net::UnixStream::connect(&socket_path).await?;
263 check_peer_uid_matches_self(&stream)?;
264 // tonic 0.14 requires the connector's response type to
265 // implement `hyper::rt::{Read, Write}`. `TokioIo` is the
266 // standard adapter and it's what tonic's own UDS connector
267 // uses internally — see
268 // `tonic/src/transport/channel/uds_connector.rs`.
269 std::io::Result::Ok(hyper_util::rt::TokioIo::new(stream))
270 }
271 });
272
273 let channel = endpoint
274 .connect_with_connector(connector)
275 .await
276 .map_err(ChannelError::Connect)?;
277
278 // Health.Check is the version handshake. Today the local daemon
279 // doesn't install a `tonic_health` reporter, so we expect either
280 // `Ok(Serving)` or `Err(Unimplemented)` — the latter is treated
281 // as "channel works, daemon predates the handshake" and accepted.
282 // Any other error means the channel is wedged and we should fall
283 // back to in-process.
284 let mut health = tonic_health::pb::health_client::HealthClient::new(channel.clone());
285 let request = tonic::Request::new(tonic_health::pb::HealthCheckRequest {
286 // Empty service name → "is the whole server serving?" per the
287 // gRPC health protocol spec.
288 service: String::new(),
289 });
290 match tokio::time::timeout(connect_timeout, health.check(request)).await {
291 Ok(Ok(response)) => {
292 let status = response.into_inner().status;
293 if status == tonic_health::pb::health_check_response::ServingStatus::Serving as i32 {
294 Ok(LocalDaemonChannel { target, channel })
295 } else {
296 Err(ChannelError::HealthNotServing)
297 }
298 }
299 // Unimplemented: daemon doesn't ship Health (today's case).
300 // We still trust the connection — the underlying HTTP/2
301 // handshake succeeded above, which is itself a strong signal.
302 Ok(Err(status)) if status.code() == tonic::Code::Unimplemented => {
303 Ok(LocalDaemonChannel { target, channel })
304 }
305 Ok(Err(status)) => Err(ChannelError::HealthRpc(status)),
306 Err(_elapsed) => Err(ChannelError::HealthRpc(tonic::Status::deadline_exceeded(
307 "Health.Check timed out",
308 ))),
309 }
310}
311
312/// Errors from the channel-build path. Kept private to the module —
313/// callers see `Option<LocalDaemonChannel>` from
314/// [`connect_local_daemon_channel`] and treat `None` as "no daemon,
315/// fall through to in-process".
316#[cfg(unix)]
317#[derive(Debug)]
318#[allow(dead_code)]
319enum ChannelError {
320 /// Detect probe said no daemon (cheap negative case).
321 NoDaemon,
322 /// Tonic refused to build the endpoint URI. Programmer error in
323 /// practice, but we surface it for the test path.
324 EndpointBuild(tonic::transport::Error),
325 /// `connect_with_connector` failed — daemon not accepting.
326 Connect(tonic::transport::Error),
327 /// Health.Check round-trip failed (transport, codec, etc.).
328 HealthRpc(tonic::Status),
329 /// Health.Check came back with `NOT_SERVING`. We don't trust it.
330 HealthNotServing,
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub struct LocalDaemonProbe {
335 pub socket_path: PathBuf,
336 pub pid_path: PathBuf,
337 pub status: LocalDaemonStatus,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq)]
341pub enum LocalDaemonStatus {
342 /// Socket and pidfile both exist, the pid is alive, and the pid resolves
343 /// to the same executable as this client.
344 Running { pid: u32 },
345 /// Pidfile exists but the pid is dead. The socket may be a leftover.
346 Stale { pid: u32 },
347 /// No current-format pidfile.
348 Absent,
349}
350
351/// Per-heddle-dir count of how many times [`probe`] has actually run
352/// the file-stat / liveness syscalls (the "cold setup step"). The
353/// `DETECT_CACHE` lets the warm path skip this: a warm
354/// [`detect_local_daemon`] hit returns the cached `Option<UdsTarget>`
355/// without entering `probe` at all, so it does not bump this counter.
356///
357/// Keyed per heddle dir (not process-global) so a test reading the
358/// count for *its* daemon is unaffected by probes that a concurrently
359/// running test issues against a *different* temp dir — the counter is
360/// deterministic regardless of test parallelism.
361///
362/// Test-support only — lets the serve_local test assert the *mechanism*
363/// (warm path skips the probe) instead of comparing wall-clock time,
364/// which flakes on shared CI runners (issue #722).
365#[doc(hidden)]
366static PROBE_RUN_COUNT: OnceMap<ProbeCacheKey, Arc<AtomicU64>> = OnceMap::new();
367
368/// Read the [`probe`] run count for `heddle_dir`. See
369/// [`PROBE_RUN_COUNT`]. A never-probed dir reads 0.
370#[doc(hidden)]
371pub fn probe_run_count(heddle_dir: &Path) -> u64 {
372 PROBE_RUN_COUNT
373 .get(&heddle_dir.to_path_buf())
374 .map(|c| c.load(Ordering::Relaxed))
375 .unwrap_or(0)
376}
377
378/// Probe the per-repo daemon directory. Cheap (two file stats, `kill(pid, 0)`,
379/// and same-executable identity for live pids).
380pub fn probe(heddle_dir: &Path) -> LocalDaemonProbe {
381 PROBE_RUN_COUNT
382 .get_or_init_with(&heddle_dir.to_path_buf(), || Arc::new(AtomicU64::new(0)))
383 .fetch_add(1, Ordering::Relaxed);
384 let socket_path = heddle_dir.join("sockets").join("grpc.sock");
385 let pid_path = heddle_dir.join("sockets").join("grpc.pid");
386 let status = match read_pid(&pid_path) {
387 Some(pid) if pid_alive(pid) && pid_identity_verified(pid) => {
388 LocalDaemonStatus::Running { pid }
389 }
390 Some(pid) => LocalDaemonStatus::Stale { pid },
391 None => LocalDaemonStatus::Absent,
392 };
393 LocalDaemonProbe {
394 socket_path,
395 pid_path,
396 status,
397 }
398}
399
400fn read_pid(path: &Path) -> Option<u32> {
401 let raw = std::fs::read_to_string(path).ok()?;
402 parse_pidfile(&raw)
403}
404
405#[cfg(unix)]
406fn parse_pidfile(raw: &str) -> Option<u32> {
407 u32::try_from(PidFileContents::parse(raw)?.pid).ok()
408}
409
410#[cfg(not(unix))]
411fn parse_pidfile(_raw: &str) -> Option<u32> {
412 None
413}
414
415#[cfg(unix)]
416fn pid_identity_verified(pid: u32) -> bool {
417 let Ok(pid) = i32::try_from(pid) else {
418 return false;
419 };
420 is_heddle_process(pid)
421}
422
423#[cfg(not(unix))]
424fn pid_identity_verified(_pid: u32) -> bool {
425 false
426}
427
428#[cfg(unix)]
429fn check_peer_uid_matches_self(stream: &tokio::net::UnixStream) -> io::Result<()> {
430 let creds = stream.peer_cred()?;
431 // SAFETY: geteuid() never fails.
432 enforce_peer_uid(creds.uid(), unsafe { libc::geteuid() })
433}
434
435#[cfg(unix)]
436fn enforce_peer_uid(peer_uid: u32, our_uid: u32) -> io::Result<()> {
437 if peer_uid != our_uid {
438 return Err(io::Error::new(
439 io::ErrorKind::PermissionDenied,
440 format!("daemon peer uid {peer_uid} does not match client uid {our_uid}"),
441 ));
442 }
443 Ok(())
444}
445
446#[cfg(unix)]
447fn pid_alive(pid: u32) -> bool {
448 // SAFETY: kill(pid, 0) only validates existence; signal 0 sends nothing.
449 unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
450}
451
452#[cfg(not(unix))]
453fn pid_alive(_pid: u32) -> bool {
454 false
455}
456
457#[cfg(test)]
458mod tests {
459 use std::path::Path;
460
461 use tempfile::TempDir;
462
463 use super::*;
464
465 fn write_pidfile(sockets: &Path, pid: u32) {
466 std::fs::write(
467 sockets.join("grpc.pid"),
468 format!("{pid}\nheddle-agent\n1700000000\n"),
469 )
470 .unwrap();
471 }
472
473 #[test]
474 fn absent_when_no_files() {
475 let temp = TempDir::new().unwrap();
476 let probe = probe(temp.path());
477 assert_eq!(probe.status, LocalDaemonStatus::Absent);
478 }
479
480 #[test]
481 fn stale_when_pidfile_holds_dead_pid() {
482 let temp = TempDir::new().unwrap();
483 let sockets = temp.path().join("sockets");
484 std::fs::create_dir_all(&sockets).unwrap();
485 // PID 2_147_483_646 is well beyond pid_max and not in use.
486 write_pidfile(&sockets, 2_147_483_646);
487 let probe = probe(temp.path());
488 assert!(matches!(probe.status, LocalDaemonStatus::Stale { .. }));
489 }
490
491 #[test]
492 fn running_when_pidfile_holds_self_pid() {
493 let temp = TempDir::new().unwrap();
494 let sockets = temp.path().join("sockets");
495 std::fs::create_dir_all(&sockets).unwrap();
496 write_pidfile(&sockets, std::process::id());
497 let probe = probe(temp.path());
498 match probe.status {
499 LocalDaemonStatus::Running { pid } => assert_eq!(pid, std::process::id()),
500 other => panic!("expected Running, got {other:?}"),
501 }
502 }
503
504 #[cfg(unix)]
505 #[test]
506 fn stale_when_pidfile_holds_live_non_heddle_pid() {
507 let mut child = std::process::Command::new("/bin/sleep")
508 .arg("30")
509 .env_clear()
510 .spawn()
511 .expect("spawn sleep");
512 let temp = TempDir::new().unwrap();
513 let sockets = temp.path().join("sockets");
514 std::fs::create_dir_all(&sockets).unwrap();
515 write_pidfile(&sockets, child.id());
516
517 let probe = probe(temp.path());
518
519 let _ = child.kill();
520 let _ = child.wait();
521 match probe.status {
522 LocalDaemonStatus::Stale { pid } => assert_eq!(pid, child.id()),
523 other => panic!("expected Stale for live non-Heddle pid, got {other:?}"),
524 }
525 }
526
527 #[test]
528 fn detect_returns_target_when_running() {
529 let temp = TempDir::new().unwrap();
530 let sockets = temp.path().join("sockets");
531 std::fs::create_dir_all(&sockets).unwrap();
532 write_pidfile(&sockets, std::process::id());
533 let target = detect_local_daemon(temp.path()).expect("daemon detected");
534 assert_eq!(target.pid, std::process::id());
535 assert!(
536 target.socket_path.ends_with("sockets/grpc.sock"),
537 "socket path was {:?}",
538 target.socket_path
539 );
540 }
541
542 #[test]
543 fn detect_returns_none_when_absent() {
544 let temp = TempDir::new().unwrap();
545 // A fresh temp dir with no `sockets/` subtree — probe returns
546 // Absent, detect collapses that to None.
547 assert!(detect_local_daemon(temp.path()).is_none());
548 }
549
550 #[test]
551 fn absent_when_pidfile_is_bare_pid() {
552 let temp = TempDir::new().unwrap();
553 let sockets = temp.path().join("sockets");
554 std::fs::create_dir_all(&sockets).unwrap();
555 std::fs::write(sockets.join("grpc.pid"), "2147483646\n").unwrap();
556
557 let probe = probe(temp.path());
558
559 assert_eq!(probe.status, LocalDaemonStatus::Absent);
560 }
561
562 #[cfg(unix)]
563 #[test]
564 fn enforce_peer_uid_accepts_matching_uid() {
565 assert!(enforce_peer_uid(1000, 1000).is_ok());
566 }
567
568 #[cfg(unix)]
569 #[test]
570 fn enforce_peer_uid_rejects_mismatched_uid() {
571 let err = enforce_peer_uid(1001, 1000).unwrap_err();
572 assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
573 }
574
575 #[cfg(unix)]
576 #[tokio::test]
577 async fn connect_probe_rejects_socketless_pidfile() {
578 // Pidfile points at our own pid (so the file-stat probe says
579 // `Running`), but no listener is bound to the socket path.
580 // The connect probe must catch this and return None — that's
581 // its whole job.
582 let temp = TempDir::new().unwrap();
583 let sockets = temp.path().join("sockets");
584 std::fs::create_dir_all(&sockets).unwrap();
585 write_pidfile(&sockets, std::process::id());
586 let result = detect_local_daemon_with_connect_probe(
587 temp.path(),
588 std::time::Duration::from_millis(50),
589 )
590 .await;
591 assert!(
592 result.is_none(),
593 "connect probe should reject when no listener is bound"
594 );
595 }
596
597 #[cfg(unix)]
598 #[tokio::test]
599 async fn connect_probe_accepts_live_listener() {
600 use tokio::net::UnixListener;
601 let temp = TempDir::new().unwrap();
602 let sockets = temp.path().join("sockets");
603 std::fs::create_dir_all(&sockets).unwrap();
604 let socket_path = sockets.join("grpc.sock");
605 let _listener = match UnixListener::bind(&socket_path) {
606 Ok(listener) => listener,
607 Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
608 eprintln!("skipping live-listener connect probe test: UDS bind denied: {err}");
609 return;
610 }
611 Err(err) => panic!("bind local daemon socket: {err}"),
612 };
613 write_pidfile(&sockets, std::process::id());
614 let result = detect_local_daemon_with_connect_probe(
615 temp.path(),
616 std::time::Duration::from_millis(200),
617 )
618 .await;
619 assert!(
620 result.is_some(),
621 "connect probe should succeed when a listener is bound"
622 );
623 }
624
625 #[cfg(unix)]
626 #[tokio::test]
627 async fn check_peer_uid_matches_self_accepts_socketpair() {
628 let (peer, _local) = tokio::net::UnixStream::pair().expect("socketpair");
629 assert!(check_peer_uid_matches_self(&peer).is_ok());
630 }
631
632 #[cfg(unix)]
633 #[tokio::test]
634 async fn connect_channel_is_none_when_daemon_absent() {
635 // No pidfile, no socket — `connect_local_daemon_channel`
636 // should short-circuit on the detect probe and return None
637 // without attempting a connect.
638 let temp = TempDir::new().unwrap();
639 let result =
640 connect_local_daemon_channel(temp.path(), std::time::Duration::from_millis(50)).await;
641 assert!(result.is_none());
642 }
643}