use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
pub const EXIT_RESTART: i32 = 51;
pub const EXIT_QUIT: i32 = 52;
pub const EXIT_BOOT: i32 = 53;
pub const SUPERVISED_ENV: &str = "CORDIS_SUPERVISED";
pub fn supervised() -> bool {
std::env::var_os(SUPERVISED_ENV).is_some()
}
pub fn watch_supervisor(
teardown: impl FnOnce() + Send + 'static,
) -> std::io::Result<std::thread::JoinHandle<()>> {
std::thread::Builder::new()
.name("cordis-supervisor-watch".to_string())
.spawn(move || {
use std::io::Read;
loop {
match std::io::stdin().lock().read(&mut [0u8; 1]) {
Ok(0) | Err(_) => {
teardown();
exit(EXIT_QUIT);
}
Ok(_) => {}
}
}
})
}
pub fn exit(code: i32) -> ! {
std::process::exit(code)
}
fn is_plugin_library(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "so" | "dylib" | "dll"))
}
const PLUGIN_QUIET_WINDOW: Duration = Duration::from_millis(250);
pub fn watch_plugin_dirs(
dirs: &[PathBuf],
on_quiet: impl FnOnce() + Send + 'static,
) -> Result<(), String> {
fn counted(item: &Result<notify::Event, notify::Error>) -> bool {
match item {
Ok(event) => event.paths.is_empty() || event.paths.iter().any(|p| is_plugin_library(p)),
Err(_) => true,
}
}
let (tx, rx) = mpsc::channel();
use notify::Watcher;
let mut watcher = notify::RecommendedWatcher::new(tx, notify::Config::default())
.map_err(|e| e.to_string())?;
for dir in dirs {
watcher
.watch(dir, notify::RecursiveMode::NonRecursive)
.map_err(|e| format!("{}: {e}", dir.display()))?;
}
std::thread::Builder::new()
.name("cordis-plugin-dir-watch".to_string())
.spawn(move || {
let _watcher = watcher;
let mut quiet_at: Option<std::time::Instant> = None;
loop {
match quiet_at {
None => match rx.recv() {
Ok(item) => {
if counted(&item) {
quiet_at = Some(std::time::Instant::now() + PLUGIN_QUIET_WINDOW);
}
}
Err(_) => break, },
Some(deadline) => {
let Some(remaining) =
deadline.checked_duration_since(std::time::Instant::now())
else {
break; };
match rx.recv_timeout(remaining) {
Ok(item) => {
if counted(&item) {
quiet_at =
Some(std::time::Instant::now() + PLUGIN_QUIET_WINDOW);
}
}
Err(mpsc::RecvTimeoutError::Timeout) => break,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
}
}
on_quiet();
})
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[test]
fn exit_codes_are_distinct() {
assert_ne!(EXIT_RESTART, EXIT_QUIT);
assert_ne!(EXIT_RESTART, EXIT_BOOT);
assert_ne!(EXIT_QUIT, EXIT_BOOT);
for code in [EXIT_RESTART, EXIT_QUIT, EXIT_BOOT] {
assert!(
(50..60).contains(&code),
"exit code {code} outside the reserved 50..60 band"
);
}
}
#[test]
fn supervised_marker_is_stable() {
assert_eq!(SUPERVISED_ENV, "CORDIS_SUPERVISED");
}
#[test]
fn watch_supervisor_spawns_named_thread() {
let handle = watch_supervisor(|| panic!("supervisor watcher must stay blocked on stdin"))
.expect("watcher thread spawns");
assert_eq!(handle.thread().name(), Some("cordis-supervisor-watch"));
std::thread::sleep(std::time::Duration::from_millis(10));
}
#[test]
fn plugin_library_extension_matches_case_insensitively() {
for name in [
"libdemo.so",
"PLUGIN.DYLIB",
"thing.Dll",
"/plugins/libdemo.SO",
] {
assert!(is_plugin_library(Path::new(name)), "{name} must match");
}
for name in [
"no-extension",
"notes.md",
"README.md",
"lib.so.bak",
".hidden.so.cfg",
] {
assert!(!is_plugin_library(Path::new(name)), "{name} must not match");
}
}
#[test]
fn watch_plugin_dirs_fires_on_quiet_after_library_write() {
let dir = tempfile::tempdir().unwrap();
let fired = Arc::new(AtomicBool::new(false));
let flag = fired.clone();
watch_plugin_dirs(&[dir.path().to_path_buf()], move || {
flag.store(true, Ordering::SeqCst)
})
.expect("watcher starts");
std::thread::sleep(Duration::from_millis(50));
std::fs::write(dir.path().join("fake_test.so"), b"pretend elf").unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(2);
while !fired.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
assert!(
fired.load(Ordering::SeqCst),
"library write must fire on_quiet"
);
}
#[test]
fn watch_plugin_dirs_ignores_non_library_extensions() {
let dir = tempfile::tempdir().unwrap();
let fired = Arc::new(AtomicBool::new(false));
let flag = fired.clone();
watch_plugin_dirs(&[dir.path().to_path_buf()], move || {
flag.store(true, Ordering::SeqCst)
})
.expect("watcher starts");
std::thread::sleep(Duration::from_millis(50));
std::fs::write(dir.path().join("README.md"), b"docs").unwrap();
std::thread::sleep(Duration::from_millis(500));
assert!(
!fired.load(Ordering::SeqCst),
"non-library writes must not reset or fire the quiet timer"
);
}
}