#![deny(unsafe_code)]
#![warn(missing_docs)]
pub mod dotenv;
pub mod worker;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
const RESTART_BACKOFF_START: Duration = Duration::from_millis(100);
const RESTART_BACKOFF_MAX: Duration = Duration::from_secs(5);
const RESTART_BACKOFF_RESET_AFTER: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Restart,
Stop,
}
pub fn supervisor_action(exit_code: Option<i32>, shutdown: bool) -> Action {
if shutdown || exit_code != Some(worker::EXIT_RESTART) {
Action::Stop
} else {
Action::Restart
}
}
pub fn daemon_exit_code(exit_code: Option<i32>, shutdown: bool) -> i32 {
if shutdown {
return 0;
}
match exit_code {
Some(code) if code == worker::EXIT_QUIT => 0,
Some(code) if code == worker::EXIT_RESTART => 0,
Some(code) if code == worker::EXIT_BOOT => 1,
Some(code) => code,
None => 1,
}
}
fn next_backoff(previous: Option<Duration>, ran_for: Duration) -> Duration {
let Some(previous) = previous else {
return RESTART_BACKOFF_START;
};
if ran_for >= RESTART_BACKOFF_RESET_AFTER {
return RESTART_BACKOFF_START;
}
previous.saturating_mul(2).min(RESTART_BACKOFF_MAX)
}
#[derive(Debug, PartialEq, Eq)]
pub struct Options {
pub config: PathBuf,
pub plugin_dirs: Vec<PathBuf>,
pub worker: bool,
}
pub fn parse_args(args: &[String]) -> Result<Options, String> {
let Some(command) = args.first() else {
return Err(usage());
};
if command != "run" {
return Err(format!("unknown command `{command}`\n\n{}", usage()));
}
let mut config = None;
let mut plugin_dirs = Vec::new();
let mut worker = false;
let mut rest = args[1..].iter();
while let Some(arg) = rest.next() {
match arg.as_str() {
"--worker" | "-w" => worker = true,
"--help" | "-h" => return Err(usage()),
"--plugin-dir" => {
let Some(value) = rest.next() else {
return Err(format!("--plugin-dir requires a directory\n\n{}", usage()));
};
plugin_dirs.push(PathBuf::from(value));
}
other if other.starts_with("--plugin-dir=") => {
let value = other.strip_prefix("--plugin-dir=").unwrap();
if value.is_empty() {
return Err(format!("--plugin-dir requires a directory\n\n{}", usage()));
}
plugin_dirs.push(PathBuf::from(value));
}
other if other.starts_with('-') => {
return Err(format!("unknown flag `{other}`\n\n{}", usage()));
}
other => {
if config.replace(PathBuf::from(other)).is_some() {
return Err(format!(
"unexpected extra argument `{other}`\n\n{}",
usage()
));
}
}
}
}
match config {
Some(config) => Ok(Options {
config,
plugin_dirs,
worker,
}),
None => Err(format!("missing config file\n\n{}", usage())),
}
}
fn usage() -> String {
"usage: cordis run <config.yml> [--plugin-dir <dir>]... [--worker]
run start the loader from an entry config file
--plugin-dir also resolve plugins from dynamic libraries in <dir>
(repeatable); library changes there hot-restart the worker
--worker internal: run as the daemon's worker process
Worker exit codes: 51 = hot restart, 52 = quit, 53 = boot failure.
Daemon exit codes: 0 = clean shutdown, 1 = worker never booted or died
abnormally, otherwise the worker's own code."
.to_owned()
}
pub fn run<I, S>(args: I) -> i32
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let args: Vec<String> = args.into_iter().map(Into::into).collect();
let options = match parse_args(&args) {
Ok(options) => options,
Err(message) => {
eprintln!("{message}");
return 2;
}
};
if let Ok(dir) = std::env::current_dir() {
dotenv::load(&dir);
}
if options.worker {
worker::run(&options.config, &options.plugin_dirs);
}
supervise(&options.config, &options.plugin_dirs)
}
fn supervise(config: &std::path::Path, plugin_dirs: &[PathBuf]) -> i32 {
let shutdown = Arc::new(AtomicBool::new(false));
let signal_flag = Arc::clone(&shutdown);
if ctrlc::set_handler(move || {
eprintln!("cordis: shutdown requested");
signal_flag.store(true, Ordering::SeqCst);
})
.is_err()
{
eprintln!("cordis: could not install signal handlers");
}
let exe = match std::env::current_exe() {
Ok(exe) => exe,
Err(error) => {
eprintln!("cordis: cannot resolve own executable: {error}");
return 1;
}
};
let mut backoff: Option<Duration> = None;
let exit_code;
loop {
if shutdown.load(Ordering::SeqCst) {
exit_code = Some(worker::EXIT_QUIT);
break;
}
let started = std::time::Instant::now();
let mut command = std::process::Command::new(&exe);
command.arg("run").arg(config).arg("--worker");
for dir in plugin_dirs {
command.arg("--plugin-dir").arg(dir);
}
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => {
eprintln!("cordis: cannot spawn worker: {error}");
return 1;
}
};
let code = child.wait().ok().and_then(|status| status.code());
if supervisor_action(code, shutdown.load(Ordering::SeqCst)) == Action::Stop {
exit_code = code;
break;
}
let delay = next_backoff(backoff, started.elapsed());
eprintln!(
"cordis: worker requested restart, respawning in {}ms",
delay.as_millis()
);
backoff = Some(delay);
std::thread::sleep(delay);
}
daemon_exit_code(exit_code, shutdown.load(Ordering::SeqCst))
}
#[cfg(test)]
mod tests {
use super::*;
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(ToString::to_string).collect()
}
#[test]
fn parses_run_command_and_flags() {
assert_eq!(
parse_args(&args(&["run", "cordis.yml"])).unwrap(),
Options {
config: "cordis.yml".into(),
plugin_dirs: Vec::new(),
worker: false,
}
);
assert_eq!(
parse_args(&args(&["run", "cordis.yml", "--worker"])).unwrap(),
Options {
config: "cordis.yml".into(),
plugin_dirs: Vec::new(),
worker: true,
}
);
}
#[test]
fn parses_plugin_dirs_in_both_forms() {
let options = parse_args(&args(&[
"run",
"cordis.yml",
"--plugin-dir",
"a",
"--plugin-dir=b",
]))
.unwrap();
assert_eq!(
options.plugin_dirs,
[PathBuf::from("a"), PathBuf::from("b")]
);
assert!(!options.worker);
}
#[test]
fn rejects_malformed_plugin_dirs() {
assert!(parse_args(&args(&["run", "cordis.yml", "--plugin-dir"])).is_err());
assert!(parse_args(&args(&["run", "cordis.yml", "--plugin-dir="])).is_err());
}
#[test]
fn rejects_missing_or_unknown_arguments() {
assert!(parse_args(&args(&[])).is_err());
assert!(parse_args(&args(&["start", "cordis.yml"])).is_err());
assert!(parse_args(&args(&["run"])).is_err());
assert!(parse_args(&args(&["run", "a.yml", "b.yml"])).is_err());
assert!(parse_args(&args(&["run", "a.yml", "--nope"])).is_err());
}
#[test]
fn only_code_51_restarts_and_never_after_shutdown() {
assert_eq!(supervisor_action(Some(51), false), Action::Restart);
assert_eq!(supervisor_action(Some(51), true), Action::Stop);
assert_eq!(supervisor_action(Some(52), false), Action::Stop);
assert_eq!(supervisor_action(Some(0), false), Action::Stop);
assert_eq!(supervisor_action(None, false), Action::Stop);
}
#[test]
fn daemon_exit_code_reflects_how_the_worker_ended() {
assert_eq!(daemon_exit_code(Some(52), false), 0);
assert_eq!(daemon_exit_code(Some(52), true), 0);
assert_eq!(daemon_exit_code(Some(51), true), 0);
assert_eq!(daemon_exit_code(Some(53), false), 1);
assert_eq!(daemon_exit_code(Some(101), false), 101);
assert_eq!(daemon_exit_code(None, false), 1);
}
#[test]
fn restart_backoff_doubles_resets_and_caps() {
assert_eq!(
next_backoff(None, Duration::from_secs(0)),
RESTART_BACKOFF_START
);
assert_eq!(
next_backoff(Some(Duration::from_millis(100)), Duration::from_secs(1)),
Duration::from_millis(200)
);
assert_eq!(
next_backoff(Some(Duration::from_secs(4)), Duration::from_secs(1)),
RESTART_BACKOFF_MAX
);
assert_eq!(
next_backoff(Some(Duration::from_secs(4)), Duration::from_secs(60)),
RESTART_BACKOFF_START,
"a worker that stayed up resets the backoff"
);
}
}