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, ®istry, &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}