Skip to main content

omni_dev/daemon/
client.rs

1//! [`DaemonClient`]: a thin client for the daemon's Unix control socket, used
2//! by `daemon status` / `stop` / `restart` and the single-instance `ping`
3//! probe.
4
5use std::path::{Path, PathBuf};
6
7use anyhow::{bail, Context, Result};
8use futures::{SinkExt, StreamExt};
9use tokio::net::UnixStream;
10use tokio_util::codec::{Framed, LinesCodec};
11
12use super::protocol::{DaemonEnvelope, DaemonReply, StatusReport, MAX_LINE_BYTES};
13
14/// A one-shot client over the daemon's Unix-domain control socket. Each call
15/// opens a fresh connection, sends one [`DaemonEnvelope`], and reads one
16/// [`DaemonReply`].
17#[derive(Debug, Clone)]
18pub struct DaemonClient {
19    socket_path: PathBuf,
20}
21
22impl DaemonClient {
23    /// Creates a client targeting the socket at `socket_path`.
24    pub fn new(socket_path: impl Into<PathBuf>) -> Self {
25        Self {
26            socket_path: socket_path.into(),
27        }
28    }
29
30    /// The socket path this client targets.
31    pub fn socket_path(&self) -> &Path {
32        &self.socket_path
33    }
34
35    /// Sends one envelope and returns the daemon's reply.
36    pub async fn request(&self, envelope: DaemonEnvelope) -> Result<DaemonReply> {
37        let stream = UnixStream::connect(&self.socket_path)
38            .await
39            .with_context(|| {
40                format!(
41                    "failed to connect to daemon socket {} (is the daemon running?)",
42                    self.socket_path.display()
43                )
44            })?;
45        let mut framed = Framed::new(stream, LinesCodec::new_with_max_length(MAX_LINE_BYTES));
46        let line = serde_json::to_string(&envelope).context("failed to encode daemon request")?;
47        framed
48            .send(line)
49            .await
50            .context("failed to send daemon request")?;
51        let response = framed
52            .next()
53            .await
54            .context("daemon closed the connection without replying")?
55            .context("failed to read daemon reply")?;
56        serde_json::from_str(&response).context("failed to decode daemon reply")
57    }
58
59    /// Opens a streaming subscription: sends `envelope` once and returns a
60    /// handle over which the daemon pushes **many** replies on the same
61    /// connection (an initial snapshot then a fresh one on each change) until the
62    /// daemon or the client closes it. Per the control-socket protocol the client
63    /// must only *read* after this — sending any further line is interpreted as an
64    /// explicit cancel — so [`DaemonSubscription`] exposes reads only.
65    pub async fn subscribe(&self, envelope: DaemonEnvelope) -> Result<DaemonSubscription> {
66        let stream = UnixStream::connect(&self.socket_path)
67            .await
68            .with_context(|| {
69                format!(
70                    "failed to connect to daemon socket {} (is the daemon running?)",
71                    self.socket_path.display()
72                )
73            })?;
74        let mut framed = Framed::new(stream, LinesCodec::new_with_max_length(MAX_LINE_BYTES));
75        let line = serde_json::to_string(&envelope).context("failed to encode daemon request")?;
76        framed
77            .send(line)
78            .await
79            .context("failed to send daemon subscription request")?;
80        Ok(DaemonSubscription { framed })
81    }
82
83    /// Sends an envelope and returns its payload, turning an `ok: false` reply
84    /// into an `Err`.
85    async fn request_ok(&self, envelope: DaemonEnvelope) -> Result<serde_json::Value> {
86        let reply = self.request(envelope).await?;
87        if reply.ok {
88            Ok(reply.payload)
89        } else {
90            bail!(
91                "daemon returned an error: {}",
92                reply.error.as_deref().unwrap_or("unknown error")
93            )
94        }
95    }
96
97    /// Probes whether a live daemon is answering on the socket.
98    pub async fn ping(&self) -> Result<()> {
99        self.request_ok(DaemonEnvelope::builtin("ping"))
100            .await
101            .map(|_| ())
102    }
103
104    /// Returns the resident daemon's advertised binary version, or `None` when it
105    /// advertises none (a pre-#1113 daemon whose `ping` carries no version).
106    ///
107    /// Lets a client warn that it is driving a stale daemon after a binary
108    /// upgrade without a separate `status` round-trip.
109    pub async fn version(&self) -> Result<Option<String>> {
110        let payload = self.request_ok(DaemonEnvelope::builtin("ping")).await?;
111        Ok(payload
112            .get("version")
113            .and_then(serde_json::Value::as_str)
114            .map(str::to_string))
115    }
116
117    /// Requests aggregated per-service status.
118    pub async fn status(&self) -> Result<StatusReport> {
119        let payload = self.request_ok(DaemonEnvelope::builtin("status")).await?;
120        serde_json::from_value(payload).context("failed to decode daemon status report")
121    }
122
123    /// Asks the daemon to shut down gracefully.
124    pub async fn shutdown(&self) -> Result<()> {
125        self.request_ok(DaemonEnvelope::builtin("shutdown"))
126            .await
127            .map(|_| ())
128    }
129}
130
131/// A live push subscription opened by [`DaemonClient::subscribe`].
132///
133/// The daemon keeps sending [`DaemonReply`] frames on the connection; call
134/// [`next`](Self::next) in a loop to consume them. It exposes reads only — the
135/// protocol treats any further write as a cancel — and dropping it (e.g. on
136/// Ctrl-C) ends the stream.
137#[derive(Debug)]
138pub struct DaemonSubscription {
139    framed: Framed<UnixStream, LinesCodec>,
140}
141
142impl DaemonSubscription {
143    /// Reads the next pushed reply, `None` once the daemon closes the stream
144    /// (EOF), or `Some(Err(..))` on a read/decode failure.
145    pub async fn next(&mut self) -> Option<Result<DaemonReply>> {
146        match self.framed.next().await {
147            Some(Ok(line)) => {
148                Some(serde_json::from_str(&line).context("failed to decode daemon stream frame"))
149            }
150            Some(Err(e)) => Some(Err(
151                anyhow::Error::new(e).context("failed to read daemon stream")
152            )),
153            None => None,
154        }
155    }
156}
157
158#[cfg(test)]
159#[allow(clippy::unwrap_used, clippy::expect_used)]
160mod tests {
161    use super::*;
162    use crate::daemon::testutil::{fake_daemon_reply, fake_daemon_stream};
163
164    #[tokio::test]
165    async fn version_reads_the_version_from_a_ping_reply() {
166        let (_dir, sock, server) = fake_daemon_reply(
167            serde_json::json!({ "ok": true, "payload": { "pong": true, "version": "1.2.3" } }),
168        );
169        let version = DaemonClient::new(&sock).version().await.unwrap();
170        assert_eq!(version.as_deref(), Some("1.2.3"));
171        server.await.unwrap();
172    }
173
174    #[tokio::test]
175    async fn version_is_none_for_a_pre_1113_daemon_without_a_version_field() {
176        // An older daemon's ping carries no `version`; `version()` must decode it
177        // as `None` rather than erroring.
178        let (_dir, sock, server) =
179            fake_daemon_reply(serde_json::json!({ "ok": true, "payload": { "pong": true } }));
180        let version = DaemonClient::new(&sock).version().await.unwrap();
181        assert!(version.is_none());
182        server.await.unwrap();
183    }
184
185    #[tokio::test]
186    async fn request_ok_maps_an_error_reply_to_an_err() {
187        let (_dir, sock, server) =
188            fake_daemon_reply(serde_json::json!({ "ok": false, "error": "boom" }));
189        let err = DaemonClient::new(&sock).version().await.unwrap_err();
190        assert!(err.to_string().contains("boom"), "{err}");
191        server.await.unwrap();
192    }
193
194    #[tokio::test]
195    async fn subscribe_yields_each_pushed_frame_then_none_on_close() {
196        // The daemon pushes two snapshot frames then closes; `next()` returns each
197        // decoded reply in order and finally `None` at EOF.
198        let (_dir, sock, server) = fake_daemon_stream(vec![
199            serde_json::json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
200            serde_json::json!({ "ok": true, "payload": { "repos": [], "show_closed": false } }),
201        ]);
202        let mut sub = DaemonClient::new(&sock)
203            .subscribe(DaemonEnvelope::service(
204                "worktrees",
205                "subscribe",
206                serde_json::Value::Null,
207            ))
208            .await
209            .unwrap();
210
211        let first = sub.next().await.unwrap().unwrap();
212        assert!(first.ok);
213        assert_eq!(first.payload["show_closed"], serde_json::json!(true));
214        let second = sub.next().await.unwrap().unwrap();
215        assert_eq!(second.payload["show_closed"], serde_json::json!(false));
216        // The daemon has closed the connection: the stream ends.
217        assert!(sub.next().await.is_none());
218        server.await.unwrap();
219    }
220
221    #[tokio::test]
222    async fn subscribe_next_surfaces_a_read_error_on_an_oversized_frame() {
223        // A frame longer than MAX_LINE_BYTES trips the client codec's max-length
224        // guard, exercising the read-error arm of `DaemonSubscription::next`.
225        let big = "x".repeat(crate::daemon::protocol::MAX_LINE_BYTES + 1);
226        let (_dir, sock, server) = fake_daemon_stream(vec![serde_json::Value::String(big)]);
227        let mut sub = DaemonClient::new(&sock)
228            .subscribe(DaemonEnvelope::service(
229                "worktrees",
230                "subscribe",
231                serde_json::Value::Null,
232            ))
233            .await
234            .unwrap();
235        let frame = sub.next().await;
236        assert!(
237            matches!(frame, Some(Err(_))),
238            "an oversized frame should surface as a read error"
239        );
240        server.await.unwrap();
241    }
242
243    #[tokio::test]
244    async fn subscribe_errors_when_the_daemon_is_unreachable() {
245        // No daemon at the socket → the connect fails with the usual context.
246        let err = DaemonClient::new("/nonexistent/omni-dev-sub.sock")
247            .subscribe(DaemonEnvelope::service(
248                "worktrees",
249                "subscribe",
250                serde_json::Value::Null,
251            ))
252            .await
253            .unwrap_err();
254        assert!(err.to_string().contains("failed to connect"), "{err}");
255    }
256}