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
9//! - [`TimeoutExt`] - Extension trait providing `.with_timeout()` and `.with_deadline()`
10//!
11//! # Example
12//!
13//! ```rust
14//! use almost_enough::{StopSource, Stop, TimeoutExt};
15//! use std::time::Duration;
16//!
17//! let source = StopSource::new();
18//! let stop = source.as_ref().with_timeout(Duration::from_secs(30));
19//!
20//! // Stop will trigger if cancelled OR if 30 seconds pass
21//! assert!(!stop.should_stop());
22//! ```
23//!
24//! # Timeout Tightening
25//!
26//! Timeouts can only get stricter, never looser. This is safe for composition:
27//!
28//! ```rust
29//! use almost_enough::{StopSource, TimeoutExt};
30//! use std::time::Duration;
31//!
32//! let source = StopSource::new();
33//! let stop = source.as_ref()
34//!     .with_timeout(Duration::from_secs(60))  // 60 second outer limit
35//!     .with_timeout(Duration::from_secs(10)); // 10 second inner limit
36//!
37//! // Effective timeout is ~10 seconds (the tighter of the two)
38//! ```
39
40use std::time::{Duration, Instant};
41
42use crate::{Stop, StopReason};
43
44/// A [`Stop`] wrapper that adds a deadline.
45///
46/// The wrapped stop will return [`StopReason::TimedOut`] if the deadline
47/// passes, or propagate the inner stop's reason if it stops first.
48///
49/// # Example
50///
51/// ```rust
52/// use almost_enough::{StopSource, Stop};
53/// use almost_enough::time::WithTimeout;
54/// use std::time::Duration;
55///
56/// let source = StopSource::new();
57/// let timeout = WithTimeout::new(source.as_ref(), Duration::from_millis(100));
58///
59/// assert!(!timeout.should_stop());
60///
61/// std::thread::sleep(Duration::from_millis(150));
62/// assert!(timeout.should_stop());
63/// ```
64#[derive(Debug, Clone)]
65pub struct WithTimeout<T> {
66    inner: T,
67    deadline: Instant,
68}
69
70impl<T: Stop> WithTimeout<T> {
71    /// Create a new timeout wrapper.
72    ///
73    /// The deadline is calculated as `Instant::now() + duration`.
74    #[inline]
75    pub fn new(inner: T, duration: Duration) -> Self {
76        Self {
77            inner,
78            deadline: Instant::now() + duration,
79        }
80    }
81
82    /// Create a timeout wrapper with an absolute deadline.
83    #[inline]
84    pub fn with_deadline(inner: T, deadline: Instant) -> Self {
85        Self { inner, deadline }
86    }
87
88    /// Get the deadline.
89    #[inline]
90    pub fn deadline(&self) -> Instant {
91        self.deadline
92    }
93
94    /// Get the remaining time until deadline.
95    ///
96    /// Returns `Duration::ZERO` if the deadline has passed.
97    #[inline]
98    pub fn remaining(&self) -> Duration {
99        self.deadline.saturating_duration_since(Instant::now())
100    }
101
102    /// Get a reference to the inner stop.
103    #[inline]
104    pub fn inner(&self) -> &T {
105        &self.inner
106    }
107
108    /// Unwrap and return the inner stop.
109    #[inline]
110    pub fn into_inner(self) -> T {
111        self.inner
112    }
113}
114
115impl<T: Stop> Stop for WithTimeout<T> {
116    #[inline]
117    fn check(&self) -> Result<(), StopReason> {
118        // Check inner first (may be Cancelled)
119        self.inner.check()?;
120        // Then check timeout
121        if Instant::now() >= self.deadline {
122            Err(StopReason::TimedOut)
123        } else {
124            Ok(())
125        }
126    }
127
128    #[inline]
129    fn should_stop(&self) -> bool {
130        self.inner.should_stop() || Instant::now() >= self.deadline
131    }
132}
133
134/// Extension trait for adding timeouts to any [`Stop`] implementation.
135///
136/// This trait is automatically implemented for all `Stop` types.
137///
138/// # Example
139///
140/// ```rust
141/// use almost_enough::{StopSource, Stop, TimeoutExt};
142/// use std::time::Duration;
143///
144/// let source = StopSource::new();
145/// let stop = source.as_ref().with_timeout(Duration::from_secs(30));
146///
147/// assert!(!stop.should_stop());
148/// ```
149pub trait TimeoutExt: Stop + Sized {
150    /// Add a timeout to this stop.
151    ///
152    /// The resulting stop will return [`StopReason::TimedOut`] if the
153    /// duration elapses before the operation completes.
154    ///
155    /// # Timeout Tightening
156    ///
157    /// If called multiple times, the earliest deadline wins:
158    ///
159    /// ```rust
160    /// use almost_enough::{StopSource, TimeoutExt};
161    /// use std::time::Duration;
162    ///
163    /// let source = StopSource::new();
164    /// let stop = source.as_ref()
165    ///     .with_timeout(Duration::from_secs(60))
166    ///     .with_timeout(Duration::from_secs(10));
167    ///
168    /// // Effective timeout is ~10 seconds
169    /// assert!(stop.remaining() < Duration::from_secs(11));
170    /// ```
171    #[inline]
172    fn with_timeout(self, duration: Duration) -> WithTimeout<Self> {
173        WithTimeout::new(self, duration)
174    }
175
176    /// Add an absolute deadline to this stop.
177    ///
178    /// If called multiple times, the earliest deadline wins.
179    #[inline]
180    fn with_deadline(self, deadline: Instant) -> WithTimeout<Self> {
181        WithTimeout::with_deadline(self, deadline)
182    }
183}
184
185impl<T: Stop> TimeoutExt for T {}
186
187impl<T: Stop> WithTimeout<T> {
188    /// Add another timeout, taking the tighter of the two deadlines.
189    ///
190    /// This prevents timeout nesting by updating the deadline in place.
191    #[inline]
192    pub fn tighten(self, duration: Duration) -> Self {
193        let new_deadline = Instant::now() + duration;
194        Self {
195            inner: self.inner,
196            deadline: self.deadline.min(new_deadline),
197        }
198    }
199
200    /// Add another deadline, taking the earlier of the two.
201    ///
202    /// This prevents timeout nesting by updating the deadline in place.
203    #[inline]
204    pub fn tighten_deadline(self, deadline: Instant) -> Self {
205        Self {
206            inner: self.inner,
207            deadline: self.deadline.min(deadline),
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::StopSource;
216
217    #[test]
218    fn with_timeout_basic() {
219        let source = StopSource::new();
220        let stop = source.as_ref().with_timeout(Duration::from_millis(100));
221
222        assert!(!stop.should_stop());
223        assert!(stop.check().is_ok());
224
225        std::thread::sleep(Duration::from_millis(150));
226
227        assert!(stop.should_stop());
228        assert_eq!(stop.check(), Err(StopReason::TimedOut));
229    }
230
231    #[test]
232    fn cancel_before_timeout() {
233        let source = StopSource::new();
234        let stop = source.as_ref().with_timeout(Duration::from_secs(60));
235
236        source.cancel();
237
238        assert!(stop.should_stop());
239        assert_eq!(stop.check(), Err(StopReason::Cancelled));
240    }
241
242    #[test]
243    fn timeout_tightens() {
244        let source = StopSource::new();
245        let stop = source
246            .as_ref()
247            .with_timeout(Duration::from_secs(60))
248            .tighten(Duration::from_secs(1));
249
250        let remaining = stop.remaining();
251        assert!(remaining < Duration::from_secs(2));
252    }
253
254    #[test]
255    fn with_deadline_basic() {
256        let source = StopSource::new();
257        let deadline = Instant::now() + Duration::from_millis(100);
258        let stop = source.as_ref().with_deadline(deadline);
259
260        assert!(!stop.should_stop());
261
262        std::thread::sleep(Duration::from_millis(150));
263
264        assert!(stop.should_stop());
265    }
266
267    #[test]
268    fn remaining_accuracy() {
269        let source = StopSource::new();
270        let stop = source.as_ref().with_timeout(Duration::from_secs(10));
271
272        let remaining = stop.remaining();
273        assert!(remaining > Duration::from_secs(9));
274        assert!(remaining <= Duration::from_secs(10));
275    }
276
277    #[test]
278    fn remaining_after_expiry() {
279        let source = StopSource::new();
280        let stop = source.as_ref().with_timeout(Duration::from_millis(1));
281
282        std::thread::sleep(Duration::from_millis(10));
283
284        assert_eq!(stop.remaining(), Duration::ZERO);
285    }
286
287    #[test]
288    fn inner_access() {
289        let source = StopSource::new();
290        let stop = source.as_ref().with_timeout(Duration::from_secs(10));
291
292        assert!(!stop.inner().should_stop());
293
294        source.cancel();
295
296        assert!(stop.inner().should_stop());
297    }
298
299    #[test]
300    fn into_inner() {
301        let source = StopSource::new();
302        let stop = source.as_ref().with_timeout(Duration::from_secs(10));
303
304        let inner = stop.into_inner();
305        assert!(!inner.should_stop());
306    }
307
308    #[test]
309    fn with_timeout_is_send_sync() {
310        fn assert_send_sync<T: Send + Sync>() {}
311        assert_send_sync::<WithTimeout<crate::StopRef<'_>>>();
312    }
313}