use super::*;
use a2a_protocol_types::error::A2aResult;
use std::future::Future;
use std::pin::Pin;
use crate::builder::RequestHandlerBuilder;
#[cfg(feature = "tracing")]
mod warning;
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")
}
#[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(start_paused = true)]
async fn shutdown_with_zero_timeout_returns_at_once_and_still_runs_instant_cleanup() {
let handler = make_handler();
let start = tokio::time::Instant::now();
let report = handler
.shutdown_with_timeout(Duration::from_millis(0))
.await;
assert!(
start.elapsed() < Duration::from_millis(1),
"a zero budget must not wait, took {:?}",
start.elapsed()
);
assert!(
report.executor_cleanup_completed,
"NoopExecutor's cleanup is ready immediately and must still be run"
);
assert_eq!(report.queues_force_destroyed, 0, "no queues were active");
}
#[tokio::test(start_paused = true)]
async fn shutdown_with_timeout_is_a_total_budget_not_a_per_phase_one() {
use a2a_protocol_types::task::TaskId;
struct NeverFinishes;
impl AgentExecutor for NeverFinishes {
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 })
}
}
let handler = RequestHandlerBuilder::new(NeverFinishes)
.build()
.expect("builder should succeed with defaults");
let (_writer, _reader) = handler
.event_queue_manager
.get_or_create(&TaskId::new("t-budget"))
.await;
let asked = Duration::from_secs(30);
let start = tokio::time::Instant::now();
let report = handler.shutdown_with_timeout(asked).await;
let elapsed = start.elapsed();
assert!(
elapsed <= asked,
"shutdown_with_timeout({asked:?}) took {elapsed:?} — the caller's budget \
is the whole call, and overrunning it is what gets a pod SIGKILLed"
);
assert_eq!(report.queues_force_destroyed, 1);
assert!(!report.executor_cleanup_completed);
}
#[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"
);
}