omni_dev/daemon/single_instance.rs
1//! Single-instance supervision: the exclusive socket bind *is* the lock.
2//!
3//! Binding the control socket fails with `EADDRINUSE` when the path is already
4//! occupied. We then [`ping`](super::client::DaemonClient::ping) it: a live
5//! daemon answers (so we refuse to start a second), while a stale socket left
6//! by a crashed daemon does not (so we reclaim it and rebind).
7//!
8//! The socket is bound owner-private (`0600`) *from birth*: [`bind_private`]
9//! tightens the process umask across the `bind` so the inode is never created
10//! group/world-accessible. This closes the window a post-bind `chmod` would
11//! leave open, so the file mode no longer depends on the parent directory
12//! already being `0700`. See issue #995.
13
14use std::io::ErrorKind;
15use std::path::Path;
16
17use anyhow::{bail, Context, Result};
18use tokio::net::UnixListener;
19
20use super::client::DaemonClient;
21use super::paths;
22
23/// Binds the control socket, reclaiming a stale socket from a crashed daemon
24/// but refusing to displace a live one.
25pub async fn bind_or_reclaim(path: &Path) -> Result<UnixListener> {
26 match bind_private(path) {
27 Ok(listener) => {
28 paths::set_file_0600(path)?;
29 Ok(listener)
30 }
31 Err(e) if e.kind() == ErrorKind::AddrInUse => {
32 if DaemonClient::new(path).ping().await.is_ok() {
33 bail!(
34 "a daemon is already running (socket {}); use `omni-dev daemon status`",
35 path.display()
36 );
37 }
38 tracing::warn!(
39 "reclaiming stale daemon socket at {} (no live daemon answered)",
40 path.display()
41 );
42 std::fs::remove_file(path)
43 .with_context(|| format!("failed to remove stale socket {}", path.display()))?;
44 let listener = bind_private(path)
45 .with_context(|| format!("failed to bind daemon socket {}", path.display()))?;
46 paths::set_file_0600(path)?;
47 Ok(listener)
48 }
49 Err(e) => {
50 Err(e).with_context(|| format!("failed to bind daemon socket {}", path.display()))
51 }
52 }
53}
54
55/// Binds a Unix socket owner-private (`0600`) by masking off the group/world
56/// permission bits while the inode is created, so there is never a window in
57/// which the control socket — over which privileged service ops run — is
58/// group/world-accessible. Returns the raw `io::Error` so callers can still
59/// match `AddrInUse`.
60///
61/// The umask is process-global, so [`UmaskGuard`] restores the prior value the
62/// instant `bind` returns. The guarded span is fully synchronous (no `.await`),
63/// so no other task on this worker thread can observe the tightened umask;
64/// genuinely-parallel OS threads creating files in the same instant are the only
65/// residual exposure, which is acceptable for a one-shot startup bind.
66///
67/// Exposed (`#[doc(hidden)]`, not a stable API) so it can be exercised directly
68/// from `tests/daemon_socket.rs`. That process-global umask write would race
69/// any *other* parallel unit test creating a file in the same instant, so the
70/// tests that drive it live in their own integration-test binary — a separate
71/// process whose umask never touches the library's unit-test threads. See #1017.
72#[doc(hidden)]
73pub fn bind_private(path: &Path) -> std::io::Result<UnixListener> {
74 #[cfg(unix)]
75 let _umask = UmaskGuard::set(nix::sys::stat::Mode::from_bits_truncate(0o177));
76 UnixListener::bind(path)
77}
78
79/// Sets the process umask for the lifetime of the guard, restoring the previous
80/// value on drop.
81#[cfg(unix)]
82struct UmaskGuard(nix::sys::stat::Mode);
83
84#[cfg(unix)]
85impl UmaskGuard {
86 fn set(mask: nix::sys::stat::Mode) -> Self {
87 Self(nix::sys::stat::umask(mask))
88 }
89}
90
91#[cfg(unix)]
92impl Drop for UmaskGuard {
93 fn drop(&mut self) {
94 nix::sys::stat::umask(self.0);
95 }
96}
97
98// The umask-driven `bind_private`/`bind_or_reclaim` tests live in
99// `tests/daemon_socket.rs`: their process-global umask write would race the
100// library's other parallel unit tests, so they run in a separate process. See
101// #1017.