Skip to main content

codex_shell_escalation/unix/
stopwatch.rs

1use std::future::Future;
2use std::sync::Arc;
3use std::time::Duration;
4use std::time::Instant;
5
6use tokio::sync::Mutex;
7use tokio::sync::Notify;
8use tokio_util::sync::CancellationToken;
9
10#[derive(Clone, Debug)]
11pub struct Stopwatch {
12    limit: Option<Duration>,
13    inner: Arc<Mutex<StopwatchState>>,
14    notify: Arc<Notify>,
15}
16
17#[derive(Debug)]
18struct StopwatchState {
19    elapsed: Duration,
20    running_since: Option<Instant>,
21    active_pauses: u32,
22}
23
24impl Stopwatch {
25    pub fn new(limit: Duration) -> Self {
26        Self {
27            inner: Arc::new(Mutex::new(StopwatchState {
28                elapsed: Duration::ZERO,
29                running_since: Some(Instant::now()),
30                active_pauses: 0,
31            })),
32            notify: Arc::new(Notify::new()),
33            limit: Some(limit),
34        }
35    }
36
37    pub fn unlimited() -> Self {
38        Self {
39            inner: Arc::new(Mutex::new(StopwatchState {
40                elapsed: Duration::ZERO,
41                running_since: Some(Instant::now()),
42                active_pauses: 0,
43            })),
44            notify: Arc::new(Notify::new()),
45            limit: None,
46        }
47    }
48
49    pub fn cancellation_token(&self) -> CancellationToken {
50        let token = CancellationToken::new();
51        let Some(limit) = self.limit else {
52            return token;
53        };
54        let cancel = token.clone();
55        let inner = Arc::clone(&self.inner);
56        let notify = Arc::clone(&self.notify);
57        tokio::spawn(async move {
58            loop {
59                let (remaining, running) = {
60                    let guard = inner.lock().await;
61                    let elapsed = guard.elapsed
62                        + guard
63                            .running_since
64                            .map(|since| since.elapsed())
65                            .unwrap_or_default();
66                    if elapsed >= limit {
67                        break;
68                    }
69                    (limit - elapsed, guard.running_since.is_some())
70                };
71
72                if !running {
73                    notify.notified().await;
74                    continue;
75                }
76
77                let sleep = tokio::time::sleep(remaining);
78                tokio::pin!(sleep);
79                tokio::select! {
80                    _ = &mut sleep => {
81                        break;
82                    }
83                    _ = notify.notified() => {
84                        continue;
85                    }
86                }
87            }
88            cancel.cancel();
89        });
90        token
91    }
92
93    /// Runs `fut`, pausing the stopwatch while the future is pending. The clock
94    /// resumes automatically when the future completes. Nested/overlapping
95    /// calls are reference-counted so the stopwatch only resumes when every
96    /// pause is lifted.
97    pub async fn pause_for<F, T>(&self, fut: F) -> T
98    where
99        F: Future<Output = T>,
100    {
101        self.pause().await;
102        let result = fut.await;
103        self.resume().await;
104        result
105    }
106
107    async fn pause(&self) {
108        let mut guard = self.inner.lock().await;
109        guard.active_pauses += 1;
110        if guard.active_pauses == 1
111            && let Some(since) = guard.running_since.take()
112        {
113            guard.elapsed += since.elapsed();
114            self.notify.notify_waiters();
115        }
116    }
117
118    async fn resume(&self) {
119        let mut guard = self.inner.lock().await;
120        if guard.active_pauses == 0 {
121            return;
122        }
123        guard.active_pauses -= 1;
124        if guard.active_pauses == 0 && guard.running_since.is_none() {
125            guard.running_since = Some(Instant::now());
126            self.notify.notify_waiters();
127        }
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::Stopwatch;
134    use tokio::time::Duration;
135    use tokio::time::Instant;
136    use tokio::time::sleep;
137    use tokio::time::timeout;
138
139    #[tokio::test]
140    async fn cancellation_receiver_fires_after_limit() {
141        let stopwatch = Stopwatch::new(Duration::from_millis(50));
142        let token = stopwatch.cancellation_token();
143        let start = Instant::now();
144        token.cancelled().await;
145        assert!(start.elapsed() >= Duration::from_millis(50));
146    }
147
148    #[tokio::test]
149    async fn pause_prevents_timeout_until_resumed() {
150        let stopwatch = Stopwatch::new(Duration::from_millis(50));
151        let token = stopwatch.cancellation_token();
152
153        let pause_handle = tokio::spawn({
154            let stopwatch = stopwatch.clone();
155            async move {
156                stopwatch
157                    .pause_for(async {
158                        sleep(Duration::from_millis(100)).await;
159                    })
160                    .await;
161            }
162        });
163
164        assert!(
165            timeout(Duration::from_millis(30), token.cancelled())
166                .await
167                .is_err()
168        );
169
170        pause_handle.await.expect("pause task should finish");
171
172        token.cancelled().await;
173    }
174
175    #[tokio::test]
176    async fn overlapping_pauses_only_resume_once() {
177        let stopwatch = Stopwatch::new(Duration::from_millis(50));
178        let token = stopwatch.cancellation_token();
179
180        // First pause.
181        let pause1 = {
182            let stopwatch = stopwatch.clone();
183            tokio::spawn(async move {
184                stopwatch
185                    .pause_for(async {
186                        sleep(Duration::from_millis(80)).await;
187                    })
188                    .await;
189            })
190        };
191
192        // Overlapping pause that ends sooner.
193        let pause2 = {
194            let stopwatch = stopwatch.clone();
195            tokio::spawn(async move {
196                stopwatch
197                    .pause_for(async {
198                        sleep(Duration::from_millis(30)).await;
199                    })
200                    .await;
201            })
202        };
203
204        // While both pauses are active, the cancellation should not fire.
205        assert!(
206            timeout(Duration::from_millis(40), token.cancelled())
207                .await
208                .is_err()
209        );
210
211        pause2.await.expect("short pause should complete");
212
213        // Still paused because the long pause is active.
214        assert!(
215            timeout(Duration::from_millis(30), token.cancelled())
216                .await
217                .is_err()
218        );
219
220        pause1.await.expect("long pause should complete");
221
222        // Now the stopwatch should resume and hit the limit shortly after.
223        token.cancelled().await;
224    }
225
226    #[tokio::test]
227    async fn unlimited_stopwatch_never_cancels() {
228        let stopwatch = Stopwatch::unlimited();
229        let token = stopwatch.cancellation_token();
230
231        assert!(
232            timeout(Duration::from_millis(30), token.cancelled())
233                .await
234                .is_err()
235        );
236    }
237}