1use std::path::PathBuf;
4use std::sync::Arc;
5use std::time::Duration;
6
7use crossfire::{mpsc, MTx};
8use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
9use tracing::{debug, error, info, warn};
10
11fn file_content_hash(path: &std::path::Path) -> Option<u64> {
12 use std::hash::{Hash, Hasher};
13 let bytes = std::fs::read(path).ok()?;
14 let mut hasher = std::collections::hash_map::DefaultHasher::new();
15 bytes.hash(&mut hasher);
16 Some(hasher.finish())
17}
18
19use crate::config;
20use crate::http::AppState;
21use crate::reload;
22
23pub async fn watch_config(config_path: PathBuf, state: Arc<AppState>, debounce: Duration) {
25 let (tx, rx) = mpsc::bounded_async::<()>(16);
26 let blocking_tx: MTx<_> = tx.clone().into_blocking();
27
28 let canonical = match config_path.canonicalize() {
30 Ok(p) => p,
31 Err(e) => {
32 warn!(
33 "Could not canonicalize config path '{}': {e} — watcher disabled",
34 config_path.display()
35 );
36 return;
37 }
38 };
39
40 let watch_dir = match canonical.parent() {
42 Some(p) => p.to_path_buf(),
43 None => {
44 warn!("Config file has no parent directory — watcher disabled");
45 return;
46 }
47 };
48
49 let target = canonical.clone();
50 let mut watcher: RecommendedWatcher =
51 match notify::recommended_watcher(move |result: Result<Event, notify::Error>| {
52 match result {
53 Ok(event) => {
54 let dominated = event.paths.iter().any(|p| {
55 p.canonicalize().ok().as_ref() == Some(&target)
57 });
58 if dominated {
59 let _ = blocking_tx.send(());
60 }
61 }
62 Err(e) => {
63 warn!("File watcher error: {e}");
64 }
65 }
66 }) {
67 Ok(w) => w,
68 Err(e) => {
69 error!("Failed to create file watcher: {e} — hot reload disabled");
70 return;
71 }
72 };
73
74 if let Err(e) = watcher.watch(&watch_dir, RecursiveMode::NonRecursive) {
75 error!(
76 "Failed to watch directory '{}': {e} — hot reload disabled",
77 watch_dir.display()
78 );
79 return;
80 }
81
82 info!("Watching config file '{}' for changes", canonical.display());
83
84 let mut last_hash = file_content_hash(&canonical);
86
87 loop {
89 if rx.recv().await.is_err() {
91 debug!("Watcher channel closed, exiting");
92 return;
93 }
94
95 tokio::time::sleep(debounce).await;
97 while rx.try_recv().is_ok() {}
98
99 let current_hash = file_content_hash(&canonical);
100 if current_hash == last_hash {
101 debug!("File event but content unchanged, skipping");
102 continue;
103 }
104
105 info!("Config file change detected, reloading...");
106
107 last_hash = current_hash;
108
109 let new_config = match config::load_config(&canonical) {
111 Ok(c) => c,
112 Err(e) => {
113 warn!("Failed to load config on file change: {e}");
114 continue;
115 }
116 };
117
118 let _guard = match state.reload_guard.try_acquire() {
120 Some(g) => g,
121 None => {
122 debug!("Another reload in progress, skipping file-triggered reload");
123 continue;
124 }
125 };
126
127 let diff = {
132 let current = state.current_config.read();
133 reload::diff_configs(¤t, &new_config)
134 };
135
136 if diff.is_empty() {
137 for w in &diff.warnings {
138 warn!("Config reload warning: {w}");
139 }
140 if diff.warnings.is_empty() {
141 debug!("No reloadable changes detected");
142 }
143 continue;
144 }
145
146 let result = reload::apply_reload(&state.db, &diff).await;
148
149 state.server_metrics.reload_total.inc();
151
152 if result.success {
153 let mut current = state.current_config.write();
154 *current = new_config;
155 info!(
156 "File-triggered reload complete: {} ops applied",
157 result.applied.len()
158 );
159 } else {
160 warn!(
161 "File-triggered reload partial failure: {} applied, {} failed",
162 result.applied.len(),
163 result.failed.len()
164 );
165 }
166
167 for w in &result.warnings {
168 warn!("Reload warning: {w}");
169 }
170 }
171}