use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Condvar, Mutex, MutexGuard, OnceLock, PoisonError};
use std::time::{Duration, Instant, SystemTime};
use super::worktree_reclaim_gh::GhFailure;
pub(crate) const TIMEOUT_STRIKES: u32 = 3;
const BACKOFF_BASE: Duration = Duration::from_secs(60);
const BACKOFF_MAX: Duration = Duration::from_secs(30 * 60);
fn backoff_for(consecutive: u32) -> Duration {
let steps = consecutive.saturating_sub(TIMEOUT_STRIKES).min(16);
let secs = BACKOFF_BASE.as_secs().saturating_mul(1u64 << steps);
Duration::from_secs(secs.min(BACKOFF_MAX.as_secs()))
}
fn format_local(at: SystemTime) -> String {
chrono::DateTime::<chrono::Local>::from(at)
.format("%H:%M:%S %Z")
.to_string()
}
#[derive(Default)]
struct CallSlot {
running: bool,
waiters: usize,
seq: u64,
last: Option<Result<String, GhFailure>>,
}
impl CallSlot {
fn is_idle(&self) -> bool {
!self.running && self.waiters == 0
}
}
#[derive(Default)]
struct RootBackoff {
consecutive_timeouts: u32,
suspended_until: Option<(Instant, SystemTime)>,
}
#[derive(Default)]
struct GateState {
calls: HashMap<String, CallSlot>,
roots: HashMap<PathBuf, RootBackoff>,
}
pub(crate) struct GhPollGate {
state: Mutex<GateState>,
finished: Condvar,
}
impl GhPollGate {
pub(crate) fn new() -> Self {
Self {
state: Mutex::new(GateState::default()),
finished: Condvar::new(),
}
}
fn lock(&self) -> MutexGuard<'_, GateState> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
pub(crate) fn poll<F>(&self, root: &Path, call: &str, run: F) -> Result<String, GhFailure>
where
F: FnOnce() -> Result<String, GhFailure>,
{
let key = format!("{}\u{1}{call}", root.display());
let mut state = self.lock();
if let Some(reason) = take_suspension(&mut state, root, Instant::now()) {
return Err(GhFailure::new(reason));
}
let slot = state.calls.entry(key.clone()).or_default();
if slot.running {
let waited_for = slot.seq;
slot.waiters += 1;
return self.wait_for(state, &key, waited_for);
}
slot.running = true;
drop(state);
let flight = InFlight {
gate: self,
root: root.to_path_buf(),
key,
armed: true,
};
flight.finish(run())
}
fn wait_for(
&self,
state: MutexGuard<'_, GateState>,
key: &str,
waited_for: u64,
) -> Result<String, GhFailure> {
let mut state = self
.finished
.wait_while(state, |s| {
s.calls
.get(key)
.is_some_and(|c| c.running || c.seq == waited_for)
})
.unwrap_or_else(PoisonError::into_inner);
let mut result = None;
if let Some(slot) = state.calls.get_mut(key) {
result = slot.last.clone();
slot.waiters = slot.waiters.saturating_sub(1);
if slot.waiters == 0 {
slot.last = None;
}
if slot.is_idle() {
state.calls.remove(key);
}
}
result.unwrap_or_else(|| {
Err(GhFailure::new(
"the in-flight `gh` poll ended without a result",
))
})
}
fn complete(
&self,
root: &Path,
key: &str,
result: Result<String, GhFailure>,
) -> Result<String, GhFailure> {
let mut state = self.lock();
note_outcome(&mut state.roots, root, &result);
if let Some(slot) = state.calls.get_mut(key) {
slot.running = false;
slot.seq = slot.seq.wrapping_add(1);
slot.last = (slot.waiters > 0).then(|| result.clone());
if slot.is_idle() {
state.calls.remove(key);
}
}
drop(state);
self.finished.notify_all();
result
}
}
struct InFlight<'a> {
gate: &'a GhPollGate,
root: PathBuf,
key: String,
armed: bool,
}
impl InFlight<'_> {
fn finish(mut self, result: Result<String, GhFailure>) -> Result<String, GhFailure> {
self.armed = false;
self.gate.complete(&self.root, &self.key, result)
}
}
impl Drop for InFlight<'_> {
fn drop(&mut self) {
if self.armed {
let _ = self.gate.complete(
&self.root,
&self.key,
Err(GhFailure::new("the `gh` poll panicked before answering")),
);
}
}
}
fn take_suspension(state: &mut GateState, root: &Path, now: Instant) -> Option<String> {
let entry = state.roots.get_mut(root)?;
let (until, until_wall) = entry.suspended_until?;
if now < until {
return Some(format!(
"gh polling suspended: {} consecutive timeouts, next retry at {}",
entry.consecutive_timeouts,
format_local(until_wall)
));
}
entry.suspended_until = None;
None
}
fn note_outcome(
roots: &mut HashMap<PathBuf, RootBackoff>,
root: &Path,
result: &Result<String, GhFailure>,
) {
let timed_out = matches!(result, Err(f) if f.timed_out());
if !timed_out {
roots.remove(root);
return;
}
let entry = roots.entry(root.to_path_buf()).or_default();
entry.consecutive_timeouts = entry.consecutive_timeouts.saturating_add(1);
if entry.consecutive_timeouts >= TIMEOUT_STRIKES {
let wait = backoff_for(entry.consecutive_timeouts);
entry.suspended_until = Some((Instant::now() + wait, SystemTime::now() + wait));
tracing::warn!(
root = %root.display(),
timeouts = entry.consecutive_timeouts,
"worktree-reclaim: gh polling suspended for {}s after consecutive timeouts — \
a hung `gh` leaks a process pair per poll (#6867)",
wait.as_secs()
);
}
}
pub(crate) fn shared() -> &'static GhPollGate {
static GATE: OnceLock<GhPollGate> = OnceLock::new();
GATE.get_or_init(GhPollGate::new)
}
#[cfg(test)]
#[path = "worktree_reclaim_gh_gate_tests.rs"]
mod worktree_reclaim_gh_gate_tests;