mecha10_behavior_runtime/
hot_reload.rs1use crate::{BehaviorLoader, BoxedBehavior};
52use anyhow::{Context as AnyhowContext, Result};
53use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
54use serde::{Deserialize, Serialize};
55use std::collections::HashMap;
56use std::path::{Path, PathBuf};
57use std::sync::Arc;
58use std::time::Duration;
59use tokio::sync::{mpsc, RwLock};
60use tracing::{debug, error, info, warn};
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct HotReloadConfig {
65 pub enabled: bool,
67
68 pub watch_paths: Vec<PathBuf>,
70
71 #[serde(default = "default_debounce_duration")]
74 pub debounce_duration_ms: u64,
75}
76
77fn default_debounce_duration() -> u64 {
78 500
79}
80
81impl Default for HotReloadConfig {
82 fn default() -> Self {
83 Self {
84 enabled: false,
85 watch_paths: vec![PathBuf::from("behaviors")],
86 debounce_duration_ms: default_debounce_duration(),
87 }
88 }
89}
90
91#[derive(Debug)]
93pub enum ReloadEvent {
94 Success {
96 path: PathBuf,
98 behavior: BoxedBehavior,
100 },
101 Error {
103 path: PathBuf,
105 error: String,
107 },
108}
109
110pub struct HotReloadWatcher {
115 config: HotReloadConfig,
116 loader: BehaviorLoader,
117 pending_reloads: Arc<RwLock<HashMap<PathBuf, tokio::time::Instant>>>,
118}
119
120impl HotReloadWatcher {
121 pub fn new(config: HotReloadConfig, loader: BehaviorLoader) -> Self {
128 Self {
129 config,
130 loader,
131 pending_reloads: Arc::new(RwLock::new(HashMap::new())),
132 }
133 }
134
135 pub async fn start(self) -> Result<mpsc::UnboundedReceiver<ReloadEvent>> {
144 if !self.config.enabled {
145 info!("Hot-reload is disabled");
146 let (_tx, rx) = mpsc::unbounded_channel();
147 return Ok(rx);
148 }
149
150 let (reload_tx, reload_rx) = mpsc::unbounded_channel();
151 let (fs_tx, mut fs_rx) = mpsc::unbounded_channel();
152
153 let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res: Result<Event, _>| match res {
155 Ok(event) => {
156 if let Err(e) = fs_tx.send(event) {
157 error!("Failed to send file system event: {}", e);
158 }
159 }
160 Err(e) => {
161 error!("File system watch error: {}", e);
162 }
163 })?;
164
165 for watch_path in &self.config.watch_paths {
167 if !watch_path.exists() {
168 warn!("Watch path does not exist: {:?}", watch_path);
169 continue;
170 }
171
172 watcher
173 .watch(watch_path, RecursiveMode::Recursive)
174 .with_context(|| format!("Failed to watch path: {:?}", watch_path))?;
175
176 info!("Watching for changes: {:?}", watch_path);
177 }
178
179 let loader = self.loader.clone();
181 let pending_reloads = self.pending_reloads.clone();
182 let debounce_duration = Duration::from_millis(self.config.debounce_duration_ms);
183
184 tokio::spawn(async move {
185 let _watcher = watcher;
187
188 while let Some(event) = fs_rx.recv().await {
189 if let Err(e) = handle_fs_event(event, &loader, &reload_tx, &pending_reloads, debounce_duration).await {
190 error!("Error handling file system event: {}", e);
191 }
192 }
193
194 info!("Hot-reload watcher stopped");
195 });
196
197 Ok(reload_rx)
198 }
199
200 pub fn is_enabled(&self) -> bool {
202 self.config.enabled
203 }
204
205 pub fn watch_paths(&self) -> &[PathBuf] {
207 &self.config.watch_paths
208 }
209}
210
211async fn handle_fs_event(
213 event: Event,
214 loader: &BehaviorLoader,
215 reload_tx: &mpsc::UnboundedSender<ReloadEvent>,
216 pending_reloads: &Arc<RwLock<HashMap<PathBuf, tokio::time::Instant>>>,
217 debounce_duration: Duration,
218) -> Result<()> {
219 match event.kind {
221 EventKind::Modify(_) | EventKind::Create(_) => {}
222 _ => return Ok(()),
223 }
224
225 for path in event.paths {
226 if !is_behavior_tree_file(&path) {
228 continue;
229 }
230
231 debug!("File change detected: {:?}", path);
232
233 let now = tokio::time::Instant::now();
235 let mut pending = pending_reloads.write().await;
236
237 if let Some(last_reload) = pending.get(&path) {
238 if now.duration_since(*last_reload) < debounce_duration {
239 debug!("Debouncing reload for: {:?}", path);
240 continue;
241 }
242 }
243
244 pending.insert(path.clone(), now);
245 drop(pending);
246
247 reload_behavior_tree(&path, loader, reload_tx).await;
249 }
250
251 Ok(())
252}
253
254fn is_behavior_tree_file(path: &Path) -> bool {
256 path.extension().map(|ext| ext == "json").unwrap_or(false)
257}
258
259async fn reload_behavior_tree(path: &Path, loader: &BehaviorLoader, reload_tx: &mpsc::UnboundedSender<ReloadEvent>) {
261 info!("Reloading behavior tree: {:?}", path);
262
263 tokio::time::sleep(Duration::from_millis(50)).await;
265
266 match loader.load_from_file(path) {
268 Ok(behavior) => {
269 info!("Successfully reloaded: {:?}", path);
270 let event = ReloadEvent::Success {
271 path: path.to_path_buf(),
272 behavior,
273 };
274 if let Err(e) = reload_tx.send(event) {
275 error!("Failed to send reload event: {}", e);
276 }
277 }
278 Err(e) => {
279 error!("Failed to reload {:?}: {}", path, e);
280 let event = ReloadEvent::Error {
281 path: path.to_path_buf(),
282 error: format!("{:#}", e),
283 };
284 if let Err(e) = reload_tx.send(event) {
285 error!("Failed to send reload error event: {}", e);
286 }
287 }
288 }
289}