Virtual Clock for Testing Blocking Code
This crate provides a clock that tests can stop and advance, with blocking sleeps, condition variables and crossbeam timers that follow it. Code reads monotonic time, wall time and elapsed durations through a Clock handle instead of std's free functions. Production passes Clock::real(), and tests pass a clock from a TestClock, which moves only when the test advances it.
Sleeps, condvar waits and timers work on ordinary threads, with no async runtime involved. Advancing a test clock wakes and fires them, so a test runs a 5 s timeout without waiting 5 s.
Clocks are explicit handles, never globals or thread-locals. Parallel tests each own their clock, and worker threads follow the one they were handed.
Quick start
Reading a clock, sleeping on it and waiting on its condvars work in every build. Timers need the crossbeam feature, and TestClock needs test-clock, without which Clock is zero-sized and always real. Enable test-clock only on a dev-dependency, since Cargo unifies features across a build. Pick the same version as the regular dependency, so the feature reaches the copy the code uses.
[]
= "0.2"
[]
= { = "0.2", = ["test-clock"] }
#
The sleeper parks on the test clock, wait_blocked returns once it has, and the advance releases it without any real waiting.
Replacing std and crossbeam calls
Each call mirrors the std or crossbeam call it replaces. The clock is the receiver of the time calls and an argument when creating a condvar, and a timed condvar wait takes a deadline in place of a timeout.
| std or crossbeam | darkbio-clock |
|---|---|
Instant::now() |
clock.now() |
start.elapsed() |
clock.elapsed(start) |
SystemTime::now() |
clock.system_time() |
thread::sleep(duration) |
clock.sleep(duration) |
thread::sleep_until(deadline) |
clock.sleep_until(deadline) |
Condvar::new() |
Condvar::new(&clock) |
condvar.wait_timeout(guard, deadline - now) |
condvar.wait_deadline(guard, deadline) |
crossbeam_channel::after(d) |
clock.after(d) |
crossbeam_channel::at(t) |
clock.at(t) |
receiver.recv_timeout(d) |
clock.recv_timeout(&receiver, d) |
receiver.recv_deadline(t) |
clock.recv_deadline(&receiver, t) |
Mutex and Condvar come from this crate's sync module in place of std::sync. Everything else they offer keeps std's names, arguments, results and poisoning.
Locks
A condvar waits on this crate's Mutex, because relocking after a wait needs the mutex, and a std guard does not give it back. The mutex wraps std's and keeps its behavior. Only a mutex that a clock's condvar waits on needs to change, and every other lock can stay std's.
wait_deadline returns once notified or once the condvar's clock reaches the deadline. Its timed_out() reports whether the clock had reached the deadline once the mutex was relocked. Like std's waits, it may also return spuriously, so callers recheck their condition, or recompute their deadline, after every return.
There is no wait_timeout. A relative timeout computed as deadline - now overshoots when an advance lands between the subtraction and the wait, and on a test clock that overshoot is a hang. A condvar has no Default either, since its clock is always explicit.
Timers
The crossbeam feature adds timers that crossbeam-channel's select! can wait on, and receives with a timeout. It re-exports crossbeam_channel, so callers can name its types at the version the clock uses. Without the feature, the crate has no dependencies. On the real clock, after, at, recv_timeout and recv_deadline are crossbeam's own.
On a test clock, at(deadline) returns a capacity-1 channel. The clock sends the deadline into it once an advance reaches it, or at once when it is already due. The message is the deadline itself, even after an advance that overshoots it, as crossbeam's is. after(duration) takes its deadline from the clock when called, and a duration past the end of Instant's range returns a receiver that never fires.
crossbeam's timers never disconnect, but a crossbeam sender cannot tell that its receiver dropped. So a test clock keeps the sender of every delivered timer while the clock lives, and a consumed timer stays connected and empty, as crossbeam's does. The cost is test-only. Memory grows with the timers a test clock delivers, and a timer whose receiver was dropped still counts in wait_timers and next_deadline until it fires.
recv_timeout and recv_deadline keep crossbeam's precedence, where a buffered message or a disconnection wins over the timeout. They are clock methods, because a receiver's own methods of the same names would shadow an extension trait's. On a test clock, a waiting receive arms a timer that wait_timers counts until it fires or the receive returns.
Testing on a test clock
Moving time
A test clock starts at the real monotonic and wall times. advance and advance_to move both together, and set_system_time jumps wall time alone, forwards or backwards, the way an NTP correction does. Only the TestClock moves time, and its controls take &mut self, so each clock has one driver. A Clock handle reads, sleeps and creates condvars and timers, but cannot advance.
An advance jumps straight to its target. Where every period matters, advance one period and wait for its effect before the next. A test clock never moves by itself, so a deadline nobody advances to never passes, and such tests belong under a runner that stops hung tests.
Dropping the TestClock stops all advances. Its parked sleeps then never end, its condvar waits end only when notified, and its unfired timers never fire, so advance past their deadlines before dropping it.
Waiting for threads
An advance returns once the sleeps and deadline waits it reaches are notified and its due timers hold their messages, not once any thread has acted. Wait for the effect itself, such as a result or a message, before checking it.
wait_blocked(n) returns once at least n threads are parked in the clock's sleeps and condvar waits, timed or not. Threads blocked in a crossbeam receive or select! are invisible to it, so with crossbeam, wait_timers(n) waits for at least n armed timers instead, counting those of waiting receives. An earlier park or timer counts too, so neither count proves progress on its own.
next_deadline() returns the earliest deadline among the clock's parked sleeps, deadline waits and unfired timers, and advancing to it runs a test to its next timeout. A woken wait stays listed until its thread runs, so await an advance's effect before reading the next deadline.
Deadlines
A sleep takes its deadline when it is called, so a sleep that starts after an advance waits for a later time. Wait with wait_blocked before advancing, or take the deadline first and use sleep_until.
Take deadlines from the handle's own clock. An Instant does not record its clock, so a deadline from Instant::now() is silently read as test time. Only calls through the clock follow it, and std's and crossbeam's own time reads, sleeps, timeouts and timers stay on real time, which the lint below flags.
Linting real-time calls
clippy's disallowed-methods can flag every real-time call that has a clock replacement. Put this list in a crate's clippy.toml and run clippy with warnings denied, and each stray call fails the build with a pointer to its replacement. This repository's make lint checks every entry against a real call.
= [
{ = "std::time::Instant::now", = "use Clock::now" },
{ = "std::time::Instant::elapsed", = "use Clock::elapsed" },
{ = "std::time::SystemTime::now", = "use Clock::system_time" },
{ = "std::time::SystemTime::elapsed", = "use Clock::system_time" },
{ = "std::thread::sleep", = "use Clock::sleep" },
{ = "std::thread::park_timeout", = "use a clock condvar" },
{ = "std::sync::Condvar::wait_timeout", = "use darkbio_clock::sync::Condvar" },
{ = "std::sync::Condvar::wait_timeout_while", = "use darkbio_clock::sync::Condvar" },
{ = "std::sync::mpsc::Receiver::recv_timeout", = "use a crossbeam receiver with Clock::recv_timeout" },
{ = "crossbeam_channel::after", = "use Clock::after" },
{ = "crossbeam_channel::at", = "use Clock::at" },
{ = "crossbeam_channel::tick", = "arm Clock::at per period" },
{ = "crossbeam_channel::Receiver::recv_timeout", = "use Clock::recv_timeout" },
{ = "crossbeam_channel::Receiver::recv_deadline", = "use Clock::recv_deadline" },
{ = "crossbeam_channel::Sender::send_timeout", = "select against a clock timer" },
{ = "crossbeam_channel::Sender::send_deadline", = "select against a clock timer" },
{ = "crossbeam_channel::Select::select_timeout", = "select against a clock timer" },
{ = "crossbeam_channel::Select::select_deadline", = "select against a clock timer" },
{ = "crossbeam_channel::Select::ready_timeout", = "select against a clock timer" },
{ = "crossbeam_channel::Select::ready_deadline", = "select against a clock timer" },
]
A site that needs real time opts out with #[expect(clippy::disallowed_methods, reason = "...")] on its function. Untimed std condvar waits and receives, crossbeam_channel::never, Instant arithmetic and untimed select! stay legal. A crate that does not depend on crossbeam ignores its entries.
clippy does not lint inside another crate's macros, so a timed arm, default(timeout), in a select! or select_biased! can escape the lint. Write the timeout as a recv(clock.after(timeout)) arm instead, which clippy checks as the caller's own code. Nor can clippy see real-time calls inside dependencies.
License
This library is licensed under the BSD 3-Clause License.