a2a_protocol_server/agent_card/
hot_reload.rs1use std::future::Future;
42use std::path::{Path, PathBuf};
43use std::pin::Pin;
44use std::sync::{Arc, RwLock};
45use std::time::{Duration, SystemTime};
46
47use a2a_protocol_types::agent_card::AgentCard;
48use a2a_protocol_types::error::A2aResult;
49
50use crate::agent_card::dynamic_handler::AgentCardProducer;
51use crate::error::{ServerError, ServerResult};
52
53#[derive(Debug, Clone)]
62pub struct HotReloadAgentCardHandler {
63 card: Arc<RwLock<AgentCard>>,
64}
65
66impl HotReloadAgentCardHandler {
67 #[must_use]
69 pub fn new(card: AgentCard) -> Self {
70 Self {
71 card: Arc::new(RwLock::new(card)),
72 }
73 }
74
75 #[must_use]
84 pub fn current(&self) -> AgentCard {
85 self.card
86 .read()
87 .expect("agent card RwLock poisoned")
88 .clone()
89 }
90
91 pub fn update(&self, card: AgentCard) {
99 let mut guard = self.card.write().expect("agent card RwLock poisoned");
100 *guard = card;
101 }
102
103 pub fn reload_from_file(&self, path: &Path) -> ServerResult<()> {
112 let contents = std::fs::read_to_string(path).map_err(|e| {
113 ServerError::Internal(format!(
114 "failed to read agent card file {}: {e}",
115 path.display()
116 ))
117 })?;
118 self.reload_from_json(&contents)
119 }
120
121 pub fn reload_from_json(&self, json: &str) -> ServerResult<()> {
129 let card: AgentCard = serde_json::from_str(json)?;
130 self.update(card);
131 Ok(())
132 }
133
134 #[must_use]
144 pub fn spawn_poll_watcher(
145 &self,
146 path: &Path,
147 interval: Duration,
148 ) -> tokio::task::JoinHandle<()> {
149 let handler = self.clone();
150 let path = path.to_path_buf();
151 tokio::spawn(poll_watcher_loop(handler, path, interval))
152 }
153
154 #[cfg(unix)]
168 #[must_use]
169 pub fn spawn_signal_watcher(&self, path: &Path) -> tokio::task::JoinHandle<()> {
170 let handler = self.clone();
171 let path = path.to_path_buf();
172 tokio::spawn(signal_watcher_loop(handler, path))
173 }
174}
175
176impl AgentCardProducer for HotReloadAgentCardHandler {
177 fn produce<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<AgentCard>> + Send + 'a>> {
178 Box::pin(async move { Ok(self.current()) })
179 }
180}
181
182fn file_mtime(path: &Path) -> Option<SystemTime> {
185 std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
186}
187
188async fn file_mtime_async(path: &Path) -> Option<SystemTime> {
192 let path = path.to_path_buf();
193 tokio::task::spawn_blocking(move || file_mtime(&path))
194 .await
195 .ok()
196 .flatten()
197}
198
199async fn reload_from_file_async(
205 handler: &HotReloadAgentCardHandler,
206 path: &Path,
207) -> ServerResult<()> {
208 let owned = path.to_path_buf();
209 let read = tokio::task::spawn_blocking(move || std::fs::read_to_string(&owned))
210 .await
211 .map_err(|e| ServerError::Internal(format!("agent card read task failed: {e}")))?;
212 let contents = read.map_err(|e| {
213 ServerError::Internal(format!(
214 "failed to read agent card file {}: {e}",
215 path.display()
216 ))
217 })?;
218 handler.reload_from_json(&contents)
219}
220
221async fn poll_watcher_loop(handler: HotReloadAgentCardHandler, path: PathBuf, interval: Duration) {
224 let mut last_mtime = file_mtime_async(&path).await;
225 let mut tick = tokio::time::interval(interval);
226 tick.tick().await;
229
230 loop {
231 tick.tick().await;
232 let current_mtime = file_mtime_async(&path).await;
233 if current_mtime != last_mtime {
234 last_mtime = current_mtime;
235 if let Err(e) = reload_from_file_async(&handler, &path).await {
236 #[cfg(feature = "tracing")]
239 tracing::warn!(
240 path = %path.display(),
241 error = %e,
242 "hot-reload: failed to reload agent card",
243 );
244 let _ = e;
245 }
246 }
247 }
248}
249
250#[cfg(unix)]
252async fn signal_watcher_loop(handler: HotReloadAgentCardHandler, path: PathBuf) {
253 use tokio::signal::unix::{signal, SignalKind};
254
255 let mut stream = signal(SignalKind::hangup()).expect("failed to register SIGHUP handler");
256
257 loop {
258 stream.recv().await;
259 if let Err(e) = reload_from_file_async(&handler, &path).await {
260 #[cfg(feature = "tracing")]
261 tracing::warn!(
262 path = %path.display(),
263 error = %e,
264 "hot-reload: SIGHUP reload failed",
265 );
266 let _ = e;
267 }
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use crate::agent_card::caching::tests::minimal_agent_card;
275
276 #[test]
277 fn new_handler_returns_initial_card() {
278 let card = minimal_agent_card();
279 let handler = HotReloadAgentCardHandler::new(card.clone());
280 let current = handler.current();
281 assert_eq!(current.name, card.name);
282 assert_eq!(current.version, card.version);
283 }
284
285 #[test]
286 fn update_replaces_card() {
287 let card1 = minimal_agent_card();
288 let handler = HotReloadAgentCardHandler::new(card1);
289
290 let mut card2 = minimal_agent_card();
291 card2.name = "Updated Agent".into();
292 handler.update(card2);
293
294 assert_eq!(handler.current().name, "Updated Agent");
295 }
296
297 #[test]
298 fn reload_from_json_valid() {
299 let card = minimal_agent_card();
300 let handler = HotReloadAgentCardHandler::new(card);
301
302 let mut new_card = minimal_agent_card();
303 new_card.name = "JSON Reloaded".into();
304 let json = serde_json::to_string(&new_card).unwrap();
305
306 handler.reload_from_json(&json).unwrap();
307 assert_eq!(handler.current().name, "JSON Reloaded");
308 }
309
310 #[test]
311 fn reload_from_json_invalid() {
312 let card = minimal_agent_card();
313 let handler = HotReloadAgentCardHandler::new(card);
314
315 let result = handler.reload_from_json("not valid json {{{");
316 assert!(result.is_err());
317 assert_eq!(handler.current().name, "Test Agent");
319 }
320
321 #[test]
322 fn reload_from_file_valid() {
323 let card = minimal_agent_card();
324 let handler = HotReloadAgentCardHandler::new(card);
325
326 let dir = std::env::temp_dir().join("a2a_hot_reload_test");
327 std::fs::create_dir_all(&dir).unwrap();
328 let file = dir.join("agent_card.json");
329
330 let mut new_card = minimal_agent_card();
331 new_card.name = "File Reloaded".into();
332 std::fs::write(&file, serde_json::to_string(&new_card).unwrap()).unwrap();
333
334 handler.reload_from_file(&file).unwrap();
335 assert_eq!(handler.current().name, "File Reloaded");
336
337 let _ = std::fs::remove_file(&file);
339 let _ = std::fs::remove_dir(&dir);
340 }
341
342 #[test]
343 fn reload_from_file_missing() {
344 let card = minimal_agent_card();
345 let handler = HotReloadAgentCardHandler::new(card);
346
347 let result = handler.reload_from_file(Path::new("/tmp/nonexistent_a2a_card.json"));
348 assert!(result.is_err());
349 }
350
351 #[test]
352 fn clone_shares_state() {
353 let card = minimal_agent_card();
354 let handler1 = HotReloadAgentCardHandler::new(card);
355 let handler2 = handler1.clone();
356
357 let mut new_card = minimal_agent_card();
358 new_card.name = "Shared Update".into();
359 handler1.update(new_card);
360
361 assert_eq!(handler2.current().name, "Shared Update");
363 }
364
365 #[tokio::test]
366 async fn producer_trait_returns_current_card() {
367 let card = minimal_agent_card();
368 let handler = HotReloadAgentCardHandler::new(card.clone());
369
370 let produced = handler.produce().await.unwrap();
371 assert_eq!(produced.name, card.name);
372 }
373
374 #[cfg(unix)]
376 #[tokio::test]
377 async fn signal_watcher_can_be_spawned_and_aborted() {
378 let card = minimal_agent_card();
379 let handler = HotReloadAgentCardHandler::new(card);
380
381 let dir = std::env::temp_dir().join("a2a_signal_watcher_test");
382 std::fs::create_dir_all(&dir).unwrap();
383 let file = dir.join("agent_card.json");
384
385 let initial = minimal_agent_card();
386 std::fs::write(&file, serde_json::to_string(&initial).unwrap()).unwrap();
387
388 let handle = handler.spawn_signal_watcher(&file);
389 handle.abort();
391
392 let _ = std::fs::remove_file(&file);
394 let _ = std::fs::remove_dir(&dir);
395 }
396
397 #[cfg(unix)]
402 #[tokio::test]
403 async fn signal_watcher_reloads_on_sighup() {
404 use tokio::signal::unix::{signal, SignalKind};
405
406 let _guard = signal(SignalKind::hangup()).expect("register guard SIGHUP handler");
411
412 let dir = std::env::temp_dir().join("a2a_signal_reload_test");
413 std::fs::create_dir_all(&dir).unwrap();
414 let file = dir.join("agent_card.json");
415
416 let initial = minimal_agent_card();
417 std::fs::write(&file, serde_json::to_string(&initial).unwrap()).unwrap();
418
419 let handler = HotReloadAgentCardHandler::new(initial);
420 let handle = handler.spawn_signal_watcher(&file);
421
422 tokio::time::sleep(Duration::from_millis(200)).await;
426
427 let mut updated = minimal_agent_card();
429 updated.name = "SIGHUP Reloaded".into();
430 std::fs::write(&file, serde_json::to_string(&updated).unwrap()).unwrap();
431
432 let status = std::process::Command::new("kill")
435 .args(["-HUP", &std::process::id().to_string()])
436 .status()
437 .expect("send SIGHUP via kill(1)");
438 assert!(status.success(), "kill -HUP <self> should succeed");
439
440 let reloaded = tokio::time::timeout(Duration::from_secs(5), async {
443 loop {
444 if handler.current().name == "SIGHUP Reloaded" {
445 return true;
446 }
447 tokio::time::sleep(Duration::from_millis(25)).await;
448 }
449 })
450 .await
451 .unwrap_or(false);
452
453 handle.abort();
454 let _ = std::fs::remove_file(&file);
455 let _ = std::fs::remove_dir(&dir);
456
457 assert!(
458 reloaded,
459 "signal_watcher_loop should reload the agent card on SIGHUP"
460 );
461 }
462
463 #[test]
465 fn file_mtime_returns_none_for_missing_file() {
466 let result = file_mtime(Path::new("/tmp/nonexistent_a2a_mtime_test.json"));
467 assert!(result.is_none(), "missing file should return None");
468 }
469
470 #[test]
472 fn file_mtime_returns_some_for_existing_file() {
473 let dir = std::env::temp_dir().join("a2a_mtime_test");
474 std::fs::create_dir_all(&dir).unwrap();
475 let file = dir.join("test.json");
476 std::fs::write(&file, "{}").unwrap();
477
478 let result = file_mtime(&file);
479 assert!(result.is_some(), "existing file should return Some");
480
481 let _ = std::fs::remove_file(&file);
482 let _ = std::fs::remove_dir(&dir);
483 }
484
485 #[tokio::test]
486 async fn poll_watcher_handles_missing_file_gracefully() {
487 let card = minimal_agent_card();
490 let handler = HotReloadAgentCardHandler::new(card);
491
492 let dir = std::env::temp_dir().join("a2a_poll_missing_test");
493 std::fs::create_dir_all(&dir).unwrap();
494 let file = dir.join("agent_card.json");
495
496 let initial = minimal_agent_card();
498 std::fs::write(&file, serde_json::to_string(&initial).unwrap()).unwrap();
499
500 let handle = handler.spawn_poll_watcher(&file, Duration::from_millis(50));
501
502 tokio::time::sleep(Duration::from_millis(100)).await;
504
505 std::fs::remove_file(&file).unwrap();
507
508 tokio::time::sleep(Duration::from_millis(200)).await;
510
511 assert_eq!(handler.current().name, "Test Agent");
513
514 handle.abort();
515 let _ = std::fs::remove_dir(&dir);
516 }
517
518 #[tokio::test]
519 async fn poll_watcher_handles_invalid_json_gracefully() {
520 let card = minimal_agent_card();
522 let handler = HotReloadAgentCardHandler::new(card);
523
524 let dir = std::env::temp_dir().join("a2a_poll_invalid_json_test");
525 std::fs::create_dir_all(&dir).unwrap();
526 let file = dir.join("agent_card.json");
527
528 let initial = minimal_agent_card();
529 std::fs::write(&file, serde_json::to_string(&initial).unwrap()).unwrap();
530
531 let handle = handler.spawn_poll_watcher(&file, Duration::from_millis(50));
532
533 tokio::time::sleep(Duration::from_millis(100)).await;
534
535 std::fs::write(&file, "not valid json {{{").unwrap();
537
538 tokio::time::sleep(Duration::from_millis(200)).await;
539
540 assert_eq!(handler.current().name, "Test Agent");
542
543 handle.abort();
544 let _ = std::fs::remove_file(&file);
545 let _ = std::fs::remove_dir(&dir);
546 }
547
548 #[tokio::test]
549 async fn poll_watcher_detects_change() {
550 let dir = std::env::temp_dir().join("a2a_poll_watcher_test");
551 std::fs::create_dir_all(&dir).unwrap();
552 let file = dir.join("agent_card.json");
553
554 let initial = minimal_agent_card();
555 std::fs::write(&file, serde_json::to_string(&initial).unwrap()).unwrap();
556
557 let handler = HotReloadAgentCardHandler::new(initial);
558 let handle = handler.spawn_poll_watcher(&file, Duration::from_millis(50));
559
560 tokio::time::sleep(Duration::from_millis(100)).await;
562
563 let mut updated = minimal_agent_card();
564 updated.name = "Poll Updated".into();
565 std::fs::write(&file, serde_json::to_string(&updated).unwrap()).unwrap();
566
567 tokio::time::sleep(Duration::from_millis(200)).await;
569
570 assert_eq!(handler.current().name, "Poll Updated");
571
572 handle.abort();
573
574 let _ = std::fs::remove_file(&file);
576 let _ = std::fs::remove_dir(&dir);
577 }
578}