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, launchd_owned) = 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 launchd-activated path the socket inode belongs to launchd,
105    // not us: unlinking it would make the next `connect(path)` hit ENOENT and
106    // never re-activate the daemon — so we leave it in place for launchd to reuse
107    // on the next demand spawn (#1081).
108    drop(listener);
109    if !launchd_owned {
110        remove_socket(&opts.socket_path);
111    }
112
113    // Drain in-flight connection handlers before stopping services (#992).
114    drain_connections(&mut conns, DRAIN_TIMEOUT).await;
115
116    tracing::info!("daemon shutting down; draining services");
117    registry.shutdown_all().await;
118    Ok(())
119}
120
121/// Acquires the control-socket listener, returning it alongside whether launchd
122/// owns the socket inode.
123///
124/// On macOS the daemon is normally **socket-activated**: launchd creates and owns
125/// the listening socket and hands us the inherited fd (`launchd::launchd_listener`
126/// — a plain code span, not an intra-doc link, since the `launchd` module is
127/// macOS-gated and absent from the cross-platform docs build), so there is
128/// no bind and no single-instance handling — launchd guarantees at most one spawn
129/// per socket. When that lookup reports no inherited socket (a manual
130/// `daemon run` from a shell, CI, or any non-macOS platform) the daemon binds the
131/// socket itself via [`single_instance::bind_or_reclaim`], which doubles as the
132/// single-instance lock. The returned bool gates whether shutdown unlinks the
133/// path: launchd's inode must be left in place to re-activate (#1081).
134async fn acquire_listener(socket_path: &Path) -> Result<(UnixListener, bool)> {
135    #[cfg(target_os = "macos")]
136    if let Some(listener) = super::launchd::launchd_listener("Listener")? {
137        tracing::info!("daemon adopting launchd-activated control socket");
138        return Ok((listener, true));
139    }
140    let listener = single_instance::bind_or_reclaim(socket_path).await?;
141    Ok((listener, false))
142}
143
144/// Removes the control-socket file, tolerating its absence (a replacement
145/// daemon may have already reclaimed it). Any other error is logged, not fatal.
146fn remove_socket(path: &Path) {
147    if let Err(e) = std::fs::remove_file(path) {
148        if e.kind() != std::io::ErrorKind::NotFound {
149            tracing::warn!("failed to remove socket {}: {e}", path.display());
150        }
151    }
152}
153
154/// Logs a reaped connection task that ended by panicking; clean exits and
155/// cancellations are ignored. Shared by the accept-loop reaper and the drain so
156/// both report a crashed handler the same way.
157fn note_reaped(result: Result<(), JoinError>) {
158    if let Err(e) = result {
159        if e.is_panic() {
160            tracing::warn!("daemon connection task panicked: {e}");
161        }
162    }
163}
164
165/// Awaits outstanding connection handlers (bounded by `timeout`) so an accepted
166/// request finishes its dispatch+reply before the daemon tears down. Called once
167/// the accept loop has stopped and *before* `shutdown_all()`, since in-flight
168/// handlers may still be dispatching into live services. Stragglers past the
169/// deadline are aborted rather than allowed to hang shutdown. (`timeout` is a
170/// parameter, fixed to [`DRAIN_TIMEOUT`] in production, so tests can drive the
171/// abort path without a multi-second wait.)
172async fn drain_connections(conns: &mut JoinSet<()>, timeout: Duration) {
173    let count = conns.len();
174    if count == 0 {
175        return;
176    }
177    tracing::info!("draining {count} in-flight connection(s)");
178    let drain = async {
179        while let Some(result) = conns.join_next().await {
180            note_reaped(result);
181        }
182    };
183    if tokio::time::timeout(timeout, drain).await.is_err() {
184        tracing::warn!(
185            "timed out draining connections after {timeout:?}; aborting {} straggler(s)",
186            conns.len()
187        );
188        conns.abort_all();
189        while conns.join_next().await.is_some() {}
190    }
191}
192
193/// Serves one client connection: decode each NDJSON line, dispatch it, and
194/// write back one reply line, until the client hangs up or a read/write error.
195///
196/// There is deliberately no `shutdown.cancelled()` arm here: an accepted line
197/// always finishes its dispatch+reply, and shutdown is handled by the server
198/// draining these tasks (see [`drain_connections`]). `shutdown` is still
199/// threaded through for the built-in `shutdown` op (see [`handle_builtin`]).
200async fn handle_connection(
201    stream: UnixStream,
202    registry: Arc<ServiceRegistry>,
203    shutdown: CancellationToken,
204) {
205    let mut framed = Framed::new(stream, LinesCodec::new_with_max_length(MAX_LINE_BYTES));
206    while let Some(line) = framed.next().await {
207        match line {
208            Ok(line) => {
209                let reply = dispatch_line(&line, &registry, &shutdown).await;
210                if !send_reply(&mut framed, reply).await {
211                    break;
212                }
213            }
214            Err(e) => {
215                // A decode error ends the `Framed` stream (the next poll yields
216                // `None`), so there is nothing more to serve on this connection:
217                // reply once (best effort) and close. `MaxLineLengthExceeded`
218                // additionally puts the codec in discard mode — the
219                // unbounded-growth case the cap exists to stop (#989) — so it
220                // gets a clearer message.
221                let msg = match e {
222                    LinesCodecError::MaxLineLengthExceeded => {
223                        format!("request line exceeds the {MAX_LINE_BYTES}-byte limit")
224                    }
225                    LinesCodecError::Io(io) => format!("read error: {io}"),
226                };
227                let _ = send_reply(&mut framed, DaemonReply::err(msg)).await;
228                break;
229            }
230        }
231    }
232}
233
234/// Encodes and writes one reply line. Returns `false` when the connection
235/// should be closed (encode failed, or the write failed).
236async fn send_reply(framed: &mut Framed<UnixStream, LinesCodec>, reply: DaemonReply) -> bool {
237    let encoded = match serde_json::to_string(&reply) {
238        Ok(encoded) => encoded,
239        Err(e) => {
240            tracing::warn!("failed to encode daemon reply: {e}");
241            return false;
242        }
243    };
244    if let Err(e) = framed.send(encoded).await {
245        tracing::debug!("daemon client write failed: {e}");
246        return false;
247    }
248    true
249}
250
251/// Parses one NDJSON request line and produces its reply.
252async fn dispatch_line(
253    line: &str,
254    registry: &ServiceRegistry,
255    shutdown: &CancellationToken,
256) -> DaemonReply {
257    let envelope: DaemonEnvelope = match serde_json::from_str(line) {
258        Ok(envelope) => envelope,
259        Err(e) => return DaemonReply::err(format!("invalid envelope: {e}")),
260    };
261    match envelope.service.as_deref() {
262        None | Some(DAEMON_SERVICE) => handle_builtin(&envelope.op, registry, shutdown).await,
263        Some(name) => match registry
264            .dispatch(name, &envelope.op, envelope.payload)
265            .await
266        {
267            Ok(payload) => DaemonReply::ok(payload),
268            // `{:#}` includes the full anyhow source chain (e.g. "Snowflake
269            // query failed: snowflake server error (000630): …") so the client
270            // can see the underlying cause, not just the top-level wrapper.
271            Err(e) => DaemonReply::err(format!("{e:#}")),
272        },
273    }
274}
275
276/// Handles the daemon's own built-in operations.
277async fn handle_builtin(
278    op: &str,
279    registry: &ServiceRegistry,
280    shutdown: &CancellationToken,
281) -> DaemonReply {
282    match op {
283        "ping" => DaemonReply::ok(json!({ "pong": true })),
284        "status" => {
285            let report = StatusReport {
286                services: registry.statuses().await,
287            };
288            match serde_json::to_value(report) {
289                Ok(payload) => DaemonReply::ok(payload),
290                Err(e) => DaemonReply::err(format!("failed to encode status: {e}")),
291            }
292        }
293        "shutdown" => {
294            shutdown.cancel();
295            DaemonReply::ok(json!({ "stopping": true }))
296        }
297        other => DaemonReply::err(format!("unknown daemon op: {other}")),
298    }
299}
300
301/// Resolves the control-socket path: the explicit override, or the per-user
302/// default from [`paths::socket_path`].
303pub fn resolve_socket(socket: Option<PathBuf>) -> Result<PathBuf> {
304    match socket {
305        Some(path) => Ok(path),
306        None => paths::socket_path().context("failed to resolve the default daemon socket path"),
307    }
308}
309
310// The daemon-server tests that bind a socket (and thus mutate the process-global
311// umask via `bind_or_reclaim`) live in `tests/daemon_socket.rs`, isolated in
312// their own process so the umask write cannot race the library's other parallel
313// unit tests. See #1017. The tests below are socket-free: they exercise the
314// connection-draining logic directly, with no `bind`, so they stay here.
315#[cfg(test)]
316#[allow(clippy::unwrap_used, clippy::expect_used)]
317mod tests {
318    use super::*;
319
320    #[tokio::test]
321    async fn drain_connections_returns_immediately_when_empty() {
322        let mut conns: JoinSet<()> = JoinSet::new();
323        drain_connections(&mut conns, Duration::from_secs(5)).await;
324        assert!(conns.is_empty());
325    }
326
327    #[tokio::test]
328    async fn drain_connections_awaits_completed_tasks() {
329        let mut conns: JoinSet<()> = JoinSet::new();
330        conns.spawn(async {});
331        drain_connections(&mut conns, Duration::from_secs(5)).await;
332        // Every tracked handler was joined.
333        assert!(conns.is_empty());
334    }
335
336    #[tokio::test]
337    async fn drain_connections_times_out_and_aborts_stragglers() {
338        let mut conns: JoinSet<()> = JoinSet::new();
339        // A task that never finishes on its own forces the timeout + abort path;
340        // the only way `drain_connections` can return is by aborting it.
341        conns.spawn(std::future::pending::<()>());
342        drain_connections(&mut conns, Duration::from_millis(50)).await;
343        assert!(
344            conns.is_empty(),
345            "straggler should have been aborted and joined"
346        );
347    }
348
349    #[tokio::test]
350    async fn note_reaped_ignores_success_and_logs_panic() {
351        // A clean exit is a no-op.
352        note_reaped(Ok(()));
353        // A panicked handler yields a `JoinError` with `is_panic()`, which
354        // `note_reaped` logs (and must not propagate).
355        let mut js: JoinSet<()> = JoinSet::new();
356        js.spawn(async { panic!("boom") });
357        let result = js.join_next().await.unwrap();
358        assert!(result.is_err());
359        note_reaped(result);
360    }
361}