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, ®istry, &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}