mecha10_behavior_runtime/
hot_reload.rs1use crate::{BehaviorLoader, BoxedBehavior};
6use anyhow::{Context as AnyhowContext, Result};
7use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::Duration;
13use tokio::sync::{mpsc, RwLock};
14use tracing::{debug, error, info, warn};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct HotReloadConfig {
18 pub enabled: bool,
19
20 pub watch_paths: Vec<PathBuf>,
21
22 #[serde(default = "default_debounce_duration")]
23 pub debounce_duration_ms: u64,
24}
25
26fn default_debounce_duration() -> u64 {
27 500
28}
29
30impl Default for HotReloadConfig {
31 fn default() -> Self {
32 Self {
33 enabled: false,
34 watch_paths: vec![PathBuf::from("configs/behaviors")],
35 debounce_duration_ms: default_debounce_duration(),
36 }
37 }
38}
39
40#[derive(Debug)]
41pub enum ReloadEvent {
42 Success { path: PathBuf, behavior: BoxedBehavior },
43 Error { path: PathBuf, error: String },
44}
45
46pub struct HotReloadWatcher {
47 config: HotReloadConfig,
48 loader: BehaviorLoader,
49 pending_reloads: Arc<RwLock<HashMap<PathBuf, tokio::time::Instant>>>,
50}
51
52impl HotReloadWatcher {
53 pub fn new(config: HotReloadConfig, loader: BehaviorLoader) -> Self {
55 Self {
56 config,
57 loader,
58 pending_reloads: Arc::new(RwLock::new(HashMap::new())),
59 }
60 }
61
62 pub async fn start(self) -> Result<mpsc::UnboundedReceiver<ReloadEvent>> {
66 if !self.config.enabled {
67 info!("Hot-reload is disabled");
68 let (_tx, rx) = mpsc::unbounded_channel();
69 return Ok(rx);
70 }
71
72 let (reload_tx, reload_rx) = mpsc::unbounded_channel();
73 let (fs_tx, mut fs_rx) = mpsc::unbounded_channel();
74
75 let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res: Result<Event, _>| match res {
76 Ok(event) => {
77 if let Err(e) = fs_tx.send(event) {
78 error!("Failed to send file system event: {}", e);
79 }
80 }
81 Err(e) => {
82 error!("File system watch error: {}", e);
83 }
84 })?;
85
86 for watch_path in &self.config.watch_paths {
87 if !watch_path.exists() {
88 warn!("Watch path does not exist: {:?}", watch_path);
89 continue;
90 }
91
92 watcher
93 .watch(watch_path, RecursiveMode::Recursive)
94 .with_context(|| format!("Failed to watch path: {:?}", watch_path))?;
95
96 info!("Watching for changes: {:?}", watch_path);
97 }
98
99 let loader = self.loader.clone();
100 let pending_reloads = self.pending_reloads.clone();
101 let debounce_duration = Duration::from_millis(self.config.debounce_duration_ms);
102
103 tokio::spawn(async move {
104 let _watcher = watcher;
105
106 while let Some(event) = fs_rx.recv().await {
107 if let Err(e) = handle_fs_event(event, &loader, &reload_tx, &pending_reloads, debounce_duration).await {
108 error!("Error handling file system event: {}", e);
109 }
110 }
111
112 info!("Hot-reload watcher stopped");
113 });
114
115 Ok(reload_rx)
116 }
117
118 pub fn is_enabled(&self) -> bool {
120 self.config.enabled
121 }
122
123 pub fn watch_paths(&self) -> &[PathBuf] {
125 &self.config.watch_paths
126 }
127}
128
129async fn handle_fs_event(
131 event: Event,
132 loader: &BehaviorLoader,
133 reload_tx: &mpsc::UnboundedSender<ReloadEvent>,
134 pending_reloads: &Arc<RwLock<HashMap<PathBuf, tokio::time::Instant>>>,
135 debounce_duration: Duration,
136) -> Result<()> {
137 match event.kind {
138 EventKind::Modify(_) | EventKind::Create(_) => {}
139 _ => return Ok(()),
140 }
141
142 for path in event.paths {
143 if !is_behavior_tree_file(&path) {
144 continue;
145 }
146
147 debug!("File change detected: {:?}", path);
148
149 let now = tokio::time::Instant::now();
150 let mut pending = pending_reloads.write().await;
151
152 if let Some(last_reload) = pending.get(&path) {
153 if now.duration_since(*last_reload) < debounce_duration {
154 debug!("Debouncing reload for: {:?}", path);
155 continue;
156 }
157 }
158
159 pending.insert(path.clone(), now);
160 drop(pending);
161
162 reload_behavior_tree(&path, loader, reload_tx).await;
163 }
164
165 Ok(())
166}
167
168fn is_behavior_tree_file(path: &Path) -> bool {
170 path.extension().map(|ext| ext == "json").unwrap_or(false)
171}
172
173async fn reload_behavior_tree(path: &Path, loader: &BehaviorLoader, reload_tx: &mpsc::UnboundedSender<ReloadEvent>) {
175 info!("Reloading behavior tree: {:?}", path);
176
177 tokio::time::sleep(Duration::from_millis(50)).await;
178
179 match loader.load_from_file(path) {
180 Ok(behavior) => {
181 info!("Successfully reloaded: {:?}", path);
182 let event = ReloadEvent::Success {
183 path: path.to_path_buf(),
184 behavior,
185 };
186 if let Err(e) = reload_tx.send(event) {
187 error!("Failed to send reload event: {}", e);
188 }
189 }
190 Err(e) => {
191 error!("Failed to reload {:?}: {}", path, e);
192 let event = ReloadEvent::Error {
193 path: path.to_path_buf(),
194 error: format!("{:#}", e),
195 };
196 if let Err(e) = reload_tx.send(event) {
197 error!("Failed to send reload error event: {}", e);
198 }
199 }
200 }
201}