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}