1#![deny(unsafe_code)]
25#![warn(missing_docs)]
26
27pub mod dotenv;
28pub mod worker;
29
30use std::path::PathBuf;
31use std::sync::Arc;
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::time::Duration;
34
35const RESTART_BACKOFF_START: Duration = Duration::from_millis(100);
37const RESTART_BACKOFF_MAX: Duration = Duration::from_secs(5);
39const RESTART_BACKOFF_RESET_AFTER: Duration = Duration::from_secs(10);
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Action {
45 Restart,
47 Stop,
49}
50
51pub fn supervisor_action(exit_code: Option<i32>, shutdown: bool) -> Action {
56 if shutdown || exit_code != Some(worker::EXIT_RESTART) {
57 Action::Stop
58 } else {
59 Action::Restart
60 }
61}
62
63pub fn daemon_exit_code(exit_code: Option<i32>, shutdown: bool) -> i32 {
70 if shutdown {
71 return 0;
72 }
73 match exit_code {
74 Some(code) if code == worker::EXIT_QUIT => 0,
75 Some(code) if code == worker::EXIT_RESTART => 0,
76 Some(code) if code == worker::EXIT_BOOT => 1,
77 Some(code) => code,
78 None => 1,
79 }
80}
81
82fn next_backoff(previous: Option<Duration>, ran_for: Duration) -> Duration {
87 let Some(previous) = previous else {
88 return RESTART_BACKOFF_START;
89 };
90 if ran_for >= RESTART_BACKOFF_RESET_AFTER {
91 return RESTART_BACKOFF_START;
92 }
93 previous.saturating_mul(2).min(RESTART_BACKOFF_MAX)
94}
95
96#[derive(Debug, PartialEq, Eq)]
98pub struct Options {
99 pub config: PathBuf,
101 pub plugin_dirs: Vec<PathBuf>,
103 pub worker: bool,
105}
106
107pub fn parse_args(args: &[String]) -> Result<Options, String> {
109 let Some(command) = args.first() else {
110 return Err(usage());
111 };
112 if command != "run" {
113 return Err(format!("unknown command `{command}`\n\n{}", usage()));
114 }
115 let mut config = None;
116 let mut plugin_dirs = Vec::new();
117 let mut worker = false;
118 let mut rest = args[1..].iter();
119 while let Some(arg) = rest.next() {
120 match arg.as_str() {
121 "--worker" | "-w" => worker = true,
122 "--help" | "-h" => return Err(usage()),
123 "--plugin-dir" => {
124 let Some(value) = rest.next() else {
125 return Err(format!("--plugin-dir requires a directory\n\n{}", usage()));
126 };
127 plugin_dirs.push(PathBuf::from(value));
128 }
129 other if other.starts_with("--plugin-dir=") => {
130 let value = other.strip_prefix("--plugin-dir=").unwrap();
131 if value.is_empty() {
132 return Err(format!("--plugin-dir requires a directory\n\n{}", usage()));
133 }
134 plugin_dirs.push(PathBuf::from(value));
135 }
136 other if other.starts_with('-') => {
137 return Err(format!("unknown flag `{other}`\n\n{}", usage()));
138 }
139 other => {
140 if config.replace(PathBuf::from(other)).is_some() {
141 return Err(format!(
142 "unexpected extra argument `{other}`\n\n{}",
143 usage()
144 ));
145 }
146 }
147 }
148 }
149 match config {
150 Some(config) => Ok(Options {
151 config,
152 plugin_dirs,
153 worker,
154 }),
155 None => Err(format!("missing config file\n\n{}", usage())),
156 }
157}
158
159fn usage() -> String {
160 "usage: cordis run <config.yml> [--plugin-dir <dir>]... [--worker]
161
162 run start the loader from an entry config file
163 --plugin-dir also resolve plugins from dynamic libraries in <dir>
164 (repeatable); library changes there hot-restart the worker
165 --worker internal: run as the daemon's worker process
166
167Worker exit codes: 51 = hot restart, 52 = quit, 53 = boot failure.
168Daemon exit codes: 0 = clean shutdown, 1 = worker never booted or died
169abnormally, otherwise the worker's own code."
170 .to_owned()
171}
172
173pub fn run<I, S>(args: I) -> i32
176where
177 I: IntoIterator<Item = S>,
178 S: Into<String>,
179{
180 let args: Vec<String> = args.into_iter().map(Into::into).collect();
181 let options = match parse_args(&args) {
182 Ok(options) => options,
183 Err(message) => {
184 eprintln!("{message}");
185 return 2;
186 }
187 };
188 if let Ok(dir) = std::env::current_dir() {
189 dotenv::load(&dir);
190 }
191 if options.worker {
192 worker::run(&options.config, &options.plugin_dirs);
193 }
194 supervise(&options.config, &options.plugin_dirs)
195}
196
197fn supervise(config: &std::path::Path, plugin_dirs: &[PathBuf]) -> i32 {
199 let shutdown = Arc::new(AtomicBool::new(false));
200 let signal_flag = Arc::clone(&shutdown);
201 if ctrlc::set_handler(move || {
202 eprintln!("cordis: shutdown requested");
203 signal_flag.store(true, Ordering::SeqCst);
204 })
205 .is_err()
206 {
207 eprintln!("cordis: could not install signal handlers");
208 }
209
210 let exe = match std::env::current_exe() {
211 Ok(exe) => exe,
212 Err(error) => {
213 eprintln!("cordis: cannot resolve own executable: {error}");
214 return 1;
215 }
216 };
217 let mut backoff: Option<Duration> = None;
220 let exit_code;
221 loop {
222 if shutdown.load(Ordering::SeqCst) {
223 exit_code = Some(worker::EXIT_QUIT);
224 break;
225 }
226 let started = std::time::Instant::now();
227 let mut command = std::process::Command::new(&exe);
228 command.arg("run").arg(config).arg("--worker");
229 for dir in plugin_dirs {
230 command.arg("--plugin-dir").arg(dir);
231 }
232 let mut child = match command.spawn() {
233 Ok(child) => child,
234 Err(error) => {
235 eprintln!("cordis: cannot spawn worker: {error}");
236 return 1;
237 }
238 };
239 let code = child.wait().ok().and_then(|status| status.code());
240 if supervisor_action(code, shutdown.load(Ordering::SeqCst)) == Action::Stop {
241 exit_code = code;
242 break;
243 }
244 let delay = next_backoff(backoff, started.elapsed());
245 eprintln!(
246 "cordis: worker requested restart, respawning in {}ms",
247 delay.as_millis()
248 );
249 backoff = Some(delay);
250 std::thread::sleep(delay);
251 }
252 daemon_exit_code(exit_code, shutdown.load(Ordering::SeqCst))
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 fn args(list: &[&str]) -> Vec<String> {
260 list.iter().map(ToString::to_string).collect()
261 }
262
263 #[test]
264 fn parses_run_command_and_flags() {
265 assert_eq!(
266 parse_args(&args(&["run", "cordis.yml"])).unwrap(),
267 Options {
268 config: "cordis.yml".into(),
269 plugin_dirs: Vec::new(),
270 worker: false,
271 }
272 );
273 assert_eq!(
274 parse_args(&args(&["run", "cordis.yml", "--worker"])).unwrap(),
275 Options {
276 config: "cordis.yml".into(),
277 plugin_dirs: Vec::new(),
278 worker: true,
279 }
280 );
281 }
282
283 #[test]
284 fn parses_plugin_dirs_in_both_forms() {
285 let options = parse_args(&args(&[
286 "run",
287 "cordis.yml",
288 "--plugin-dir",
289 "a",
290 "--plugin-dir=b",
291 ]))
292 .unwrap();
293 assert_eq!(
294 options.plugin_dirs,
295 [PathBuf::from("a"), PathBuf::from("b")]
296 );
297 assert!(!options.worker);
298 }
299
300 #[test]
301 fn rejects_malformed_plugin_dirs() {
302 assert!(parse_args(&args(&["run", "cordis.yml", "--plugin-dir"])).is_err());
303 assert!(parse_args(&args(&["run", "cordis.yml", "--plugin-dir="])).is_err());
304 }
305
306 #[test]
307 fn rejects_missing_or_unknown_arguments() {
308 assert!(parse_args(&args(&[])).is_err());
309 assert!(parse_args(&args(&["start", "cordis.yml"])).is_err());
310 assert!(parse_args(&args(&["run"])).is_err());
311 assert!(parse_args(&args(&["run", "a.yml", "b.yml"])).is_err());
312 assert!(parse_args(&args(&["run", "a.yml", "--nope"])).is_err());
313 }
314
315 #[test]
316 fn only_code_51_restarts_and_never_after_shutdown() {
317 assert_eq!(supervisor_action(Some(51), false), Action::Restart);
318 assert_eq!(supervisor_action(Some(51), true), Action::Stop);
319 assert_eq!(supervisor_action(Some(52), false), Action::Stop);
320 assert_eq!(supervisor_action(Some(0), false), Action::Stop);
321 assert_eq!(supervisor_action(None, false), Action::Stop);
322 }
323
324 #[test]
325 fn daemon_exit_code_reflects_how_the_worker_ended() {
326 assert_eq!(daemon_exit_code(Some(52), false), 0);
328 assert_eq!(daemon_exit_code(Some(52), true), 0);
329 assert_eq!(daemon_exit_code(Some(51), true), 0);
330 assert_eq!(daemon_exit_code(Some(53), false), 1);
332 assert_eq!(daemon_exit_code(Some(101), false), 101);
334 assert_eq!(daemon_exit_code(None, false), 1);
335 }
336
337 #[test]
338 fn restart_backoff_doubles_resets_and_caps() {
339 assert_eq!(
340 next_backoff(None, Duration::from_secs(0)),
341 RESTART_BACKOFF_START
342 );
343 assert_eq!(
344 next_backoff(Some(Duration::from_millis(100)), Duration::from_secs(1)),
345 Duration::from_millis(200)
346 );
347 assert_eq!(
348 next_backoff(Some(Duration::from_secs(4)), Duration::from_secs(1)),
349 RESTART_BACKOFF_MAX
350 );
351 assert_eq!(
352 next_backoff(Some(Duration::from_secs(4)), Duration::from_secs(60)),
353 RESTART_BACKOFF_START,
354 "a worker that stayed up resets the backoff"
355 );
356 }
357}