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};