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)]
109pub struct Options {
110 pub config: PathBuf,
112 pub plugin_dirs: Vec<PathBuf>,
114 pub worker: bool,
116}
117
118pub 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
185fn 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
217pub 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
244fn 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 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 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 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
318fn 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 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 assert_eq!(daemon_exit_code(Some(53), false), 1);
431 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 #[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}