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