Skip to main content

omni_dev/daemon/
server.rs

1//! The daemon server core: bind the control socket, accept NDJSON connections,
2//! route envelopes to services (or built-in ops), and shut down gracefully.
3
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::time::Duration;
7
8use anyhow::{Context, Result};
9use futures::{SinkExt, StreamExt};
10use serde_json::json;
11use tokio::net::{UnixListener, UnixStream};
12use tokio::task::{JoinError, JoinSet};
13use tokio_util::codec::{Framed, LinesCodec, LinesCodecError};
14use tokio_util::sync::CancellationToken;
15
16use super::lifecycle;
17use super::paths;
18use super::protocol::{DaemonEnvelope, DaemonReply, StatusReport, DAEMON_SERVICE, MAX_LINE_BYTES};
19use super::registry::ServiceRegistry;
20use super::single_instance;
21
22/// How long to wait for accepted-but-unfinished connections to drain on
23/// shutdown before aborting the stragglers. Generous enough for a normal
24/// in-flight dispatch+reply, bounded so a stuck or idle client cannot hang
25/// shutdown indefinitely (a service manager would `SIGKILL` us eventually).
26const DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
27
28/// Configuration for a [`run`] invocation.
29#[derive(Debug, Clone)]
30pub struct DaemonOptions {
31    /// Path the control socket is bound to.
32    pub socket_path: PathBuf,
33}
34
35/// Runs the daemon until a `SIGTERM`/`SIGINT` or a built-in `shutdown` op,
36/// then drains every service and removes the socket.
37///
38/// Binding the socket doubles as the single-instance lock (see
39/// [`single_instance`]).
40pub async fn run(registry: ServiceRegistry, opts: DaemonOptions) -> Result<()> {
41    run_with_shutdown(Arc::new(registry), opts, CancellationToken::new()).await
42}
43
44/// Like [`run`], but with a shared registry and an externally-owned token.
45///
46/// The menu-bar host uses this to share the [`ServiceRegistry`] with the tray
47/// and to stop the daemon from a "Quit" menu action via the
48/// [`CancellationToken`].
49pub async fn run_with_shutdown(
50    registry: Arc<ServiceRegistry>,
51    opts: DaemonOptions,
52    shutdown: CancellationToken,
53) -> Result<()> {
54    if let Some(parent) = opts.socket_path.parent() {
55        paths::ensure_dir_0700(parent)?;
56    }
57    paths::check_socket_path_len(&opts.socket_path)?;
58
59    let (listener, socket_activated) = acquire_listener(&opts.socket_path).await?;
60    tracing::info!("daemon listening on {}", opts.socket_path.display());
61
62    lifecycle::install_signal_handlers(shutdown.clone());
63
64    // Connection handlers are tracked here rather than detached, so accepted
65    // requests can be drained on shutdown instead of being abandoned (#992).
66    let mut conns: JoinSet<()> = JoinSet::new();
67    loop {
68        tokio::select! {
69            () = shutdown.cancelled() => break,
70            accepted = listener.accept() => {
71                match accepted {
72                    Ok((stream, _addr)) => {
73                        conns.spawn(handle_connection(
74                            stream,
75                            registry.clone(),
76                            shutdown.clone(),
77                        ));
78                    }
79                    Err(e) => tracing::warn!("daemon accept error: {e}"),
80                }
81            }
82            // Reap finished handlers during normal operation so the set does
83            // not grow unbounded over a long-lived daemon. The guard disables
84            // this arm when empty (an empty `JoinSet` yields `None` at once,
85            // which would otherwise busy-loop the select).
86            joined = conns.join_next(), if !conns.is_empty() => {
87                if let Some(result) = joined {
88                    note_reaped(result);
89                }
90            }
91        }
92    }
93
94    // Close the control socket *before* draining (see #993). The accept loop has
95    // already exited, so any `connect`+`ping` arriving during the drain below
96    // would otherwise sit unaccepted in the backlog and block the caller until
97    // process exit. Dropping the listener makes those connects fail fast
98    // (ECONNREFUSED) on the self-bound path.
99    //
100    // Unlinking the path is conditional. On the self-bound path we remove it here
101    // — rather than after the drain — to avoid a restart race: a replacement
102    // daemon could reclaim the stale socket and rebind its *own* listener
103    // mid-drain, and a late unlink would then delete that fresh socket out from
104    // under it. On the socket-activated path the socket inode belongs to the
105    // service manager (launchd on macOS, systemd on Linux), not us: unlinking it
106    // would make the next `connect(path)` hit ENOENT and never re-activate the
107    // daemon — so we leave it in place for the manager to reuse on the next demand
108    // spawn (#1081).
109    drop(listener);
110    if !socket_activated {
111        remove_socket(&opts.socket_path);
112    }
113
114    // Drain in-flight connection handlers before stopping services (#992).
115    drain_connections(&mut conns, DRAIN_TIMEOUT).await;
116
117    tracing::info!("daemon shutting down; draining services");
118    registry.shutdown_all().await;
119    Ok(())
120}
121
122/// Acquires the control-socket listener, returning it alongside whether the
123/// service manager owns the socket inode (i.e. the daemon was socket-activated).
124///
125/// On macOS (launchd) and Linux (systemd) the daemon is normally
126/// **socket-activated**: the service manager creates and owns the listening
127/// socket and hands us the inherited fd (`launchd::launchd_listener` /
128/// `systemd::systemd_listener` — plain code spans, not intra-doc links, since
129/// those modules are OS-gated and absent from the cross-platform docs build), so
130/// there is no bind and no single-instance handling — the manager guarantees at
131/// most one spawn per socket. When that lookup reports no inherited socket (a
132/// manual `daemon run` from a shell, CI, the detached-spawn fallback, or any
133/// other platform) the daemon binds the socket itself via
134/// [`single_instance::bind_or_reclaim`], which doubles as the single-instance
135/// lock. The returned bool gates whether shutdown unlinks the path: a
136/// manager-owned inode must be left in place to re-activate (#1081).
137async fn acquire_listener(socket_path: &Path) -> Result<(UnixListener, bool)> {
138    #[cfg(target_os = "macos")]
139    if let Some(listener) = super::launchd::launchd_listener("Listener")? {
140        tracing::info!("daemon adopting launchd-activated control socket");
141        return Ok((listener, true));
142    }
143    #[cfg(target_os = "linux")]
144    if let Some(listener) = super::systemd::systemd_listener()? {
145        tracing::info!("daemon adopting systemd-activated control socket");
146        return Ok((listener, true));
147    }
148    let listener = single_instance::bind_or_reclaim(socket_path).await?;
149    Ok((listener, false))
150}
151
152/// Removes the control-socket file, tolerating its absence (a replacement
153/// daemon may have already reclaimed it). Any other error is logged, not fatal.
154fn remove_socket(path: &Path) {
155    if let Err(e) = std::fs::remove_file(path) {
156        if e.kind() != std::io::ErrorKind::NotFound {
157            tracing::warn!("failed to remove socket {}: {e}", path.display());
158        }
159    }
160}
161
162/// Logs a reaped connection task that ended by panicking; clean exits and
163/// cancellations are ignored. Shared by the accept-loop reaper and the drain so
164/// both report a crashed handler the same way.
165fn note_reaped(result: Result<(), JoinError>) {
166    if let Err(e) = result {
167        if e.is_panic() {
168            tracing::warn!("daemon connection task panicked: {e}");
169        }
170    }
171}
172
173/// Awaits outstanding connection handlers (bounded by `timeout`) so an accepted
174/// request finishes its dispatch+reply before the daemon tears down. Called once
175/// the accept loop has stopped and *before* `shutdown_all()`, since in-flight
176/// handlers may still be dispatching into live services. Stragglers past the
177/// deadline are aborted rather than allowed to hang shutdown. (`timeout` is a
178/// parameter, fixed to [`DRAIN_TIMEOUT`] in production, so tests can drive the
179/// abort path without a multi-second wait.)
180async fn drain_connections(conns: &mut JoinSet<()>, timeout: Duration) {
181    let count = conns.len();
182    if count == 0 {
183        return;
184    }
185    tracing::info!("draining {count} in-flight connection(s)");
186    let drain = async {
187        while let Some(result) = conns.join_next().await {
188            note_reaped(result);
189        }
190    };
191    if tokio::time::timeout(timeout, drain).await.is_err() {
192        tracing::warn!(
193            "timed out draining connections after {timeout:?}; aborting {} straggler(s)",
194            conns.len()
195        );
196        conns.abort_all();
197        while conns.join_next().await.is_some() {}
198    }
199}
200
201/// Serves one client connection: decode each NDJSON line, dispatch it, and
202/// write back one reply line, until the client hangs up or a read/write error.
203///
204/// There is deliberately no `shutdown.cancelled()` arm here: an accepted line
205/// always finishes its dispatch+reply, and shutdown is handled by the server
206/// draining these tasks (see [`drain_connections`]). `shutdown` is still
207/// threaded through for the built-in `shutdown` op (see [`handle_builtin`]).
208async fn handle_connection(
209    stream: UnixStream,
210    registry: Arc<ServiceRegistry>,
211    shutdown: CancellationToken,
212) {
213    let mut framed = Framed::new(stream, LinesCodec::new_with_max_length(MAX_LINE_BYTES));
214    while let Some(line) = framed.next().await {
215        match line {
216            Ok(line) => {
217                let reply = dispatch_line(&line, &registry, &shutdown).await;
218                if !send_reply(&mut framed, reply).await {
219                    break;
220                }
221            }
222            Err(e) => {
223                // A decode error ends the `Framed` stream (the next poll yields
224                // `None`), so there is nothing more to serve on this connection:
225                // reply once (best effort) and close. `MaxLineLengthExceeded`
226                // additionally puts the codec in discard mode — the
227                // unbounded-growth case the cap exists to stop (#989) — so it
228                // gets a clearer message.
229                let msg = match e {
230                    LinesCodecError::MaxLineLengthExceeded => {
231                        format!("request line exceeds the {MAX_LINE_BYTES}-byte limit")
232                    }
233                    LinesCodecError::Io(io) => format!("read error: {io}"),
234                };
235                let _ = send_reply(&mut framed, DaemonReply::err(msg)).await;
236                break;
237            }
238        }
239    }
240}
241
242/// Encodes and writes one reply line. Returns `false` when the connection
243/// should be closed (encode failed, or the write failed).
244async fn send_reply(framed: &mut Framed<UnixStream, LinesCodec>, reply: DaemonReply) -> bool {
245    let encoded = match serde_json::to_string(&reply) {
246        Ok(encoded) => encoded,
247        Err(e) => {
248            tracing::warn!("failed to encode daemon reply: {e}");
249            return false;
250        }
251    };
252    if let Err(e) = framed.send(encoded).await {
253        tracing::debug!("daemon client write failed: {e}");
254        return false;
255    }
256    true
257}
258
259/// Parses one NDJSON request line and produces its reply.
260async fn dispatch_line(
261    line: &str,
262    registry: &ServiceRegistry,
263    shutdown: &CancellationToken,
264) -> DaemonReply {
265    let envelope: DaemonEnvelope = match serde_json::from_str(line) {
266        Ok(envelope) => envelope,
267        Err(e) => return DaemonReply::err(format!("invalid envelope: {e}")),
268    };
269    match envelope.service.as_deref() {
270        None | Some(DAEMON_SERVICE) => handle_builtin(&envelope.op, registry, shutdown).await,
271        Some(name) => match registry
272            .dispatch(name, &envelope.op, envelope.payload)
273            .await
274        {
275            Ok(payload) => DaemonReply::ok(payload),
276            // `{:#}` includes the full anyhow source chain (e.g. "Snowflake
277            // query failed: snowflake server error (000630): …") so the client
278            // can see the underlying cause, not just the top-level wrapper.
279            Err(e) => DaemonReply::err(format!("{e:#}")),
280        },
281    }
282}
283
284/// Handles the daemon's own built-in operations.
285async fn handle_builtin(
286    op: &str,
287    registry: &ServiceRegistry,
288    shutdown: &CancellationToken,
289) -> DaemonReply {
290    match op {
291        "ping" => DaemonReply::ok(json!({ "pong": true })),
292        "status" => {
293            let report = StatusReport {
294                services: registry.statuses().await,
295            };
296            match serde_json::to_value(report) {
297                Ok(payload) => DaemonReply::ok(payload),
298                Err(e) => DaemonReply::err(format!("failed to encode status: {e}")),
299            }
300        }
301        "shutdown" => {
302            shutdown.cancel();
303            DaemonReply::ok(json!({ "stopping": true }))
304        }
305        other => DaemonReply::err(format!("unknown daemon op: {other}")),
306    }
307}
308
309/// Resolves the control-socket path: the explicit override, or the per-user
310/// default from [`paths::socket_path`].
311pub fn resolve_socket(socket: Option<PathBuf>) -> Result<PathBuf> {
312    match socket {
313        Some(path) => Ok(path),
314        None => paths::socket_path().context("failed to resolve the default daemon socket path"),
315    }
316}
317
318// The daemon-server tests that bind a socket (and thus mutate the process-global
319// umask via `bind_or_reclaim`) live in `tests/daemon_socket.rs`, isolated in
320// their own process so the umask write cannot race the library's other parallel
321// unit tests. See #1017. The tests below are socket-free: they exercise the
322// connection-draining logic directly, with no `bind`, so they stay here.
323#[cfg(test)]
324#[allow(clippy::unwrap_used, clippy::expect_used)]
325mod tests {
326    use super::*;
327
328    #[tokio::test]
329    async fn drain_connections_returns_immediately_when_empty() {
330        let mut conns: JoinSet<()> = JoinSet::new();
331        drain_connections(&mut conns, Duration::from_secs(5)).await;
332        assert!(conns.is_empty());
333    }
334
335    #[tokio::test]
336    async fn drain_connections_awaits_completed_tasks() {
337        let mut conns: JoinSet<()> = JoinSet::new();
338        conns.spawn(async {});
339        drain_connections(&mut conns, Duration::from_secs(5)).await;
340        // Every tracked handler was joined.
341        assert!(conns.is_empty());
342    }
343
344    #[tokio::test]
345    async fn drain_connections_times_out_and_aborts_stragglers() {
346        let mut conns: JoinSet<()> = JoinSet::new();
347        // A task that never finishes on its own forces the timeout + abort path;
348        // the only way `drain_connections` can return is by aborting it.
349        conns.spawn(std::future::pending::<()>());
350        drain_connections(&mut conns, Duration::from_millis(50)).await;
351        assert!(
352            conns.is_empty(),
353            "straggler should have been aborted and joined"
354        );
355    }
356
357    #[tokio::test]
358    async fn note_reaped_ignores_success_and_logs_panic() {
359        // A clean exit is a no-op.
360        note_reaped(Ok(()));
361        // A panicked handler yields a `JoinError` with `is_panic()`, which
362        // `note_reaped` logs (and must not propagate).
363        let mut js: JoinSet<()> = JoinSet::new();
364        js.spawn(async { panic!("boom") });
365        let result = js.join_next().await.unwrap();
366        assert!(result.is_err());
367        note_reaped(result);
368    }
369}