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///
109/// Mirrors `cordis run <config> [--plugin-dir <dir>]… [--worker|-w]`:
110/// [`config`](Options::config) is the positional entry file,
111/// [`plugin_dirs`](Options::plugin_dirs) collects every `--plugin-dir`
112/// (repeatable), and [`worker`](Options::worker) is set by `--worker`/`-w`.
113#[derive(Debug, PartialEq, Eq)]
114pub struct Options {
115    /// Entry config file (the positional `cordis run` argument).
116    pub config: PathBuf,
117    /// Directories searched for dynamic-library plugins (repeatable
118    /// `--plugin-dir <dir>`).
119    pub plugin_dirs: Vec<PathBuf>,
120    /// Run as the daemon's worker process instead of supervising
121    /// (`--worker`/`-w`).
122    pub worker: bool,
123}
124
125/// Parse `cordis` arguments (after the binary name).
126///
127/// Accepted shape: `cordis run <config> [--plugin-dir <dir>]…
128/// [--worker|-w]` — the result is [`Options`].
129///
130/// Arguments stay [`OsString`] end to end so config paths with non-UTF-8
131/// bytes (legal on Unix filesystems) reach the loader intact instead of
132/// being mangled into replacement characters; they are only lossily
133/// rendered for usage and error text.
134pub fn parse_args(args: &[OsString]) -> Result<Options, String> {
135    let Some(command) = args.first() else {
136        return Err(usage());
137    };
138    if command.as_os_str() != OsStr::new("run") {
139        return Err(format!(
140            "unknown command `{}`\n\n{}",
141            command.to_string_lossy(),
142            usage()
143        ));
144    }
145    let mut config = None;
146    let mut plugin_dirs = Vec::new();
147    let mut worker = false;
148    let mut rest = args[1..].iter();
149    while let Some(arg) = rest.next() {
150        let bytes = arg.as_encoded_bytes();
151        match bytes {
152            b"--worker" | b"-w" => worker = true,
153            b"--help" | b"-h" => return Err(usage()),
154            b"--plugin-dir" => {
155                let Some(value) = rest.next() else {
156                    return Err(format!("--plugin-dir requires a directory\n\n{}", usage()));
157                };
158                plugin_dirs.push(PathBuf::from(value));
159            }
160            _ if bytes.starts_with(b"--plugin-dir=") => {
161                let value = os_string_from_encoded_bytes(&bytes[b"--plugin-dir=".len()..]);
162                if value.is_empty() {
163                    return Err(format!("--plugin-dir requires a directory\n\n{}", usage()));
164                }
165                plugin_dirs.push(PathBuf::from(value));
166            }
167            _ if bytes.first() == Some(&b'-') => {
168                return Err(format!(
169                    "unknown flag `{}`\n\n{}",
170                    arg.to_string_lossy(),
171                    usage()
172                ));
173            }
174            _ => {
175                if config.replace(PathBuf::from(arg)).is_some() {
176                    return Err(format!(
177                        "unexpected extra argument `{}`\n\n{}",
178                        arg.to_string_lossy(),
179                        usage()
180                    ));
181                }
182            }
183        }
184    }
185    match config {
186        Some(config) => Ok(Options {
187            config,
188            plugin_dirs,
189            worker,
190        }),
191        None => Err(format!("missing config file\n\n{}", usage())),
192    }
193}
194
195/// Rebuild an [`OsString`] from the [`OsStr::as_encoded_bytes`]
196/// representation. Lossless on Unix (raw bytes); on Windows and elsewhere
197/// the bytes decode as UTF-8 — Windows arguments are UTF-16-representable
198/// in practice, so this only degrades for lone-surrogate arguments, which
199/// `OsStr::from_encoded_bytes` (unstable at our MSRV) could not have
200/// preserved either way.
201fn os_string_from_encoded_bytes(bytes: &[u8]) -> OsString {
202    #[cfg(unix)]
203    {
204        use std::os::unix::ffi::OsStringExt;
205        OsString::from_vec(bytes.to_vec())
206    }
207    #[cfg(not(unix))]
208    {
209        OsString::from(String::from_utf8_lossy(bytes).into_owned())
210    }
211}
212
213fn usage() -> String {
214    "usage: cordis run <config.yml> [--plugin-dir <dir>]... [--worker]
215
216  run             start the loader from an entry config file
217  --plugin-dir    also resolve plugins from dynamic libraries in <dir>
218                  (repeatable); library changes there hot-restart the worker
219  --worker        internal: run as the daemon's worker process
220
221Worker exit codes: 51 = hot restart, 52 = quit, 53 = boot failure.
222Daemon exit codes: 0 = clean shutdown, 1 = worker never booted or died
223abnormally, otherwise the worker's own code."
224        .to_owned()
225}
226
227/// Entry point used by the `cordis` binary: parse arguments, load dotenv,
228/// then supervise or run as the worker.
229///
230/// Accepts [`OsString`]s so non-UTF-8 arguments (notably config paths on
231/// Unix) survive to the loader unchanged.
232pub fn run<I, S>(args: I) -> i32
233where
234    I: IntoIterator<Item = S>,
235    S: Into<OsString>,
236{
237    let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
238    let options = match parse_args(&args) {
239        Ok(options) => options,
240        Err(message) => {
241            eprintln!("{message}");
242            return 2;
243        }
244    };
245    if let Ok(dir) = std::env::current_dir() {
246        dotenv::load(&dir);
247    }
248    if options.worker {
249        worker::run(&options.config, &options.plugin_dirs);
250    }
251    supervise(&options.config, &options.plugin_dirs)
252}
253
254/// Supervise worker subprocesses until they quit or a signal arrives.
255fn supervise(config: &std::path::Path, plugin_dirs: &[PathBuf]) -> i32 {
256    let shutdown = Arc::new(AtomicBool::new(false));
257    let signal_flag = Arc::clone(&shutdown);
258    if ctrlc::set_handler(move || {
259        eprintln!("cordis: shutdown requested");
260        signal_flag.store(true, Ordering::SeqCst);
261    })
262    .is_err()
263    {
264        eprintln!("cordis: could not install signal handlers");
265    }
266
267    let exe = match std::env::current_exe() {
268        Ok(exe) => exe,
269        Err(error) => {
270            eprintln!("cordis: cannot resolve own executable: {error}");
271            return 1;
272        }
273    };
274    // Restart backoff so a restart loop cannot spin the CPU; a worker that
275    // stayed up long enough resets it.
276    let mut backoff: Option<Duration> = None;
277    let exit_code;
278    'supervise: loop {
279        if shutdown.load(Ordering::SeqCst) {
280            exit_code = Some(worker::EXIT_QUIT);
281            break;
282        }
283        let started = std::time::Instant::now();
284        let mut command = std::process::Command::new(&exe);
285        command.arg("run").arg(config).arg("--worker");
286        for dir in plugin_dirs {
287            command.arg("--plugin-dir").arg(dir);
288        }
289        // The worker watches this pipe: the daemon closes it when shutting
290        // down, and the OS closes it when the daemon dies for any reason —
291        // the std-only stand-in for forwarding SIGTERM, which also covers a
292        // daemon killed with SIGKILL.
293        command
294            .stdin(std::process::Stdio::piped())
295            .env(worker::SUPERVISED_ENV, "1");
296        let mut child = match command.spawn() {
297            Ok(child) => child,
298            Err(error) => {
299                eprintln!("cordis: cannot spawn worker: {error}");
300                return 1;
301            }
302        };
303        let code = supervise_worker(&mut child, &shutdown);
304        if supervisor_action(code, shutdown.load(Ordering::SeqCst)) == Action::Stop {
305            exit_code = code;
306            break;
307        }
308        let delay = next_backoff(backoff, started.elapsed());
309        eprintln!(
310            "cordis: worker requested restart, respawning in {}ms",
311            delay.as_millis()
312        );
313        backoff = Some(delay);
314        // Interruptible backoff: a shutdown request during the delay
315        // aborts the wait instead of deferring the exit by up to 5s.
316        let deadline = std::time::Instant::now() + delay;
317        while std::time::Instant::now() < deadline {
318            if shutdown.load(Ordering::SeqCst) {
319                continue 'supervise;
320            }
321            let now = std::time::Instant::now();
322            std::thread::sleep(WORKER_WAIT_POLL.min(deadline.saturating_duration_since(now)));
323        }
324    }
325    daemon_exit_code(exit_code, shutdown.load(Ordering::SeqCst))
326}
327
328/// Wait for one worker, forwarding daemon shutdown requests: closing the
329/// worker's stdin pipe triggers its graceful teardown (the same path as a
330/// signal), and a worker that ignores it for [`WORKER_SHUTDOWN_GRACE`] is
331/// killed. Never blocks indefinitely, so the shutdown flag stays
332/// observable.
333fn supervise_worker(child: &mut std::process::Child, shutdown: &AtomicBool) -> Option<i32> {
334    let mut stdin = child.stdin.take();
335    let mut requested = None::<std::time::Instant>;
336    loop {
337        match child.try_wait() {
338            Ok(Some(status)) => return status.code(),
339            Ok(None) => {}
340            Err(error) => {
341                eprintln!("cordis: cannot wait for worker: {error}");
342                return None;
343            }
344        }
345        if shutdown.load(Ordering::SeqCst) {
346            match requested {
347                None => {
348                    eprintln!("cordis: forwarding shutdown to the worker");
349                    drop(stdin.take());
350                    requested = Some(std::time::Instant::now());
351                }
352                Some(at) if at.elapsed() >= WORKER_SHUTDOWN_GRACE => {
353                    eprintln!("cordis: worker did not exit in time, killing it");
354                    let _ = child.kill();
355                    return child.wait().ok().and_then(|status| status.code());
356                }
357                _ => {}
358            }
359        }
360        std::thread::sleep(WORKER_WAIT_POLL);
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    fn args(list: &[&str]) -> Vec<OsString> {
369        list.iter().map(OsString::from).collect()
370    }
371
372    #[test]
373    fn parses_run_command_and_flags() {
374        assert_eq!(
375            parse_args(&args(&["run", "cordis.yml"])).unwrap(),
376            Options {
377                config: "cordis.yml".into(),
378                plugin_dirs: Vec::new(),
379                worker: false,
380            }
381        );
382        assert_eq!(
383            parse_args(&args(&["run", "cordis.yml", "--worker"])).unwrap(),
384            Options {
385                config: "cordis.yml".into(),
386                plugin_dirs: Vec::new(),
387                worker: true,
388            }
389        );
390    }
391
392    #[test]
393    fn parses_plugin_dirs_in_both_forms() {
394        let options = parse_args(&args(&[
395            "run",
396            "cordis.yml",
397            "--plugin-dir",
398            "a",
399            "--plugin-dir=b",
400        ]))
401        .unwrap();
402        assert_eq!(
403            options.plugin_dirs,
404            [PathBuf::from("a"), PathBuf::from("b")]
405        );
406        assert!(!options.worker);
407    }
408
409    #[test]
410    fn rejects_malformed_plugin_dirs() {
411        assert!(parse_args(&args(&["run", "cordis.yml", "--plugin-dir"])).is_err());
412        assert!(parse_args(&args(&["run", "cordis.yml", "--plugin-dir="])).is_err());
413    }
414
415    #[test]
416    fn rejects_missing_or_unknown_arguments() {
417        assert!(parse_args(&args(&[])).is_err());
418        assert!(parse_args(&args(&["start", "cordis.yml"])).is_err());
419        assert!(parse_args(&args(&["run"])).is_err());
420        assert!(parse_args(&args(&["run", "a.yml", "b.yml"])).is_err());
421        assert!(parse_args(&args(&["run", "a.yml", "--nope"])).is_err());
422    }
423
424    #[test]
425    fn only_code_51_restarts_and_never_after_shutdown() {
426        assert_eq!(supervisor_action(Some(51), false), Action::Restart);
427        assert_eq!(supervisor_action(Some(51), true), Action::Stop);
428        assert_eq!(supervisor_action(Some(52), false), Action::Stop);
429        assert_eq!(supervisor_action(Some(0), false), Action::Stop);
430        assert_eq!(supervisor_action(None, false), Action::Stop);
431    }
432
433    #[test]
434    fn daemon_exit_code_reflects_how_the_worker_ended() {
435        // Clean quit and daemon-side shutdown stay successful.
436        assert_eq!(daemon_exit_code(Some(52), false), 0);
437        assert_eq!(daemon_exit_code(Some(52), true), 0);
438        assert_eq!(daemon_exit_code(Some(51), true), 0);
439        // A worker that never booted fails the daemon.
440        assert_eq!(daemon_exit_code(Some(53), false), 1);
441        // Crashes propagate instead of being masked as success.
442        assert_eq!(daemon_exit_code(Some(101), false), 101);
443        assert_eq!(daemon_exit_code(None, false), 1);
444    }
445
446    #[test]
447    fn restart_backoff_doubles_resets_and_caps() {
448        assert_eq!(
449            next_backoff(None, Duration::from_secs(0)),
450            RESTART_BACKOFF_START
451        );
452        assert_eq!(
453            next_backoff(Some(Duration::from_millis(100)), Duration::from_secs(1)),
454            Duration::from_millis(200)
455        );
456        assert_eq!(
457            next_backoff(Some(Duration::from_secs(4)), Duration::from_secs(1)),
458            RESTART_BACKOFF_MAX
459        );
460        assert_eq!(
461            next_backoff(Some(Duration::from_secs(4)), Duration::from_secs(60)),
462            RESTART_BACKOFF_START,
463            "a worker that stayed up resets the backoff"
464        );
465    }
466
467    /// Regression (#38): arguments with non-UTF-8 bytes (legal config paths
468    /// on Unix) must reach `Options` unchanged, not as replacement
469    /// characters.
470    #[cfg(unix)]
471    #[test]
472    fn non_utf8_arguments_survive_parsing() {
473        use std::os::unix::ffi::OsStringExt;
474        let bad = OsString::from_vec(vec![b'c', 0xff, b'.', b'y', b'm', b'l']);
475        let options = parse_args(&[OsString::from("run"), bad.clone()]).unwrap();
476        assert_eq!(options.config.as_os_str(), bad.as_os_str());
477
478        let bad_dir = OsString::from_vec(vec![b'd', 0xfe, b'i', 0xff, b'r']);
479        let options = parse_args(&[
480            OsString::from("run"),
481            OsString::from("c.yml"),
482            OsString::from("--plugin-dir"),
483            bad_dir.clone(),
484        ])
485        .unwrap();
486        assert_eq!(options.plugin_dirs, [PathBuf::from(bad_dir)]);
487    }
488}