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