use super::*;
use a2a_protocol_types::error::A2aResult;
use std::future::Future;
use std::pin::Pin;
use crate::builder::RequestHandlerBuilder;
use crate::executor::AgentExecutor;
use crate::request_context::RequestContext;
use crate::streaming::EventQueueWriter;
struct NoopExecutor;
impl AgentExecutor for NoopExecutor {
fn execute<'a>(
&'a self,
_ctx: &'a RequestContext,
_queue: &'a dyn EventQueueWriter,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async { Ok(()) })
}
}
fn make_handler() -> RequestHandler {
RequestHandlerBuilder::new(NoopExecutor)
.build()
.expect("builder should succeed with defaults")
}
#[cfg(feature = "tracing")]
mod warning {
use super::*;
use std::sync::{Arc, Mutex};
use tracing::subscriber::with_default;
use tracing::Level;
use tracing_subscriber::layer::{Context, Layer, SubscriberExt};
use tracing_subscriber::Registry;
#[derive(Clone, Default)]
struct WarnCapture(Arc<Mutex<Vec<String>>>);
impl<S: tracing::Subscriber> Layer<S> for WarnCapture {
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
struct Visit(String);
impl tracing::field::Visit for Visit {
fn record_debug(
&mut self,
field: &tracing::field::Field,
value: &dyn std::fmt::Debug,
) {
if field.name() == "message" {
self.0 = format!("{value:?}");
}
}
}
if *event.metadata().level() != Level::WARN {
return;
}
let mut v = Visit(String::new());
event.record(&mut v);
self.0.lock().expect("warn log").push(v.0);
}
}
struct HangingExecutor;
impl AgentExecutor for HangingExecutor {
fn execute<'a>(
&'a self,
_ctx: &'a RequestContext,
_queue: &'a dyn EventQueueWriter,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async { Ok(()) })
}
fn on_shutdown<'a>(&'a self) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
Box::pin(async { std::future::pending::<()>().await })
}
}
fn warnings_during<F>(f: F) -> Vec<String>
where
F: FnOnce(),
{
let capture = WarnCapture::default();
let subscriber = Registry::default().with(capture.clone());
with_default(subscriber, f);
let out = capture.0.lock().expect("warn log").clone();
out
}
fn mentions_cleanup(warnings: &[String]) -> bool {
warnings.iter().any(|w| w.contains("executor cleanup"))
}
#[test]
fn clean_shutdown_warns_about_nothing() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.expect("runtime");
let warnings = warnings_during(|| {
rt.block_on(async {
let handler = make_handler();
let report = handler
.shutdown_with_timeout(Duration::from_millis(50))
.await;
assert!(
report.executor_cleanup_completed,
"the no-op executor's cleanup returns immediately"
);
});
});
assert!(
!mentions_cleanup(&warnings),
"a clean shutdown must not warn about executor cleanup; got {warnings:?}"
);
}
#[test]
fn fixed_budget_shutdown_warns_only_when_cleanup_hangs() {
let rt = || {
tokio::runtime::Builder::new_current_thread()
.enable_time()
.start_paused(true)
.build()
.expect("runtime")
};
let clean = warnings_during(|| {
rt().block_on(async {
let handler = make_handler();
let report = handler.shutdown().await;
assert!(report.executor_cleanup_completed);
});
});
assert!(
!mentions_cleanup(&clean),
"a clean shutdown() must not warn about executor cleanup; got {clean:?}"
);
let hung = warnings_during(|| {
rt().block_on(async {
let handler = RequestHandlerBuilder::new(HangingExecutor)
.build()
.expect("builder should succeed");
let report = handler.shutdown().await;
assert!(!report.executor_cleanup_completed);
});
});
assert!(
mentions_cleanup(&hung),
"a hung cleanup under shutdown() must be warned about; got {hung:?}"
);
}
#[test]
fn hung_cleanup_is_warned_about() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.expect("runtime");
let warnings = warnings_during(|| {
rt.block_on(async {
let handler = RequestHandlerBuilder::new(HangingExecutor)
.build()
.expect("builder should succeed");
let report = handler
.shutdown_with_timeout(Duration::from_millis(50))
.await;
assert!(
!report.executor_cleanup_completed,
"a hanging cleanup must be reported as incomplete"
);
});
});
assert!(
mentions_cleanup(&warnings),
"a hung executor cleanup must be warned about; got {warnings:?}"
);
}
}
#[tokio::test]
async fn shutdown_completes_without_panic() {
let handler = make_handler();
let _ = handler.shutdown().await;
}
#[tokio::test]
async fn shutdown_is_idempotent() {
let handler = make_handler();
let _ = handler.shutdown().await;
let _ = handler.shutdown().await;
}
#[tokio::test]
async fn shutdown_clears_cancellation_tokens() {
let handler = make_handler();
{
let mut tokens = handler.cancellation_tokens.write().await;
tokens.insert(
a2a_protocol_types::task::TaskId::new("t-1"),
super::super::CancellationEntry {
token: tokio_util::sync::CancellationToken::new(),
created_at: Instant::now(),
},
);
}
assert_eq!(
handler.cancellation_tokens.read().await.len(),
1,
"should have 1 token before shutdown"
);
let _ = handler.shutdown().await;
assert!(
handler.cancellation_tokens.read().await.is_empty(),
"cancellation tokens should be cleared after shutdown"
);
}
#[tokio::test]
async fn shutdown_with_timeout_completes_within_timeout() {
let handler = make_handler();
let start = Instant::now();
let _ = handler.shutdown_with_timeout(Duration::from_secs(5)).await;
assert!(
start.elapsed() < Duration::from_secs(5),
"shutdown with no active queues should complete well before the timeout"
);
}
#[tokio::test]
async fn shutdown_with_timeout_clears_cancellation_tokens() {
let handler = make_handler();
{
let mut tokens = handler.cancellation_tokens.write().await;
tokens.insert(
a2a_protocol_types::task::TaskId::new("t-2"),
super::super::CancellationEntry {
token: tokio_util::sync::CancellationToken::new(),
created_at: Instant::now(),
},
);
}
let _ = handler
.shutdown_with_timeout(Duration::from_millis(200))
.await;
assert!(
handler.cancellation_tokens.read().await.is_empty(),
"cancellation tokens should be cleared after shutdown_with_timeout"
);
}
#[tokio::test]
async fn shutdown_with_timeout_cancels_tokens() {
let handler = make_handler();
let token = tokio_util::sync::CancellationToken::new();
let token_clone = token.clone();
{
let mut tokens = handler.cancellation_tokens.write().await;
tokens.insert(
a2a_protocol_types::task::TaskId::new("t-3"),
super::super::CancellationEntry {
token: token_clone,
created_at: Instant::now(),
},
);
}
let _ = handler
.shutdown_with_timeout(Duration::from_millis(200))
.await;
assert!(
token.is_cancelled(),
"cancellation token should be cancelled after shutdown"
);
}
#[tokio::test]
async fn shutdown_with_zero_timeout_still_completes() {
let handler = make_handler();
let _ = handler
.shutdown_with_timeout(Duration::from_millis(0))
.await;
}
#[tokio::test]
async fn shutdown_with_timeout_drains_active_queues() {
use a2a_protocol_types::task::TaskId;
let handler = make_handler();
let task_id = TaskId::new("t-drain");
let (_writer, _reader) = handler.event_queue_manager.get_or_create(&task_id).await;
assert_eq!(
handler.event_queue_manager.active_count().await,
1,
"should have 1 active queue before shutdown"
);
let eqm = handler.event_queue_manager.clone();
let tid = task_id.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
eqm.destroy(&tid).await;
});
let start = Instant::now();
let _ = handler.shutdown_with_timeout(Duration::from_secs(5)).await;
assert!(
start.elapsed() < Duration::from_secs(2),
"shutdown should complete quickly once queues drain"
);
}
#[tokio::test]
async fn shutdown_with_timeout_force_destroys_on_timeout() {
use a2a_protocol_types::task::TaskId;
let handler = make_handler();
let task_id = TaskId::new("t-force");
let (_writer, _reader) = handler.event_queue_manager.get_or_create(&task_id).await;
assert_eq!(
handler.event_queue_manager.active_count().await,
1,
"should have 1 active queue before shutdown"
);
let start = Instant::now();
let report = handler
.shutdown_with_timeout(Duration::from_millis(100))
.await;
assert!(
report.queues_force_destroyed > 0,
"the drain deadline passed with a queue active, so the report must \
show it was force-destroyed: {report:?}"
);
assert!(
!report.is_graceful(),
"force-destroying a live queue is not a graceful shutdown"
);
assert!(
start.elapsed() >= Duration::from_millis(100),
"shutdown should wait at least the timeout duration"
);
assert_eq!(
handler.event_queue_manager.active_count().await,
0,
"all queues should be destroyed after shutdown timeout"
);
}