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