Skip to main content

rlg/config/
hot_reload.rs

1// hot_reload.rs
2// Copyright © 2024-2026 RustLogs (RLG). All rights reserved.
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Polling hot-reload for [`Config`], behind the `tokio` feature.
6
7use super::{Config, ConfigError};
8use parking_lot::RwLock;
9use std::fs;
10use std::path::Path;
11use std::sync::Arc;
12use tokio::sync::mpsc;
13
14/// How often [`Config::hot_reload_async`] checks the watched file.
15pub const HOT_RELOAD_POLL_INTERVAL: std::time::Duration =
16    std::time::Duration::from_millis(250);
17
18/// A file's modification time and length, compared by hot-reload.
19type Fingerprint = (Option<std::time::SystemTime>, u64);
20
21fn file_fingerprint(path: &Path) -> std::io::Result<Fingerprint> {
22    Ok(fingerprint_of(&fs::metadata(path)?))
23}
24
25/// The same, without blocking the runtime: for the polling task.
26async fn file_fingerprint_async(
27    path: &Path,
28) -> std::io::Result<Fingerprint> {
29    Ok(fingerprint_of(&tokio::fs::metadata(path).await?))
30}
31
32fn fingerprint_of(meta: &fs::Metadata) -> Fingerprint {
33    (meta.modified().ok(), meta.len())
34}
35
36impl Config {
37    /// Hot-reloads configuration on file change.
38    ///
39    /// Polls the file every [`HOT_RELOAD_POLL_INTERVAL`] and reloads
40    /// it when its modification time or size changes, including
41    /// when an editor replaces it by renaming a new file over it.
42    /// A file that fails to load leaves the current configuration in
43    /// place. Send `()` on the returned channel, or drop it, to stop.
44    ///
45    /// Requires the `tokio` feature and a running Tokio runtime.
46    ///
47    /// # Errors
48    ///
49    /// Returns [`ConfigError::WatcherError`] if the file cannot be
50    /// read when watching starts.
51    pub fn hot_reload_async(
52        config_path: &str,
53        config: &Arc<RwLock<Self>>,
54    ) -> Result<mpsc::Sender<()>, ConfigError> {
55        let mut seen = file_fingerprint(Path::new(config_path))
56            .map_err(ConfigError::WatcherError)?;
57        let (stop_tx, mut stop_rx) = mpsc::channel::<()>(1);
58        let config = Arc::clone(config);
59        let path = config_path.to_string();
60        tokio::spawn(async move {
61            let mut ticks =
62                tokio::time::interval(HOT_RELOAD_POLL_INTERVAL);
63            ticks.set_missed_tick_behavior(
64                tokio::time::MissedTickBehavior::Delay,
65            );
66            loop {
67                tokio::select! {
68                    _ = ticks.tick() => {
69                        Self::reload_if_changed(&path, &mut seen, &config).await;
70                    }
71                    _ = stop_rx.recv() => break,
72                }
73            }
74        });
75        Ok(stop_tx)
76    }
77
78    /// Reload `path` into `config` if its fingerprint moved on from
79    /// `seen`. A missing file is skipped until it reappears.
80    async fn reload_if_changed(
81        path: &str,
82        seen: &mut Fingerprint,
83        config: &Arc<RwLock<Self>>,
84    ) {
85        let Ok(now) = file_fingerprint_async(Path::new(path)).await
86        else {
87            return;
88        };
89        if now == *seen {
90            return;
91        }
92        *seen = now;
93        if let Ok(new_config) = Self::load_async(Some(path)).await {
94            let fresh = new_config.read().clone();
95            *config.write() = fresh;
96        }
97    }
98}