Skip to main content

stackless_daemon/
client.rs

1//! The CLI side: connect to the daemon, spawning it on demand under a
2//! lock file so concurrent commands race safely (§3). Carries the version
3//! handshake: a newer CLI tells an older daemon to drain and exit — but
4//! only when this process *is* the CLI (never when an SDK consumer resolved
5//! a PATH/env CLI binary).
6
7use std::io::{BufRead, BufReader, Write};
8use std::os::unix::net::UnixStream;
9use std::os::unix::process::CommandExt;
10use std::path::Path;
11use std::time::{Duration, Instant};
12
13use stackless_core::fault::{Fault, codes};
14use stackless_core::paths::Paths;
15use stackless_core::types::{ProtocolVersion, TcpPort};
16
17use crate::binary::{ResolveSource, resolve_daemon_bin, should_replace_daemon};
18use crate::proxy;
19use crate::rpc::{Envelope, Request, Response, ResponseBody, build_version};
20use crate::server::{DaemonRole, socket_path_for};
21
22#[derive(Debug, thiserror::Error)]
23pub enum DaemonError {
24    #[error("cannot reach the stackless daemon: {detail}")]
25    Unreachable { detail: String },
26
27    #[error("daemon request failed: {error}")]
28    Request { error: String },
29
30    #[error("daemon spawn failed: {detail}")]
31    Spawn { detail: String },
32
33    #[error("stackless CLI binary not found: {detail}")]
34    BinaryNotFound { detail: String },
35}
36
37impl Fault for DaemonError {
38    fn code(&self) -> &'static str {
39        match self {
40            Self::Unreachable { .. } => codes::DAEMON_UNREACHABLE,
41            Self::Request { .. } => codes::DAEMON_REQUEST_FAILED,
42            Self::Spawn { .. } => codes::DAEMON_SPAWN_FAILED,
43            Self::BinaryNotFound { .. } => codes::DAEMON_BINARY_NOT_FOUND,
44        }
45    }
46
47    fn remediation(&self) -> String {
48        match self {
49            Self::BinaryNotFound { .. } => {
50                "install the stackless CLI, ensure `stackless` is on PATH, or set STACKLESS_BIN \
51                 to the CLI absolute path; for hermetic tests use Client::builder().paths(...) \
52                 / TestContext (feature test-support)"
53                    .into()
54            }
55            Self::Unreachable { .. } | Self::Spawn { .. } => {
56                "check `stackless daemon run` starts in a terminal and that the state dir is \
57                 writable; if using the SDK from another binary, install the CLI or set \
58                 STACKLESS_BIN"
59                    .into()
60            }
61            Self::Request { .. } => {
62                "re-run the command; the daemon may have been restarting".into()
63            }
64        }
65    }
66}
67
68#[derive(Debug)]
69pub struct DaemonClient {
70    stream: UnixStream,
71}
72
73impl DaemonClient {
74    /// Connect, spawning the daemon if nothing answers. Resolves the CLI
75    /// binary via [`resolve_daemon_bin`] (never assumes `current_exe` is the
76    /// CLI). Restarts an older daemon only when this process is the CLI.
77    pub fn ensure() -> Result<Self, DaemonError> {
78        let paths = Paths::from_env();
79        Self::ensure_resolved(&paths, proxy::proxy_port(), DaemonRole::Operator)
80    }
81
82    /// Like [`Self::ensure`], but with an injectable state layout and proxy
83    /// port. Resolves the CLI binary only when a spawn (or CLI self-upgrade)
84    /// is required — an already-running daemon (e.g. hermetic `TestContext`)
85    /// does not need `stackless` on `PATH`.
86    pub fn ensure_resolved(
87        paths: &Paths,
88        proxy_port: TcpPort,
89        role: DaemonRole,
90    ) -> Result<Self, DaemonError> {
91        let client = match Self::connect_with(paths) {
92            Ok(client) => client,
93            Err(_) => {
94                let (exe, _source) = resolve_daemon_bin()?;
95                spawn_daemon(paths, &exe, proxy_port, role)?;
96                Self::wait_for_socket(paths, Duration::from_secs(5))?
97            }
98        };
99        // Single replace policy for the operator path: only the CLI process
100        // may drain-and-replace, and it always respawns `current_exe` (never
101        // `resolve_daemon_bin()`, which prefers a possibly-stale STACKLESS_BIN).
102        Self::cli_self_upgrade_if_needed(client, paths, proxy_port, role)
103    }
104
105    /// Drain-and-replace a version-mismatched daemon with this process's
106    /// `current_exe` when we are the CLI. No-op for SDK consumers.
107    fn cli_self_upgrade_if_needed(
108        mut client: Self,
109        paths: &Paths,
110        proxy_port: TcpPort,
111        role: DaemonRole,
112    ) -> Result<Self, DaemonError> {
113        let daemon_version = client.ping()?;
114        if !crate::is_cli_process() || daemon_version == build_version() {
115            return Ok(client);
116        }
117        let exe = std::env::current_exe().map_err(|err| DaemonError::Spawn {
118            detail: format!("cannot resolve current executable: {err}"),
119        })?;
120        let _ = client.call(Request::Shutdown);
121        std::thread::sleep(Duration::from_millis(200));
122        spawn_daemon(paths, &exe, proxy_port, role)?;
123        client = Self::wait_for_socket(paths, Duration::from_secs(5))?;
124        client.ping()?;
125        Ok(client)
126    }
127
128    /// Like [`Self::ensure`], but uses an injectable state layout, proxy
129    /// port, daemon binary, and [`DaemonRole`] instead of process-global
130    /// defaults. Embedded spawns pass `--state-dir` / `--proxy-port` /
131    /// `--embedded` so the child binds the same layout the client waits on.
132    ///
133    /// Version kill-and-replace is disabled for this entry point (source
134    /// [`ResolveSource::Explicit`]): hermetic tests inject `CARGO_BIN_EXE`
135    /// and must not thrash a mismatched operator daemon.
136    pub fn ensure_with(
137        paths: &Paths,
138        executable: &Path,
139        proxy_port: TcpPort,
140        role: DaemonRole,
141    ) -> Result<Self, DaemonError> {
142        Self::ensure_with_source(paths, executable, proxy_port, role, ResolveSource::Explicit)
143    }
144
145    fn ensure_with_source(
146        paths: &Paths,
147        executable: &Path,
148        proxy_port: TcpPort,
149        role: DaemonRole,
150        source: ResolveSource,
151    ) -> Result<Self, DaemonError> {
152        let mut client = match Self::connect_with(paths) {
153            Ok(client) => client,
154            Err(_) => {
155                spawn_daemon(paths, executable, proxy_port, role)?;
156                Self::wait_for_socket(paths, Duration::from_secs(5))?
157            }
158        };
159        let daemon_version = client.ping()?;
160        if should_replace_daemon(&daemon_version, build_version(), source) {
161            let _ = client.call(Request::Shutdown);
162            std::thread::sleep(Duration::from_millis(200));
163            spawn_daemon(paths, executable, proxy_port, role)?;
164            client = Self::wait_for_socket(paths, Duration::from_secs(5))?;
165            // One-shot: accept whatever answers after a single replace.
166            client.ping()?;
167        }
168        Ok(client)
169    }
170
171    pub fn connect() -> Result<Self, DaemonError> {
172        Self::connect_with(&Paths::from_env())
173    }
174
175    pub fn connect_with(paths: &Paths) -> Result<Self, DaemonError> {
176        let stream = UnixStream::connect(socket_path_for(paths)).map_err(|err| {
177            DaemonError::Unreachable {
178                detail: err.to_string(),
179            }
180        })?;
181        stream.set_read_timeout(Some(Duration::from_secs(10))).ok();
182        Ok(Self { stream })
183    }
184
185    fn wait_for_socket(paths: &Paths, budget: Duration) -> Result<Self, DaemonError> {
186        let deadline = Instant::now() + budget;
187        loop {
188            match Self::connect_with(paths) {
189                Ok(client) => return Ok(client),
190                Err(err) if Instant::now() > deadline => return Err(err),
191                Err(_) => std::thread::sleep(Duration::from_millis(50)),
192            }
193        }
194    }
195
196    /// Returns the daemon's version.
197    pub fn ping(&mut self) -> Result<String, DaemonError> {
198        let (version, _body) = self.call_versioned(Request::Ping)?;
199        Ok(version)
200    }
201
202    pub fn call(&mut self, request: Request) -> Result<ResponseBody, DaemonError> {
203        self.call_versioned(request).map(|(_, body)| body)
204    }
205
206    fn call_versioned(&mut self, request: Request) -> Result<(String, ResponseBody), DaemonError> {
207        let envelope = Envelope {
208            protocol: ProtocolVersion::V1,
209            version: build_version().to_owned(),
210            body: request,
211        };
212        let mut line = serde_json::to_string(&envelope).map_err(|err| DaemonError::Request {
213            error: err.to_string(),
214        })?;
215        line.push('\n');
216        self.stream
217            .write_all(line.as_bytes())
218            .map_err(|err| DaemonError::Unreachable {
219                detail: err.to_string(),
220            })?;
221        let mut reader = BufReader::new(&self.stream);
222        let mut response_line = String::new();
223        reader
224            .read_line(&mut response_line)
225            .map_err(|err| DaemonError::Unreachable {
226                detail: err.to_string(),
227            })?;
228        let envelope: Envelope<Response> =
229            serde_json::from_str(&response_line).map_err(|err| DaemonError::Request {
230                error: format!("unparseable response: {err}"),
231            })?;
232        match envelope.body {
233            Response::Ok(body) => Ok((envelope.version, body)),
234            Response::Err { error } => Err(DaemonError::Request { error }),
235        }
236    }
237}
238
239/// Start `stackless daemon run` detached, under a lock file so two CLIs
240/// racing here start exactly one daemon.
241///
242/// Lifecycle (§3): prefer launchd supervision. When the LaunchAgent is
243/// already usable — plist present, naming *this* binary, and bootstrapped —
244/// `kickstart_if_supervised` runs the service so the steady-state daemon
245/// lives under launchd and KeepAlive({SuccessfulExit=false}) actually
246/// restarts a `kill -9`. A clean `daemon stop` (exit 0) still stays down,
247/// since SuccessfulExit gates the restart on a *crash*.
248///
249/// Otherwise we fall back to a direct `Command` spawn (unsupervised). This
250/// covers the first-ever run (no plist) and the post-upgrade respawn (plist
251/// still names the old binary). That spawned daemon's `ensure_registered`
252/// rewrites the plist to the current exe and re-bootstraps, so the *next*
253/// spawn converges onto the supervised kickstart path:
254///   direct spawn → daemon rewrites plist + re-bootstraps → kickstart.
255///
256/// Either path runs under the spawn lock so concurrent CLIs start one
257/// daemon, not a herd.
258fn spawn_daemon(
259    paths: &Paths,
260    executable: &Path,
261    proxy_port: TcpPort,
262    role: DaemonRole,
263) -> Result<(), DaemonError> {
264    let lock_path = paths.spawn_lock();
265    if let Some(dir) = lock_path.parent() {
266        std::fs::create_dir_all(dir).map_err(|err| DaemonError::Spawn {
267            detail: err.to_string(),
268        })?;
269    }
270    let _lock = match stackless_core::lockfile::FileLock::try_acquire(&lock_path) {
271        Ok(lock) => lock,
272        // Someone else is spawning; wait for their daemon instead.
273        Err(_) => return Ok(()),
274    };
275    // Supervised start: operator on the default proxy only — the LaunchAgent
276    // plist runs bare `daemon run` (env default port). Custom ports and
277    // embedded roots always direct-spawn with explicit flags.
278    let default_proxy = proxy::proxy_port();
279    if role == DaemonRole::Operator
280        && proxy_port == default_proxy
281        && crate::launchd::kickstart_if_supervised()
282    {
283        return Ok(());
284    }
285    let log = std::fs::OpenOptions::new()
286        .create(true)
287        .append(true)
288        .open(paths.daemon_log())
289        .map_err(|err| DaemonError::Spawn {
290            detail: err.to_string(),
291        })?;
292    let log_err = log.try_clone().map_err(|err| DaemonError::Spawn {
293        detail: err.to_string(),
294    })?;
295    let mut command = std::process::Command::new(executable);
296    command
297        .args(["daemon", "run"])
298        .arg("--state-dir")
299        .arg(paths.state_dir())
300        .arg("--proxy-port")
301        .arg(proxy_port.get().to_string());
302    if role == DaemonRole::Embedded {
303        command.arg("--embedded");
304    }
305    command
306        .stdin(std::process::Stdio::null())
307        .stdout(log)
308        .stderr(log_err)
309        .process_group(0)
310        .spawn()
311        .map_err(|err| DaemonError::Spawn {
312            detail: err.to_string(),
313        })?;
314    Ok(())
315}