qubit_clock/test_util/fault_injecting_timer.rs
1// =============================================================================
2// Copyright (c) 2025 - 2026 Haixing Hu.
3//
4// SPDX-License-Identifier: Apache-2.0
5//
6// Licensed under the Apache License, Version 2.0.
7// =============================================================================
8//! Defines a Timer that deterministically injects configured failures.
9
10use std::io;
11use std::sync::atomic::AtomicUsize;
12use std::sync::atomic::Ordering;
13
14use super::TimerFailurePoint;
15use crate::ManualMonotonicClock;
16use crate::MonotonicClock;
17use crate::MonotonicInstant;
18use crate::TimeError;
19use crate::Timer;
20use crate::TimerFuture;
21use crate::TimerUnavailableError;
22
23/// A deterministic Timer fixture that fails registration or completion.
24///
25/// The fixture owns a private manual clock domain. Foreign deadlines and
26/// already reached deadlines retain the normal [`Timer`] contract; only valid
27/// future deadlines reach the configured failure point.
28///
29/// # Examples
30///
31/// ```
32/// use qubit_clock::{
33/// Timer,
34/// test_util::{FaultInjectingTimer, TimerFailurePoint},
35/// };
36/// use std::time::Duration;
37///
38/// let timer = FaultInjectingTimer::backend_unavailable(
39/// TimerFailurePoint::Registration,
40/// "example",
41/// "backend offline",
42/// );
43/// assert!(timer.after(Duration::from_secs(1)).is_err());
44/// assert_eq!(1, timer.registration_count());
45/// ```
46pub struct FaultInjectingTimer {
47 /// Manual clock defining the fixture's private monotonic domain.
48 clock: ManualMonotonicClock,
49 /// Timer lifecycle point where the configured error is returned.
50 failure_point: TimerFailurePoint,
51 /// Thread-safe factory producing one fresh error per failed registration.
52 error_factory: Box<dyn Fn() -> TimeError + Send + Sync + 'static>,
53 /// Number of valid future-deadline registrations attempted by the fixture.
54 registration_count: AtomicUsize,
55}
56
57impl FaultInjectingTimer {
58 /// Creates a Timer that invokes `error_factory` at `failure_point`.
59 ///
60 /// # Parameters
61 ///
62 /// * `failure_point` - Registration or completion stage to fail.
63 /// * `error_factory` - Thread-safe factory returning one fresh error for
64 /// every failed future-deadline registration.
65 ///
66 /// # Returns
67 ///
68 /// A fault-injecting Timer with a new private monotonic clock domain.
69 ///
70 /// # Panics
71 ///
72 /// Panics if process-wide clock-domain identifiers are exhausted.
73 #[must_use]
74 pub fn new<F>(failure_point: TimerFailurePoint, error_factory: F) -> Self
75 where
76 F: Fn() -> TimeError + Send + Sync + 'static,
77 {
78 Self {
79 clock: ManualMonotonicClock::new(),
80 failure_point,
81 error_factory: Box::new(error_factory),
82 registration_count: AtomicUsize::new(0),
83 }
84 }
85
86 /// Creates a Timer reporting a custom backend-unavailable error.
87 ///
88 /// # Parameters
89 ///
90 /// * `failure_point` - Registration or completion stage to fail.
91 /// * `backend` - Stable static name identifying the unavailable backend.
92 /// * `message` - Error message copied into each fresh source error.
93 ///
94 /// # Returns
95 ///
96 /// A fault-injecting Timer producing
97 /// [`TimerUnavailableError::BackendUnavailable`].
98 ///
99 /// # Panics
100 ///
101 /// Panics if process-wide clock-domain identifiers are exhausted.
102 #[must_use]
103 pub fn backend_unavailable(failure_point: TimerFailurePoint, backend: &'static str, message: &str) -> Self {
104 let message = message.to_owned();
105 Self::new(failure_point, move || TimeError::TimerUnavailable {
106 source: TimerUnavailableError::BackendUnavailable {
107 backend,
108 source: Box::new(io::Error::other(message.clone())),
109 },
110 })
111 }
112
113 /// Returns the configured Timer lifecycle failure point.
114 ///
115 /// # Returns
116 ///
117 /// Registration or completion according to fixture construction.
118 #[must_use]
119 pub fn failure_point(&self) -> TimerFailurePoint {
120 self.failure_point
121 }
122
123 /// Returns the number of valid future-deadline registrations attempted.
124 ///
125 /// Foreign and already reached deadlines do not increment this count.
126 ///
127 /// # Returns
128 ///
129 /// The current registration count. Concurrent observations are intended
130 /// for test diagnostics and use relaxed ordering.
131 #[must_use]
132 #[inline(always)]
133 pub fn registration_count(&self) -> usize {
134 self.registration_count.load(Ordering::Relaxed)
135 }
136}
137
138impl Timer for FaultInjectingTimer {
139 /// Returns the fixture's private manual monotonic clock.
140 ///
141 /// # Returns
142 ///
143 /// The clock defining valid deadlines for this Timer.
144 #[inline(always)]
145 fn clock(&self) -> &dyn MonotonicClock {
146 &self.clock
147 }
148
149 /// Registers a future deadline and injects the configured failure.
150 ///
151 /// # Parameters
152 ///
153 /// * `deadline` - Absolute deadline expected in this Timer's clock domain.
154 ///
155 /// # Returns
156 ///
157 /// An immediately ready successful future for a reached deadline, or a
158 /// failing future when completion failure is configured.
159 ///
160 /// # Errors
161 ///
162 /// Returns [`TimeError::ClockDomainMismatch`] for a foreign deadline.
163 /// Returns the error factory's value directly when registration failure is
164 /// configured.
165 ///
166 /// # Panics
167 ///
168 /// Propagates a panic raised by the configured error factory.
169 fn at(&self, deadline: MonotonicInstant) -> Result<TimerFuture, TimeError> {
170 let now = self.clock.now();
171 deadline.validate_domain(now.domain())?;
172 if deadline <= now {
173 return Ok(Box::pin(std::future::ready(Ok(()))));
174 }
175 self.registration_count.fetch_add(1, Ordering::Relaxed);
176 let error = (self.error_factory)();
177 match self.failure_point {
178 TimerFailurePoint::Registration => Err(error),
179 TimerFailurePoint::Completion => Ok(Box::pin(std::future::ready(Err(error)))),
180 }
181 }
182}