use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
fn timeout() -> Result<String, GhFailure> {
Err(GhFailure::timeout("`gh` did not answer within 10s"))
}
#[test]
fn two_concurrent_polls_spawn_one_child() {
let gate = Arc::new(GhPollGate::new());
let spawned = Arc::new(AtomicUsize::new(0));
let started = Arc::new(std::sync::Barrier::new(2));
let first = {
let (gate, spawned, started) = (gate.clone(), spawned.clone(), started.clone());
std::thread::spawn(move || {
gate.poll(Path::new("/tmp/root"), "index", || {
spawned.fetch_add(1, Ordering::SeqCst);
started.wait();
std::thread::sleep(std::time::Duration::from_millis(150));
Ok("first call's answer".to_string())
})
})
};
started.wait();
let second = gate.poll(Path::new("/tmp/root"), "index", || {
spawned.fetch_add(1, Ordering::SeqCst);
Ok("a second child ran".to_string())
});
let first = first.join().expect("first poll thread");
assert_eq!(
spawned.load(Ordering::SeqCst),
1,
"an overlapping poll must join the in-flight call, not spawn a second `gh`"
);
assert_eq!(first.as_deref().ok(), Some("first call's answer"));
assert_eq!(
second.as_deref().ok(),
Some("first call's answer"),
"the waiter must receive the in-flight call's result"
);
}
#[test]
fn a_different_call_for_the_same_root_is_not_shared() {
let gate = GhPollGate::new();
let root = Path::new("/tmp/root");
let a = gate.poll(root, "head:feat/a", || Ok("A".to_string()));
let b = gate.poll(root, "head:feat/b", || Ok("B".to_string()));
assert_eq!(a.as_deref().ok(), Some("A"));
assert_eq!(b.as_deref().ok(), Some("B"));
}
#[test]
fn a_fourth_call_is_skipped_after_three_timeouts() {
let gate = GhPollGate::new();
let root = Path::new("/tmp/wedged");
let spawned = AtomicUsize::new(0);
for _ in 0..TIMEOUT_STRIKES {
let out = gate.poll(root, "index", || {
spawned.fetch_add(1, Ordering::SeqCst);
timeout()
});
assert!(out.is_err(), "a timeout must be reported as a failure");
}
assert_eq!(spawned.load(Ordering::SeqCst), TIMEOUT_STRIKES as usize);
let fourth = gate
.poll(root, "index", || {
spawned.fetch_add(1, Ordering::SeqCst);
timeout()
})
.expect_err("a suspended root must refuse the call");
assert_eq!(
spawned.load(Ordering::SeqCst),
TIMEOUT_STRIKES as usize,
"the fourth call must spawn nothing"
);
let line = fourth.to_string();
assert!(line.contains("gh polling suspended"), "{line}");
assert!(line.contains("3 consecutive timeouts"), "{line}");
assert!(line.contains("next retry at"), "{line}");
assert!(
!fourth.timed_out(),
"a skip is not itself a timeout — counting it would make the suspension permanent"
);
}
#[test]
fn suspension_does_not_spread_to_another_root() {
let gate = GhPollGate::new();
for _ in 0..TIMEOUT_STRIKES {
let _ = gate.poll(Path::new("/tmp/wedged"), "index", timeout);
}
let other = gate.poll(Path::new("/tmp/healthy"), "index", || Ok("ok".to_string()));
assert_eq!(other.as_deref().ok(), Some("ok"));
}
#[test]
fn an_answer_clears_the_strikes() {
let gate = GhPollGate::new();
let root = Path::new("/tmp/root");
let _ = gate.poll(root, "index", timeout);
let _ = gate.poll(root, "index", timeout);
let _ = gate.poll(root, "index", || Err(GhFailure::new("`gh` exited 4: auth")));
let _ = gate.poll(root, "index", timeout);
let _ = gate.poll(root, "index", timeout);
let spawned = AtomicUsize::new(0);
let out = gate.poll(root, "index", || {
spawned.fetch_add(1, Ordering::SeqCst);
Ok("ran".to_string())
});
assert_eq!(out.as_deref().ok(), Some("ran"));
assert_eq!(
spawned.load(Ordering::SeqCst),
1,
"the counter must have restarted at the answer, leaving only two strikes"
);
}
#[test]
fn an_expired_suspension_lets_the_next_call_through() {
let mut state = GateState::default();
let root = Path::new("/tmp/root");
for _ in 0..TIMEOUT_STRIKES {
note_outcome(&mut state.roots, root, &timeout());
}
let now = Instant::now();
assert!(
take_suspension(&mut state, root, now).is_some(),
"the suspension must hold while its deadline is in the future"
);
let after = now + BACKOFF_MAX + Duration::from_secs(1);
assert!(
take_suspension(&mut state, root, after).is_none(),
"an expired deadline must not keep refusing calls"
);
assert!(
take_suspension(&mut state, root, now).is_none(),
"the expired deadline must have been cleared, not re-evaluated"
);
}
#[test]
fn backoff_grows_and_then_saturates() {
assert_eq!(backoff_for(TIMEOUT_STRIKES), BACKOFF_BASE);
assert_eq!(backoff_for(TIMEOUT_STRIKES + 1), BACKOFF_BASE * 2);
assert_eq!(backoff_for(TIMEOUT_STRIKES + 2), BACKOFF_BASE * 4);
assert_eq!(backoff_for(TIMEOUT_STRIKES + 40), BACKOFF_MAX);
assert_eq!(backoff_for(u32::MAX), BACKOFF_MAX);
}
#[test]
fn a_panicking_run_releases_its_waiters() {
let gate = Arc::new(GhPollGate::new());
let started = Arc::new(std::sync::Barrier::new(2));
let panicker = {
let (gate, started) = (gate.clone(), started.clone());
std::thread::spawn(move || {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
gate.poll(Path::new("/tmp/root"), "index", || {
started.wait();
std::thread::sleep(std::time::Duration::from_millis(100));
panic!("the poll blew up");
})
}));
})
};
started.wait();
let waiter = gate.poll(Path::new("/tmp/root"), "index", || {
Ok("the waiter ran its own call".to_string())
});
panicker
.join()
.expect("the panicking thread must be joinable");
match waiter {
Ok(out) => assert_eq!(out, "the waiter ran its own call"),
Err(f) => assert!(f.to_string().contains("panicked"), "{f}"),
}
}
#[test]
fn the_shared_gate_is_a_single_instance() {
assert!(std::ptr::eq(shared(), shared()));
}