Skip to main content

cordis_cli/
worker.rs

1//! Worker runtime: boot the loader, watch the config, exit on signals.
2
3use cordis::Context;
4use cordis_loader::{Loader, LoaderConfig, PluginRegistry};
5use notify::{Config as NotifyConfig, RecursiveMode, Watcher};
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use std::sync::mpsc;
9use std::time::Duration;
10
11/// Exit code asking the daemon for a hot restart.
12pub const EXIT_RESTART: i32 = 51;
13/// Exit code telling the daemon to quit without restarting.
14pub const EXIT_QUIT: i32 = 52;
15
16/// Quiet window a plugin library must stay unchanged before the worker
17/// restarts, so incremental linker output does not restart mid-write.
18const PLUGIN_CHANGE_DEBOUNCE: Duration = Duration::from_millis(250);
19
20/// Handle exposed as the `worker` service so plugins can stop or restart
21/// the process (upstream's `ctx.loader.exit` / full-reload protocol).
22pub struct WorkerHandle {
23    inner: Arc<WorkerInner>,
24}
25
26impl WorkerHandle {
27    /// Hot restart: dispose everything and ask the daemon for a new worker.
28    pub fn restart(&self) -> ! {
29        self.inner.teardown();
30        std::process::exit(EXIT_RESTART);
31    }
32
33    /// Quit: dispose everything and tell the daemon not to restart.
34    pub fn shutdown(&self) -> ! {
35        self.inner.teardown();
36        std::process::exit(EXIT_QUIT);
37    }
38}
39
40/// Everything the worker owns; shared with the signal handler.
41struct WorkerInner {
42    root: Context,
43    loader: Option<Loader>,
44}
45
46impl WorkerInner {
47    fn teardown(&self) {
48        if let Some(loader) = &self.loader {
49            let _ = loader.dispose();
50        }
51        let _ = self.root.fiber().and_then(|fiber| fiber.dispose());
52    }
53}
54
55/// Run the worker process: load dotenv, boot the loader (with dynamic
56/// plugin directories, if any), watch the entry file and the plugin
57/// directories, and block until a signal (or a `worker` service call)
58/// exits the process. Never returns.
59pub fn run(config_path: &Path, plugin_dirs: &[PathBuf]) -> ! {
60    let root = Context::new();
61    let mut registry = PluginRegistry::new();
62    if !plugin_dirs.is_empty() {
63        registry = registry.with_dynamic_dirs(plugin_dirs.iter());
64    }
65    let loader = match Loader::open(
66        &root,
67        LoaderConfig::new(config_path).with_registry(registry),
68    ) {
69        Ok(loader) => loader,
70        Err(error) => {
71            eprintln!(
72                "cordis: failed to start from {}: {error}",
73                config_path.display()
74            );
75            std::process::exit(EXIT_QUIT);
76        }
77    };
78    let inner = Arc::new(WorkerInner {
79        root: root.clone(),
80        loader: Some(loader.clone()),
81    });
82
83    let handle = Arc::new(WorkerHandle {
84        inner: Arc::clone(&inner),
85    });
86    if let Err(error) = root.provide_arc("worker", handle.clone()) {
87        eprintln!("cordis: could not expose the worker service: {error}");
88    }
89
90    let signal_inner = Arc::clone(&inner);
91    if ctrlc::set_handler(move || {
92        eprintln!("cordis: signal received, shutting down");
93        signal_inner.teardown();
94        std::process::exit(EXIT_QUIT);
95    })
96    .is_err()
97    {
98        eprintln!("cordis: could not install signal handlers");
99    }
100
101    match loader.watch() {
102        Ok(_watcher) => {}
103        Err(error) => eprintln!(
104            "cordis: config hot reload disabled ({error}); restart manually to apply changes"
105        ),
106    }
107
108    if !plugin_dirs.is_empty() {
109        // A changed library only takes effect through a fresh worker: the
110        // old process never unloads a mapped library, so the watcher asks
111        // the daemon for a restart instead of reloading in place.
112        match watch_plugin_dirs(plugin_dirs, handle) {
113            Ok(()) => eprintln!(
114                "cordis: dynamic plugins from {} (library changes hot-restart the worker)",
115                plugin_dirs
116                    .iter()
117                    .map(|dir| dir.display().to_string())
118                    .collect::<Vec<_>>()
119                    .join(", ")
120            ),
121            Err(error) => eprintln!(
122                "cordis: plugin library watching disabled ({error}); restart manually to apply changes"
123            ),
124        }
125    }
126
127    if let Some(error) = loader.last_error() {
128        eprintln!("cordis: startup issue: {error}");
129    }
130
131    eprintln!(
132        "cordis: worker ready ({} entries, config: {})",
133        loader.tree().entries().len(),
134        config_path.display()
135    );
136
137    // The worker's work happens on fiber threads and watcher callbacks;
138    // park until a signal or service call ends the process.
139    loop {
140        std::thread::park();
141    }
142}
143
144/// Watch `dirs` (non-recursively) for plugin library changes and hot
145/// restart the worker through `handle` once a change settles.
146///
147/// Only files with a dynamic-library extension count; matching by
148/// extension (instead of exact paths) also absorbs macOS FSEvents
149/// reporting realpaths. The thread ends the process on the first settled
150/// change, so there is nothing to return.
151fn watch_plugin_dirs(dirs: &[PathBuf], handle: Arc<WorkerHandle>) -> Result<(), String> {
152    let (tx, rx) = mpsc::channel();
153    let mut watcher = notify::RecommendedWatcher::new(tx, NotifyConfig::default())
154        .map_err(|error| format!("cannot create watcher: {error}"))?;
155    for dir in dirs {
156        watcher
157            .watch(dir, RecursiveMode::NonRecursive)
158            .map_err(|error| format!("cannot watch {}: {error}", dir.display()))?;
159    }
160
161    std::thread::Builder::new()
162        .name("cordis-plugin-watch".to_owned())
163        .spawn(move || {
164            let _watcher = watcher; // keep the watch alive for the thread's lifetime
165            let mut pending = false;
166            loop {
167                match rx.recv_timeout(PLUGIN_CHANGE_DEBOUNCE) {
168                    Ok(Ok(event)) => {
169                        if event.paths.iter().any(|path| is_plugin_library(path)) {
170                            pending = true;
171                        }
172                    }
173                    Ok(Err(_)) => {}
174                    Err(mpsc::RecvTimeoutError::Timeout) => {
175                        if pending {
176                            eprintln!("cordis: plugin library changed, restarting worker");
177                            handle.restart();
178                        }
179                    }
180                    Err(mpsc::RecvTimeoutError::Disconnected) => break,
181                }
182            }
183        })
184        .map_err(|error| format!("cannot spawn watcher thread: {error}"))?;
185    Ok(())
186}
187
188/// Whether `path` looks like a dynamic library by extension.
189fn is_plugin_library(path: &Path) -> bool {
190    path.extension().is_some_and(|extension| {
191        matches!(
192            extension.to_ascii_lowercase().to_str(),
193            Some("so" | "dylib" | "dll")
194        )
195    })
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn dynamic_library_extensions_match() {
204        assert!(is_plugin_library(Path::new("libgreeter.so")));
205        assert!(is_plugin_library(Path::new("libgreeter.dylib")));
206        assert!(is_plugin_library(Path::new("greeter.dll")));
207        assert!(is_plugin_library(Path::new("GREETER.SO")));
208        assert!(!is_plugin_library(Path::new("cordis.yml")));
209        assert!(!is_plugin_library(Path::new("libgreeter.so.tmp")));
210        assert!(!is_plugin_library(Path::new("plugins")));
211    }
212}