Skip to main content

almost_enough/time/
mod.rs

1//! Timeout support for cancellation.
2//!
3//! This module provides timeout wrappers that add deadline-based cancellation
4//! to any [`Stop`] implementation.
5//!
6//! # Overview
7//!
8//! - [`WithTimeout`] - Wraps any `Stop` and adds a deadline; reads the clock
9//!   on every check
10//! - [`DebouncedTimeout`] - The same, reading the clock about once per 100 µs
11//!   of checks and at least every 64 checks; it times the first two checks to
12//!   choose, and [`clear_calibration`](DebouncedTimeout::clear_calibration)
13//!   starts that over where checks change pace
14//! - [`TimeoutExt`] - Extension trait providing `.with_timeout()` and `.with_deadline()`
15//! - [`DebouncedTimeoutExt`] - `.with_debounced_timeout()` and
16//!   `.with_debounced_deadline()`
17//!
18//! # Example
19//!
20//! ```rust
21//! use almost_enough::{StopSource, Stop, TimeoutExt};
22//! use std::time::Duration;
23//!
24//! let source = StopSource::new();
25//! let stop = source.as_ref().with_timeout(Duration::from_secs(30));
26//!
27//! // Stop will trigger if cancelled OR if 30 seconds pass
28//! assert!(!stop.should_stop());
29//! ```
30//!
31//! # Timeout Tightening
32//!
33//! Timeouts can only get stricter, never looser. This is safe for composition:
34//!
35//! ```rust
36//! use almost_enough::{StopSource, TimeoutExt};
37//! use std::time::Duration;
38//!
39//! let source = StopSource::new();
40//! let stop = source.as_ref()
41//!     .with_timeout(Duration::from_secs(60))  // 60 second outer limit
42//!     .with_timeout(Duration::from_secs(10)); // 10 second inner limit
43//!
44//! // Effective timeout is ~10 seconds (the tighter of the two)
45//! ```
46
47mod debounced;
48
49pub use debounced::{DebouncedTimeout, DebouncedTimeoutExt};
50
51use std::time::{Duration, Instant};
52
53use crate::{Stop, StopReason};
54
55/// A [`Stop`] wrapper that adds a deadline.
56///
57/// The wrapped stop will return [`StopReason::TimedOut`] if the deadline
58/// passes, or propagate the inner stop's reason if it stops first.
59///
60/// # Cost
61///
62/// Every check reads the clock (`Instant::now()`). Measured with perf on a
63/// loop that checks after every 1 KiB of PNG defiltering ([record]), that is
64/// about 112 instructions per check, and the loop took 22% more cycles on
65/// x86-64 (12% on a Neoverse-N1); a plain `Stopper` measured within noise. In
66/// return it stops no later than one check after the deadline.
67///
68/// If a library checks that often and the deadline doesn't need that
69/// precision, [`DebouncedTimeout`] reads the clock only every N checks, at
70/// most 64, with N timed from two consecutive checks so that reads come about
71/// once per 100 µs. After checks slow down it can stop up to 64 of the slower
72/// checks late, unless the code holding it calls
73/// [`clear_calibration`](DebouncedTimeout::clear_calibration) where the pace
74/// changes.
75///
76/// [record]: https://github.com/imazen/how-far/blob/6bc984f06fac1fb8755643c2247fff9fbeba643e/benchmarks/how-far-2026-10-06.md#enough-stop-policies-by-check-pattern
77///
78/// # Example
79///
80/// ```rust
81/// use almost_enough::{StopSource, Stop};
82/// use almost_enough::time::WithTimeout;
83/// use std::time::Duration;
84///
85/// let source = StopSource::new();
86/// let timeout = WithTimeout::new(source.as_ref(), Duration::from_millis(100));
87///
88/// assert!(!timeout.should_stop());
89///
90/// std::thread::sleep(Duration::from_millis(150));
91/// assert!(timeout.should_stop());
92/// ```
93#[derive(Debug, Clone)]
94pub struct WithTimeout<T> {
95    inner: T,
96    deadline: Instant,
97}
98
99impl<T: Stop> WithTimeout<T> {
100    /// Create a new timeout wrapper.
101    ///
102    /// The deadline is calculated as `Instant::now() + duration`.
103    #[inline]
104    pub fn new(inner: T, duration: Duration) -> Self {
105        Self {
106            inner,
107            deadline: Instant::now() + duration,
108        }
109    }
110
111    /// Create a timeout wrapper with an absolute deadline.
112    #[inline]
113    pub fn with_deadline(inner: T, deadline: Instant) -> Self {
114        Self { inner, deadline }
115    }
116
117    /// Get the deadline.
118    #[inline]
119    pub fn deadline(&self) -> Instant {
120        self.deadline
121    }
122
123    /// Get the remaining time until deadline.
124    ///
125    /// Returns `Duration::ZERO` if the deadline has passed.
126    #[inline]
127    pub fn remaining(&self) -> Duration {
128        self.deadline.saturating_duration_since(Instant::now())
129    }
130
131    /// Get a reference to the inner stop.
132    #[inline]
133    pub fn inner(&self) -> &T {
134        &self.inner
135    }
136
137    /// Unwrap and return the inner stop.
138    #[inline]
139    pub fn into_inner(self) -> T {
140        self.inner
141    }
142}
143
144impl<T: Stop> Stop for WithTimeout<T> {
145    #[inline]
146    #[track_caller]
147    fn check(&self) -> Result<(), StopReason> {
148        // Check inner first (may be Cancelled)
149        self.inner.check()?;
150        // Then check timeout
151        if Instant::now() >= self.deadline {
152            Err(StopReason::TimedOut)
153        } else {
154            Ok(())
155        }
156    }
157
158    #[inline]
159    #[track_caller]
160    fn should_stop(&self) -> bool {
161        self.inner.should_stop() || Instant::now() >= self.deadline
162    }
163}
164
165/// Extension trait for adding timeouts to any [`Stop`] implementation.
166///
167/// This trait is automatically implemented for all `Stop` types.
168///
169/// # Example
170///
171/// ```rust
172/// use almost_enough::{StopSource, Stop, TimeoutExt};
173/// use std::time::Duration;
174///
175/// let source = StopSource::new();
176/// let stop = source.as_ref().with_timeout(Duration::from_secs(30));
177///
178/// assert!(!stop.should_stop());
179/// ```
180pub trait TimeoutExt: Stop + Sized {
181    /// Add a timeout to this stop.
182    ///
183    /// The resulting stop will return [`StopReason::TimedOut`] if the
184    /// duration elapses before the operation completes.
185    ///
186    /// # Timeout Tightening
187    ///
188    /// If called multiple times, the earliest deadline wins:
189    ///
190    /// ```rust
191    /// use almost_enough::{StopSource, TimeoutExt};
192    /// use std::time::Duration;
193    ///
194    /// let source = StopSource::new();
195    /// let stop = source.as_ref()
196    ///     .with_timeout(Duration::from_secs(60))
197    ///     .with_timeout(Duration::from_secs(10));
198    ///
199    /// // Effective timeout is ~10 seconds
200    /// assert!(stop.remaining() < Duration::from_secs(11));
201    /// ```
202    #[inline]
203    fn with_timeout(self, duration: Duration) -> WithTimeout<Self> {
204        WithTimeout::new(self, duration)
205    }
206
207    /// Add an absolute deadline to this stop.
208    ///
209    /// If called multiple times, the earliest deadline wins.
210    #[inline]
211    fn with_deadline(self, deadline: Instant) -> WithTimeout<Self> {
212        WithTimeout::with_deadline(self, deadline)
213    }
214}
215
216impl<T: Stop> TimeoutExt for T {}
217
218impl<T: Stop> WithTimeout<T> {
219    /// Add another timeout, taking the tighter of the two deadlines.
220    ///
221    /// This prevents timeout nesting by updating the deadline in place.
222    #[inline]
223    pub fn tighten(self, duration: Duration) -> Self {
224        let new_deadline = Instant::now() + duration;
225        Self {
226            inner: self.inner,
227            deadline: self.deadline.min(new_deadline),
228        }
229    }
230
231    /// Add another deadline, taking the earlier of the two.
232    ///
233    /// This prevents timeout nesting by updating the deadline in place.
234    #[inline]
235    pub fn tighten_deadline(self, deadline: Instant) -> Self {
236        Self {
237            inner: self.inner,
238            deadline: self.deadline.min(deadline),
239        }
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::StopSource;
247
248    #[test]
249    fn with_timeout_basic() {
250        let source = StopSource::new();
251        let stop = source.as_ref().with_timeout(Duration::from_millis(100));
252
253        assert!(!stop.should_stop());
254        assert!(stop.check().is_ok());
255
256        std::thread::sleep(Duration::from_millis(150));
257
258        assert!(stop.should_stop());
259        assert_eq!(stop.check(), Err(StopReason::TimedOut));
260    }
261
262    #[test]
263    fn cancel_before_timeout() {
264        let source = StopSource::new();
265        let stop = source.as_ref().with_timeout(Duration::from_secs(60));
266
267        source.cancel();
268
269        assert!(stop.should_stop());
270        assert_eq!(stop.check(), Err(StopReason::Cancelled));
271    }
272
273    #[test]
274    fn timeout_tightens() {
275        let source = StopSource::new();
276        let stop = source
277            .as_ref()
278            .with_timeout(Duration::from_secs(60))
279            .tighten(Duration::from_secs(1));
280
281        let remaining = stop.remaining();
282        assert!(remaining < Duration::from_secs(2));
283    }
284
285    #[test]
286    fn with_deadline_basic() {
287        let source = StopSource::new();
288        let deadline = Instant::now() + Duration::from_millis(100);
289        let stop = source.as_ref().with_deadline(deadline);
290
291        assert!(!stop.should_stop());
292
293        std::thread::sleep(Duration::from_millis(150));
294
295        assert!(stop.should_stop());
296    }
297
298    #[test]
299    fn remaining_accuracy() {
300        let source = StopSource::new();
301        let stop = source.as_ref().with_timeout(Duration::from_secs(10));
302
303        let remaining = stop.remaining();
304        assert!(remaining > Duration::from_secs(9));
305        assert!(remaining <= Duration::from_secs(10));
306    }
307
308    #[test]
309    fn remaining_after_expiry() {
310        let source = StopSource::new();
311        let stop = source.as_ref().with_timeout(Duration::from_millis(1));
312
313        std::thread::sleep(Duration::from_millis(10));
314
315        assert_eq!(stop.remaining(), Duration::ZERO);
316    }
317
318    #[test]
319    fn inner_access() {
320        let source = StopSource::new();
321        let stop = source.as_ref().with_timeout(Duration::from_secs(10));
322
323        assert!(!stop.inner().should_stop());
324
325        source.cancel();
326
327        assert!(stop.inner().should_stop());
328    }
329
330    #[test]
331    fn into_inner() {
332        let source = StopSource::new();
333        let stop = source.as_ref().with_timeout(Duration::from_secs(10));
334
335        let inner = stop.into_inner();
336        assert!(!inner.should_stop());
337    }
338
339    #[test]
340    fn with_timeout_is_send_sync() {
341        fn assert_send_sync<T: Send + Sync>() {}
342        assert_send_sync::<WithTimeout<crate::StopRef<'_>>>();
343    }
344}