Skip to main content

stackless_daemon/
binary.rs

1//! Resolve the stackless CLI binary used to spawn the operator daemon.
2//!
3//! Third-party SDK consumers must not be spawned as the daemon: only the
4//! installed CLI understands `daemon run`. Resolution is pure-testable via
5//! [`resolve_daemon_bin_from`] because edition 2024 + `unsafe_code = "forbid"`
6//! blocks in-process `set_var`.
7
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicBool, Ordering};
10
11use crate::client::DaemonError;
12
13static CLI_PROCESS: AtomicBool = AtomicBool::new(false);
14
15/// Mark this process as the stackless CLI entrypoint (`stackless::cli::run`).
16///
17/// Spawning the operator daemon (and registering launchd / running the reaper)
18/// is only valid from a process that has called this.
19pub fn mark_cli_process() {
20    CLI_PROCESS.store(true, Ordering::SeqCst);
21}
22
23/// Whether [`mark_cli_process`] has been called in this process.
24pub fn is_cli_process() -> bool {
25    CLI_PROCESS.load(Ordering::SeqCst)
26}
27
28/// How the daemon executable was resolved.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ResolveSource {
31    /// Absolute path from `STACKLESS_BIN`.
32    EnvOverride,
33    /// This process is the CLI (`mark_cli_process`) — use `current_exe`.
34    SelfAsCli,
35    /// `stackless` found on `PATH`.
36    Path,
37    /// Found under a well-known install directory.
38    WellKnown,
39    /// Caller injected an explicit path (`ensure_with`).
40    Explicit,
41}
42
43/// Whether a version mismatch should drain and respawn the daemon.
44///
45/// Only the CLI upgrading itself replaces a running daemon. SDK consumers
46/// that resolved a CLI via PATH/env must never kill a healthy daemon and
47/// must never respawn in a version-mismatch thrash loop.
48pub fn should_replace_daemon(
49    daemon_version: &str,
50    own_version: &str,
51    source: ResolveSource,
52) -> bool {
53    source == ResolveSource::SelfAsCli && daemon_version != own_version
54}
55
56const STACKLESS_BIN_ENV: &str = "STACKLESS_BIN";
57
58/// Resolve the CLI binary from the real process environment.
59pub fn resolve_daemon_bin() -> Result<(PathBuf, ResolveSource), DaemonError> {
60    let override_bin = std::env::var_os(STACKLESS_BIN_ENV).map(PathBuf::from);
61    let self_exe = std::env::current_exe().map_err(|err| DaemonError::Spawn {
62        detail: format!("cannot resolve current executable: {err}"),
63    })?;
64    let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
65        .map(|p| std::env::split_paths(&p).collect())
66        .unwrap_or_default();
67    let home = std::env::var_os("HOME").map(PathBuf::from);
68    resolve_daemon_bin_from(
69        override_bin.as_deref(),
70        &self_exe,
71        is_cli_process(),
72        &path_dirs,
73        home.as_deref(),
74    )
75}
76
77/// Pure resolver for tests and [`resolve_daemon_bin`].
78pub fn resolve_daemon_bin_from(
79    override_bin: Option<&Path>,
80    self_exe: &Path,
81    is_cli: bool,
82    path_dirs: &[PathBuf],
83    home: Option<&Path>,
84) -> Result<(PathBuf, ResolveSource), DaemonError> {
85    if let Some(path) = override_bin {
86        if is_executable(path) {
87            return Ok((path.to_path_buf(), ResolveSource::EnvOverride));
88        }
89        return Err(DaemonError::BinaryNotFound {
90            detail: format!(
91                "{STACKLESS_BIN_ENV}={path} is set but is not an executable file",
92                path = path.display()
93            ),
94        });
95    }
96
97    if is_cli && is_executable(self_exe) {
98        return Ok((self_exe.to_path_buf(), ResolveSource::SelfAsCli));
99    }
100
101    for dir in path_dirs {
102        let candidate = dir.join("stackless");
103        if is_executable(&candidate) {
104            return Ok((candidate, ResolveSource::Path));
105        }
106    }
107
108    for dir in well_known_dirs(home) {
109        let candidate = dir.join("stackless");
110        if is_executable(&candidate) {
111            return Ok((candidate, ResolveSource::WellKnown));
112        }
113    }
114
115    Err(DaemonError::BinaryNotFound {
116        detail: format!(
117            "Client::system() needs the stackless CLI to run the operator daemon. \
118             Install it (https://github.com/snowmead/stackless#install), ensure \
119             `stackless` is on PATH, or set {STACKLESS_BIN_ENV}. \
120             For hermetic tests use Client::builder().paths(...) / TestContext \
121             (feature test-support)."
122        ),
123    })
124}
125
126fn well_known_dirs(home: Option<&Path>) -> Vec<PathBuf> {
127    let mut dirs = Vec::new();
128    if let Some(home) = home {
129        dirs.push(home.join(".cargo").join("bin"));
130    }
131    dirs.push(PathBuf::from("/usr/local/bin"));
132    dirs.push(PathBuf::from("/opt/homebrew/bin"));
133    dirs
134}
135
136fn is_executable(path: &Path) -> bool {
137    use std::os::unix::fs::PermissionsExt;
138    let Ok(meta) = std::fs::metadata(path) else {
139        return false;
140    };
141    meta.is_file() && meta.permissions().mode() & 0o111 != 0
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use std::os::unix::fs::PermissionsExt;
148
149    fn touch_exe(dir: &Path, name: &str) -> PathBuf {
150        let path = dir.join(name);
151        std::fs::write(&path, b"#!/bin/sh\n").unwrap();
152        let mut perms = std::fs::metadata(&path).unwrap().permissions();
153        perms.set_mode(0o755);
154        std::fs::set_permissions(&path, perms).unwrap();
155        path
156    }
157
158    #[test]
159    fn override_wins_when_executable() {
160        let dir = tempfile::tempdir().unwrap();
161        let bin = touch_exe(dir.path(), "custom-stackless");
162        let (resolved, source) =
163            resolve_daemon_bin_from(Some(&bin), &dir.path().join("consumer"), false, &[], None)
164                .unwrap();
165        assert_eq!(resolved, bin);
166        assert_eq!(source, ResolveSource::EnvOverride);
167    }
168
169    #[test]
170    fn bad_override_is_hard_error() {
171        let dir = tempfile::tempdir().unwrap();
172        let missing = dir.path().join("missing");
173        let err = resolve_daemon_bin_from(Some(&missing), &dir.path().join("x"), false, &[], None)
174            .unwrap_err();
175        assert!(matches!(err, DaemonError::BinaryNotFound { .. }));
176        assert!(err.to_string().contains(STACKLESS_BIN_ENV));
177    }
178
179    #[test]
180    fn cli_marker_uses_self() {
181        let dir = tempfile::tempdir().unwrap();
182        let self_exe = touch_exe(dir.path(), "stackless");
183        let (resolved, source) = resolve_daemon_bin_from(None, &self_exe, true, &[], None).unwrap();
184        assert_eq!(resolved, self_exe);
185        assert_eq!(source, ResolveSource::SelfAsCli);
186    }
187
188    #[test]
189    fn unmarked_self_never_returned_even_if_named_stackless() {
190        let dir = tempfile::tempdir().unwrap();
191        let self_exe = touch_exe(dir.path(), "stackless");
192        let path_dir = tempfile::tempdir().unwrap();
193        let on_path = touch_exe(path_dir.path(), "stackless");
194        let (resolved, source) = resolve_daemon_bin_from(
195            None,
196            &self_exe,
197            false,
198            &[path_dir.path().to_path_buf()],
199            None,
200        )
201        .unwrap();
202        assert_eq!(resolved, on_path);
203        assert_eq!(source, ResolveSource::Path);
204        assert_ne!(resolved, self_exe);
205    }
206
207    #[test]
208    fn unmarked_with_nothing_errors() {
209        let dir = tempfile::tempdir().unwrap();
210        let self_exe = touch_exe(dir.path(), "jinttai-e2e");
211        let err = resolve_daemon_bin_from(None, &self_exe, false, &[], None).unwrap_err();
212        assert!(matches!(err, DaemonError::BinaryNotFound { .. }));
213        assert!(!err.to_string().contains("jinttai-e2e daemon run"));
214    }
215
216    #[test]
217    fn well_known_cargo_bin() {
218        let home = tempfile::tempdir().unwrap();
219        let cargo_bin = home.path().join(".cargo").join("bin");
220        std::fs::create_dir_all(&cargo_bin).unwrap();
221        let bin = touch_exe(&cargo_bin, "stackless");
222        let self_exe = home.path().join("consumer");
223        let (resolved, source) =
224            resolve_daemon_bin_from(None, &self_exe, false, &[], Some(home.path())).unwrap();
225        assert_eq!(resolved, bin);
226        assert_eq!(source, ResolveSource::WellKnown);
227    }
228
229    #[test]
230    fn should_replace_only_self_as_cli() {
231        assert!(should_replace_daemon(
232            "0.1.4",
233            "0.1.5",
234            ResolveSource::SelfAsCli
235        ));
236        assert!(!should_replace_daemon(
237            "0.1.4",
238            "0.1.5",
239            ResolveSource::Path
240        ));
241        assert!(!should_replace_daemon(
242            "0.1.4",
243            "0.1.5",
244            ResolveSource::EnvOverride
245        ));
246        assert!(!should_replace_daemon(
247            "0.1.4",
248            "0.1.5",
249            ResolveSource::WellKnown
250        ));
251        assert!(!should_replace_daemon(
252            "0.1.4",
253            "0.1.5",
254            ResolveSource::Explicit
255        ));
256        assert!(!should_replace_daemon(
257            "0.1.5",
258            "0.1.5",
259            ResolveSource::SelfAsCli
260        ));
261    }
262}