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