Extract signal-client for the socket path and frame iteration !15

merged merged by cmc on 2026-09-04 15:56 UTC · krz/ambient-companions:feat/signal-client into main

9 files changed, +156 −109

Cargo.lock +9
@@ -169,6 +169,13 @@ version = "2.0.1"
169169source = "registry+https://github.com/rust-lang/crates.io-index"
170170checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
171171
172[[package]]
173name = "signal-client"
174version = "0.6.1"
175dependencies = [
176 "signal-schema",
177]
178
172179[[package]]
173180name = "signal-schema"
174181version = "0.6.1"
@@ -178,6 +185,7 @@ name = "signald"
178185version = "0.6.1"
179186dependencies = [
180187 "rusqlite",
188 "signal-client",
181189 "signal-schema",
182190]
183191
@@ -225,6 +233,7 @@ dependencies = [
225233name = "terminal-garden"
226234version = "0.6.1"
227235dependencies = [
236 "signal-client",
228237 "signal-schema",
229238]
230239
Cargo.toml +1
@@ -2,6 +2,7 @@
22resolver = "2"
33members = [
44 "crates/signal-schema",
5 "crates/signal-client",
56 "crates/signald",
67 "crates/terminal-garden",
78]
README.md +3 −3
@@ -262,9 +262,9 @@ that starts it.
262262
263263### Paths
264264
265Defaults, resolved the same way by `signald`, `terminal-garden`, and
266`shell-hooks/signald-hooks.zsh`. This table is the single place they are
267written down; unit tests in both binaries pin the code to it.
265Defaults. `crates/signal-client` resolves the socket path once for the daemon
266and every renderer, and its test pins the values below;
267`shell-hooks/signald-hooks.zsh` derives the spool the same way in shell.
268268
269269| | `$XDG_RUNTIME_DIR` set | otherwise |
270270|---|---|---|
crates/signal-client/Cargo.toml added +9
@@ -0,0 +1,9 @@
1[package]
2name = "signal-client"
3version.workspace = true
4edition.workspace = true
5license.workspace = true
6description = "Client side of the ambient-companions signal bus: where the daemon's socket lives, and reading frames off it. Shared by every renderer so the path is defined once."
7
8[dependencies]
9signal-schema = { path = "../signal-schema" }
crates/signal-client/src/lib.rs added +121
@@ -0,0 +1,121 @@
1//! # signal-client
2//!
3//! The client side of the signal bus: where the daemon's socket lives, and how
4//! to read frames off it.
5//!
6//! The socket path was resolved by three copies of the same function — the
7//! daemon and each renderer — kept honest by a table in the README and a
8//! duplicated test in every binary. Copies drift; this crate is the one
9//! definition.
10//!
11//! It holds no privacy surface of its own. Everything it yields came off the
12//! wire and through `signal_schema`'s decode, so the `f64`-only payload rule is
13//! already enforced upstream.
14
15use std::io::BufReader;
16use std::os::unix::net::UnixStream;
17use std::path::{Path, PathBuf};
18
19use signal_schema::wire;
20
21/// The daemon's socket: `$XDG_RUNTIME_DIR/signald.sock`, else
22/// `$HOME/.local/state/signald/sock`.
23///
24/// The daemon derives its history db and terminal spool from this path's
25/// directory, so all three move together when `--socket` is given.
26pub fn default_socket_path() -> PathBuf {
27 socket_path_from(
28 std::env::var("XDG_RUNTIME_DIR").ok().as_deref(),
29 std::env::var("HOME").ok().as_deref(),
30 )
31}
32
33/// [`default_socket_path`] with the environment passed in, so it can be tested
34/// without mutating process state.
35pub fn socket_path_from(xdg_runtime_dir: Option<&str>, home: Option<&str>) -> PathBuf {
36 if let Some(dir) = xdg_runtime_dir {
37 return PathBuf::from(dir).join("signald.sock");
38 }
39 PathBuf::from(home.unwrap_or(".")).join(".local/state/signald/sock")
40}
41
42/// Connect to the daemon and iterate the frames it publishes.
43///
44/// The iterator ends at a clean EOF and on an unframeable stream. Frames this
45/// build cannot decode are skipped rather than ending the stream, so a renderer
46/// built before a metric was appended keeps working against a newer daemon.
47pub struct Frames {
48 reader: BufReader<UnixStream>,
49 done: bool,
50}
51
52impl Frames {
53 /// Connect to the daemon listening at `socket`.
54 pub fn connect(socket: &Path) -> std::io::Result<Frames> {
55 Ok(Frames {
56 reader: BufReader::new(UnixStream::connect(socket)?),
57 done: false,
58 })
59 }
60}
61
62impl Iterator for Frames {
63 /// `Err` is an unframeable stream. A skipped frame is not surfaced: the
64 /// iterator swallows it and reads on, which is the whole point of
65 /// [`wire::Frame::Skipped`].
66 type Item = std::io::Result<signal_schema::Signal>;
67
68 fn next(&mut self) -> Option<Self::Item> {
69 while !self.done {
70 match wire::read_frame(&mut self.reader) {
71 Ok(wire::Frame::Signal(s)) => return Some(Ok(s)),
72 Ok(wire::Frame::Skipped) => continue,
73 Ok(wire::Frame::Eof) => {
74 self.done = true;
75 return None;
76 }
77 Err(e) => {
78 self.done = true;
79 return Some(Err(e));
80 }
81 }
82 }
83 None
84 }
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90
91 /// The one place the socket default is pinned. It used to be asserted
92 /// separately in each binary against a README table.
93 #[test]
94 fn socket_default_follows_xdg_then_home() {
95 assert_eq!(
96 socket_path_from(Some("/run/user/501"), Some("/Users/x")),
97 PathBuf::from("/run/user/501/signald.sock")
98 );
99 assert_eq!(
100 socket_path_from(None, Some("/Users/x")),
101 PathBuf::from("/Users/x/.local/state/signald/sock")
102 );
103 }
104
105 /// The db and the spool are derived from the socket's directory, so all
106 /// three move together when --socket is given.
107 #[test]
108 fn db_and_spool_sit_beside_the_socket() {
109 let socket = socket_path_from(None, Some("/Users/x"));
110 let base = socket.parent().unwrap();
111 assert_eq!(
112 base.join("signald.sqlite"),
113 PathBuf::from("/Users/x/.local/state/signald/signald.sqlite")
114 );
115 assert_eq!(
116 base.join("terminal.spool"),
117 PathBuf::from("/Users/x/.local/state/signald/terminal.spool"),
118 "must equal SIGNALD_SPOOL's default in shell-hooks/signald-hooks.zsh"
119 );
120 }
121}
crates/signald/Cargo.toml +2
@@ -11,6 +11,8 @@ path = "src/main.rs"
1111
1212[dependencies]
1313signal-schema = { path = "../signal-schema" }
14# Socket-path resolution only; the daemon is not a client of its own bus.
15signal-client = { path = "../signal-client" }
1416# History store: SQLite in WAL mode. `bundled` compiles SQLite in-
1517# tree so there is no system-library dependency. This is the one new dependency
1618# added in v0.2; signal-schema stays dependency-free by design.
crates/signald/src/main.rs +1 −51
@@ -182,7 +182,7 @@ fn parse_args() -> Config {
182182 if repos.is_empty() {
183183 repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
184184 }
185 let socket = socket.unwrap_or_else(default_socket_path);
185 let socket = socket.unwrap_or_else(signal_client::default_socket_path);
186186 let base = socket.parent().map(PathBuf::from).unwrap_or_default();
187187 Config {
188188 db: db.unwrap_or_else(|| base.join("signald.sqlite")),
@@ -195,26 +195,6 @@ fn parse_args() -> Config {
195195 }
196196}
197197
198/// `$XDG_RUNTIME_DIR/signald.sock`, else `$HOME/.local/state/signald/sock`.
199///
200/// Split from the environment so it can be tested without mutating it. The
201/// same resolution is duplicated in the other binary and in
202/// `shell-hooks/signald-hooks.zsh`; the README's "Paths" table is the one
203/// place they are written down, and the tests below pin them to it.
204fn socket_path_from(xdg_runtime_dir: Option<&str>, home: Option<&str>) -> PathBuf {
205 if let Some(dir) = xdg_runtime_dir {
206 return PathBuf::from(dir).join("signald.sock");
207 }
208 PathBuf::from(home.unwrap_or(".")).join(".local/state/signald/sock")
209}
210
211fn default_socket_path() -> PathBuf {
212 socket_path_from(
213 std::env::var("XDG_RUNTIME_DIR").ok().as_deref(),
214 std::env::var("HOME").ok().as_deref(),
215 )
216}
217
218198/// `$XDG_CONFIG_HOME/signald/repos`, else `$HOME/.config/signald/repos`.
219199fn repos_config_path(xdg_config_home: Option<&str>, home: Option<&str>) -> PathBuf {
220200 match xdg_config_home {
@@ -284,20 +264,6 @@ fn print_self_attestation(cfg: &Config) {
284264mod path_tests {
285265 use super::*;
286266
287 /// Pinned to the README "Paths" table. If this changes, the table and
288 /// `shell-hooks/signald-hooks.zsh` change with it.
289 #[test]
290 fn socket_default_follows_xdg_then_home() {
291 assert_eq!(
292 socket_path_from(Some("/run/user/501"), Some("/Users/x")),
293 PathBuf::from("/run/user/501/signald.sock")
294 );
295 assert_eq!(
296 socket_path_from(None, Some("/Users/x")),
297 PathBuf::from("/Users/x/.local/state/signald/sock")
298 );
299 }
300
301267 #[test]
302268 fn repos_file_skips_blanks_and_comments() {
303269 let repos = parse_repos_file(
@@ -324,20 +290,4 @@ mod path_tests {
324290 );
325291 }
326292
327 /// The db and the spool are derived from the socket's directory, so all
328 /// three move together when --socket is given.
329 #[test]
330 fn db_and_spool_sit_beside_the_socket() {
331 let socket = socket_path_from(None, Some("/Users/x"));
332 let base = socket.parent().unwrap();
333 assert_eq!(
334 base.join("signald.sqlite"),
335 PathBuf::from("/Users/x/.local/state/signald/signald.sqlite")
336 );
337 assert_eq!(
338 base.join("terminal.spool"),
339 PathBuf::from("/Users/x/.local/state/signald/terminal.spool"),
340 "must equal SIGNALD_SPOOL's default in shell-hooks/signald-hooks.zsh"
341 );
342 }
343293}
crates/terminal-garden/Cargo.toml +1
@@ -11,3 +11,4 @@ path = "src/main.rs"
1111
1212[dependencies]
1313signal-schema = { path = "../signal-schema" }
14signal-client = { path = "../signal-client" }
crates/terminal-garden/src/main.rs +9 −55
@@ -19,11 +19,9 @@
1919//! `~/.local/state/signald/sock`.
2020
2121use std::collections::BTreeMap;
22use std::io::BufReader;
23use std::os::unix::net::UnixStream;
24use std::path::PathBuf;
22use std::path::{Path, PathBuf};
2523
26use signal_schema::{wire, Signal, SignalName};
24use signal_schema::{Signal, SignalName};
2725use terminal_garden::{plots_from_signals, render};
2826
2927/// The `name`s the garden cares about: the git aggregates.
@@ -48,22 +46,17 @@ fn main() {
4846/// and repo tag) and re-render the garden on every frame. `Ok(())` is a clean EOF
4947/// (the daemon closed the stream). Frames this build cannot decode are skipped,
5048/// so an older renderer keeps working against a newer daemon.
51fn run(socket: &PathBuf) -> std::io::Result<()> {
52 let stream = UnixStream::connect(socket)?;
53 let mut reader = BufReader::new(stream);
49fn run(socket: &Path) -> std::io::Result<()> {
50 let frames = signal_client::Frames::connect(socket)?;
5451
5552 // Keyed by (metric name, source, repo tag) so per-repo signals coexist,
5653 // two collectors reporting the same name stay distinct, and updates replace
5754 // prior values rather than accumulating.
5855 let mut latest: BTreeMap<(u8, u8, Option<String>), Signal> = BTreeMap::new();
59 loop {
60 let sig = match wire::read_frame(&mut reader)? {
61 wire::Frame::Signal(sig) => sig,
62 // A record this build does not understand: a newer daemon, or a
63 // metric appended after this renderer was built. Keep rendering.
64 wire::Frame::Skipped => continue,
65 wire::Frame::Eof => break,
66 };
56 // Frames this build cannot decode are skipped by the iterator, so a metric
57 // appended after this renderer was built does not end the stream.
58 for sig in frames {
59 let sig = sig?;
6760 if !SUBSCRIBE.contains(&sig.name) {
6861 continue;
6962 }
@@ -111,44 +104,5 @@ fn parse_socket() -> PathBuf {
111104 }
112105 }
113106 }
114 default_socket_path()
115}
116
117/// `$XDG_RUNTIME_DIR/signald.sock`, else `$HOME/.local/state/signald/sock`.
118///
119/// Split from the environment so it can be tested without mutating it. The
120/// same resolution is duplicated in the other binary and in
121/// `shell-hooks/signald-hooks.zsh`; the README's "Paths" table is the one
122/// place they are written down, and the tests below pin them to it.
123fn socket_path_from(xdg_runtime_dir: Option<&str>, home: Option<&str>) -> PathBuf {
124 if let Some(dir) = xdg_runtime_dir {
125 return PathBuf::from(dir).join("signald.sock");
126 }
127 PathBuf::from(home.unwrap_or(".")).join(".local/state/signald/sock")
128}
129
130fn default_socket_path() -> PathBuf {
131 socket_path_from(
132 std::env::var("XDG_RUNTIME_DIR").ok().as_deref(),
133 std::env::var("HOME").ok().as_deref(),
134 )
135}
136
137#[cfg(test)]
138mod path_tests {
139 use super::*;
140
141 /// Pinned to the README "Paths" table. If this changes, the table and
142 /// `shell-hooks/signald-hooks.zsh` change with it.
143 #[test]
144 fn socket_default_follows_xdg_then_home() {
145 assert_eq!(
146 socket_path_from(Some("/run/user/501"), Some("/Users/x")),
147 PathBuf::from("/run/user/501/signald.sock")
148 );
149 assert_eq!(
150 socket_path_from(None, Some("/Users/x")),
151 PathBuf::from("/Users/x/.local/state/signald/sock")
152 );
153 }
107 signal_client::default_socket_path()
154108}