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::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, &registry, &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}