use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration;
use polyoxide_rt::{Instant, Runtime};
const FAR_FUTURE: Duration = Duration::from_secs(86_400 * 365 * 30);
#[derive(Clone)]
pub struct Hold {
inner: Arc<HoldInner>,
}
struct HoldInner {
until: Mutex<Option<Instant>>,
ceiling: Option<Duration>,
runtime: Arc<dyn Runtime>,
}
impl Hold {
pub fn unbounded() -> Self {
Self::unbounded_on(polyoxide_rt::default_runtime())
}
pub fn unbounded_on(runtime: Arc<dyn Runtime>) -> Self {
Self::new(None, runtime)
}
pub fn with_ceiling(ceiling: Duration) -> Self {
Self::with_ceiling_on(ceiling, polyoxide_rt::default_runtime())
}
pub fn with_ceiling_on(ceiling: Duration, runtime: Arc<dyn Runtime>) -> Self {
Self::new(Some(ceiling), runtime)
}
fn new(ceiling: Option<Duration>, runtime: Arc<dyn Runtime>) -> Self {
Self {
inner: Arc::new(HoldInner {
until: Mutex::new(None),
ceiling,
runtime,
}),
}
}
pub fn runtime(&self) -> &Arc<dyn Runtime> {
&self.inner.runtime
}
pub fn extend(&self, delay: Duration) {
let delay = self
.inner
.ceiling
.map_or(delay, |ceiling| delay.min(ceiling));
let now = self.inner.runtime.now();
let until = now.checked_add(delay).unwrap_or(now + FAR_FUTURE);
let mut slot = self.lock();
if slot.is_none_or(|current| until > current) {
*slot = Some(until);
}
}
pub async fn wait(&self) {
loop {
let deadline = *self.lock();
let Some(deadline) = deadline else { return };
if deadline <= self.inner.runtime.now() {
return;
}
self.inner.runtime.sleep_until(deadline).await;
}
}
pub fn is_held(&self) -> bool {
self.lock()
.is_some_and(|until| until > self.inner.runtime.now())
}
fn lock(&self) -> MutexGuard<'_, Option<Instant>> {
self.inner
.until
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
}
impl Default for Hold {
fn default() -> Self {
Self::unbounded()
}
}
impl std::fmt::Debug for Hold {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let remaining =
(*self.lock()).and_then(|until| until.checked_duration_since(self.inner.runtime.now()));
f.debug_struct("Hold")
.field("remaining", &remaining)
.field("ceiling", &self.inner.ceiling)
.finish()
}
}
#[cfg(test)]
mod tests {
use tokio::time::Instant;
use super::*;
#[tokio::test(start_paused = true)]
async fn a_hold_never_shortens() {
let hold = Hold::unbounded();
hold.extend(Duration::from_secs(10));
hold.extend(Duration::from_secs(1));
let t = Instant::now();
hold.wait().await;
assert!(
t.elapsed() >= Duration::from_secs(10),
"the longer hold was cut to {:?}",
t.elapsed()
);
}
#[tokio::test(start_paused = true)]
async fn a_hold_extended_mid_wait_is_honoured_in_full() {
let hold = Hold::unbounded();
hold.extend(Duration::from_secs(2));
let extender = {
let hold = hold.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(1)).await;
hold.extend(Duration::from_secs(5));
})
};
let t = Instant::now();
hold.wait().await;
extender.await.unwrap();
assert!(
t.elapsed() >= Duration::from_secs(6),
"woke at {:?}, at the deadline it first read",
t.elapsed()
);
}
#[tokio::test(start_paused = true)]
async fn the_ceiling_clamps_a_longer_hold() {
const DAY: Duration = Duration::from_secs(86_400);
let hold = Hold::with_ceiling(3 * DAY);
hold.extend(10 * DAY);
let t = Instant::now();
hold.wait().await;
assert!(
t.elapsed() >= 3 * DAY && t.elapsed() < 10 * DAY,
"held {:?}, not the 3-day ceiling",
t.elapsed()
);
}
#[tokio::test(start_paused = true)]
async fn a_huge_delay_saturates_rather_than_panics() {
let hold = Hold::unbounded();
hold.extend(Duration::MAX);
assert!(
tokio::time::timeout(Duration::from_secs(86_400 * 365), hold.wait())
.await
.is_err(),
"a hold of Duration::MAX released within a year"
);
}
}