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