Skip to main content

gwm/
daemon.rs

1//! Daemon mode: a long-running JSON-RPC 2.0 server exposing gwm's
2//! machine-readable surface over a unix domain socket (issue #38,
3//! phase 2). Editors, statusbars, and tooling connect once and call
4//! `list` / `doctor` / `path`, or `subscribe` for pushed updates when
5//! the worktree set changes — instead of spawning `gwm` per query and
6//! parsing human output.
7//!
8//! Layering, deliberately split so the testable core needs no socket and
9//! compiles on every platform:
10//!
11//! - **Pure RPC core** (`parse` → [`dispatch`] → serialize, tied together
12//!   by [`handle_line`]): always compiled, unit-tested in
13//!   `tests/daemon_tests.rs`. Does git I/O against a repo workdir but
14//!   never touches a socket.
15//! - **Socket server** ([`serve`] / [`socket_path`]): `cfg(all(unix,
16//!   feature = "daemon"))`. A thin accept loop that shuttles
17//!   newline-delimited JSON between the socket and [`handle_line`].
18//!
19//! Wire format: newline-delimited JSON (NDJSON). One request object per
20//! line, one response object per line. `subscribe` is the exception — it
21//! turns the connection into a one-way stream of `worktrees.changed`
22//! notifications (the first is the current snapshot).
23//!
24//! The reference scheme in issue #38 calls for a filesystem watch on
25//! `.git/worktrees/`; this MVP uses interval polling instead (no `notify`
26//! dependency, deterministic to test, MSRV-safe). The trade-off — update
27//! latency bounded by the poll interval — is documented on the `daemon`
28//! subcommand's `--poll-ms` flag.
29
30use crate::error::Result;
31use crate::json_api::{self, JsonDoctorReport, JsonPath, JsonWorktree};
32use crate::{config::Config, doctor, worktree};
33use serde::Deserialize;
34use serde_json::{json, Value};
35use std::path::Path;
36
37/// JSON-RPC 2.0 standard error codes (subset we emit).
38pub const PARSE_ERROR: i64 = -32700;
39pub const INVALID_REQUEST: i64 = -32600;
40pub const METHOD_NOT_FOUND: i64 = -32601;
41pub const INVALID_PARAMS: i64 = -32602;
42pub const INTERNAL_ERROR: i64 = -32603;
43
44/// A parsed JSON-RPC 2.0 request. `params` and `id` default so a minimal
45/// `{"method":"list"}` line still parses. When the `id` member is absent
46/// the request is a **notification** (no response is sent — see
47/// [`handle_line`]); an explicit `"id": null` is a request and is echoed
48/// back as `null`. The two are distinguished at parse time in
49/// [`handle_line`], not here.
50#[derive(Debug, Clone, Deserialize)]
51pub struct RpcRequest {
52  #[serde(default)]
53  pub jsonrpc: String,
54  pub method: String,
55  #[serde(default)]
56  pub params: Value,
57  #[serde(default)]
58  pub id: Value,
59}
60
61/// Build a JSON-RPC success envelope echoing the request `id`.
62pub fn success(id: &Value, result: Value) -> Value {
63  json!({ "jsonrpc": "2.0", "result": result, "id": id })
64}
65
66/// Build a JSON-RPC error envelope echoing the request `id`.
67pub fn error(id: &Value, code: i64, message: &str) -> Value {
68  json!({ "jsonrpc": "2.0", "error": { "code": code, "message": message }, "id": id })
69}
70
71/// Open the repo that owns `workdir`. A daemon is pinned to one repo
72/// (the one it was launched in); `discover_repo` walks back to the main
73/// workdir if `workdir` is itself a linked worktree.
74fn open_repo(workdir: &Path) -> Result<git2::Repository> {
75  worktree::discover_repo(Some(workdir))
76}
77
78fn run_list(workdir: &Path) -> Result<Vec<JsonWorktree>> {
79  let repo = open_repo(workdir)?;
80  json_api::worktrees(&repo)
81}
82
83fn run_path(workdir: &Path, pattern: &str) -> Result<JsonPath> {
84  let repo = open_repo(workdir)?;
85  let found = worktree::find_fuzzy(&repo, pattern)?;
86  Ok(JsonPath::from(&found))
87}
88
89fn run_doctor(workdir: &Path) -> Result<JsonDoctorReport> {
90  // Mirror `cli::repo_context_lenient` + `cmd_doctor`: lenient config
91  // load and the real global layer, so the daemon's doctor matches
92  // `gwm doctor --format json` byte-for-byte.
93  let repo = open_repo(workdir)?;
94  let repo_workdir = repo
95    .workdir()
96    .ok_or(crate::error::GwmError::NotInGitRepo)?
97    .to_path_buf();
98  let config = Config::load_for_repo(&repo_workdir).unwrap_or_default();
99  let global = crate::config::global_config_path();
100  let ctx = doctor::DoctorCtx {
101    repo_workdir: &repo_workdir,
102    repo: &repo,
103    config: &config,
104    global_config_path: global.as_deref(),
105  };
106  Ok(JsonDoctorReport::from(&doctor::run(&ctx)?))
107}
108
109/// Route one parsed request to its handler and build the response
110/// envelope. Pure of any socket concern; git I/O only. The single place
111/// that knows the method set, shared verbatim by the CLI-equivalent
112/// surface so `list`/`doctor`/`path` over RPC match the `--format=json`
113/// flags.
114pub fn dispatch(workdir: &Path, req: &RpcRequest) -> Value {
115  let id = &req.id;
116  match req.method.as_str() {
117    "list" => match run_list(workdir) {
118      Ok(list) => match serde_json::to_value(list) {
119        Ok(v) => success(id, v),
120        Err(e) => error(id, INTERNAL_ERROR, &e.to_string()),
121      },
122      Err(e) => error(id, INTERNAL_ERROR, &e.to_string()),
123    },
124    "doctor" => match run_doctor(workdir) {
125      Ok(report) => match serde_json::to_value(report) {
126        Ok(v) => success(id, v),
127        Err(e) => error(id, INTERNAL_ERROR, &e.to_string()),
128      },
129      Err(e) => error(id, INTERNAL_ERROR, &e.to_string()),
130    },
131    "path" => match req.params.get("pattern").and_then(|v| v.as_str()) {
132      None => error(id, INVALID_PARAMS, "method 'path' requires a string 'pattern' param"),
133      Some(pattern) => match run_path(workdir, pattern) {
134        Ok(p) => match serde_json::to_value(p) {
135          Ok(v) => success(id, v),
136          Err(e) => error(id, INTERNAL_ERROR, &e.to_string()),
137        },
138        Err(e) => error(id, INTERNAL_ERROR, &e.to_string()),
139      },
140    },
141    // `subscribe` is handled by the connection loop (it streams
142    // notifications), not here; reaching dispatch with it means a caller
143    // used a request/response transport that can't stream.
144    "subscribe" => error(
145      id,
146      INVALID_PARAMS,
147      "method 'subscribe' is only valid over a streaming socket connection",
148    ),
149    other => error(id, METHOD_NOT_FOUND, &format!("unknown method '{other}'")),
150  }
151}
152
153/// Parse one NDJSON request line and return the serialized response line,
154/// or `None` when no response must be sent.
155///
156/// JSON-RPC 2.0 notification handling: a request object with **no `id`
157/// member** is a notification — it is processed but MUST NOT be answered
158/// (returns `None`). An explicit `"id": null` is a normal request and is
159/// answered with `"id": null`. The absent-vs-null distinction is made on
160/// the raw value here (serde would collapse both to `Value::Null`).
161///
162/// A malformed line yields a JSON-RPC parse error (`null` id) rather than
163/// crashing the connection; a well-formed object that isn't a valid
164/// request yields an invalid-request error.
165pub fn handle_line(workdir: &Path, line: &str) -> Option<String> {
166  let value: Value = match serde_json::from_str(line) {
167    Ok(v) => v,
168    Err(e) => return Some(error(&Value::Null, PARSE_ERROR, &format!("parse error: {e}")).to_string()),
169  };
170  let req: RpcRequest = match serde_json::from_value(value.clone()) {
171    Ok(r) => r,
172    Err(e) => return Some(error(&Value::Null, INVALID_REQUEST, &format!("invalid request: {e}")).to_string()),
173  };
174  // Absent `id` ⇒ notification: process for side effects (none for our
175  // read-only methods) but send nothing back. The `?` short-circuits to
176  // `None` (no response) when the `id` member is missing.
177  value.get("id")?;
178  Some(dispatch(workdir, &req).to_string())
179}
180
181/// Build the `worktrees.changed` notification payload (no `id` — it's a
182/// JSON-RPC notification, not a response). Used for the initial
183/// `subscribe` snapshot and every subsequent change.
184///
185/// `params.schema_version` carries [`crate::contract::SCHEMA_VERSION`] so a
186/// long-lived `subscribe` client can detect a contract drift it was not
187/// built for (issue #317). It is an additive, ignorable field — older
188/// clients that only read `params.worktrees` are unaffected.
189pub fn worktrees_changed_notification(worktrees: &[JsonWorktree]) -> Value {
190  json!({
191    "jsonrpc": "2.0",
192    "method": "worktrees.changed",
193    "params": {
194      "schema_version": crate::contract::SCHEMA_VERSION,
195      "worktrees": worktrees,
196    },
197  })
198}
199
200/// True when two worktree snapshots differ in a way a `subscribe` client
201/// should be notified about.
202///
203/// Deliberately **excludes `age_seconds`**: it is recomputed from the
204/// current time on every poll, so for any non-trunk branch it ticks up
205/// each second. Comparing it (a naive `old != new`) would fire a spurious
206/// `worktrees.changed` on every poll, breaking the documented "one per
207/// detected change" contract. Every other field is compared.
208pub fn worktrees_differ(old: &[JsonWorktree], new: &[JsonWorktree]) -> bool {
209  if old.len() != new.len() {
210    return true;
211  }
212  old.iter().zip(new).any(|(a, b)| {
213    a.name != b.name
214      || a.id != b.id
215      || a.path != b.path
216      || a.branch != b.branch
217      || a.head != b.head
218      || a.is_main != b.is_main
219      || a.is_locked != b.is_locked
220      || a.is_prunable != b.is_prunable
221      || a.status != b.status
222      || a.issue != b.issue
223      || a.pr != b.pr
224  })
225}
226
227// ---------------------------------------------------------------------------
228// Client side — request lines + response/notification parsers (issue #309).
229// Cross-platform and pure: the statusline consumer and any other client
230// reuse these to talk to a running daemon. The socket transport that wraps
231// them lives in the `client` submodule below (unix + `daemon` feature).
232// ---------------------------------------------------------------------------
233
234/// Canonical `list` request line a client writes to the socket.
235pub const LIST_REQUEST: &str = r#"{"jsonrpc":"2.0","method":"list","id":1}"#;
236
237/// Canonical `subscribe` request line a client writes to upgrade the
238/// connection into a one-way `worktrees.changed` stream.
239pub const SUBSCRIBE_REQUEST: &str = r#"{"jsonrpc":"2.0","method":"subscribe","id":1}"#;
240
241/// Parse a `list` JSON-RPC **response** line into its worktree vec — the
242/// client counterpart to [`dispatch`]'s `list` arm. A server-sent `error`
243/// envelope is surfaced as a [`GwmError`] rather than silently yielding an
244/// empty list.
245pub fn parse_list_result(line: &str) -> Result<Vec<JsonWorktree>> {
246  let v: Value = serde_json::from_str(line)
247    .map_err(|e| crate::error::GwmError::Other(format!("daemon: malformed list response: {e}")))?;
248  if let Some(err) = v.get("error") {
249    let msg = err.get("message").and_then(Value::as_str).unwrap_or("unknown error");
250    return Err(crate::error::GwmError::Other(format!("daemon list error: {msg}")));
251  }
252  let result = v
253    .get("result")
254    .ok_or_else(|| crate::error::GwmError::Other("daemon list response missing 'result'".into()))?;
255  serde_json::from_value(result.clone())
256    .map_err(|e| crate::error::GwmError::Other(format!("daemon: cannot decode worktree list: {e}")))
257}
258
259/// Parse a `worktrees.changed` **notification** line into its worktree vec
260/// (the `params.worktrees` array). The client counterpart to
261/// [`worktrees_changed_notification`]; consumed by a `subscribe` stream.
262pub fn parse_worktrees_changed(line: &str) -> Result<Vec<JsonWorktree>> {
263  let v: Value = serde_json::from_str(line)
264    .map_err(|e| crate::error::GwmError::Other(format!("daemon: malformed notification: {e}")))?;
265  let arr = v
266    .get("params")
267    .and_then(|p| p.get("worktrees"))
268    .ok_or_else(|| crate::error::GwmError::Other("daemon notification missing 'params.worktrees'".into()))?;
269  serde_json::from_value(arr.clone())
270    .map_err(|e| crate::error::GwmError::Other(format!("daemon: cannot decode notification worktrees: {e}")))
271}
272
273/// Daemon **client** transport — connect / one-shot `list` / `subscribe`
274/// stream. Unix + `daemon` feature, mirroring the server gate: the pure
275/// parsers above stay cross-platform, only the socket I/O is gated.
276#[cfg(all(unix, feature = "daemon"))]
277pub mod client {
278  use super::*;
279  use crate::error::GwmError;
280  use std::io::{BufRead, BufReader, Write};
281  use std::os::unix::net::UnixStream;
282  use std::time::Duration;
283
284  /// Bounded wait for the daemon's first response. A wedged or foreign process
285  /// can accept the connection and then stay silent; without a deadline the
286  /// blocking read would hang the caller — e.g. a shell prompt that shells out
287  /// to `gwm statusline` would freeze instead of degrading. On timeout the read
288  /// errors, which the CLI treats as the documented blank-line degradation.
289  /// Generous enough not to false-trip a slow `run_list` git scan on a large
290  /// repo. `subscribe` drops it once the first snapshot arrives so a long-lived
291  /// `--watch` stream can wait indefinitely between change pushes.
292  const CLIENT_TIMEOUT: Duration = Duration::from_secs(5);
293
294  fn connect(socket: &Path, timeout: Option<Duration>) -> Result<UnixStream> {
295    let stream = UnixStream::connect(socket)
296      .map_err(|e| GwmError::Other(format!("daemon: cannot connect to {}: {e}", socket.display())))?;
297    stream
298      .set_read_timeout(timeout)
299      .map_err(|e| GwmError::Other(format!("daemon: {e}")))?;
300    stream
301      .set_write_timeout(timeout)
302      .map_err(|e| GwmError::Other(format!("daemon: {e}")))?;
303    Ok(stream)
304  }
305
306  /// One-shot `list`: connect, send the request, read and parse the single
307  /// response line. Powers a non-`--watch` statusline render.
308  pub fn list_once(socket: &Path) -> Result<Vec<JsonWorktree>> {
309    list_once_with_timeout(socket, Some(CLIENT_TIMEOUT))
310  }
311
312  /// [`list_once`] with an explicit read/write deadline. Public so tests can
313  /// drive the timeout path quickly; production callers use [`list_once`],
314  /// which applies [`CLIENT_TIMEOUT`].
315  #[doc(hidden)]
316  pub fn list_once_with_timeout(socket: &Path, timeout: Option<Duration>) -> Result<Vec<JsonWorktree>> {
317    let stream = connect(socket, timeout)?;
318    let mut writer = stream
319      .try_clone()
320      .map_err(|e| GwmError::Other(format!("daemon: {e}")))?;
321    writeln!(writer, "{LIST_REQUEST}").map_err(|e| GwmError::Other(format!("daemon: {e}")))?;
322    writer.flush().map_err(|e| GwmError::Other(format!("daemon: {e}")))?;
323
324    let mut reader = BufReader::new(stream);
325    let mut line = String::new();
326    reader
327      .read_line(&mut line)
328      .map_err(|e| GwmError::Other(format!("daemon: {e}")))?;
329    parse_list_result(line.trim())
330  }
331
332  /// Subscribe to `worktrees.changed`: connect, send `subscribe`, then
333  /// invoke `on_snapshot` once per notification — the initial snapshot plus
334  /// every detected change. The loop ends when `on_snapshot` returns
335  /// `false` or the stream closes. Generic over the callback so a `--watch`
336  /// CLI loops forever while a test stops after a fixed number of updates.
337  pub fn subscribe(socket: &Path, mut on_snapshot: impl FnMut(&[JsonWorktree]) -> bool) -> Result<()> {
338    let stream = connect(socket, Some(CLIENT_TIMEOUT))?;
339    let mut writer = stream
340      .try_clone()
341      .map_err(|e| GwmError::Other(format!("daemon: {e}")))?;
342    writeln!(writer, "{SUBSCRIBE_REQUEST}").map_err(|e| GwmError::Other(format!("daemon: {e}")))?;
343    writer.flush().map_err(|e| GwmError::Other(format!("daemon: {e}")))?;
344
345    let mut reader = BufReader::new(stream);
346    let mut delivered_any = false;
347    let mut line = String::new();
348    loop {
349      line.clear();
350      match reader.read_line(&mut line) {
351        Ok(0) => break, // EOF — peer closed
352        Ok(_) => {}
353        Err(_) => break, // timeout / dead link — end the stream
354      }
355      let trimmed = line.trim();
356      if trimmed.is_empty() {
357        continue;
358      }
359      let worktrees = parse_worktrees_changed(trimmed)?;
360      if !delivered_any {
361        // First snapshot arrived: drop the handshake deadline so the
362        // long-lived stream can wait indefinitely between change pushes.
363        let _ = reader.get_ref().set_read_timeout(None);
364      }
365      delivered_any = true;
366      if !on_snapshot(&worktrees) {
367        break;
368      }
369    }
370    // The stream ended without ever yielding a snapshot: the daemon accepted
371    // then closed before its first push (crash right after `accept`, or a
372    // foreign process on the path). Surface this as an error so the caller's
373    // graceful-degradation branch fires — e.g. `statusline --watch` still
374    // emits its promised empty line instead of nothing (issue #312).
375    if !delivered_any {
376      return Err(GwmError::Other(
377        "daemon: stream closed before the first snapshot".to_string(),
378      ));
379    }
380    Ok(())
381  }
382}
383
384// ---------------------------------------------------------------------------
385// Socket server — unix only, behind the `daemon` feature.
386// ---------------------------------------------------------------------------
387
388#[cfg(all(unix, feature = "daemon"))]
389mod server {
390  use super::*;
391  use crate::error::GwmError;
392  use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
393  use std::os::unix::fs::FileTypeExt;
394  use std::os::unix::net::{UnixListener, UnixStream};
395  use std::path::PathBuf;
396  use std::sync::atomic::{AtomicBool, Ordering};
397  use std::sync::Arc;
398  use std::time::Duration;
399
400  /// How long the accept loop blocks before re-checking the shutdown
401  /// flag. Independent of the worktree poll interval; small enough that a
402  /// test's `serve` thread tears down promptly.
403  const ACCEPT_TICK: Duration = Duration::from_millis(50);
404
405  /// Configuration for [`serve`].
406  pub struct ServeOptions {
407    /// Path to bind the unix domain socket at.
408    pub socket: PathBuf,
409    /// The repo this daemon answers for (its main workdir).
410    pub repo_workdir: PathBuf,
411    /// Interval between worktree-state polls for `subscribe` streams.
412    pub poll_interval: Duration,
413  }
414
415  /// Resolve the default socket path: `$XDG_RUNTIME_DIR/gwm.sock`, falling
416  /// back to `$TMPDIR`, then `/tmp`. `XDG_RUNTIME_DIR` is unset on macOS,
417  /// so the fallback chain matters on the dev box and the macOS CI runner.
418  pub fn socket_path() -> PathBuf {
419    let base = std::env::var_os("XDG_RUNTIME_DIR")
420      .filter(|s| !s.is_empty())
421      .or_else(|| std::env::var_os("TMPDIR").filter(|s| !s.is_empty()))
422      .map(PathBuf::from)
423      .unwrap_or_else(|| PathBuf::from("/tmp"));
424    base.join("gwm.sock")
425  }
426
427  /// If a socket file already exists at `path`, decide whether it's stale.
428  /// A successful connect means a live daemon owns it → refuse. A failed
429  /// connect means the previous daemon crashed and left the file →
430  /// unlink it so `bind` can succeed (a stale socket otherwise fails
431  /// `bind` with `EADDRINUSE`).
432  fn clear_stale_socket(path: &Path) -> Result<()> {
433    // `symlink_metadata` (not `metadata`) so a symlink is seen as a
434    // symlink, not followed to its target.
435    let meta = match std::fs::symlink_metadata(path) {
436      Ok(m) => m,
437      Err(_) => return Ok(()), // nothing there — bind will create it
438    };
439    // Refuse to touch anything that isn't a unix socket. A regular file or
440    // symlink at `--socket <path>` would otherwise be deleted as if it were
441    // a stale socket — a data-loss footgun (issue #38 review).
442    if !meta.file_type().is_socket() {
443      return Err(GwmError::Other(format!(
444        "daemon: refusing to use {}: exists and is not a unix socket",
445        path.display()
446      )));
447    }
448    if UnixStream::connect(path).is_ok() {
449      return Err(GwmError::Other(format!(
450        "daemon: socket {} is already in use by a live daemon",
451        path.display()
452      )));
453    }
454    // A socket that no one is listening on — left by a crashed daemon.
455    // Unlink it so `bind` can succeed (it otherwise fails `EADDRINUSE`).
456    let _ = std::fs::remove_file(path);
457    Ok(())
458  }
459
460  /// Bind the socket and serve connections until `shutdown` flips. Each
461  /// connection is handled on its own detached thread. In production the
462  /// flag never flips (the process runs until killed); tests pass a flag
463  /// they flip on teardown.
464  pub fn serve(opts: &ServeOptions, shutdown: Arc<AtomicBool>) -> Result<()> {
465    clear_stale_socket(&opts.socket)?;
466    let listener = UnixListener::bind(&opts.socket)
467      .map_err(|e| GwmError::Other(format!("daemon: failed to bind {}: {e}", opts.socket.display())))?;
468    listener
469      .set_nonblocking(true)
470      .map_err(|e| GwmError::Other(format!("daemon: set_nonblocking failed: {e}")))?;
471
472    // Announce readiness ONLY now that the socket is bound, so the line
473    // can't precede a bind failure and mislead a wrapper that treats it as
474    // a readiness signal (issue #38 review). stderr keeps stdout clean.
475    eprintln!("gwm daemon listening on {}", opts.socket.display());
476
477    loop {
478      if shutdown.load(Ordering::Relaxed) {
479        break;
480      }
481      match listener.accept() {
482        Ok((stream, _addr)) => {
483          // The listener is non-blocking so the accept loop can poll the
484          // shutdown flag. On macOS the accepted stream INHERITS that
485          // non-blocking flag (unlike Linux, where accept() clears it),
486          // which would make the per-connection blocking read loop spin
487          // out on the first `WouldBlock`. Force the connection back to
488          // blocking so reads wait for the next request.
489          if let Err(e) = stream.set_nonblocking(false) {
490            eprintln!("daemon: failed to set connection blocking: {e}");
491            continue;
492          }
493          let workdir = opts.repo_workdir.clone();
494          let poll = opts.poll_interval;
495          let shutdown = Arc::clone(&shutdown);
496          // Detached: a long-running daemon must not accumulate JoinHandles
497          // for every short-lived client (`nc`, reconnecting integrations).
498          // Each connection thread observes the shared `shutdown` flag and
499          // exits on its own (issue #38 review).
500          std::thread::spawn(move || {
501            handle_connection(stream, &workdir, poll, &shutdown);
502          });
503        }
504        Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
505          std::thread::sleep(ACCEPT_TICK);
506        }
507        Err(e) => {
508          // Transient accept error — log and keep serving rather than
509          // tearing the daemon down.
510          eprintln!("daemon: accept error: {e}");
511          std::thread::sleep(ACCEPT_TICK);
512        }
513      }
514    }
515
516    // Best-effort cleanup so the next launch sees no stale socket.
517    let _ = std::fs::remove_file(&opts.socket);
518    Ok(())
519  }
520
521  /// Serve one connection: a loop of request→response lines, until the
522  /// client disconnects — or, on a `subscribe`, a switch into a one-way
523  /// notification stream.
524  fn handle_connection(stream: UnixStream, workdir: &Path, poll: Duration, shutdown: &AtomicBool) {
525    let read_half = match stream.try_clone() {
526      Ok(s) => s,
527      Err(_) => return,
528    };
529    let mut writer = stream;
530    let reader = BufReader::new(read_half);
531
532    for line in reader.lines() {
533      let line = match line {
534        Ok(l) => l,
535        Err(_) => break,
536      };
537      if line.trim().is_empty() {
538        continue;
539      }
540
541      // Peek the method: `subscribe` upgrades the connection to a stream
542      // and never returns to request/response mode.
543      let is_subscribe = serde_json::from_str::<RpcRequest>(&line)
544        .map(|r| r.method == "subscribe")
545        .unwrap_or(false);
546      if is_subscribe {
547        stream_subscription(&mut writer, workdir, poll, shutdown);
548        return;
549      }
550
551      // A notification (no `id`) returns None — process, send nothing.
552      if let Some(response) = handle_line(workdir, &line) {
553        if writeln!(writer, "{response}").is_err() || writer.flush().is_err() {
554          break;
555        }
556      }
557    }
558  }
559
560  /// Push `worktrees.changed` notifications: an immediate snapshot, then
561  /// one per detected change. Change detection uses [`worktrees_differ`],
562  /// which ignores the always-ticking `age_seconds` so a non-trunk branch
563  /// doesn't spam a notification every poll.
564  ///
565  /// The read timeout doubles as the poll cadence AND the disconnect
566  /// detector: a closed peer makes `read` return `Ok(0)` promptly. Without
567  /// it, the loop only ever *writes* (on change), so a subscriber that
568  /// disconnects during a no-change period would never be observed and the
569  /// detached thread would keep scanning git forever (issue #38 review).
570  fn stream_subscription(stream: &mut UnixStream, workdir: &Path, poll: Duration, shutdown: &AtomicBool) {
571    if stream.set_read_timeout(Some(poll)).is_err() {
572      return;
573    }
574    let mut last = run_list(workdir).unwrap_or_default();
575    if send_notification(stream, &last).is_err() {
576      return;
577    }
578    let mut buf = [0u8; 64];
579    loop {
580      if shutdown.load(Ordering::Relaxed) {
581        return;
582      }
583      // Blocks up to `poll` waiting for client input — this read IS the
584      // poll wait. A timeout (`WouldBlock`/`TimedOut`) is the normal idle
585      // tick; `Ok(0)` is the peer closing; other errors are a dead link.
586      match stream.read(&mut buf) {
587        Ok(0) => return,
588        Ok(_) => {} // unexpected client chatter on a push stream — ignore
589        Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {}
590        Err(_) => return,
591      }
592      let now = run_list(workdir).unwrap_or_default();
593      if worktrees_differ(&last, &now) {
594        if send_notification(stream, &now).is_err() {
595          return;
596        }
597        last = now;
598      }
599    }
600  }
601
602  fn send_notification(writer: &mut UnixStream, worktrees: &[JsonWorktree]) -> std::io::Result<()> {
603    let note = worktrees_changed_notification(worktrees);
604    writeln!(writer, "{note}")?;
605    writer.flush()
606  }
607}
608
609#[cfg(all(unix, feature = "daemon"))]
610pub use server::{serve, socket_path, ServeOptions};