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 so a client can detect it is talking
456        // to a stale resident daemon after a binary upgrade (#1113).
457        "ping" => DaemonReply::ok(json!({ "pong": true, "version": crate::VERSION })),
458        "status" => {
459            let report = StatusReport {
460                services: registry.statuses().await,
461                version: Some(crate::VERSION.to_string()),
462            };
463            match serde_json::to_value(report) {
464                Ok(payload) => DaemonReply::ok(payload),
465                Err(e) => DaemonReply::err(format!("failed to encode status: {e}")),
466            }
467        }
468        "shutdown" => {
469            shutdown.cancel();
470            DaemonReply::ok(json!({ "stopping": true }))
471        }
472        other => DaemonReply::err(format!("unknown daemon op: {other}")),
473    }
474}
475
476/// Resolves the control-socket path: the explicit override, or the per-user
477/// default from [`paths::socket_path`].
478pub fn resolve_socket(socket: Option<PathBuf>) -> Result<PathBuf> {
479    match socket {
480        Some(path) => Ok(path),
481        None => paths::socket_path().context("failed to resolve the default daemon socket path"),
482    }
483}
484
485// The daemon-server tests that bind a socket (and thus mutate the process-global
486// umask via `bind_or_reclaim`) live in `tests/daemon_socket.rs`, isolated in
487// their own process so the umask write cannot race the library's other parallel
488// unit tests. See #1017. The tests below are socket-free: they exercise the
489// connection-draining logic directly, with no `bind`, so they stay here.
490#[cfg(test)]
491#[allow(clippy::unwrap_used, clippy::expect_used)]
492mod tests {
493    use super::*;
494
495    #[test]
496    fn tighten_daemon_log_sets_0600_and_tolerates_absence() {
497        use std::os::unix::fs::PermissionsExt;
498
499        let dir = tempfile::tempdir().unwrap();
500        let socket = dir.path().join("daemon.sock");
501
502        // No log yet → best-effort no-op, no panic.
503        tighten_daemon_log(&socket);
504
505        // A launchd-created log lands with a looser umask mode; tighten it to 0600.
506        let log = paths::log_path_for_socket(&socket);
507        std::fs::write(&log, b"daemon listening on ...\n").unwrap();
508        std::fs::set_permissions(&log, std::fs::Permissions::from_mode(0o644)).unwrap();
509        tighten_daemon_log(&socket);
510        assert_eq!(
511            std::fs::metadata(&log).unwrap().permissions().mode() & 0o777,
512            0o600
513        );
514    }
515
516    #[tokio::test]
517    async fn drain_connections_returns_immediately_when_empty() {
518        let mut conns: JoinSet<()> = JoinSet::new();
519        drain_connections(&mut conns, Duration::from_secs(5)).await;
520        assert!(conns.is_empty());
521    }
522
523    #[tokio::test]
524    async fn drain_connections_awaits_completed_tasks() {
525        let mut conns: JoinSet<()> = JoinSet::new();
526        conns.spawn(async {});
527        drain_connections(&mut conns, Duration::from_secs(5)).await;
528        // Every tracked handler was joined.
529        assert!(conns.is_empty());
530    }
531
532    #[tokio::test]
533    async fn drain_connections_times_out_and_aborts_stragglers() {
534        let mut conns: JoinSet<()> = JoinSet::new();
535        // A task that never finishes on its own forces the timeout + abort path;
536        // the only way `drain_connections` can return is by aborting it.
537        conns.spawn(std::future::pending::<()>());
538        drain_connections(&mut conns, Duration::from_millis(50)).await;
539        assert!(
540            conns.is_empty(),
541            "straggler should have been aborted and joined"
542        );
543    }
544
545    #[tokio::test]
546    async fn note_reaped_ignores_success_and_logs_panic() {
547        // A clean exit is a no-op.
548        note_reaped(Ok(()));
549        // A panicked handler yields a `JoinError` with `is_panic()`, which
550        // `note_reaped` logs (and must not propagate).
551        let mut js: JoinSet<()> = JoinSet::new();
552        js.spawn(async { panic!("boom") });
553        let result = js.join_next().await.unwrap();
554        assert!(result.is_err());
555        note_reaped(result);
556    }
557
558    #[test]
559    fn duration_secs_from_raw_parses_seconds_and_falls_back_on_junk() {
560        let default = Duration::from_secs(10);
561        // Absent / blank / non-numeric / zero all fall back to the default;
562        // `0` must not slip through — it would busy-spin the timer loops.
563        assert_eq!(duration_secs_from_raw(None, default), default);
564        assert_eq!(
565            duration_secs_from_raw(Some(String::new()), default),
566            default
567        );
568        assert_eq!(
569            duration_secs_from_raw(Some("garbage".to_string()), default),
570            default
571        );
572        assert_eq!(
573            duration_secs_from_raw(Some(" 0 ".to_string()), default),
574            default
575        );
576        // A valid whole-seconds value wins over the default, trimming whitespace.
577        assert_eq!(
578            duration_secs_from_raw(Some("30".to_string()), default),
579            Duration::from_secs(30)
580        );
581        assert_eq!(
582            duration_secs_from_raw(Some("  5\n".to_string()), default),
583            Duration::from_secs(5)
584        );
585    }
586
587    // --- Push-subscription streaming (#1267) --------------------------------
588    //
589    // `UnixStream::pair()` is an unbound, connected socket pair — no `bind`, so
590    // no umask mutation — so these `run_stream` tests stay here (in-process)
591    // rather than in the socket-binding `tests/daemon_socket.rs` binary.
592
593    use std::sync::Mutex as StdMutex;
594    use tokio::io::{AsyncBufReadExt, BufReader};
595    use tokio::sync::watch;
596
597    /// A controllable [`ServiceStream`] for driving `run_stream` directly: the
598    /// test bumps `tx` to wake it and swaps `snap` to change what it reports.
599    struct FakeStream {
600        rx: watch::Receiver<u64>,
601        snap: Arc<StdMutex<serde_json::Value>>,
602    }
603
604    #[async_trait::async_trait]
605    impl ServiceStream for FakeStream {
606        async fn changed(&mut self) {
607            // Mirror the real impl: park (rather than spin) once the sender drops.
608            if self.rx.changed().await.is_err() {
609                std::future::pending::<()>().await;
610            }
611        }
612        async fn snapshot(&self) -> serde_json::Value {
613            self.snap.lock().unwrap().clone()
614        }
615    }
616
617    /// Reads one NDJSON reply line from the client end, asserting it is not EOF.
618    /// Generic over the reader so it works on both an owned `BufReader<UnixStream>`
619    /// and one wrapping a `&mut UnixStream` (test 2 keeps the stream to write to).
620    async fn read_reply<R: tokio::io::AsyncBufRead + Unpin>(reader: &mut R) -> DaemonReply {
621        let mut line = String::new();
622        let n = reader.read_line(&mut line).await.unwrap();
623        assert!(n > 0, "expected a reply line, got EOF");
624        serde_json::from_str(line.trim_end()).unwrap()
625    }
626
627    #[tokio::test]
628    async fn run_stream_pushes_initial_then_deltas_and_dedupes() {
629        let (client, server) = UnixStream::pair().unwrap();
630        let (tx, rx) = watch::channel(0u64);
631        let snap = Arc::new(StdMutex::new(json!({ "n": 0 })));
632        let fake = FakeStream {
633            rx,
634            snap: snap.clone(),
635        };
636        let shutdown = CancellationToken::new();
637        let server_shutdown = shutdown.clone();
638
639        let server_task = tokio::spawn(async move {
640            let mut framed = Framed::new(server, LinesCodec::new_with_max_length(MAX_LINE_BYTES));
641            run_stream(&mut framed, Box::new(fake), &server_shutdown).await;
642        });
643
644        let mut reader = BufReader::new(client);
645
646        // 1) The initial snapshot is pushed up front.
647        let initial = read_reply(&mut reader).await;
648        assert!(initial.ok);
649        assert_eq!(initial.payload, json!({ "n": 0 }));
650
651        // 2) A wake whose snapshot is unchanged is NOT re-sent (the diff dedupes).
652        //    Then a real change is. Because the next frame we read is the changed
653        //    one, a spurious duplicate of `{n:0}` would fail this assertion.
654        tx.send(1).unwrap(); // wake; snapshot still {n:0} → suppressed
655        *snap.lock().unwrap() = json!({ "n": 1 });
656        tx.send(2).unwrap(); // wake; snapshot now {n:1} → pushed
657        let delta = read_reply(&mut reader).await;
658        assert_eq!(delta.payload, json!({ "n": 1 }));
659
660        // 3) Shutdown tears the stream down cleanly: the client hits EOF.
661        shutdown.cancel();
662        let mut tail = String::new();
663        let n = reader.read_line(&mut tail).await.unwrap();
664        assert_eq!(n, 0, "stream should close cleanly on shutdown");
665        server_task.await.unwrap();
666    }
667
668    #[tokio::test]
669    async fn run_stream_ends_when_client_sends_a_line() {
670        use tokio::io::AsyncWriteExt;
671
672        let (mut client, server) = UnixStream::pair().unwrap();
673        let (_tx, rx) = watch::channel(0u64);
674        let snap = Arc::new(StdMutex::new(json!({ "n": 0 })));
675        let fake = FakeStream { rx, snap };
676        let shutdown = CancellationToken::new();
677        let server_shutdown = shutdown.clone();
678
679        let server_task = tokio::spawn(async move {
680            let mut framed = Framed::new(server, LinesCodec::new_with_max_length(MAX_LINE_BYTES));
681            run_stream(&mut framed, Box::new(fake), &server_shutdown).await;
682        });
683
684        let mut reader = BufReader::new(&mut client);
685        let _initial = read_reply(&mut reader).await;
686        // Release the borrow of `client` so it can be written to below.
687        drop(reader);
688
689        // Any inbound line is a cancel: the stream ends and the task completes
690        // even though shutdown was never signalled.
691        client.write_all(b"cancel\n").await.unwrap();
692        tokio::time::timeout(Duration::from_secs(2), server_task)
693            .await
694            .expect("run_stream should end after a client line")
695            .unwrap();
696    }
697
698    /// `handle_connection`'s parse/route path: a malformed envelope replies with
699    /// an error but keeps the connection open, and a well-formed non-subscribe op
700    /// then falls through the streaming check to the normal one-reply dispatch.
701    #[tokio::test]
702    async fn handle_connection_rejects_bad_envelope_then_serves_normal_op() {
703        use tokio::io::AsyncWriteExt;
704
705        let (client, server) = UnixStream::pair().unwrap();
706        let mut registry = ServiceRegistry::new();
707        registry.register(Arc::new(
708            crate::daemon::services::worktrees::WorktreesService::new(),
709        ));
710        let shutdown = CancellationToken::new();
711        let task = tokio::spawn(handle_connection(server, Arc::new(registry), shutdown));
712
713        let (read_half, mut write_half) = client.into_split();
714        let mut reader = BufReader::new(read_half);
715
716        // 1) A syntactically invalid line → error reply; the connection stays up.
717        write_half.write_all(b"not json\n").await.unwrap();
718        let bad = read_reply(&mut reader).await;
719        assert!(!bad.ok);
720        assert!(bad.error.unwrap().contains("invalid envelope"));
721
722        // 2) A well-formed non-subscribe op is served on the same connection
723        //    (the streaming check declines `list`, so it dispatches normally).
724        let env = serde_json::to_string(&DaemonEnvelope::service(
725            "worktrees",
726            "list",
727            serde_json::Value::Null,
728        ))
729        .unwrap();
730        write_half.write_all(env.as_bytes()).await.unwrap();
731        write_half.write_all(b"\n").await.unwrap();
732        let listed = read_reply(&mut reader).await;
733        assert!(listed.ok);
734        assert!(listed.payload.get("windows").is_some());
735
736        // Client hangs up → the handler task ends cleanly.
737        drop(write_half);
738        drop(reader);
739        tokio::time::timeout(Duration::from_secs(2), task)
740            .await
741            .expect("handler should end after the client hangs up")
742            .unwrap();
743    }
744
745    /// `handle_connection` routes a `subscribe` op into streaming mode: the
746    /// client gets the pushed initial snapshot, and daemon shutdown ends both the
747    /// stream and the handler task.
748    #[tokio::test]
749    async fn handle_connection_enters_streaming_for_subscribe() {
750        use tokio::io::AsyncWriteExt;
751
752        let (client, server) = UnixStream::pair().unwrap();
753        let mut registry = ServiceRegistry::new();
754        registry.register(Arc::new(
755            crate::daemon::services::worktrees::WorktreesService::new(),
756        ));
757        let shutdown = CancellationToken::new();
758        let task = tokio::spawn(handle_connection(
759            server,
760            Arc::new(registry),
761            shutdown.clone(),
762        ));
763
764        let (read_half, mut write_half) = client.into_split();
765        let mut reader = BufReader::new(read_half);
766        let env = serde_json::to_string(&DaemonEnvelope::service(
767            "worktrees",
768            "subscribe",
769            serde_json::Value::Null,
770        ))
771        .unwrap();
772        write_half.write_all(env.as_bytes()).await.unwrap();
773        write_half.write_all(b"\n").await.unwrap();
774
775        // The subscription pushes an initial snapshot (no windows → empty repos),
776        // with the show/hide-closed toggle at its default (show all).
777        let initial = read_reply(&mut reader).await;
778        assert!(initial.ok);
779        assert_eq!(initial.payload, json!({ "repos": [], "show_closed": true }));
780
781        // Shutdown ends the stream and the handler task.
782        shutdown.cancel();
783        tokio::time::timeout(Duration::from_secs(2), task)
784            .await
785            .expect("shutdown should end the streaming handler")
786            .unwrap();
787    }
788
789    /// `run_stream` returns immediately when even the initial snapshot cannot be
790    /// sent (the client is already gone) rather than entering the select loop.
791    #[tokio::test]
792    async fn run_stream_returns_when_initial_send_fails() {
793        let (client, server) = UnixStream::pair().unwrap();
794        // Close the peer before `run_stream` writes, so the first send fails.
795        drop(client);
796        let (_tx, rx) = watch::channel(0u64);
797        let fake = FakeStream {
798            rx,
799            snap: Arc::new(StdMutex::new(json!({ "n": 0 }))),
800        };
801        let shutdown = CancellationToken::new();
802        let mut framed = Framed::new(server, LinesCodec::new_with_max_length(MAX_LINE_BYTES));
803        tokio::time::timeout(
804            Duration::from_secs(2),
805            run_stream(&mut framed, Box::new(fake), &shutdown),
806        )
807        .await
808        .expect("run_stream should return promptly when the initial send fails");
809    }
810}