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}