use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
use anyhow::Result;
use mvm_core::user_config::MvmConfig;
use notify_debouncer_mini::{DebouncedEventKind, new_debouncer};
pub enum ConfigReloadEvent {
Reloaded(MvmConfig),
ParseError(String),
}
pub struct ConfigWatcher {
pub receiver: mpsc::Receiver<ConfigReloadEvent>,
}
impl ConfigWatcher {
pub fn start(path: &Path) -> Result<Self> {
Self::start_with_debounce(path, Duration::from_millis(500))
}
pub fn start_with_debounce(path: &Path, debounce: Duration) -> Result<Self> {
let watch_file = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let watch_dir = watch_file
.parent()
.ok_or_else(|| anyhow::anyhow!("config path has no parent directory"))?
.to_path_buf();
let (event_tx, event_rx) = mpsc::channel::<ConfigReloadEvent>();
let (raw_tx, raw_rx) = mpsc::channel();
let mut debouncer = new_debouncer(debounce, raw_tx)?;
debouncer
.watcher()
.watch(&watch_dir, notify::RecursiveMode::NonRecursive)?;
std::thread::spawn(move || {
let _debouncer = debouncer;
loop {
match raw_rx.recv() {
Ok(Ok(events)) => {
for event in &events {
if event.kind != DebouncedEventKind::Any {
continue;
}
let event_file = event
.path
.canonicalize()
.unwrap_or_else(|_| event.path.clone());
if event_file != watch_file {
continue;
}
let reload = match std::fs::read_to_string(&watch_file) {
Ok(text) => match toml::from_str::<MvmConfig>(&text) {
Ok(cfg) => ConfigReloadEvent::Reloaded(cfg),
Err(e) => ConfigReloadEvent::ParseError(e.to_string()),
},
Err(e) => ConfigReloadEvent::ParseError(e.to_string()),
};
if event_tx.send(reload).is_err() {
return;
}
}
}
Ok(Err(e)) => {
tracing::warn!("config watcher error: {e}");
}
Err(_) => {
return;
}
}
}
});
Ok(ConfigWatcher { receiver: event_rx })
}
}
pub fn apply_pending_reloads(cfg: MvmConfig, rx: &mpsc::Receiver<ConfigReloadEvent>) -> MvmConfig {
let mut current = cfg;
while let Ok(event) = rx.try_recv() {
match event {
ConfigReloadEvent::Reloaded(new_cfg) => {
tracing::info!("Config reloaded from ~/.mvm/config.toml");
current = new_cfg;
}
ConfigReloadEvent::ParseError(msg) => {
tracing::warn!("Config reload failed: {msg}; keeping previous config");
}
}
}
current
}
#[cfg(test)]
mod tests {
use super::*;
use mvm_core::user_config::MvmConfig;
fn write_config(path: &Path, cfg: &MvmConfig) {
let text = toml::to_string_pretty(cfg).unwrap();
std::fs::write(path, text).unwrap();
}
#[test]
fn test_config_watcher_detects_change() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
write_config(&config_path, &MvmConfig::default());
let watcher =
ConfigWatcher::start_with_debounce(&config_path, Duration::from_millis(50)).unwrap();
std::thread::sleep(Duration::from_millis(50));
let updated = MvmConfig {
lima_cpus: 4,
..MvmConfig::default()
};
write_config(&config_path, &updated);
let deadline = std::time::Instant::now() + Duration::from_secs(2);
let mut received = false;
while std::time::Instant::now() < deadline {
match watcher.receiver.try_recv() {
Ok(ConfigReloadEvent::Reloaded(cfg)) => {
assert_eq!(cfg.lima_cpus, 4);
received = true;
break;
}
Ok(ConfigReloadEvent::ParseError(e)) => {
panic!("Unexpected parse error: {e}");
}
Err(_) => {
std::thread::sleep(Duration::from_millis(50));
}
}
}
assert!(
received,
"No ConfigReloadEvent::Reloaded received within 2 s"
);
}
#[test]
fn test_config_watcher_invalid_toml_sends_parse_error() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
write_config(&config_path, &MvmConfig::default());
let watcher =
ConfigWatcher::start_with_debounce(&config_path, Duration::from_millis(50)).unwrap();
std::thread::sleep(Duration::from_millis(50));
std::fs::write(&config_path, b"this is [[ not valid toml").unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(2);
let mut received = false;
while std::time::Instant::now() < deadline {
match watcher.receiver.try_recv() {
Ok(ConfigReloadEvent::ParseError(_)) => {
received = true;
break;
}
Ok(ConfigReloadEvent::Reloaded(_)) => {
}
Err(_) => {
std::thread::sleep(Duration::from_millis(50));
}
}
}
assert!(
received,
"No ConfigReloadEvent::ParseError received within 2 s"
);
}
#[test]
fn test_apply_pending_reloads_updates_cfg() {
let (tx, rx) = mpsc::channel();
let mut cfg = MvmConfig::default();
let new_cfg = MvmConfig {
lima_cpus: 12,
..MvmConfig::default()
};
tx.send(ConfigReloadEvent::Reloaded(new_cfg)).unwrap();
cfg = apply_pending_reloads(cfg, &rx);
assert_eq!(cfg.lima_cpus, 12);
}
#[test]
fn test_apply_pending_reloads_keeps_cfg_on_error() {
let (tx, rx) = mpsc::channel();
let mut cfg = MvmConfig {
lima_cpus: 6,
..MvmConfig::default()
};
tx.send(ConfigReloadEvent::ParseError("bad toml".to_string()))
.unwrap();
cfg = apply_pending_reloads(cfg, &rx);
assert_eq!(cfg.lima_cpus, 6);
}
}