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("configs/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 {
154 Ok(event) => {
155 if let Err(e) = fs_tx.send(event) {
156 error!("Failed to send file system event: {}", e);
157 }
158 }
159 Err(e) => {
160 error!("File system watch error: {}", e);
161 }
162 })?;
163
164 for watch_path in &self.config.watch_paths {
165 if !watch_path.exists() {
166 warn!("Watch path does not exist: {:?}", watch_path);
167 continue;
168 }
169
170 watcher
171 .watch(watch_path, RecursiveMode::Recursive)
172 .with_context(|| format!("Failed to watch path: {:?}", watch_path))?;
173
174 info!("Watching for changes: {:?}", watch_path);
175 }
176
177 let loader = self.loader.clone();
178 let pending_reloads = self.pending_reloads.clone();
179 let debounce_duration = Duration::from_millis(self.config.debounce_duration_ms);
180
181 tokio::spawn(async move {
182 let _watcher = watcher;
183
184 while let Some(event) = fs_rx.recv().await {
185 if let Err(e) = handle_fs_event(event, &loader, &reload_tx, &pending_reloads, debounce_duration).await {
186 error!("Error handling file system event: {}", e);
187 }
188 }
189
190 info!("Hot-reload watcher stopped");
191 });
192
193 Ok(reload_rx)
194 }
195
196 pub fn is_enabled(&self) -> bool {
198 self.config.enabled
199 }
200
201 pub fn watch_paths(&self) -> &[PathBuf] {
203 &self.config.watch_paths
204 }
205}
206
207async fn handle_fs_event(
209 event: Event,
210 loader: &BehaviorLoader,
211 reload_tx: &mpsc::UnboundedSender<ReloadEvent>,
212 pending_reloads: &Arc<RwLock<HashMap<PathBuf, tokio::time::Instant>>>,
213 debounce_duration: Duration,
214) -> Result<()> {
215 match event.kind {
216 EventKind::Modify(_) | EventKind::Create(_) => {}
217 _ => return Ok(()),
218 }
219
220 for path in event.paths {
221 if !is_behavior_tree_file(&path) {
222 continue;
223 }
224
225 debug!("File change detected: {:?}", path);
226
227 let now = tokio::time::Instant::now();
228 let mut pending = pending_reloads.write().await;
229
230 if let Some(last_reload) = pending.get(&path) {
231 if now.duration_since(*last_reload) < debounce_duration {
232 debug!("Debouncing reload for: {:?}", path);
233 continue;
234 }
235 }
236
237 pending.insert(path.clone(), now);
238 drop(pending);
239
240 reload_behavior_tree(&path, loader, reload_tx).await;
241 }
242
243 Ok(())
244}
245
246fn is_behavior_tree_file(path: &Path) -> bool {
248 path.extension().map(|ext| ext == "json").unwrap_or(false)
249}
250
251async fn reload_behavior_tree(path: &Path, loader: &BehaviorLoader, reload_tx: &mpsc::UnboundedSender<ReloadEvent>) {
253 info!("Reloading behavior tree: {:?}", path);
254
255 tokio::time::sleep(Duration::from_millis(50)).await;
256
257 match loader.load_from_file(path) {
258 Ok(behavior) => {
259 info!("Successfully reloaded: {:?}", path);
260 let event = ReloadEvent::Success {
261 path: path.to_path_buf(),
262 behavior,
263 };
264 if let Err(e) = reload_tx.send(event) {
265 error!("Failed to send reload event: {}", e);
266 }
267 }
268 Err(e) => {
269 error!("Failed to reload {:?}: {}", path, e);
270 let event = ReloadEvent::Error {
271 path: path.to_path_buf(),
272 error: format!("{:#}", e),
273 };
274 if let Err(e) = reload_tx.send(event) {
275 error!("Failed to send reload error event: {}", e);
276 }
277 }
278 }
279}