Skip to main content

prospero_core/
discovery.rs

1//! Resolve a repo root to its caliband control socket, and ensure a daemon is
2//! running for it.
3//!
4//! Mirrors caliban's socket-path rule:
5//! `${CALIBAN_DAEMON_RUNTIME_DIR:-$XDG_RUNTIME_DIR/caliban}/<hash16>.sock`,
6//! falling back to `${TMPDIR}/caliban-daemon/<hash16>.sock`, where `hash16` is
7//! the first 16 hex chars of `SHA-256(canonical_workspace_root)`.
8
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12use sha2::{Digest, Sha256};
13use tokio::net::UnixStream;
14
15use crate::caliband::client::CalibandClient;
16use crate::error::{CoreError, Result};
17
18/// First 16 hex chars of `SHA-256` of the path's string form.
19pub fn hash16(path: &Path) -> String {
20    let mut hasher = Sha256::new();
21    hasher.update(path.to_string_lossy().as_bytes());
22    let digest = hasher.finalize();
23    hex::encode(digest)[..16].to_string()
24}
25
26/// The subset of process environment that affects socket-path resolution.
27/// Captured explicitly so resolution is a pure, testable function.
28#[derive(Debug, Clone, Default)]
29pub struct DiscoveryEnv {
30    /// `$CALIBAN_DAEMON_RUNTIME_DIR` — highest priority base dir.
31    pub caliban_daemon_runtime_dir: Option<PathBuf>,
32    /// `$XDG_RUNTIME_DIR` — `caliban` subdir is used when set.
33    pub xdg_runtime_dir: Option<PathBuf>,
34    /// `$TMPDIR` — fallback base, `caliban-daemon` subdir.
35    pub tmpdir: Option<PathBuf>,
36}
37
38impl DiscoveryEnv {
39    /// Capture the relevant variables from the real process environment.
40    pub fn from_process() -> Self {
41        Self {
42            caliban_daemon_runtime_dir: std::env::var_os("CALIBAN_DAEMON_RUNTIME_DIR")
43                .map(Into::into),
44            xdg_runtime_dir: std::env::var_os("XDG_RUNTIME_DIR").map(Into::into),
45            tmpdir: std::env::var_os("TMPDIR").map(Into::into),
46        }
47    }
48
49    /// The base directory caliband sockets live in, per the resolution rule.
50    fn socket_base_dir(&self) -> PathBuf {
51        if let Some(dir) = &self.caliban_daemon_runtime_dir {
52            dir.clone()
53        } else if let Some(xdg) = &self.xdg_runtime_dir {
54            xdg.join("caliban")
55        } else {
56            self.tmpdir
57                .clone()
58                .unwrap_or_else(|| PathBuf::from("/tmp"))
59                .join("caliban-daemon")
60        }
61    }
62}
63
64/// Compute the control socket path for a (already canonical) repo root.
65pub fn control_socket_path(workspace_root_canonical: &Path, env: &DiscoveryEnv) -> PathBuf {
66    env.socket_base_dir()
67        .join(format!("{}.sock", hash16(workspace_root_canonical)))
68}
69
70/// Canonicalize a repo root, mapping the IO error to a discovery error.
71///
72/// caliband derives its control-socket name by hashing the raw `--workspace-root`
73/// argument it is given, so prospero must hand caliband the *same* canonical
74/// form it hashes for socket lookup — otherwise a symlinked path (e.g. macOS
75/// `/tmp` → `/private/tmp`) yields two different socket names and discovery
76/// waits forever. (#45)
77pub fn canonical_root(workspace_root: &Path) -> Result<PathBuf> {
78    workspace_root.canonicalize().map_err(|e| {
79        CoreError::Discovery(format!(
80            "cannot canonicalize {}: {e}",
81            workspace_root.display()
82        ))
83    })
84}
85
86/// Canonicalize a repo root and compute its control socket path.
87pub fn resolve_socket(workspace_root: &Path, env: &DiscoveryEnv) -> Result<PathBuf> {
88    Ok(control_socket_path(&canonical_root(workspace_root)?, env))
89}
90
91/// Configuration for [`ensure_caliband`].
92#[derive(Debug, Clone)]
93pub struct EnsureConfig {
94    /// Spawn `caliband --workspace-root <root>` if no daemon is reachable.
95    pub autostart: bool,
96    /// The caliban daemon binary name/path.
97    pub caliband_bin: String,
98    /// How long to wait for the socket to come up after autostart.
99    pub startup_timeout: Duration,
100    /// Extra environment variables layered onto the caliband process.
101    pub env: std::collections::BTreeMap<String, String>,
102}
103
104impl Default for EnsureConfig {
105    fn default() -> Self {
106        Self {
107            autostart: true,
108            caliband_bin: "caliband".to_string(),
109            startup_timeout: Duration::from_secs(10),
110            env: std::collections::BTreeMap::new(),
111        }
112    }
113}
114
115/// Ensure a caliband daemon is reachable for `workspace_root`, returning a client
116/// bound to its control socket. If none is reachable and `autostart` is set,
117/// spawns the daemon and waits for the socket.
118pub async fn ensure_caliband(
119    workspace_root: &Path,
120    env: &DiscoveryEnv,
121    cfg: &EnsureConfig,
122) -> Result<CalibandClient> {
123    // Canonicalize ONCE: the socket we wait on and the `--workspace-root` we hand
124    // caliband must derive from the same path, or their socket names diverge on
125    // symlinked roots and we wait forever. (#45)
126    let canonical = canonical_root(workspace_root)?;
127    let socket = control_socket_path(&canonical, env);
128
129    if UnixStream::connect(&socket).await.is_ok() {
130        return Ok(CalibandClient::new(socket));
131    }
132
133    if !cfg.autostart {
134        return Err(CoreError::Discovery(format!(
135            "no caliband reachable at {} and autostart is disabled",
136            socket.display()
137        )));
138    }
139
140    tokio::process::Command::new(&cfg.caliband_bin)
141        .arg("--workspace-root")
142        .arg(&canonical)
143        .envs(&cfg.env)
144        .spawn()
145        .map_err(|e| CoreError::Discovery(format!("failed to spawn {} : {e}", cfg.caliband_bin)))?;
146
147    // Poll until the socket accepts a connection or we time out.
148    let deadline = tokio::time::Instant::now() + cfg.startup_timeout;
149    loop {
150        if UnixStream::connect(&socket).await.is_ok() {
151            return Ok(CalibandClient::new(socket));
152        }
153        if tokio::time::Instant::now() >= deadline {
154            return Err(CoreError::Discovery(format!(
155                "caliband did not come up at {} within {:?}",
156                socket.display(),
157                cfg.startup_timeout
158            )));
159        }
160        tokio::time::sleep(Duration::from_millis(50)).await;
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn hash16_is_stable_and_16_chars() {
170        let h = hash16(Path::new("/home/u/dev/prospero"));
171        assert_eq!(h.len(), 16);
172        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
173        // Deterministic.
174        assert_eq!(h, hash16(Path::new("/home/u/dev/prospero")));
175        // Different path → different hash.
176        assert_ne!(h, hash16(Path::new("/home/u/dev/caliban")));
177    }
178
179    #[test]
180    fn runtime_dir_env_takes_priority() {
181        let env = DiscoveryEnv {
182            caliban_daemon_runtime_dir: Some("/run/cal".into()),
183            xdg_runtime_dir: Some("/run/user/1000".into()),
184            tmpdir: Some("/tmp".into()),
185        };
186        let p = control_socket_path(Path::new("/repo"), &env);
187        assert_eq!(
188            p,
189            PathBuf::from(format!("/run/cal/{}.sock", hash16(Path::new("/repo"))))
190        );
191    }
192
193    #[test]
194    fn xdg_runtime_dir_used_when_no_override() {
195        let env = DiscoveryEnv {
196            caliban_daemon_runtime_dir: None,
197            xdg_runtime_dir: Some("/run/user/1000".into()),
198            tmpdir: Some("/tmp".into()),
199        };
200        let p = control_socket_path(Path::new("/repo"), &env);
201        assert_eq!(
202            p,
203            PathBuf::from(format!(
204                "/run/user/1000/caliban/{}.sock",
205                hash16(Path::new("/repo"))
206            ))
207        );
208    }
209
210    #[test]
211    fn tmpdir_fallback_when_nothing_else() {
212        let env = DiscoveryEnv {
213            caliban_daemon_runtime_dir: None,
214            xdg_runtime_dir: None,
215            tmpdir: Some("/var/tmp".into()),
216        };
217        let p = control_socket_path(Path::new("/repo"), &env);
218        assert_eq!(
219            p,
220            PathBuf::from(format!(
221                "/var/tmp/caliban-daemon/{}.sock",
222                hash16(Path::new("/repo"))
223            ))
224        );
225    }
226
227    #[cfg(unix)]
228    #[test]
229    fn resolve_socket_canonicalizes_symlinked_roots() {
230        use std::os::unix::fs::symlink;
231        // A symlink whose path differs from its canonical target (mirrors the
232        // macOS `/tmp` -> `/private/tmp` case). The socket must be derived from
233        // the canonical form so it matches the one caliband creates. (#45)
234        let real = tempfile::tempdir().unwrap();
235        let real_canon = real.path().canonicalize().unwrap();
236        let scratch = tempfile::tempdir().unwrap();
237        let link = scratch.path().join("link");
238        symlink(&real_canon, &link).unwrap();
239        assert_ne!(link, real_canon, "symlink path must differ from canonical");
240
241        let env = DiscoveryEnv {
242            tmpdir: Some("/var/tmp".into()),
243            ..Default::default()
244        };
245        assert_eq!(
246            resolve_socket(&link, &env).unwrap(),
247            resolve_socket(&real_canon, &env).unwrap(),
248            "a symlinked root and its canonical form must resolve to one socket"
249        );
250    }
251
252    #[cfg(unix)]
253    #[tokio::test]
254    async fn ensure_caliband_spawns_caliband_with_the_canonical_root() {
255        use std::os::unix::fs::{PermissionsExt, symlink};
256        // prospero waits on the socket derived from the CANONICAL root, but
257        // caliband hashes the raw `--workspace-root` it is handed. If we spawn it
258        // with a symlinked path, the two socket names diverge and discovery
259        // hangs. This pins the spawn arg to the canonical form. (#45)
260        let real = tempfile::tempdir().unwrap();
261        let real_canon = real.path().canonicalize().unwrap();
262        let scratch = tempfile::tempdir().unwrap();
263        let link = scratch.path().join("link");
264        symlink(&real_canon, &link).unwrap();
265        assert_ne!(link, real_canon);
266
267        // A stand-in caliband that records the `--workspace-root` it received, then
268        // exits without ever creating a socket.
269        let recorded = scratch.path().join("recorded-root");
270        let script = scratch.path().join("fake-caliband.sh");
271        std::fs::write(
272            &script,
273            format!("#!/bin/sh\nprintf '%s' \"$2\" > '{}'\n", recorded.display()),
274        )
275        .unwrap();
276        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
277
278        let cfg = EnsureConfig {
279            autostart: true,
280            caliband_bin: script.to_string_lossy().into_owned(),
281            startup_timeout: Duration::from_millis(300),
282            env: std::collections::BTreeMap::new(),
283        };
284        let env = DiscoveryEnv {
285            tmpdir: Some(scratch.path().to_path_buf()),
286            ..Default::default()
287        };
288
289        // No socket ever appears, so this returns Err after the timeout — we
290        // only assert on which root caliband was spawned with.
291        let _ = ensure_caliband(&link, &env, &cfg).await;
292
293        // The stand-in caliband is a separate process racing this test, so poll
294        // for its recorded output up to a deadline instead of guessing a fixed
295        // sleep. A fixed wait passes locally and flakes on loaded CI runners,
296        // where fork+exec can lose the race. (#103)
297        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
298        let got = loop {
299            match std::fs::read_to_string(&recorded) {
300                // The script writes the root in a single `printf`, so any
301                // non-empty content is the complete value.
302                Ok(contents) if !contents.is_empty() => break contents,
303                _ if tokio::time::Instant::now() >= deadline => panic!(
304                    "fake caliband never recorded its root at {}",
305                    recorded.display()
306                ),
307                _ => tokio::time::sleep(Duration::from_millis(10)).await,
308            }
309        };
310        assert_eq!(
311            got,
312            real_canon.to_string_lossy(),
313            "caliband must be spawned with the canonical root, not the symlinked path"
314        );
315    }
316}