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