use crate::routing::Lifecycle;
use crate::tests::fake;
use rootle_provider::{ErrorKind, Provider};
use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
#[test]
fn restart_recovers_after_child_death() {
let provider = fake("die-on-2", Duration::from_secs(5));
provider
.request("repo/tree", json!({ "repo": "o/r" }))
.expect_err("dead child must fail the request");
let start = std::time::Instant::now();
provider
.request("org/repos", json!({ "org": "o" }))
.expect("respawn must recover the transport");
assert!(
start.elapsed() >= Duration::from_secs(1),
"first restart pays the 1s backoff"
);
let notice = provider.take_notice().expect("restart leaves a notice");
assert!(
notice.contains("provider restarted"),
"notice names the restart, got: {notice}"
);
assert!(provider.take_notice().is_none(), "notice is one-shot");
}
#[test]
fn waiter_during_rebuild_proceeds_on_validated_transport() {
let provider = Arc::new(fake("die-on-2", Duration::from_secs(5)));
provider
.request("repo/tree", json!({ "repo": "o/r" }))
.expect_err("dead child must fail the request");
let rebuilder = {
let p = Arc::clone(&provider);
std::thread::spawn(move || {
p.request("org/repos", json!({ "org": "o" }))
.expect("rebuild must recover the transport");
})
};
std::thread::sleep(Duration::from_millis(200));
let waiter_start = std::time::Instant::now();
provider
.request("repo/tree", json!({ "repo": "o/r" }))
.expect("waiter must succeed on the rebuilt transport");
rebuilder.join().expect("rebuilder panics");
assert!(
waiter_start.elapsed() < Duration::from_secs(4),
"waiter rides the in-flight rebuild instead of chaining sleeps"
);
}
#[test]
fn failed_rebuild_fails_waiters_without_resleeping() {
let provider = Arc::new(fake("die-on-2", Duration::from_secs(5)));
provider
.request("repo/tree", json!({ "repo": "o/r" }))
.expect_err("dead child must fail the request");
{
let mut routing = provider.shared.routing.lock();
assert_eq!(routing.lifecycle, Lifecycle::Dead);
routing.lifecycle = Lifecycle::Respawning;
}
let notifier = {
let p = Arc::clone(&provider);
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(300));
let err = rootle_provider::ProviderError::new(ErrorKind::Provider, "handshake boom");
let _ = p.finish_rebuild(1, Err(&err));
})
};
let start = std::time::Instant::now();
let err = provider
.ensure_alive()
.expect_err("waiter must fail when the rebuild fails");
notifier.join().unwrap();
assert_eq!(err.kind, ErrorKind::Provider);
assert!(
err.message.contains("handshake boom"),
"waiter sees the attempt's stored reason, got: {err}"
);
assert!(
start.elapsed() < Duration::from_secs(2),
"waiter woke and failed fast (no second backoff sleep); took {:?}",
start.elapsed()
);
}
#[test]
fn failed_spawn_publishes_failure_and_stays_recoverable() {
let mut provider = fake("die-on-2", Duration::from_secs(5));
provider
.request("repo/tree", json!({ "repo": "o/r" }))
.expect_err("dead child must fail the request");
provider.command = vec!["/nonexistent/rootle-test-provider".into()];
let err = provider
.request("org/repos", json!({ "org": "o" }))
.expect_err("unspawneable provider must fail the rebuild");
assert!(
err.message.contains("spawn"),
"expected the spawn error, got: {err}"
);
let routing = provider.shared.routing.lock();
assert_eq!(
routing.lifecycle,
Lifecycle::Dead,
"a failed spawn must land in Dead (recoverable), not wedge in Respawning"
);
assert!(
routing
.restart_error
.as_deref()
.is_some_and(|e| e.contains("spawn")),
"waiters must see the spawn failure as the stored reason"
);
}
#[test]
fn closed_provider_rebuild_does_not_spawn_and_publishes() {
let provider = Arc::new(fake("die-on-2", Duration::from_secs(5)));
provider
.request("repo/tree", json!({ "repo": "o/r" }))
.expect_err("dead child must fail the request");
provider
.closed
.store(true, std::sync::atomic::Ordering::Release);
let err = provider
.rebuild(1)
.expect_err("closed provider must fail the rebuild");
assert_eq!(err.kind, ErrorKind::Other);
assert!(err.message.contains("dropped"), "got: {err}");
let routing = provider.shared.routing.lock();
assert_eq!(
routing.lifecycle,
Lifecycle::Dead,
"closed rebuild must land in Dead, not wedge in Respawning"
);
assert!(
routing
.restart_error
.as_deref()
.is_some_and(|e| e.contains("dropped")),
"the outcome is published so parked waiters wake and fail"
);
}
#[test]
fn armed_rebuild_guard_publishes_failure_on_drop() {
let provider = fake("die-on-2", Duration::from_secs(5));
provider
.request("repo/tree", json!({ "repo": "o/r" }))
.expect_err("dead child must fail the request");
{
let mut routing = provider.shared.routing.lock();
assert_eq!(routing.lifecycle, Lifecycle::Dead);
routing.lifecycle = Lifecycle::Respawning;
}
let guard = super::RebuildGuard {
provider: &provider,
attempt: 1,
armed: true,
};
drop(guard);
let routing = provider.shared.routing.lock();
assert_eq!(
routing.lifecycle,
Lifecycle::Dead,
"an aborted rebuild must resolve Respawning, not wedge it"
);
assert!(
routing
.restart_error
.as_deref()
.is_some_and(|e| e.contains("aborted")),
"waiters must see the abort as the stored reason"
);
}
#[test]
fn backoff_ladder() {
assert_eq!(super::backoff_for(1), Duration::from_secs(1));
assert_eq!(super::backoff_for(2), Duration::from_secs(2));
assert_eq!(super::backoff_for(3), Duration::from_secs(5));
assert_eq!(super::backoff_for(9), Duration::from_secs(30));
}
#[test]
fn failure_streak_notices_once() {
let provider = fake("die-on-reinit", Duration::from_secs(1));
let _ = provider.request("org/repos", json!({ "org": "o" }));
let _ = provider.request("org/repos", json!({ "org": "o" }));
let notice = provider
.take_notice()
.expect("a failing streak leaves a notice");
assert!(
notice.contains("keeps failing to restart"),
"streak notice: {notice}"
);
assert!(
provider.take_notice().is_none(),
"the streak notices once, not per attempt"
);
}