use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use super::*;
struct HooksStub {
seen: Arc<AtomicU32>,
fail_first: u32,
fail_status: axum::http::StatusCode,
}
impl HooksStub {
fn new(fail_first: u32, fail_status: axum::http::StatusCode) -> Self {
Self {
seen: Arc::new(AtomicU32::new(0)),
fail_first,
fail_status,
}
}
async fn serve(&self) -> String {
use axum::routing::post;
let seen = Arc::clone(&self.seen);
let fail_first = self.fail_first;
let fail_status = self.fail_status;
let router = axum::Router::new().route(
"/hooks",
post(move || {
let seen = Arc::clone(&seen);
async move {
let n = seen.fetch_add(1, Ordering::SeqCst);
if n < fail_first {
(fail_status, "stub refusal")
} else {
(axum::http::StatusCode::OK, "{}")
}
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback port");
let addr: SocketAddr = listener.local_addr().expect("resolve bound addr");
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
format!("http://{addr}")
}
fn requests(&self) -> u32 {
self.seen.load(Ordering::SeqCst)
}
}
fn stop_body() -> serde_json::Value {
serde_json::json!({
"session_id": "11111111-1111-1111-1111-111111111111",
"event": "SubagentStop",
"payload": {"agent_id": "a403cdbc078b5c474"},
})
}
fn attempt_lines(outcome: &HookPostOutcome) -> Vec<&String> {
outcome
.lines
.iter()
.filter(|l| l.contains("attempt "))
.collect()
}
#[tokio::test]
async fn a_stop_delivered_on_the_third_attempt_logs_every_one() {
let stub = HooksStub::new(2, axum::http::StatusCode::SERVICE_UNAVAILABLE);
let url = stub.serve().await;
let outcome = post_hook_with_retry(&url, &stop_body()).await;
assert!(outcome.delivered, "lines: {:?}", outcome.lines);
assert_eq!(outcome.attempts, 3, "two failures then a success");
assert_eq!(
stub.requests(),
3,
"the daemon saw exactly one successful stop, after two refusals"
);
assert_eq!(
attempt_lines(&outcome).len(),
3,
"every attempt is logged, not only the last: {:?}",
outcome.lines
);
assert!(
outcome
.lines
.last()
.is_some_and(|l| l.contains("delivered after 3 attempt(s)")),
"the verdict names the outcome and the count: {:?}",
outcome.lines
);
}
#[tokio::test]
async fn a_stop_delivered_first_try_makes_one_attempt() {
let stub = HooksStub::new(0, axum::http::StatusCode::SERVICE_UNAVAILABLE);
let url = stub.serve().await;
let outcome = post_hook_with_retry(&url, &stop_body()).await;
assert!(outcome.delivered);
assert_eq!(outcome.attempts, 1);
assert_eq!(stub.requests(), 1);
}
#[tokio::test]
async fn a_daemon_that_never_answers_exhausts_the_attempts() {
let stub = HooksStub::new(u32::MAX, axum::http::StatusCode::INTERNAL_SERVER_ERROR);
let url = stub.serve().await;
let started = std::time::Instant::now();
let outcome = post_hook_with_retry(&url, &stop_body()).await;
let elapsed = started.elapsed();
assert!(!outcome.delivered, "lines: {:?}", outcome.lines);
assert_eq!(outcome.attempts, HOOK_POST_ATTEMPTS);
assert_eq!(stub.requests(), HOOK_POST_ATTEMPTS);
assert!(
elapsed <= HOOK_POST_BUDGET + std::time::Duration::from_millis(500),
"the loop must return inside its budget; took {elapsed:?}"
);
assert!(
outcome
.lines
.last()
.is_some_and(|l| l.contains("UNDELIVERED after 3 attempt(s)")),
"the verdict is what an operator greps for: {:?}",
outcome.lines
);
}
#[tokio::test]
async fn a_refused_body_is_not_retried() {
let stub = HooksStub::new(u32::MAX, axum::http::StatusCode::BAD_REQUEST);
let url = stub.serve().await;
let outcome = post_hook_with_retry(&url, &stop_body()).await;
assert!(!outcome.delivered);
assert_eq!(outcome.attempts, 1, "a 400 is permanent");
assert_eq!(stub.requests(), 1);
assert!(
outcome.lines.iter().any(|l| l.contains("permanent")),
"the line says why it stopped: {:?}",
outcome.lines
);
}
#[tokio::test]
async fn an_unreachable_daemon_retries_and_reports_undelivered() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback port");
let addr: SocketAddr = listener.local_addr().expect("resolve bound addr");
drop(listener);
let outcome = post_hook_with_retry(&format!("http://{addr}"), &stop_body()).await;
assert!(!outcome.delivered);
assert_eq!(outcome.attempts, HOOK_POST_ATTEMPTS);
}
#[test]
fn the_stop_post_budget_stays_inside_the_registered_hook_timeout() {
let registered =
registered_subagent_stop_timeout().expect("the SubagentStop group carries a timeout");
let worst_case = trusty_mpm::core::discovery::GATEWAY_PROBE_TIMEOUT
+ crate::commands::hook_stdin::HOOK_STDIN_TIMEOUT
+ crate::commands::misc::IDLE_PARK_DETECT_TIMEOUT
+ HOOK_POST_BUDGET;
let headroom = registered.checked_sub(worst_case).unwrap_or_default();
assert!(
headroom >= std::time::Duration::from_secs(1),
"worst case {worst_case:?} of the registered {registered:?} leaves only {headroom:?} for \
exec, the spool write and teardown"
);
}
#[test]
fn the_registered_subagent_stop_timeout_is_readable() {
assert_eq!(
registered_subagent_stop_timeout(),
Some(std::time::Duration::from_secs(5)),
"the SubagentStop hook is registered with a 5 s timeout"
);
}
#[test]
fn the_attempt_bounds_fit_inside_the_budget() {
let backoffs: std::time::Duration =
(1..HOOK_POST_ATTEMPTS).map(|n| HOOK_POST_BACKOFF * n).sum();
let nominal = HOOK_POST_ATTEMPT_TIMEOUT * HOOK_POST_ATTEMPTS + backoffs;
assert!(
nominal <= HOOK_POST_BUDGET,
"nominal worst case {nominal:?} overruns the {HOOK_POST_BUDGET:?} budget, so the budget \
would truncate the third attempt while the log claimed it was made"
);
assert!(HOOK_POST_CONNECT_TIMEOUT <= HOOK_POST_ATTEMPT_TIMEOUT);
}
#[test]
fn a_parked_stop_lands_where_the_daemon_drains_it() {
let home = tempfile::tempdir().expect("temp dir");
let root = trusty_mpm::core::paths::FrameworkPaths::under(home.path()).root;
let line = spool_undelivered_stop_under(&root, &stop_body());
let parked = trusty_mpm::core::stop_spool::read_unposted_stops(&root);
assert_eq!(
parked.len(),
1,
"one record, not a directory of them: {line}"
);
assert_eq!(
parked[0].1.as_ref(),
Some(&stop_body()),
"the replayed body must be the POST body verbatim"
);
assert!(
line.contains("parked at"),
"the line names the park: {line}"
);
}
#[test]
fn an_unusable_root_reports_the_stop_as_lost() {
let home = tempfile::tempdir().expect("temp dir");
let root = home.path().join("root-is-a-file");
std::fs::write(&root, "not a directory").expect("write");
let line = spool_undelivered_stop_under(&root, &stop_body());
assert!(line.contains("could not park"), "{line}");
assert!(
trusty_mpm::core::stop_spool::read_unposted_stops(&root).is_empty(),
"nothing was written, and the line must not claim otherwise"
);
}