| 1 | //! Live reload: a background thread that reloads the config when a watched file changes, on |
| 2 | //! SIGUSR1, or on request, and hands the result to the event loop. |
| 3 | |
| 4 | use std::collections::HashSet; |
| 5 | use std::path::{Path, PathBuf}; |
| 6 | use std::time::Duration; |
| 7 | |
| 8 | use crossbeam_channel::{Receiver, RecvTimeoutError, Sender}; |
| 9 | use notify::event::{AccessKind, AccessMode}; |
| 10 | use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher}; |
| 11 | |
| 12 | use super::{LoadError, Loaded, load}; |
| 13 | |
| 14 | /// Quiet time before reloading, so editors' multi-step saves reload once. |
| 15 | const DEBOUNCE: Duration = Duration::from_millis(100); |
| 16 | |
| 17 | enum Msg { |
| 18 | Fs(notify::Event), |
| 19 | Reload, |
| 20 | } |
| 21 | |
| 22 | /// Handle to the reload thread. Dropping it stops nothing; the thread lives with the process. |
| 23 | #[derive(Clone)] |
| 24 | pub struct ConfigWatch { |
| 25 | tx: Sender<Msg>, |
| 26 | } |
| 27 | |
| 28 | impl ConfigWatch { |
| 29 | /// Reload now (the `reload-config` action). |
| 30 | pub fn reload(&self) { |
| 31 | let _ = self.tx.send(Msg::Reload); |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | /// Start watching `watch` (from the initial load); `deliver` receives every reload result. |
| 36 | pub fn spawn( |
| 37 | path: Option<PathBuf>, |
| 38 | watch: Vec<PathBuf>, |
| 39 | deliver: impl Fn(Result<Loaded, LoadError>) + Send + 'static, |
| 40 | ) -> ConfigWatch { |
| 41 | let (tx, rx) = crossbeam_channel::unbounded(); |
| 42 | let fs_tx = tx.clone(); |
| 43 | let sig_tx = tx.clone(); |
| 44 | std::thread::Builder::new() |
| 45 | .name("remage-sigusr1".into()) |
| 46 | .spawn( |
| 47 | move || match signal_hook::iterator::Signals::new([signal_hook::consts::SIGUSR1]) { |
| 48 | Ok(mut signals) => { |
| 49 | for _ in signals.forever() { |
| 50 | if sig_tx.send(Msg::Reload).is_err() { |
| 51 | break; |
| 52 | } |
| 53 | } |
| 54 | } |
| 55 | Err(e) => log::warn!("SIGUSR1 handler: {e}"), |
| 56 | }, |
| 57 | ) |
| 58 | .ok(); |
| 59 | std::thread::Builder::new() |
| 60 | .name("remage-config".into()) |
| 61 | .spawn(move || { |
| 62 | let watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| { |
| 63 | if let Ok(ev) = res { |
| 64 | let _ = fs_tx.send(Msg::Fs(ev)); |
| 65 | } |
| 66 | }); |
| 67 | let mut watcher = match watcher { |
| 68 | Ok(w) => Some(w), |
| 69 | Err(e) => { |
| 70 | log::warn!("config watcher: {e}"); |
| 71 | None |
| 72 | } |
| 73 | }; |
| 74 | run(path.as_deref(), watch, &rx, watcher.as_mut(), deliver); |
| 75 | }) |
| 76 | .ok(); |
| 77 | ConfigWatch { tx } |
| 78 | } |
| 79 | |
| 80 | fn run( |
| 81 | path: Option<&Path>, |
| 82 | mut files: Vec<PathBuf>, |
| 83 | rx: &Receiver<Msg>, |
| 84 | mut watcher: Option<&mut RecommendedWatcher>, |
| 85 | deliver: impl Fn(Result<Loaded, LoadError>), |
| 86 | ) { |
| 87 | let mut dirs = HashSet::new(); |
| 88 | if let Some(w) = watcher.as_deref_mut() { |
| 89 | watch_dirs(w, &mut dirs, &files); |
| 90 | } |
| 91 | while let Ok(msg) = rx.recv() { |
| 92 | if let Msg::Fs(ev) = &msg |
| 93 | && !relevant(ev, &files) |
| 94 | { |
| 95 | continue; |
| 96 | } |
| 97 | loop { |
| 98 | match rx.recv_timeout(DEBOUNCE) { |
| 99 | Ok(_) => continue, |
| 100 | Err(RecvTimeoutError::Timeout) => break, |
| 101 | Err(RecvTimeoutError::Disconnected) => return, |
| 102 | } |
| 103 | } |
| 104 | let result = load(path); |
| 105 | files = match &result { |
| 106 | Ok(l) => l.watch.clone(), |
| 107 | Err(e) => e.watch.clone(), |
| 108 | }; |
| 109 | if let Some(w) = watcher.as_deref_mut() { |
| 110 | watch_dirs(w, &mut dirs, &files); |
| 111 | } |
| 112 | deliver(result); |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | /// Watch the parent directory of every file: editors replace files by renaming, which a |
| 117 | /// watch on the file itself would lose. |
| 118 | fn watch_dirs(w: &mut RecommendedWatcher, dirs: &mut HashSet<PathBuf>, files: &[PathBuf]) { |
| 119 | let wanted: HashSet<PathBuf> = files |
| 120 | .iter() |
| 121 | .filter_map(|f| f.parent()) |
| 122 | .filter(|d| d.is_dir()) |
| 123 | .map(Path::to_path_buf) |
| 124 | .collect(); |
| 125 | for d in dirs.difference(&wanted) { |
| 126 | let _ = w.unwatch(d); |
| 127 | } |
| 128 | for d in wanted.difference(dirs) { |
| 129 | if let Err(e) = w.watch(d, RecursiveMode::NonRecursive) { |
| 130 | log::warn!("watch {}: {e}", d.display()); |
| 131 | } |
| 132 | } |
| 133 | *dirs = wanted; |
| 134 | } |
| 135 | |
| 136 | fn relevant(ev: ¬ify::Event, files: &[PathBuf]) -> bool { |
| 137 | let changes = match ev.kind { |
| 138 | EventKind::Access(AccessKind::Close(AccessMode::Write)) => true, |
| 139 | EventKind::Access(_) => false, |
| 140 | _ => true, |
| 141 | }; |
| 142 | changes && ev.paths.iter().any(|p| files.iter().any(|f| f == p)) |
| 143 | } |