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}