Skip to main content

cordis_cli/
lib.rs

1//! Command-line runner for the [cordis-rs](https://crates.io/crates/cordis-rs)
2//! plugin framework: `cordis run <config.yml>`.
3//!
4//! The process model follows upstream Cordis' NodeLoader: `cordis run`
5//! supervises a worker subprocess running the loader. The worker exits
6//! with code `51` to request a hot restart (respawned with a doubling,
7//! capped backoff), `52` to quit, and `53` when the loader never came up.
8//! The daemon exits `0` on clean shutdown, `1` when the worker never
9//! booted or died abnormally, and otherwise propagates the worker's code —
10//! deployment pipelines always see the real outcome. `SIGINT` / `SIGTERM`
11//! dispose the root context gracefully and exit `52` (daemon `0`); the
12//! daemon forwards its own shutdown to the worker by closing the pipe on
13//! the worker's stdin (so `kill <daemon-pid>` and even a `SIGKILL`ed
14//! daemon take the worker down too, not just terminal-wide signals), and
15//! kills the worker after a grace period if it will not quit. `.env`
16//! and `.env.local` are loaded (without overriding existing variables)
17//! before the worker boots, and the entry file is watched for hot reload.
18//!
19//! `--plugin-dir <dir>` (repeatable) additionally resolves entries from
20//! dynamic-library plugins compiled against the same toolchain; a change
21//! to a library in those directories hot-restarts the worker so the new
22//! build is loaded by a fresh process.
23//!
24//! Plugins reach the process controls through the `worker` service
25//! ([`worker::WorkerHandle`]): `restart()` maps to a full worker reload,
26//! and `shutdown()` stops the whole application.
27
28#![deny(unsafe_code)]
29#![warn(missing_docs)]
30
31pub mod dotenv;
32pub mod worker;
33
34use std::ffi::{OsStr, OsString};
35use std::path::PathBuf;
36use std::sync::Arc;
37use std::sync::atomic::{AtomicBool, Ordering};
38use std::time::Duration;
39
40/// Initial delay before respawning a restart-looping worker.
41const RESTART_BACKOFF_START: Duration = Duration::from_millis(100);
42/// Ceiling for the doubling restart delay.
43const RESTART_BACKOFF_MAX: Duration = Duration::from_secs(5);
44/// A worker that stayed up at least this long resets the restart backoff.
45const RESTART_BACKOFF_RESET_AFTER: Duration = Duration::from_secs(10);
46/// How often the supervisor polls the worker's exit status. Polling keeps
47/// the shutdown flag observable where a blocking `wait()` would hang.
48const WORKER_WAIT_POLL: Duration = Duration::from_millis(50);
49/// Grace period for the worker to exit after the daemon requested shutdown
50/// (by closing the worker's stdin pipe) before it is killed outright.
51const WORKER_SHUTDOWN_GRACE: Duration = Duration::from_secs(10);
52
53/// What the supervisor does after a worker exits.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Action {
56    /// Spawn a fresh worker (hot restart).
57    Restart,
58    /// Stop supervising.
59    Stop,
60}
61
62/// Decide whether to restart the worker after it exited with `exit_code`.
63///
64/// Only exit code [`worker::EXIT_RESTART`] (51) restarts, and never once
65/// the daemon itself received a shutdown signal.
66pub fn supervisor_action(exit_code: Option<i32>, shutdown: bool) -> Action {
67    if shutdown || exit_code != Some(worker::EXIT_RESTART) {
68        Action::Stop
69    } else {
70        Action::Restart
71    }
72}
73
74/// The daemon's own exit code after the worker stopped.
75///
76/// A clean quit (52) and a daemon-side shutdown signal exit 0; a worker
77/// that never booted (53) exits 1 so deployment pipelines see the failure;
78/// a crashed worker's code is propagated instead of being masked as
79/// success.
80pub fn daemon_exit_code(exit_code: Option<i32>, shutdown: bool) -> i32 {
81    if shutdown {
82        return 0;
83    }
84    match exit_code {
85        Some(code) if code == worker::EXIT_QUIT => 0,
86        Some(code) if code == worker::EXIT_RESTART => 0,
87        Some(code) if code == worker::EXIT_BOOT => 1,
88        Some(code) => code,
89        None => 1,
90    }
91}
92
93/// The delay before respawning a restart-looping worker: doubling from
94/// [`RESTART_BACKOFF_START`], capped at [`RESTART_BACKOFF_MAX`], and reset
95/// whenever the previous worker stayed up at least
96/// [`RESTART_BACKOFF_RESET_AFTER`].
97fn next_backoff(previous: Option<Duration>, ran_for: Duration) -> Duration {
98    let Some(previous) = previous else {
99        return RESTART_BACKOFF_START;
100    };
101    if ran_for >= RESTART_BACKOFF_RESET_AFTER {
102        return RESTART_BACKOFF_START;
103    }
104    previous.saturating_mul(2).min(RESTART_BACKOFF_MAX)
105}
106
107/// Parsed command line.
108#[derive(Debug, PartialEq, Eq)]
109pub struct Options {
110    /// Entry config file.
111    pub config: PathBuf,
112    /// Directories searched for dynamic-library plugins (repeatable).
113    pub plugin_dirs: Vec<PathBuf>,
114    /// Run as the daemon's worker process instead of supervising.
115    pub worker: bool,
116}
117
118/// Parse `cordis` arguments (after the binary name).
119///
120/// Arguments stay [`OsString`] end to end so config paths with non-UTF-8
121/// bytes (legal on Unix filesystems) reach the loader intact instead of
122/// being mangled into replacement characters; they are only lossily
123/// rendered for usage and error text.
124pub fn parse_args(args: &[OsString]) -> Result<Options, String> {
125    let Some(command) = args.first() else {
126        return Err(usage());
127    };
128    if command.as_os_str() != OsStr::new("run") {
129        return Err(format!(
130            "unknown command `{}`\n\n{}",
131            command.to_string_lossy(),
132            usage()
133        ));
134    }
135    let mut config = None;
136    let mut plugin_dirs = Vec::new();
137    let mut worker = false;
138    let mut rest = args[1..].iter();
139    while let Some(arg) = rest.next() {
140        let bytes = arg.as_encoded_bytes();
141        match bytes {
142            b"--worker" | b"-w" => worker = true,
143            b"--help" | b"-h" => return Err(usage()),
144            b"--plugin-dir" => {
145                let Some(value) = rest.next() else {
146                    return Err(format!("--plugin-dir requires a directory\n\n{}", usage()));
147                };
148                plugin_dirs.push(PathBuf::from(value));
149            }
150            _ if bytes.starts_with(b"--plugin-dir=") => {
151                let value = os_string_from_encoded_bytes(&bytes[b"--plugin-dir=".len()..]);
152                if value.is_empty() {
153                    return Err(format!("--plugin-dir requires a directory\n\n{}", usage()));
154                }
155                plugin_dirs.push(PathBuf::from(value));
156            }
157            _ if bytes.first() == Some(&b'-') => {
158                return Err(format!(
159                    "unknown flag `{}`\n\n{}",
160                    arg.to_string_lossy(),
161                    usage()
162                ));
163            }
164            _ => {
165                if config.replace(PathBuf::from(arg)).is_some() {
166                    return Err(format!(
167                        "unexpected extra argument `{}`\n\n{}",
168                        arg.to_string_lossy(),
169                        usage()
170                    ));
171                }
172            }
173        }
174    }
175    match config {
176        Some(config) => Ok(Options {
177            config,
178            plugin_dirs,
179            worker,
180        }),
181        None => Err(format!("missing config file\n\n{}", usage())),
182    }
183}
184
185/// Rebuild an [`OsString`] from the [`OsStr::as_encoded_bytes`]
186/// representation. Lossless on Unix (raw bytes); on Windows and elsewhere
187/// the bytes decode as UTF-8 — Windows arguments are UTF-16-representable
188/// in practice, so this only degrades for lone-surrogate arguments, which
189/// `OsStr::from_encoded_bytes` (unstable at our MSRV) could not have
190/// preserved either way.
191fn os_string_from_encoded_bytes(bytes: &[u8]) -> OsString {
192    #[cfg(unix)]
193    {
194        use std::os::unix::ffi::OsStringExt;
195        OsString::from_vec(bytes.to_vec())
196    }
197    #[cfg(not(unix))]
198    {
199        OsString::from(String::from_utf8_lossy(bytes).into_owned())
200    }
201}
202
203fn usage() -> String {
204    "usage: cordis run <config.yml> [--plugin-dir <dir>]... [--worker]
205
206  run             start the loader from an entry config file
207  --plugin-dir    also resolve plugins from dynamic libraries in <dir>
208                  (repeatable); library changes there hot-restart the worker
209  --worker        internal: run as the daemon's worker process
210
211Worker exit codes: 51 = hot restart, 52 = quit, 53 = boot failure.
212Daemon exit codes: 0 = clean shutdown, 1 = worker never booted or died
213abnormally, otherwise the worker's own code."
214        .to_owned()
215}
216
217/// Entry point used by the `cordis` binary: parse arguments, load dotenv,
218/// then supervise or run as the worker.
219///
220/// Accepts [`OsString`]s so non-UTF-8 arguments (notably config paths on
221/// Unix) survive to the loader unchanged.
222pub fn run<I, S>(args: I) -> i32
223where
224    I: IntoIterator<Item = S>,
225    S: Into<OsString>,
226{
227    let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
228    let options = match parse_args(&args) {
229        Ok(options) => options,
230        Err(message) => {
231            eprintln!("{message}");
232            return 2;
233        }
234    };
235    if let Ok(dir) = std::env::current_dir() {
236        dotenv::load(&dir);
237    }
238    if options.worker {
239        worker::run(&options.config, &options.plugin_dirs);
240    }
241    supervise(&options.config, &options.plugin_dirs)
242}
243
244/// Supervise worker subprocesses until they quit or a signal arrives.
245fn supervise(config: &std::path::Path, plugin_dirs: &[PathBuf]) -> i32 {
246    let shutdown = Arc::new(AtomicBool::new(false));
247    let signal_flag = Arc::clone(&shutdown);
248    if ctrlc::set_handler(move || {
249        eprintln!("cordis: shutdown requested");
250        signal_flag.store(true, Ordering::SeqCst);
251    })
252    .is_err()
253    {
254        eprintln!("cordis: could not install signal handlers");
255    }
256
257    let exe = match std::env::current_exe() {
258        Ok(exe) => exe,
259        Err(error) => {
260            eprintln!("cordis: cannot resolve own executable: {error}");
261            return 1;
262        }
263    };
264    // Restart backoff so a restart loop cannot spin the CPU; a worker that
265    // stayed up long enough resets it.
266    let mut backoff: Option<Duration> = None;
267    let exit_code;
268    'supervise: loop {
269        if shutdown.load(Ordering::SeqCst) {
270            exit_code = Some(worker::EXIT_QUIT);
271            break;
272        }
273        let started = std::time::Instant::now();
274        let mut command = std::process::Command::new(&exe);
275        command.arg("run").arg(config).arg("--worker");
276        for dir in plugin_dirs {
277            command.arg("--plugin-dir").arg(dir);
278        }
279        // The worker watches this pipe: the daemon closes it when shutting
280        // down, and the OS closes it when the daemon dies for any reason —
281        // the std-only stand-in for forwarding SIGTERM, which also covers a
282        // daemon killed with SIGKILL.
283        command
284            .stdin(std::process::Stdio::piped())
285            .env(worker::SUPERVISED_ENV, "1");
286        let mut child = match command.spawn() {
287            Ok(child) => child,
288            Err(error) => {
289                eprintln!("cordis: cannot spawn worker: {error}");
290                return 1;
291            }
292        };
293        let code = supervise_worker(&mut child, &shutdown);
294        if supervisor_action(code, shutdown.load(Ordering::SeqCst)) == Action::Stop {
295            exit_code = code;
296            break;
297        }
298        let delay = next_backoff(backoff, started.elapsed());
299        eprintln!(
300            "cordis: worker requested restart, respawning in {}ms",
301            delay.as_millis()
302        );
303        backoff = Some(delay);
304        // Interruptible backoff: a shutdown request during the delay
305        // aborts the wait instead of deferring the exit by up to 5s.
306        let deadline = std::time::Instant::now() + delay;
307        while std::time::Instant::now() < deadline {
308            if shutdown.load(Ordering::SeqCst) {
309                continue 'supervise;
310            }
311            let now = std::time::Instant::now();
312            std::thread::sleep(WORKER_WAIT_POLL.min(deadline.saturating_duration_since(now)));
313        }
314    }
315    daemon_exit_code(exit_code, shutdown.load(Ordering::SeqCst))
316}
317
318/// Wait for one worker, forwarding daemon shutdown requests: closing the
319/// worker's stdin pipe triggers its graceful teardown (the same path as a
320/// signal), and a worker that ignores it for [`WORKER_SHUTDOWN_GRACE`] is
321/// killed. Never blocks indefinitely, so the shutdown flag stays
322/// observable.
323fn supervise_worker(child: &mut std::process::Child, shutdown: &AtomicBool) -> Option<i32> {
324    let mut stdin = child.stdin.take();
325    let mut requested = None::<std::time::Instant>;
326    loop {
327        match child.try_wait() {
328            Ok(Some(status)) => return status.code(),
329            Ok(None) => {}
330            Err(error) => {
331                eprintln!("cordis: cannot wait for worker: {error}");
332                return None;
333            }
334        }
335        if shutdown.load(Ordering::SeqCst) {
336            match requested {
337                None => {
338                    eprintln!("cordis: forwarding shutdown to the worker");
339                    drop(stdin.take());
340                    requested = Some(std::time::Instant::now());
341                }
342                Some(at) if at.elapsed() >= WORKER_SHUTDOWN_GRACE => {
343                    eprintln!("cordis: worker did not exit in time, killing it");
344                    let _ = child.kill();
345                    return child.wait().ok().and_then(|status| status.code());
346                }
347                _ => {}
348            }
349        }
350        std::thread::sleep(WORKER_WAIT_POLL);
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    fn args(list: &[&str]) -> Vec<OsString> {
359        list.iter().map(OsString::from).collect()
360    }
361
362    #[test]
363    fn parses_run_command_and_flags() {
364        assert_eq!(
365            parse_args(&args(&["run", "cordis.yml"])).unwrap(),
366            Options {
367                config: "cordis.yml".into(),
368                plugin_dirs: Vec::new(),
369                worker: false,
370            }
371        );
372        assert_eq!(
373            parse_args(&args(&["run", "cordis.yml", "--worker"])).unwrap(),
374            Options {
375                config: "cordis.yml".into(),
376                plugin_dirs: Vec::new(),
377                worker: true,
378            }
379        );
380    }
381
382    #[test]
383    fn parses_plugin_dirs_in_both_forms() {
384        let options = parse_args(&args(&[
385            "run",
386            "cordis.yml",
387            "--plugin-dir",
388            "a",
389            "--plugin-dir=b",
390        ]))
391        .unwrap();
392        assert_eq!(
393            options.plugin_dirs,
394            [PathBuf::from("a"), PathBuf::from("b")]
395        );
396        assert!(!options.worker);
397    }
398
399    #[test]
400    fn rejects_malformed_plugin_dirs() {
401        assert!(parse_args(&args(&["run", "cordis.yml", "--plugin-dir"])).is_err());
402        assert!(parse_args(&args(&["run", "cordis.yml", "--plugin-dir="])).is_err());
403    }
404
405    #[test]
406    fn rejects_missing_or_unknown_arguments() {
407        assert!(parse_args(&args(&[])).is_err());
408        assert!(parse_args(&args(&["start", "cordis.yml"])).is_err());
409        assert!(parse_args(&args(&["run"])).is_err());
410        assert!(parse_args(&args(&["run", "a.yml", "b.yml"])).is_err());
411        assert!(parse_args(&args(&["run", "a.yml", "--nope"])).is_err());
412    }
413
414    #[test]
415    fn only_code_51_restarts_and_never_after_shutdown() {
416        assert_eq!(supervisor_action(Some(51), false), Action::Restart);
417        assert_eq!(supervisor_action(Some(51), true), Action::Stop);
418        assert_eq!(supervisor_action(Some(52), false), Action::Stop);
419        assert_eq!(supervisor_action(Some(0), false), Action::Stop);
420        assert_eq!(supervisor_action(None, false), Action::Stop);
421    }
422
423    #[test]
424    fn daemon_exit_code_reflects_how_the_worker_ended() {
425        // Clean quit and daemon-side shutdown stay successful.
426        assert_eq!(daemon_exit_code(Some(52), false), 0);
427        assert_eq!(daemon_exit_code(Some(52), true), 0);
428        assert_eq!(daemon_exit_code(Some(51), true), 0);
429        // A worker that never booted fails the daemon.
430        assert_eq!(daemon_exit_code(Some(53), false), 1);
431        // Crashes propagate instead of being masked as success.
432        assert_eq!(daemon_exit_code(Some(101), false), 101);
433        assert_eq!(daemon_exit_code(None, false), 1);
434    }
435
436    #[test]
437    fn restart_backoff_doubles_resets_and_caps() {
438        assert_eq!(
439            next_backoff(None, Duration::from_secs(0)),
440            RESTART_BACKOFF_START
441        );
442        assert_eq!(
443            next_backoff(Some(Duration::from_millis(100)), Duration::from_secs(1)),
444            Duration::from_millis(200)
445        );
446        assert_eq!(
447            next_backoff(Some(Duration::from_secs(4)), Duration::from_secs(1)),
448            RESTART_BACKOFF_MAX
449        );
450        assert_eq!(
451            next_backoff(Some(Duration::from_secs(4)), Duration::from_secs(60)),
452            RESTART_BACKOFF_START,
453            "a worker that stayed up resets the backoff"
454        );
455    }
456
457    /// Regression (#38): arguments with non-UTF-8 bytes (legal config paths
458    /// on Unix) must reach `Options` unchanged, not as replacement
459    /// characters.
460    #[cfg(unix)]
461    #[test]
462    fn non_utf8_arguments_survive_parsing() {
463        use std::os::unix::ffi::OsStringExt;
464        let bad = OsString::from_vec(vec![b'c', 0xff, b'.', b'y', b'm', b'l']);
465        let options = parse_args(&[OsString::from("run"), bad.clone()]).unwrap();
466        assert_eq!(options.config.as_os_str(), bad.as_os_str());
467
468        let bad_dir = OsString::from_vec(vec![b'd', 0xfe, b'i', 0xff, b'r']);
469        let options = parse_args(&[
470            OsString::from("run"),
471            OsString::from("c.yml"),
472            OsString::from("--plugin-dir"),
473            bad_dir.clone(),
474        ])
475        .unwrap();
476        assert_eq!(options.plugin_dirs, [PathBuf::from(bad_dir)]);
477    }
478}