use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use a2a_protocol_server::dispatch::JsonRpcDispatcher;
use a2a_protocol_server::serve::{ServeConfig, Server};
use a2a_protocol_server::store::task_store::TaskStoreConfig;
use a2a_protocol_server::store::InMemoryTaskStore;
use a2a_protocol_server::RequestHandlerBuilder;
const DEFAULT_SECONDS: u64 = 60;
const WORKERS: usize = 8;
const MAX_BYTES_PER_REQUEST: f64 = 1024.0;
const MAX_LATENCY_GROWTH: f64 = 3.0;
const STORE_CAPACITY: usize = 2_000;
struct WorkingExec;
impl a2a_protocol_server::executor::AgentExecutor for WorkingExec {
fn execute<'a>(
&'a self,
ctx: &'a a2a_protocol_server::request_context::RequestContext,
queue: &'a dyn a2a_protocol_server::streaming::EventQueueWriter,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>,
> {
Box::pin(async move {
use a2a_protocol_server::executor_helpers::EventEmitter;
let emitter = EventEmitter::new(ctx, queue);
emitter
.artifact(
"out",
vec![a2a_protocol_types::Part::text("soak")],
Some(true),
Some(true),
)
.await?;
emitter
.status(a2a_protocol_types::TaskState::Completed)
.await?;
Ok(())
})
}
}
fn resident_bytes() -> u64 {
let statm = std::fs::read_to_string("/proc/self/statm").expect("this soak test needs /proc");
let pages: u64 = statm
.split_whitespace()
.nth(1)
.expect("statm has a resident field")
.parse()
.expect("resident pages parse");
pages * 4096
}
fn send_message_body(n: u64) -> String {
serde_json::json!({
"jsonrpc": "2.0",
"method": "SendMessage",
"id": format!("req-{n}"),
"params": {
"message": {
"messageId": format!("m-{n}"),
"role": "ROLE_USER",
"parts": [{"text": "soak"}]
}
}
})
.to_string()
}
fn p95(mut samples: Vec<u64>) -> u64 {
if samples.is_empty() {
return 0;
}
samples.sort_unstable();
let idx = (samples.len() as f64 * 0.95) as usize;
samples[idx.min(samples.len() - 1)]
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "sustained load; run explicitly with --ignored (see the module docs)"]
async fn server_survives_sustained_load_without_leaking() {
let seconds: u64 = std::env::var("A2A_SOAK_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_SECONDS);
let duration = Duration::from_secs(seconds);
let handler = Arc::new(
RequestHandlerBuilder::new(WorkingExec)
.with_task_store(InMemoryTaskStore::with_config(TaskStoreConfig {
max_capacity: Some(STORE_CAPACITY),
..TaskStoreConfig::default()
}))
.build()
.expect("handler builds"),
);
let server = Server::bind("127.0.0.1:0")
.await
.expect("bind")
.with_config(ServeConfig::new().with_max_connections(64));
let addr = server.local_addr().expect("addr");
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>();
let serving = tokio::spawn({
let dispatcher = JsonRpcDispatcher::new(Arc::clone(&handler));
async move {
server
.serve_with_shutdown(dispatcher, async {
stop_rx.await.ok();
})
.await
}
});
let stop = Arc::new(AtomicBool::new(false));
let completed = Arc::new(AtomicU64::new(0));
let failures = Arc::new(AtomicU64::new(0));
let latencies = Arc::new(std::sync::Mutex::new(Vec::<(u64, u64)>::new()));
let start = Instant::now();
let mut workers = Vec::with_capacity(WORKERS);
for worker in 0..WORKERS {
let stop = Arc::clone(&stop);
let completed = Arc::clone(&completed);
let failures = Arc::clone(&failures);
let latencies = Arc::clone(&latencies);
workers.push(tokio::spawn(async move {
let client =
hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
.build_http::<http_body_util::Full<hyper::body::Bytes>>();
let mut n = worker as u64;
while !stop.load(Ordering::Relaxed) {
n += WORKERS as u64;
let request = hyper::Request::builder()
.method("POST")
.uri(format!("http://{addr}/"))
.header("content-type", "application/json")
.header("A2A-Version", "1.0")
.body(http_body_util::Full::new(hyper::body::Bytes::from(
send_message_body(n),
)))
.expect("request builds");
let sent = Instant::now();
match client.request(request).await {
Ok(response) => {
use http_body_util::BodyExt as _;
let _ = response.into_body().collect().await;
let elapsed = sent.elapsed().as_micros() as u64;
completed.fetch_add(1, Ordering::Relaxed);
latencies
.lock()
.expect("latency lock")
.push((start.elapsed().as_secs(), elapsed));
}
Err(_) => {
failures.fetch_add(1, Ordering::Relaxed);
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
}
}));
}
let mut samples: Vec<(u64, u64, u64)> = Vec::new();
while start.elapsed() < duration {
tokio::time::sleep(Duration::from_secs(1)).await;
samples.push((
start.elapsed().as_secs(),
resident_bytes(),
completed.load(Ordering::Relaxed),
));
}
stop.store(true, Ordering::Relaxed);
for worker in workers {
worker.await.expect("worker joins");
}
stop_tx.send(()).ok();
let report = serving.await.expect("server joins");
let _ = handler.shutdown().await;
let total = completed.load(Ordering::Relaxed);
let failed = failures.load(Ordering::Relaxed);
println!("\nsoak: {seconds}s, {WORKERS} workers, {total} requests, {failed} failures");
println!("drained={} abandoned={}", report.drained, report.abandoned);
println!("\n{:>5} {:>10} {:>10}", "t(s)", "rss(MiB)", "requests");
for (t, rss, done) in &samples {
if t % 5 == 0 || *t == seconds {
println!(
"{t:>5} {:>10.1} {done:>10}",
*rss as f64 / (1024.0 * 1024.0)
);
}
}
assert!(
total > 0,
"no request completed, so this measured nothing at all"
);
assert_eq!(
failed, 0,
"{failed} request(s) failed outright during the run"
);
let warmup = samples.len() / 4;
let baseline = samples
.get(warmup)
.copied()
.expect("the run is long enough to have a post-warm-up sample");
let last = *samples.last().expect("at least one sample");
let bytes_grown = last.1.saturating_sub(baseline.1) as f64;
let requests_since = last.2.saturating_sub(baseline.2).max(1) as f64;
let per_request = bytes_grown / requests_since;
println!(
"\nresident growth after warm-up: {:.1} MiB over {requests_since} requests \
= {per_request:.1} bytes/request (ceiling {MAX_BYTES_PER_REQUEST:.0})",
bytes_grown / (1024.0 * 1024.0)
);
assert!(
per_request < MAX_BYTES_PER_REQUEST,
"resident memory grew {per_request:.1} bytes per request after warm-up, \
over the {MAX_BYTES_PER_REQUEST:.0} ceiling — something in the request \
path is retained rather than released"
);
let recorded = latencies.lock().expect("latency lock").clone();
let quarter = duration.as_secs() / 4;
let head: Vec<u64> = recorded
.iter()
.filter(|(t, _)| *t < quarter)
.map(|(_, us)| *us)
.collect();
let tail: Vec<u64> = recorded
.iter()
.filter(|(t, _)| *t >= quarter * 3)
.map(|(_, us)| *us)
.collect();
if !head.is_empty() && !tail.is_empty() {
let (head_p95, tail_p95) = (p95(head), p95(tail));
let growth = tail_p95 as f64 / head_p95.max(1) as f64;
println!("p95 latency: {head_p95}us -> {tail_p95}us ({growth:.2}x)");
assert!(
growth < MAX_LATENCY_GROWTH,
"p95 latency grew {growth:.2}x from the first quarter to the last \
({head_p95}us -> {tail_p95}us); work per request is growing with \
history even though memory is not"
);
}
}