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::service::ServiceStream;
21use super::single_instance;
22
23/// How long to wait for accepted-but-unfinished connections to drain on
24/// shutdown before aborting the stragglers. Generous enough for a normal
25/// in-flight dispatch+reply, bounded so a stuck or idle client cannot hang
26/// shutdown indefinitely (a service manager would `SIGKILL` us eventually).
27const DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
28
29/// Environment override for [`stream_tick`] (whole seconds; a blank,
30/// non-numeric, or `0` value falls back to [`DEFAULT_STREAM_TICK`]).
31const ENV_STREAM_TICK: &str = "OMNI_DEV_DAEMON_STREAM_TICK";
32
33/// Default push-subscription re-sample interval when `OMNI_DEV_DAEMON_STREAM_TICK`
34/// is unset: how often a subscription re-samples and diffs its snapshot even
35/// without a change notification, so purely on-disk state changes (a branch
36/// switch, new commits) — which fire **no** registry event — are still reflected
37/// within the interval.
38///
39/// Raised from 3 s to 10 s (#1305): registry changes (a window open/close, a
40/// show-closed toggle) still push promptly via the change-notify, so only the
41/// periodic re-sample of on-disk git state slows — a modest, tunable freshness
42/// cost for a background tree view.
43const DEFAULT_STREAM_TICK: Duration = Duration::from_secs(10);
44
45/// The resolved push-subscription re-sample interval: `OMNI_DEV_DAEMON_STREAM_TICK`
46/// (whole seconds) when valid, else [`DEFAULT_STREAM_TICK`].
47///
48/// The worktrees service sizes its coalescing snapshot cache to this same tick
49/// (#1303) by calling straight through here, so the shared `build_tree` runs at
50/// most once per tick regardless of how many windows are subscribed and the two
51/// can never drift.
52pub(crate) fn stream_tick() -> Duration {
53 duration_secs_from_env(ENV_STREAM_TICK, DEFAULT_STREAM_TICK)
54}
55
56/// Reads a whole-seconds [`Duration`] from environment variable `var`, falling
57/// back to `default` when the value is unset, blank, non-numeric, or `0` (a
58/// zero interval would busy-spin the timer loops that consume it). Shared by the
59/// daemon's interval knobs so they parse identically. Delegates to
60/// [`duration_secs_from_raw`] so the parse is unit-tested without touching the
61/// process environment (the Snowflake `heartbeat_interval_from` pattern).
62pub(crate) fn duration_secs_from_env(var: &str, default: Duration) -> Duration {
63 duration_secs_from_raw(std::env::var(var).ok(), default)
64}
65
66/// Parses a whole-seconds [`Duration`] from a raw env value, falling back to
67/// `default` when it is absent, blank, non-numeric, or `0`.
68fn duration_secs_from_raw(raw: Option<String>, default: Duration) -> Duration {
69 raw.and_then(|s| s.trim().parse::<u64>().ok())
70 .filter(|&secs| secs > 0)
71 .map_or(default, Duration::from_secs)
72}
73
74/// Configuration for a [`run`] invocation.
75#[derive(Debug, Clone)]
76pub struct DaemonOptions {
77 /// Path the control socket is bound to.
78 pub socket_path: PathBuf,
79}
80
81/// Runs the daemon until a `SIGTERM`/`SIGINT` or a built-in `shutdown` op,
82/// then drains every service and removes the socket.
83///
84/// Binding the socket doubles as the single-instance lock (see
85/// [`single_instance`]).
86pub async fn run(registry: ServiceRegistry, opts: DaemonOptions) -> Result<()> {
87 run_with_shutdown(Arc::new(registry), opts, CancellationToken::new()).await
88}
89
90/// Like [`run`], but with a shared registry and an externally-owned token.
91///
92/// The menu-bar host uses this to share the [`ServiceRegistry`] with the tray
93/// and to stop the daemon from a "Quit" menu action via the
94/// [`CancellationToken`].
95pub async fn run_with_shutdown(
96 registry: Arc<ServiceRegistry>,
97 opts: DaemonOptions,
98 shutdown: CancellationToken,
99) -> Result<()> {
100 if let Some(parent) = opts.socket_path.parent() {
101 paths::ensure_dir_0700(parent)?;
102 }
103 // macOS launchd creates the `StandardErrorPath`/`StandardOutPath` log sink
104 // (`daemon.log` beside the socket) under its own umask, not `0600`. Tighten it
105 // to owner-only before anything is written, matching the socket/token posture
106 // — launchd opens the file at spawn, so it already exists and is empty here.
107 // No-op when absent (the systemd-journal path, or a fresh self-bound run) or
108 // already tight (the detached-spawn launcher created it `0600`). See #1316.
109 tighten_daemon_log(&opts.socket_path);
110 paths::check_socket_path_len(&opts.socket_path)?;
111
112 let (listener, socket_activated) = acquire_listener(&opts.socket_path).await?;
113 tracing::info!("daemon listening on {}", opts.socket_path.display());
114
115 lifecycle::install_signal_handlers(shutdown.clone());
116
117 // Connection handlers are tracked here rather than detached, so accepted
118 // requests can be drained on shutdown instead of being abandoned (#992).
119 let mut conns: JoinSet<()> = JoinSet::new();
120 loop {
121 tokio::select! {
122 () = shutdown.cancelled() => break,
123 accepted = listener.accept() => {
124 match accepted {
125 Ok((stream, _addr)) => {
126 conns.spawn(handle_connection(
127 stream,
128 registry.clone(),
129 shutdown.clone(),
130 ));
131 }
132 Err(e) => tracing::warn!("daemon accept error: {e}"),
133 }
134 }
135 // Reap finished handlers during normal operation so the set does
136 // not grow unbounded over a long-lived daemon. The guard disables
137 // this arm when empty (an empty `JoinSet` yields `None` at once,
138 // which would otherwise busy-loop the select).
139 joined = conns.join_next(), if !conns.is_empty() => {
140 if let Some(result) = joined {
141 note_reaped(result);
142 }
143 }
144 }
145 }
146
147 // Close the control socket *before* draining (see #993). The accept loop has
148 // already exited, so any `connect`+`ping` arriving during the drain below
149 // would otherwise sit unaccepted in the backlog and block the caller until
150 // process exit. Dropping the listener makes those connects fail fast
151 // (ECONNREFUSED) on the self-bound path.
152 //
153 // Unlinking the path is conditional. On the self-bound path we remove it here
154 // — rather than after the drain — to avoid a restart race: a replacement
155 // daemon could reclaim the stale socket and rebind its *own* listener
156 // mid-drain, and a late unlink would then delete that fresh socket out from
157 // under it. On the socket-activated path the socket inode belongs to the
158 // service manager (launchd on macOS, systemd on Linux), not us: unlinking it
159 // would make the next `connect(path)` hit ENOENT and never re-activate the
160 // daemon — so we leave it in place for the manager to reuse on the next demand
161 // spawn (#1081).
162 drop(listener);
163 if !socket_activated {
164 remove_socket(&opts.socket_path);
165 }
166
167 // Drain in-flight connection handlers before stopping services (#992).
168 drain_connections(&mut conns, DRAIN_TIMEOUT).await;
169
170 tracing::info!("daemon shutting down; draining services");
171 registry.shutdown_all().await;
172 Ok(())
173}
174
175/// Acquires the control-socket listener, returning it alongside whether the
176/// service manager owns the socket inode (i.e. the daemon was socket-activated).
177///
178/// On macOS (launchd) and Linux (systemd) the daemon is normally
179/// **socket-activated**: the service manager creates and owns the listening
180/// socket and hands us the inherited fd (`launchd::launchd_listener` /
181/// `systemd::systemd_listener` — plain code spans, not intra-doc links, since
182/// those modules are OS-gated and absent from the cross-platform docs build), so
183/// there is no bind and no single-instance handling — the manager guarantees at
184/// most one spawn per socket. When that lookup reports no inherited socket (a
185/// manual `daemon run` from a shell, CI, the detached-spawn fallback, or any
186/// other platform) the daemon binds the socket itself via
187/// [`single_instance::bind_or_reclaim`], which doubles as the single-instance
188/// lock. The returned bool gates whether shutdown unlinks the path: a
189/// manager-owned inode must be left in place to re-activate (#1081).
190async fn acquire_listener(socket_path: &Path) -> Result<(UnixListener, bool)> {
191 #[cfg(target_os = "macos")]
192 if let Some(listener) = super::launchd::launchd_listener("Listener")? {
193 tracing::info!("daemon adopting launchd-activated control socket");
194 return Ok((listener, true));
195 }
196 #[cfg(target_os = "linux")]
197 if let Some(listener) = super::systemd::systemd_listener()? {
198 tracing::info!("daemon adopting systemd-activated control socket");
199 return Ok((listener, true));
200 }
201 let listener = single_instance::bind_or_reclaim(socket_path).await?;
202 Ok((listener, false))
203}
204
205/// Tightens the daemon log co-located with the socket (`daemon.log`) to
206/// owner-only (`0600`) if it exists.
207///
208/// The launchd-spawned daemon inherits its stdout/stderr from a
209/// `StandardErrorPath`/`StandardOutPath` sink launchd creates under its own umask
210/// (not `0600`), so the daemon re-tightens it to match the socket/token posture
211/// (#1316). Best-effort and idempotent: absent on the systemd-journal path and a
212/// no-op where the detached-spawn launcher already created it `0600`. A failure
213/// is logged, never fatal — the daemon must still come up.
214fn tighten_daemon_log(socket_path: &Path) {
215 let log_path = paths::log_path_for_socket(socket_path);
216 if !log_path.exists() {
217 return;
218 }
219 if let Err(e) = paths::set_file_0600(&log_path) {
220 tracing::warn!("failed to tighten {} to 0600: {e}", log_path.display());
221 }
222}
223
224/// Removes the control-socket file, tolerating its absence (a replacement
225/// daemon may have already reclaimed it). Any other error is logged, not fatal.
226fn remove_socket(path: &Path) {
227 if let Err(e) = std::fs::remove_file(path) {
228 if e.kind() != std::io::ErrorKind::NotFound {
229 tracing::warn!("failed to remove socket {}: {e}", path.display());
230 }
231 }
232}
233
234/// Logs a reaped connection task that ended by panicking; clean exits and
235/// cancellations are ignored. Shared by the accept-loop reaper and the drain so
236/// both report a crashed handler the same way.
237fn note_reaped(result: Result<(), JoinError>) {
238 if let Err(e) = result {
239 if e.is_panic() {
240 tracing::warn!("daemon connection task panicked: {e}");
241 }
242 }
243}
244
245/// Awaits outstanding connection handlers (bounded by `timeout`) so an accepted
246/// request finishes its dispatch+reply before the daemon tears down. Called once
247/// the accept loop has stopped and *before* `shutdown_all()`, since in-flight
248/// handlers may still be dispatching into live services. Stragglers past the
249/// deadline are aborted rather than allowed to hang shutdown. (`timeout` is a
250/// parameter, fixed to [`DRAIN_TIMEOUT`] in production, so tests can drive the
251/// abort path without a multi-second wait.)
252async fn drain_connections(conns: &mut JoinSet<()>, timeout: Duration) {
253 let count = conns.len();
254 if count == 0 {
255 return;
256 }
257 tracing::info!("draining {count} in-flight connection(s)");
258 let drain = async {
259 while let Some(result) = conns.join_next().await {
260 note_reaped(result);
261 }
262 };
263 if tokio::time::timeout(timeout, drain).await.is_err() {
264 tracing::warn!(
265 "timed out draining connections after {timeout:?}; aborting {} straggler(s)",
266 conns.len()
267 );
268 conns.abort_all();
269 while conns.join_next().await.is_some() {}
270 }
271}
272
273/// Serves one client connection: decode each NDJSON line, dispatch it, and
274/// write back one reply line, until the client hangs up or a read/write error.
275///
276/// The normal request→one-reply path has deliberately no `shutdown.cancelled()`
277/// arm: an accepted line always finishes its dispatch+reply, and shutdown is
278/// handled by the server draining these tasks (see [`drain_connections`]). A
279/// **subscription** op is the exception — it takes over the connection via
280/// [`run_stream`], which *does* select on `shutdown` so a long-lived stream is
281/// torn down promptly on drain rather than waiting out [`DRAIN_TIMEOUT`].
282/// `shutdown` is threaded through for both (also the built-in `shutdown` op, see
283/// [`handle_builtin`]).
284async fn handle_connection(
285 stream: UnixStream,
286 registry: Arc<ServiceRegistry>,
287 shutdown: CancellationToken,
288) {
289 let mut framed = Framed::new(stream, LinesCodec::new_with_max_length(MAX_LINE_BYTES));
290 while let Some(line) = framed.next().await {
291 let line = match line {
292 Ok(line) => line,
293 Err(e) => {
294 // A decode error ends the `Framed` stream (the next poll yields
295 // `None`), so there is nothing more to serve on this connection:
296 // reply once (best effort) and close. `MaxLineLengthExceeded`
297 // additionally puts the codec in discard mode — the
298 // unbounded-growth case the cap exists to stop (#989) — so it
299 // gets a clearer message.
300 let msg = match e {
301 LinesCodecError::MaxLineLengthExceeded => {
302 format!("request line exceeds the {MAX_LINE_BYTES}-byte limit")
303 }
304 LinesCodecError::Io(io) => format!("read error: {io}"),
305 };
306 let _ = send_reply(&mut framed, DaemonReply::err(msg)).await;
307 break;
308 }
309 };
310
311 // Parse once, so a subscription op can be detected before it is
312 // dispatched as a normal one-reply op. A malformed envelope replies with
313 // an error but keeps the connection open, matching the pre-#1267 path.
314 let envelope: DaemonEnvelope = match serde_json::from_str(&line) {
315 Ok(envelope) => envelope,
316 Err(e) => {
317 if !send_reply(
318 &mut framed,
319 DaemonReply::err(format!("invalid envelope: {e}")),
320 )
321 .await
322 {
323 break;
324 }
325 continue;
326 }
327 };
328
329 // A streaming op takes over the connection for its whole lifetime: it
330 // never returns a single reply, so once `run_stream` finishes (client
331 // gone or daemon shutting down) the connection is done.
332 if let Some(name) = envelope.service.as_deref() {
333 if name != DAEMON_SERVICE {
334 if let Some(stream) = registry.subscribe(name, &envelope.op, &envelope.payload) {
335 run_stream(&mut framed, stream, &shutdown).await;
336 return;
337 }
338 }
339 }
340
341 let reply = dispatch_envelope(envelope, ®istry, &shutdown).await;
342 if !send_reply(&mut framed, reply).await {
343 break;
344 }
345 }
346}
347
348/// Drives a push subscription over `framed` until the client goes away or the
349/// daemon shuts down. Sends an initial snapshot, then re-samples the stream on
350/// each change notification and on a periodic [`stream_tick`], pushing **only**
351/// snapshots that differ from the last one sent — so identical frames are never
352/// duplicated (the acceptance criterion). Mirrors the browser bridge's
353/// `start_stream` coalescing shape, but on the control socket.
354///
355/// The subscription owns the connection for its lifetime: any further inbound
356/// line is treated as an explicit cancel and ends the stream, matching the
357/// one-op-per-connection the companion uses (a dedicated subscribe socket).
358async fn run_stream(
359 framed: &mut Framed<UnixStream, LinesCodec>,
360 mut stream: Box<dyn ServiceStream>,
361 shutdown: &CancellationToken,
362) {
363 // Initial snapshot up front. The stream's change source was captured when it
364 // was built (before this snapshot), so the loop below only pushes deltas —
365 // and any change racing this initial sample is caught by the first wakeup.
366 let mut last = stream.snapshot().await;
367 if !send_reply(framed, DaemonReply::ok(last.clone())).await {
368 return;
369 }
370
371 // `interval` fires immediately on the first `tick()`; consume that so the
372 // periodic re-sample starts one full interval out.
373 let mut tick = tokio::time::interval(stream_tick());
374 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
375 tick.tick().await;
376
377 loop {
378 tokio::select! {
379 () = stream.changed() => {}
380 _ = tick.tick() => {}
381 // Reading `framed` serves double duty and every outcome ends the
382 // stream: an inbound line is an explicit cancel, `None` is the client
383 // hanging up, and an `Err` is a read/decode error. `Framed`'s decode
384 // buffer lives in the codec, not this future, so cancelling this arm
385 // mid-poll loses no buffered bytes.
386 _ = framed.next() => break,
387 () = shutdown.cancelled() => break,
388 }
389 // Any wakeup means "maybe changed": re-sample and push only a real delta.
390 let snap = stream.snapshot().await;
391 if snap != last {
392 if !send_reply(framed, DaemonReply::ok(snap.clone())).await {
393 break;
394 }
395 last = snap;
396 }
397 }
398}
399
400/// Encodes and writes one reply line. Returns `false` when the connection
401/// should be closed (encode failed, or the write failed).
402async fn send_reply(framed: &mut Framed<UnixStream, LinesCodec>, reply: DaemonReply) -> bool {
403 let encoded = match serde_json::to_string(&reply) {
404 Ok(encoded) => encoded,
405 Err(e) => {
406 tracing::warn!("failed to encode daemon reply: {e}");
407 return false;
408 }
409 };
410 if let Err(e) = framed.send(encoded).await {
411 tracing::debug!("daemon client write failed: {e}");
412 return false;
413 }
414 true
415}
416
417/// Produces the one-reply response for a (already-parsed, non-streaming)
418/// request envelope. Streaming ops are peeled off earlier in
419/// [`handle_connection`]; everything else routes here.
420async fn dispatch_envelope(
421 envelope: DaemonEnvelope,
422 registry: &ServiceRegistry,
423 shutdown: &CancellationToken,
424) -> DaemonReply {
425 match envelope.service.as_deref() {
426 None | Some(DAEMON_SERVICE) => handle_builtin(&envelope.op, registry, shutdown).await,
427 Some(name) => {
428 // Correlate any HTTP the service issues to the originating client's
429 // invocation, when it threaded its id across the socket (#1198).
430 // Built-in ops issue no HTTP, so only the service path is scoped.
431 let dispatch = registry.dispatch(name, &envelope.op, envelope.payload);
432 let result = match envelope.origin_invocation_id {
433 Some(origin) => crate::request_log::scope_origin_id(origin, dispatch).await,
434 None => dispatch.await,
435 };
436 match result {
437 Ok(payload) => DaemonReply::ok(payload),
438 // `{:#}` includes the full anyhow source chain (e.g. "Snowflake
439 // query failed: snowflake server error (000630): …") so the
440 // client can see the underlying cause, not just the top-level
441 // wrapper.
442 Err(e) => DaemonReply::err(format!("{e:#}")),
443 }
444 }
445 }
446}
447
448/// Handles the daemon's own built-in operations.
449async fn handle_builtin(
450 op: &str,
451 registry: &ServiceRegistry,
452 shutdown: &CancellationToken,
453) -> DaemonReply {
454 match op {
455 // Carry the daemon binary's version and git provenance so a client can
456 // detect it is talking to a stale resident daemon after a binary upgrade
457 // (#1113, #1374). Provenance keys are added only when present, keeping the
458 // reply byte-identical to a pre-#1374 daemon's when built without git.
459 "ping" => {
460 let mut payload = serde_json::Map::new();
461 payload.insert("pong".to_string(), json!(true));
462 payload.insert("version".to_string(), json!(crate::VERSION));
463 if let Ok(serde_json::Value::Object(prov)) =
464 serde_json::to_value(crate::build_info::provenance())
465 {
466 payload.extend(prov);
467 }
468 DaemonReply::ok(serde_json::Value::Object(payload))
469 }
470 "status" => {
471 // `current()` stamps the build-time fields (version + git provenance,
472 // #1374); the runtime rate-limit reading (#1375) is injected after.
473 let mut report = StatusReport::current(registry.statuses().await);
474 report.github_rate_limit = registry.github_rate_limit();
475 match serde_json::to_value(report) {
476 Ok(payload) => DaemonReply::ok(payload),
477 Err(e) => DaemonReply::err(format!("failed to encode status: {e}")),
478 }
479 }
480 "shutdown" => {
481 shutdown.cancel();
482 DaemonReply::ok(json!({ "stopping": true }))
483 }
484 other => DaemonReply::err(format!("unknown daemon op: {other}")),
485 }
486}
487
488/// Resolves the control-socket path: the explicit override, or the per-user
489/// default from [`paths::socket_path`].
490pub fn resolve_socket(socket: Option<PathBuf>) -> Result<PathBuf> {
491 match socket {
492 Some(path) => Ok(path),
493 None => paths::socket_path().context("failed to resolve the default daemon socket path"),
494 }
495}
496
497// The daemon-server tests that bind a socket (and thus mutate the process-global
498// umask via `bind_or_reclaim`) live in `tests/daemon_socket.rs`, isolated in
499// their own process so the umask write cannot race the library's other parallel
500// unit tests. See #1017. The tests below are socket-free: they exercise the
501// connection-draining logic directly, with no `bind`, so they stay here.
502#[cfg(test)]
503#[allow(clippy::unwrap_used, clippy::expect_used)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn tighten_daemon_log_sets_0600_and_tolerates_absence() {
509 use std::os::unix::fs::PermissionsExt;
510
511 let dir = tempfile::tempdir().unwrap();
512 let socket = dir.path().join("daemon.sock");
513
514 // No log yet → best-effort no-op, no panic.
515 tighten_daemon_log(&socket);
516
517 // A launchd-created log lands with a looser umask mode; tighten it to 0600.
518 let log = paths::log_path_for_socket(&socket);
519 std::fs::write(&log, b"daemon listening on ...\n").unwrap();
520 std::fs::set_permissions(&log, std::fs::Permissions::from_mode(0o644)).unwrap();
521 tighten_daemon_log(&socket);
522 assert_eq!(
523 std::fs::metadata(&log).unwrap().permissions().mode() & 0o777,
524 0o600
525 );
526 }
527
528 #[tokio::test]
529 async fn drain_connections_returns_immediately_when_empty() {
530 let mut conns: JoinSet<()> = JoinSet::new();
531 drain_connections(&mut conns, Duration::from_secs(5)).await;
532 assert!(conns.is_empty());
533 }
534
535 #[tokio::test]
536 async fn drain_connections_awaits_completed_tasks() {
537 let mut conns: JoinSet<()> = JoinSet::new();
538 conns.spawn(async {});
539 drain_connections(&mut conns, Duration::from_secs(5)).await;
540 // Every tracked handler was joined.
541 assert!(conns.is_empty());
542 }
543
544 #[tokio::test]
545 async fn drain_connections_times_out_and_aborts_stragglers() {
546 let mut conns: JoinSet<()> = JoinSet::new();
547 // A task that never finishes on its own forces the timeout + abort path;
548 // the only way `drain_connections` can return is by aborting it.
549 conns.spawn(std::future::pending::<()>());
550 drain_connections(&mut conns, Duration::from_millis(50)).await;
551 assert!(
552 conns.is_empty(),
553 "straggler should have been aborted and joined"
554 );
555 }
556
557 #[tokio::test]
558 async fn note_reaped_ignores_success_and_logs_panic() {
559 // A clean exit is a no-op.
560 note_reaped(Ok(()));
561 // A panicked handler yields a `JoinError` with `is_panic()`, which
562 // `note_reaped` logs (and must not propagate).
563 let mut js: JoinSet<()> = JoinSet::new();
564 js.spawn(async { panic!("boom") });
565 let result = js.join_next().await.unwrap();
566 assert!(result.is_err());
567 note_reaped(result);
568 }
569
570 #[test]
571 fn duration_secs_from_raw_parses_seconds_and_falls_back_on_junk() {
572 let default = Duration::from_secs(10);
573 // Absent / blank / non-numeric / zero all fall back to the default;
574 // `0` must not slip through — it would busy-spin the timer loops.
575 assert_eq!(duration_secs_from_raw(None, default), default);
576 assert_eq!(
577 duration_secs_from_raw(Some(String::new()), default),
578 default
579 );
580 assert_eq!(
581 duration_secs_from_raw(Some("garbage".to_string()), default),
582 default
583 );
584 assert_eq!(
585 duration_secs_from_raw(Some(" 0 ".to_string()), default),
586 default
587 );
588 // A valid whole-seconds value wins over the default, trimming whitespace.
589 assert_eq!(
590 duration_secs_from_raw(Some("30".to_string()), default),
591 Duration::from_secs(30)
592 );
593 assert_eq!(
594 duration_secs_from_raw(Some(" 5\n".to_string()), default),
595 Duration::from_secs(5)
596 );
597 }
598
599 // --- Push-subscription streaming (#1267) --------------------------------
600 //
601 // `UnixStream::pair()` is an unbound, connected socket pair — no `bind`, so
602 // no umask mutation — so these `run_stream` tests stay here (in-process)
603 // rather than in the socket-binding `tests/daemon_socket.rs` binary.
604
605 use std::sync::Mutex as StdMutex;
606 use tokio::io::{AsyncBufReadExt, BufReader};
607 use tokio::sync::watch;
608
609 /// A controllable [`ServiceStream`] for driving `run_stream` directly: the
610 /// test bumps `tx` to wake it and swaps `snap` to change what it reports.
611 struct FakeStream {
612 rx: watch::Receiver<u64>,
613 snap: Arc<StdMutex<serde_json::Value>>,
614 }
615
616 #[async_trait::async_trait]
617 impl ServiceStream for FakeStream {
618 async fn changed(&mut self) {
619 // Mirror the real impl: park (rather than spin) once the sender drops.
620 if self.rx.changed().await.is_err() {
621 std::future::pending::<()>().await;
622 }
623 }
624 async fn snapshot(&self) -> serde_json::Value {
625 self.snap.lock().unwrap().clone()
626 }
627 }
628
629 /// Reads one NDJSON reply line from the client end, asserting it is not EOF.
630 /// Generic over the reader so it works on both an owned `BufReader<UnixStream>`
631 /// and one wrapping a `&mut UnixStream` (test 2 keeps the stream to write to).
632 async fn read_reply<R: tokio::io::AsyncBufRead + Unpin>(reader: &mut R) -> DaemonReply {
633 let mut line = String::new();
634 let n = reader.read_line(&mut line).await.unwrap();
635 assert!(n > 0, "expected a reply line, got EOF");
636 serde_json::from_str(line.trim_end()).unwrap()
637 }
638
639 #[tokio::test]
640 async fn run_stream_pushes_initial_then_deltas_and_dedupes() {
641 let (client, server) = UnixStream::pair().unwrap();
642 let (tx, rx) = watch::channel(0u64);
643 let snap = Arc::new(StdMutex::new(json!({ "n": 0 })));
644 let fake = FakeStream {
645 rx,
646 snap: snap.clone(),
647 };
648 let shutdown = CancellationToken::new();
649 let server_shutdown = shutdown.clone();
650
651 let server_task = tokio::spawn(async move {
652 let mut framed = Framed::new(server, LinesCodec::new_with_max_length(MAX_LINE_BYTES));
653 run_stream(&mut framed, Box::new(fake), &server_shutdown).await;
654 });
655
656 let mut reader = BufReader::new(client);
657
658 // 1) The initial snapshot is pushed up front.
659 let initial = read_reply(&mut reader).await;
660 assert!(initial.ok);
661 assert_eq!(initial.payload, json!({ "n": 0 }));
662
663 // 2) A wake whose snapshot is unchanged is NOT re-sent (the diff dedupes).
664 // Then a real change is. Because the next frame we read is the changed
665 // one, a spurious duplicate of `{n:0}` would fail this assertion.
666 tx.send(1).unwrap(); // wake; snapshot still {n:0} → suppressed
667 *snap.lock().unwrap() = json!({ "n": 1 });
668 tx.send(2).unwrap(); // wake; snapshot now {n:1} → pushed
669 let delta = read_reply(&mut reader).await;
670 assert_eq!(delta.payload, json!({ "n": 1 }));
671
672 // 3) Shutdown tears the stream down cleanly: the client hits EOF.
673 shutdown.cancel();
674 let mut tail = String::new();
675 let n = reader.read_line(&mut tail).await.unwrap();
676 assert_eq!(n, 0, "stream should close cleanly on shutdown");
677 server_task.await.unwrap();
678 }
679
680 #[tokio::test]
681 async fn run_stream_ends_when_client_sends_a_line() {
682 use tokio::io::AsyncWriteExt;
683
684 let (mut client, server) = UnixStream::pair().unwrap();
685 let (_tx, rx) = watch::channel(0u64);
686 let snap = Arc::new(StdMutex::new(json!({ "n": 0 })));
687 let fake = FakeStream { rx, snap };
688 let shutdown = CancellationToken::new();
689 let server_shutdown = shutdown.clone();
690
691 let server_task = tokio::spawn(async move {
692 let mut framed = Framed::new(server, LinesCodec::new_with_max_length(MAX_LINE_BYTES));
693 run_stream(&mut framed, Box::new(fake), &server_shutdown).await;
694 });
695
696 let mut reader = BufReader::new(&mut client);
697 let _initial = read_reply(&mut reader).await;
698 // Release the borrow of `client` so it can be written to below.
699 drop(reader);
700
701 // Any inbound line is a cancel: the stream ends and the task completes
702 // even though shutdown was never signalled.
703 client.write_all(b"cancel\n").await.unwrap();
704 tokio::time::timeout(Duration::from_secs(2), server_task)
705 .await
706 .expect("run_stream should end after a client line")
707 .unwrap();
708 }
709
710 /// `handle_connection`'s parse/route path: a malformed envelope replies with
711 /// an error but keeps the connection open, and a well-formed non-subscribe op
712 /// then falls through the streaming check to the normal one-reply dispatch.
713 #[tokio::test]
714 async fn handle_connection_rejects_bad_envelope_then_serves_normal_op() {
715 use tokio::io::AsyncWriteExt;
716
717 let (client, server) = UnixStream::pair().unwrap();
718 let mut registry = ServiceRegistry::new();
719 registry.register(Arc::new(
720 crate::daemon::services::worktrees::WorktreesService::new(),
721 ));
722 let shutdown = CancellationToken::new();
723 let task = tokio::spawn(handle_connection(server, Arc::new(registry), shutdown));
724
725 let (read_half, mut write_half) = client.into_split();
726 let mut reader = BufReader::new(read_half);
727
728 // 1) A syntactically invalid line → error reply; the connection stays up.
729 write_half.write_all(b"not json\n").await.unwrap();
730 let bad = read_reply(&mut reader).await;
731 assert!(!bad.ok);
732 assert!(bad.error.unwrap().contains("invalid envelope"));
733
734 // 2) A well-formed non-subscribe op is served on the same connection
735 // (the streaming check declines `list`, so it dispatches normally).
736 let env = serde_json::to_string(&DaemonEnvelope::service(
737 "worktrees",
738 "list",
739 serde_json::Value::Null,
740 ))
741 .unwrap();
742 write_half.write_all(env.as_bytes()).await.unwrap();
743 write_half.write_all(b"\n").await.unwrap();
744 let listed = read_reply(&mut reader).await;
745 assert!(listed.ok);
746 assert!(listed.payload.get("windows").is_some());
747
748 // Client hangs up → the handler task ends cleanly.
749 drop(write_half);
750 drop(reader);
751 tokio::time::timeout(Duration::from_secs(2), task)
752 .await
753 .expect("handler should end after the client hangs up")
754 .unwrap();
755 }
756
757 /// `handle_connection` routes a `subscribe` op into streaming mode: the
758 /// client gets the pushed initial snapshot, and daemon shutdown ends both the
759 /// stream and the handler task.
760 #[tokio::test]
761 async fn handle_connection_enters_streaming_for_subscribe() {
762 use tokio::io::AsyncWriteExt;
763
764 let (client, server) = UnixStream::pair().unwrap();
765 let mut registry = ServiceRegistry::new();
766 registry.register(Arc::new(
767 crate::daemon::services::worktrees::WorktreesService::new(),
768 ));
769 let shutdown = CancellationToken::new();
770 let task = tokio::spawn(handle_connection(
771 server,
772 Arc::new(registry),
773 shutdown.clone(),
774 ));
775
776 let (read_half, mut write_half) = client.into_split();
777 let mut reader = BufReader::new(read_half);
778 let env = serde_json::to_string(&DaemonEnvelope::service(
779 "worktrees",
780 "subscribe",
781 serde_json::Value::Null,
782 ))
783 .unwrap();
784 write_half.write_all(env.as_bytes()).await.unwrap();
785 write_half.write_all(b"\n").await.unwrap();
786
787 // The subscription pushes an initial snapshot (no windows → empty repos),
788 // with the show/hide-closed toggle at its default (show all).
789 let initial = read_reply(&mut reader).await;
790 assert!(initial.ok);
791 assert_eq!(initial.payload, json!({ "repos": [], "show_closed": true }));
792
793 // Shutdown ends the stream and the handler task.
794 shutdown.cancel();
795 tokio::time::timeout(Duration::from_secs(2), task)
796 .await
797 .expect("shutdown should end the streaming handler")
798 .unwrap();
799 }
800
801 /// `run_stream` returns immediately when even the initial snapshot cannot be
802 /// sent (the client is already gone) rather than entering the select loop.
803 #[tokio::test]
804 async fn run_stream_returns_when_initial_send_fails() {
805 let (client, server) = UnixStream::pair().unwrap();
806 // Close the peer before `run_stream` writes, so the first send fails.
807 drop(client);
808 let (_tx, rx) = watch::channel(0u64);
809 let fake = FakeStream {
810 rx,
811 snap: Arc::new(StdMutex::new(json!({ "n": 0 }))),
812 };
813 let shutdown = CancellationToken::new();
814 let mut framed = Framed::new(server, LinesCodec::new_with_max_length(MAX_LINE_BYTES));
815 tokio::time::timeout(
816 Duration::from_secs(2),
817 run_stream(&mut framed, Box::new(fake), &shutdown),
818 )
819 .await
820 .expect("run_stream should return promptly when the initial send fails");
821 }
822}