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]
82 pub fn current(&self) -> AgentCard {
83 self.card
84 .read()
85 .unwrap_or_else(std::sync::PoisonError::into_inner)
86 .clone()
87 }
88
89 pub fn update(&self, card: AgentCard) {
108 let mut guard = self
109 .card
110 .write()
111 .unwrap_or_else(std::sync::PoisonError::into_inner);
112 *guard = card;
113 }
114
115 pub fn reload_from_file(&self, path: &Path) -> ServerResult<()> {
124 let contents = std::fs::read_to_string(path).map_err(|e| {
125 ServerError::Internal(format!(
126 "failed to read agent card file {}: {e}",
127 path.display()
128 ))
129 })?;
130 self.reload_from_json(&contents)
131 }
132
133 pub fn reload_from_json(&self, json: &str) -> ServerResult<()> {
141 let card: AgentCard = serde_json::from_str(json)?;
142 self.update(card);
143 Ok(())
144 }
145
146 #[must_use]
156 pub fn spawn_poll_watcher(
157 &self,
158 path: &Path,
159 interval: Duration,
160 ) -> tokio::task::JoinHandle<()> {
161 let handler = self.clone();
162 let path = path.to_path_buf();
163 tokio::spawn(poll_watcher_loop(handler, path, interval))
164 }
165
166 #[cfg(unix)]
198 #[must_use]
199 pub fn spawn_signal_watcher(&self, path: &Path) -> tokio::task::JoinHandle<()> {
200 use tokio::signal::unix::{signal, SignalKind};
201
202 let stream = signal(SignalKind::hangup());
204 let handler = self.clone();
205 let path = path.to_path_buf();
206 tokio::spawn(signal_watcher_loop(handler, path, stream))
207 }
208}
209
210impl AgentCardProducer for HotReloadAgentCardHandler {
211 fn produce<'a>(&'a self) -> Pin<Box<dyn Future<Output = A2aResult<AgentCard>> + Send + 'a>> {
212 Box::pin(async move { Ok(self.current()) })
213 }
214}
215
216fn file_mtime(path: &Path) -> Option<SystemTime> {
219 std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
220}
221
222async fn file_mtime_async(path: &Path) -> Option<SystemTime> {
226 let path = path.to_path_buf();
227 tokio::task::spawn_blocking(move || file_mtime(&path))
228 .await
229 .ok()
230 .flatten()
231}
232
233async fn reload_from_file_async(
239 handler: &HotReloadAgentCardHandler,
240 path: &Path,
241) -> ServerResult<()> {
242 let owned = path.to_path_buf();
243 let read = tokio::task::spawn_blocking(move || std::fs::read_to_string(&owned))
244 .await
245 .map_err(|e| ServerError::Internal(format!("agent card read task failed: {e}")))?;
246 let contents = read.map_err(|e| {
247 ServerError::Internal(format!(
248 "failed to read agent card file {}: {e}",
249 path.display()
250 ))
251 })?;
252 handler.reload_from_json(&contents)
253}
254
255async fn poll_watcher_loop(handler: HotReloadAgentCardHandler, path: PathBuf, interval: Duration) {
258 let mut last_mtime = file_mtime_async(&path).await;
259 let mut tick = tokio::time::interval(interval);
260 tick.tick().await;
263
264 loop {
265 tick.tick().await;
266 let current_mtime = file_mtime_async(&path).await;
267 if current_mtime != last_mtime {
268 last_mtime = current_mtime;
269 if let Err(e) = reload_from_file_async(&handler, &path).await {
270 #[cfg(feature = "tracing")]
273 tracing::warn!(
274 path = %path.display(),
275 error = %e,
276 "hot-reload: failed to reload agent card",
277 );
278 let _ = e;
279 }
280 }
281 }
282}
283
284#[cfg(unix)]
290async fn signal_watcher_loop(
291 handler: HotReloadAgentCardHandler,
292 path: PathBuf,
293 stream: std::io::Result<tokio::signal::unix::Signal>,
294) {
295 let mut stream = match stream {
296 Ok(stream) => stream,
297 Err(e) => {
298 #[cfg(feature = "tracing")]
299 tracing::warn!(
300 error = %e,
301 "hot-reload: could not register a SIGHUP handler; \
302 reload-on-SIGHUP is unavailable for this process"
303 );
304 let _ = e;
307 return;
308 }
309 };
310
311 while stream.recv().await.is_some() {
316 if let Err(e) = reload_from_file_async(&handler, &path).await {
317 #[cfg(feature = "tracing")]
318 tracing::warn!(
319 path = %path.display(),
320 error = %e,
321 "hot-reload: SIGHUP reload failed",
322 );
323 let _ = e;
324 }
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use crate::agent_card::caching::tests::minimal_agent_card;
332
333 #[cfg(unix)]
350 #[test]
351 fn a_runtime_without_a_signal_driver_fails_at_the_call_site() {
352 let rt = tokio::runtime::Builder::new_current_thread()
353 .enable_time() .build()
355 .expect("runtime");
356 let handler = HotReloadAgentCardHandler::new(minimal_agent_card());
357
358 let hook = std::panic::take_hook();
359 std::panic::set_hook(Box::new(|_| {}));
360 let outcome = rt.block_on(async {
361 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
362 let _handle = handler.spawn_signal_watcher(Path::new("/nonexistent"));
363 }))
364 });
365 std::panic::set_hook(hook);
366
367 let payload = outcome.expect_err(
368 "registration must happen inside spawn_signal_watcher, so the failure \
369 is the caller's — returning a handle here means it fires later, detached",
370 );
371 let msg = payload
372 .downcast_ref::<String>()
373 .cloned()
374 .unwrap_or_else(|| {
375 payload
376 .downcast_ref::<&str>()
377 .map_or_else(String::new, |s| (*s).to_string())
378 });
379 assert!(
380 msg.contains("signal driver"),
381 "expected tokio's missing-signal-driver panic, got: {msg}"
382 );
383 }
384
385 #[test]
394 fn a_poisoned_lock_does_not_disable_the_card_handler() {
395 let handler = HotReloadAgentCardHandler::new(minimal_agent_card());
396 let name_before = handler.current().name;
397
398 let poisoner = handler.clone();
399 let hook = std::panic::take_hook();
400 std::panic::set_hook(Box::new(|_| {}));
401 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
402 let _guard = poisoner.card.write().expect("uncontended");
403 panic!("poison the lock");
404 }));
405 std::panic::set_hook(hook);
406 assert!(outcome.is_err(), "the closure must actually have panicked");
407 assert!(
408 handler.card.is_poisoned(),
409 "and it must actually have poisoned the lock"
410 );
411
412 assert_eq!(
413 handler.current().name,
414 name_before,
415 "reads must still work through a poisoned lock"
416 );
417
418 let mut replacement = minimal_agent_card();
419 replacement.name = "after-poison".to_string();
420 handler.update(replacement);
421 assert_eq!(handler.current().name, "after-poison", "and so must writes");
422 }
423
424 #[test]
425 fn new_handler_returns_initial_card() {
426 let card = minimal_agent_card();
427 let handler = HotReloadAgentCardHandler::new(card.clone());
428 let current = handler.current();
429 assert_eq!(current.name, card.name);
430 assert_eq!(current.version, card.version);
431 }
432
433 #[test]
434 fn update_replaces_card() {
435 let card1 = minimal_agent_card();
436 let handler = HotReloadAgentCardHandler::new(card1);
437
438 let mut card2 = minimal_agent_card();
439 card2.name = "Updated Agent".into();
440 handler.update(card2);
441
442 assert_eq!(handler.current().name, "Updated Agent");
443 }
444
445 #[test]
446 fn reload_from_json_valid() {
447 let card = minimal_agent_card();
448 let handler = HotReloadAgentCardHandler::new(card);
449
450 let mut new_card = minimal_agent_card();
451 new_card.name = "JSON Reloaded".into();
452 let json = serde_json::to_string(&new_card).unwrap();
453
454 handler.reload_from_json(&json).unwrap();
455 assert_eq!(handler.current().name, "JSON Reloaded");
456 }
457
458 #[test]
459 fn reload_from_json_invalid() {
460 let card = minimal_agent_card();
461 let handler = HotReloadAgentCardHandler::new(card);
462
463 let result = handler.reload_from_json("not valid json {{{");
464 assert!(result.is_err());
465 assert_eq!(handler.current().name, "Test Agent");
467 }
468
469 #[test]
470 fn reload_from_file_valid() {
471 let card = minimal_agent_card();
472 let handler = HotReloadAgentCardHandler::new(card);
473
474 let dir = std::env::temp_dir().join("a2a_hot_reload_test");
475 std::fs::create_dir_all(&dir).unwrap();
476 let file = dir.join("agent_card.json");
477
478 let mut new_card = minimal_agent_card();
479 new_card.name = "File Reloaded".into();
480 std::fs::write(&file, serde_json::to_string(&new_card).unwrap()).unwrap();
481
482 handler.reload_from_file(&file).unwrap();
483 assert_eq!(handler.current().name, "File Reloaded");
484
485 let _ = std::fs::remove_file(&file);
487 let _ = std::fs::remove_dir(&dir);
488 }
489
490 #[test]
491 fn reload_from_file_missing() {
492 let card = minimal_agent_card();
493 let handler = HotReloadAgentCardHandler::new(card);
494
495 let result = handler.reload_from_file(Path::new("/tmp/nonexistent_a2a_card.json"));
496 assert!(result.is_err());
497 }
498
499 #[test]
500 fn clone_shares_state() {
501 let card = minimal_agent_card();
502 let handler1 = HotReloadAgentCardHandler::new(card);
503 let handler2 = handler1.clone();
504
505 let mut new_card = minimal_agent_card();
506 new_card.name = "Shared Update".into();
507 handler1.update(new_card);
508
509 assert_eq!(handler2.current().name, "Shared Update");
511 }
512
513 #[tokio::test]
514 async fn producer_trait_returns_current_card() {
515 let card = minimal_agent_card();
516 let handler = HotReloadAgentCardHandler::new(card.clone());
517
518 let produced = handler.produce().await.unwrap();
519 assert_eq!(produced.name, card.name);
520 }
521
522 #[cfg(unix)]
524 #[tokio::test]
525 async fn signal_watcher_can_be_spawned_and_aborted() {
526 let card = minimal_agent_card();
527 let handler = HotReloadAgentCardHandler::new(card);
528
529 let dir = std::env::temp_dir().join("a2a_signal_watcher_test");
530 std::fs::create_dir_all(&dir).unwrap();
531 let file = dir.join("agent_card.json");
532
533 let initial = minimal_agent_card();
534 std::fs::write(&file, serde_json::to_string(&initial).unwrap()).unwrap();
535
536 let handle = handler.spawn_signal_watcher(&file);
537 handle.abort();
539
540 let _ = std::fs::remove_file(&file);
542 let _ = std::fs::remove_dir(&dir);
543 }
544
545 #[cfg(unix)]
550 #[tokio::test]
551 async fn signal_watcher_reloads_on_sighup() {
552 use tokio::signal::unix::{signal, SignalKind};
553
554 let _guard = signal(SignalKind::hangup()).expect("register guard SIGHUP handler");
559
560 let dir = std::env::temp_dir().join("a2a_signal_reload_test");
561 std::fs::create_dir_all(&dir).unwrap();
562 let file = dir.join("agent_card.json");
563
564 let initial = minimal_agent_card();
565 std::fs::write(&file, serde_json::to_string(&initial).unwrap()).unwrap();
566
567 let handler = HotReloadAgentCardHandler::new(initial);
568 let handle = handler.spawn_signal_watcher(&file);
569
570 tokio::time::sleep(Duration::from_millis(200)).await;
574
575 let mut updated = minimal_agent_card();
577 updated.name = "SIGHUP Reloaded".into();
578 std::fs::write(&file, serde_json::to_string(&updated).unwrap()).unwrap();
579
580 let status = std::process::Command::new("kill")
583 .args(["-HUP", &std::process::id().to_string()])
584 .status()
585 .expect("send SIGHUP via kill(1)");
586 assert!(status.success(), "kill -HUP <self> should succeed");
587
588 let reloaded = tokio::time::timeout(Duration::from_secs(5), async {
591 loop {
592 if handler.current().name == "SIGHUP Reloaded" {
593 return true;
594 }
595 tokio::time::sleep(Duration::from_millis(25)).await;
596 }
597 })
598 .await
599 .unwrap_or(false);
600
601 handle.abort();
602 let _ = std::fs::remove_file(&file);
603 let _ = std::fs::remove_dir(&dir);
604
605 assert!(
606 reloaded,
607 "signal_watcher_loop should reload the agent card on SIGHUP"
608 );
609 }
610
611 #[test]
613 fn file_mtime_returns_none_for_missing_file() {
614 let result = file_mtime(Path::new("/tmp/nonexistent_a2a_mtime_test.json"));
615 assert!(result.is_none(), "missing file should return None");
616 }
617
618 #[test]
620 fn file_mtime_returns_some_for_existing_file() {
621 let dir = std::env::temp_dir().join("a2a_mtime_test");
622 std::fs::create_dir_all(&dir).unwrap();
623 let file = dir.join("test.json");
624 std::fs::write(&file, "{}").unwrap();
625
626 let result = file_mtime(&file);
627 assert!(result.is_some(), "existing file should return Some");
628
629 let _ = std::fs::remove_file(&file);
630 let _ = std::fs::remove_dir(&dir);
631 }
632
633 #[tokio::test]
634 async fn poll_watcher_handles_missing_file_gracefully() {
635 let card = minimal_agent_card();
638 let handler = HotReloadAgentCardHandler::new(card);
639
640 let dir = std::env::temp_dir().join("a2a_poll_missing_test");
641 std::fs::create_dir_all(&dir).unwrap();
642 let file = dir.join("agent_card.json");
643
644 let initial = minimal_agent_card();
646 std::fs::write(&file, serde_json::to_string(&initial).unwrap()).unwrap();
647
648 let handle = handler.spawn_poll_watcher(&file, Duration::from_millis(50));
649
650 tokio::time::sleep(Duration::from_millis(100)).await;
652
653 std::fs::remove_file(&file).unwrap();
655
656 tokio::time::sleep(Duration::from_millis(200)).await;
658
659 assert_eq!(handler.current().name, "Test Agent");
661
662 handle.abort();
663 let _ = std::fs::remove_dir(&dir);
664 }
665
666 #[tokio::test]
667 async fn poll_watcher_handles_invalid_json_gracefully() {
668 let card = minimal_agent_card();
670 let handler = HotReloadAgentCardHandler::new(card);
671
672 let dir = std::env::temp_dir().join("a2a_poll_invalid_json_test");
673 std::fs::create_dir_all(&dir).unwrap();
674 let file = dir.join("agent_card.json");
675
676 let initial = minimal_agent_card();
677 std::fs::write(&file, serde_json::to_string(&initial).unwrap()).unwrap();
678
679 let handle = handler.spawn_poll_watcher(&file, Duration::from_millis(50));
680
681 tokio::time::sleep(Duration::from_millis(100)).await;
682
683 std::fs::write(&file, "not valid json {{{").unwrap();
685
686 tokio::time::sleep(Duration::from_millis(200)).await;
687
688 assert_eq!(handler.current().name, "Test Agent");
690
691 handle.abort();
692 let _ = std::fs::remove_file(&file);
693 let _ = std::fs::remove_dir(&dir);
694 }
695
696 #[tokio::test]
697 async fn poll_watcher_detects_change() {
698 let dir = std::env::temp_dir().join("a2a_poll_watcher_test");
699 std::fs::create_dir_all(&dir).unwrap();
700 let file = dir.join("agent_card.json");
701
702 let initial = minimal_agent_card();
703 std::fs::write(&file, serde_json::to_string(&initial).unwrap()).unwrap();
704
705 let handler = HotReloadAgentCardHandler::new(initial);
706 let handle = handler.spawn_poll_watcher(&file, Duration::from_millis(50));
707
708 tokio::time::sleep(Duration::from_millis(100)).await;
710
711 let mut updated = minimal_agent_card();
712 updated.name = "Poll Updated".into();
713 std::fs::write(&file, serde_json::to_string(&updated).unwrap()).unwrap();
714
715 tokio::time::sleep(Duration::from_millis(200)).await;
717
718 assert_eq!(handler.current().name, "Poll Updated");
719
720 handle.abort();
721
722 let _ = std::fs::remove_file(&file);
724 let _ = std::fs::remove_dir(&dir);
725 }
726}