nextest_runner/time/
stopwatch.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Stopwatch for tracking how long it takes to run tests.
5//!
6//! Tests need to track a start time and a duration. For that we use a combination of a `SystemTime`
7//! (realtime clock) and an `Instant` (monotonic clock). Once the stopwatch transitions to the "end"
8//! state, we can report the elapsed time using the monotonic clock.
9
10use chrono::{DateTime, Local};
11use std::time::{Duration, Instant};
12
13pub(crate) fn stopwatch() -> StopwatchStart {
14    StopwatchStart::new()
15}
16
17/// The start state of a stopwatch.
18#[derive(Clone, Debug)]
19pub(crate) struct StopwatchStart {
20    start_time: DateTime<Local>,
21    instant: Instant,
22    paused_time: Duration,
23    pause_state: StopwatchPauseState,
24}
25
26impl StopwatchStart {
27    fn new() -> Self {
28        Self {
29            // These two syscalls will happen imperceptibly close to each other, which is good
30            // enough for our purposes.
31            start_time: Local::now(),
32            instant: Instant::now(),
33            paused_time: Duration::ZERO,
34            pause_state: StopwatchPauseState::Running,
35        }
36    }
37
38    pub(crate) fn is_paused(&self) -> bool {
39        matches!(self.pause_state, StopwatchPauseState::Paused { .. })
40    }
41
42    pub(crate) fn pause(&mut self) {
43        match &self.pause_state {
44            StopwatchPauseState::Running => {
45                self.pause_state = StopwatchPauseState::Paused {
46                    paused_at: Instant::now(),
47                };
48            }
49            StopwatchPauseState::Paused { .. } => {
50                panic!("illegal state transition: pause() called while stopwatch was paused")
51            }
52        }
53    }
54
55    pub(crate) fn resume(&mut self) {
56        match &self.pause_state {
57            StopwatchPauseState::Paused { paused_at } => {
58                self.paused_time += paused_at.elapsed();
59                self.pause_state = StopwatchPauseState::Running;
60            }
61            StopwatchPauseState::Running => {
62                panic!("illegal state transition: resume() called while stopwatch was running")
63            }
64        }
65    }
66
67    pub(crate) fn snapshot(&self) -> StopwatchSnapshot {
68        StopwatchSnapshot {
69            start_time: self.start_time,
70            // self.instant is supposed to be monotonic but might not be so on
71            // some weird systems. If the duration underflows, just return 0.
72            active: self.instant.elapsed().saturating_sub(self.paused_time),
73            paused: self.paused_time,
74        }
75    }
76}
77
78/// A snapshot of the state of the stopwatch.
79#[derive(Clone, Copy, Debug)]
80pub(crate) struct StopwatchSnapshot {
81    /// The time at which the stopwatch was started.
82    pub(crate) start_time: DateTime<Local>,
83
84    /// The amount of time spent while the stopwatch was active.
85    pub(crate) active: Duration,
86
87    /// The amount of time spent while the stopwatch was paused.
88    #[expect(dead_code)]
89    pub(crate) paused: Duration,
90}
91
92#[derive(Clone, Debug)]
93enum StopwatchPauseState {
94    Running,
95    Paused { paused_at: Instant },
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn stopwatch_pause() {
104        let mut start = stopwatch();
105        let unpaused_start = start.clone();
106
107        start.pause();
108        std::thread::sleep(Duration::from_millis(250));
109        start.resume();
110
111        start.pause();
112        std::thread::sleep(Duration::from_millis(300));
113        start.resume();
114
115        let end = start.snapshot();
116        let unpaused_end = unpaused_start.snapshot();
117
118        // The total time we've paused is 550ms. We can assume that unpaused_end is at least 550ms
119        // greater than end. Add a a fudge factor of 100ms.
120        //
121        // (Previously, this used to cap the difference at 650ms, but empirically, the test would
122        // sometimes fail on GitHub CI. Just setting a minimum bound is enough.)
123        let difference = unpaused_end.active - end.active;
124        assert!(
125            difference > Duration::from_millis(450),
126            "difference between unpaused_end and end ({difference:?}) is at least 450ms"
127        );
128    }
129}