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)]
63pub struct HotReloadConfig {
64 pub enabled: bool,
65
66 pub watch_paths: Vec<PathBuf>,
67
68 #[serde(default = "default_debounce_duration")]
69 pub debounce_duration_ms: u64,
70}
71
72fn default_debounce_duration() -> u64 {
73 500
74}
75
76impl Default for HotReloadConfig {
77 fn default() -> Self {
78 Self {
79 enabled: false,
80 watch_paths: vec![PathBuf::from("configs/behaviors")],
81 debounce_duration_ms: default_debounce_duration(),
82 }
83 }
84}
85
86#[derive(Debug)]
87pub enum ReloadEvent {
88 Success { path: PathBuf, behavior: BoxedBehavior },
89 Error { path: PathBuf, error: String },
90}
91
92pub struct HotReloadWatcher {
93 config: HotReloadConfig,
94 loader: BehaviorLoader,
95 pending_reloads: Arc<RwLock<HashMap<PathBuf, tokio::time::Instant>>>,
96}
97
98impl HotReloadWatcher {
99 pub fn new(config: HotReloadConfig, loader: BehaviorLoader) -> Self {
106 Self {
107 config,
108 loader,
109 pending_reloads: Arc::new(RwLock::new(HashMap::new())),
110 }
111 }
112
113 pub async fn start(self) -> Result<mpsc::UnboundedReceiver<ReloadEvent>> {
122 if !self.config.enabled {
123 info!("Hot-reload is disabled");
124 let (_tx, rx) = mpsc::unbounded_channel();
125 return Ok(rx);
126 }
127
128 let (reload_tx, reload_rx) = mpsc::unbounded_channel();
129 let (fs_tx, mut fs_rx) = mpsc::unbounded_channel();
130
131 let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res: Result<Event, _>| match res {
132 Ok(event) => {
133 if let Err(e) = fs_tx.send(event) {
134 error!("Failed to send file system event: {}", e);
135 }
136 }
137 Err(e) => {
138 error!("File system watch error: {}", e);
139 }
140 })?;
141
142 for watch_path in &self.config.watch_paths {
143 if !watch_path.exists() {
144 warn!("Watch path does not exist: {:?}", watch_path);
145 continue;
146 }
147
148 watcher
149 .watch(watch_path, RecursiveMode::Recursive)
150 .with_context(|| format!("Failed to watch path: {:?}", watch_path))?;
151
152 info!("Watching for changes: {:?}", watch_path);
153 }
154
155 let loader = self.loader.clone();
156 let pending_reloads = self.pending_reloads.clone();
157 let debounce_duration = Duration::from_millis(self.config.debounce_duration_ms);
158
159 tokio::spawn(async move {
160 let _watcher = watcher;
161
162 while let Some(event) = fs_rx.recv().await {
163 if let Err(e) = handle_fs_event(event, &loader, &reload_tx, &pending_reloads, debounce_duration).await {
164 error!("Error handling file system event: {}", e);
165 }
166 }
167
168 info!("Hot-reload watcher stopped");
169 });
170
171 Ok(reload_rx)
172 }
173
174 pub fn is_enabled(&self) -> bool {
176 self.config.enabled
177 }
178
179 pub fn watch_paths(&self) -> &[PathBuf] {
181 &self.config.watch_paths
182 }
183}
184
185async fn handle_fs_event(
187 event: Event,
188 loader: &BehaviorLoader,
189 reload_tx: &mpsc::UnboundedSender<ReloadEvent>,
190 pending_reloads: &Arc<RwLock<HashMap<PathBuf, tokio::time::Instant>>>,
191 debounce_duration: Duration,
192) -> Result<()> {
193 match event.kind {
194 EventKind::Modify(_) | EventKind::Create(_) => {}
195 _ => return Ok(()),
196 }
197
198 for path in event.paths {
199 if !is_behavior_tree_file(&path) {
200 continue;
201 }
202
203 debug!("File change detected: {:?}", path);
204
205 let now = tokio::time::Instant::now();
206 let mut pending = pending_reloads.write().await;
207
208 if let Some(last_reload) = pending.get(&path) {
209 if now.duration_since(*last_reload) < debounce_duration {
210 debug!("Debouncing reload for: {:?}", path);
211 continue;
212 }
213 }
214
215 pending.insert(path.clone(), now);
216 drop(pending);
217
218 reload_behavior_tree(&path, loader, reload_tx).await;
219 }
220
221 Ok(())
222}
223
224fn is_behavior_tree_file(path: &Path) -> bool {
226 path.extension().map(|ext| ext == "json").unwrap_or(false)
227}
228
229async fn reload_behavior_tree(path: &Path, loader: &BehaviorLoader, reload_tx: &mpsc::UnboundedSender<ReloadEvent>) {
231 info!("Reloading behavior tree: {:?}", path);
232
233 tokio::time::sleep(Duration::from_millis(50)).await;
234
235 match loader.load_from_file(path) {
236 Ok(behavior) => {
237 info!("Successfully reloaded: {:?}", path);
238 let event = ReloadEvent::Success {
239 path: path.to_path_buf(),
240 behavior,
241 };
242 if let Err(e) = reload_tx.send(event) {
243 error!("Failed to send reload event: {}", e);
244 }
245 }
246 Err(e) => {
247 error!("Failed to reload {:?}: {}", path, e);
248 let event = ReloadEvent::Error {
249 path: path.to_path_buf(),
250 error: format!("{:#}", e),
251 };
252 if let Err(e) = reload_tx.send(event) {
253 error!("Failed to send reload error event: {}", e);
254 }
255 }
256 }
257}