1#![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
40const RESTART_BACKOFF_START: Duration = Duration::from_millis(100);
42const RESTART_BACKOFF_MAX: Duration = Duration::from_secs(5);
44const RESTART_BACKOFF_RESET_AFTER: Duration = Duration::from_secs(10);
46const WORKER_WAIT_POLL: Duration = Duration::from_millis(50);
49const WORKER_SHUTDOWN_GRACE: Duration = Duration::from_secs(10);
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Action {
56 Restart,
58 Stop,
60}
61
62pub 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
74pub 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
93fn 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#[derive(Debug, PartialEq, Eq)]
114pub struct Options {
115 pub config: PathBuf,
117 pub plugin_dirs: Vec<PathBuf>,
120 pub worker: bool,
123}
124
125pub 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
195fn 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
227pub 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
254fn 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 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 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 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
328fn 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 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 assert_eq!(daemon_exit_code(Some(53), false), 1);
441 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 #[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}