1use serde_json::{json, Value};
10use std::fmt;
11use std::time::{Duration, Instant};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum ReadinessStatus {
16 #[default]
18 Ready,
19 TimedOut,
21 Failed,
23}
24
25impl fmt::Display for ReadinessStatus {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 match self {
28 ReadinessStatus::Ready => write!(f, "ready"),
29 ReadinessStatus::TimedOut => write!(f, "timed_out"),
30 ReadinessStatus::Failed => write!(f, "failed"),
31 }
32 }
33}
34
35#[derive(Debug, Clone)]
42pub struct Deadline {
43 started_at: Instant,
44 timeout: Duration,
45}
46
47impl Deadline {
48 #[must_use]
50 pub fn new(timeout: Duration) -> Self {
51 Self {
52 started_at: Instant::now(),
53 timeout,
54 }
55 }
56
57 #[must_use]
59 pub fn timeout(&self) -> Duration {
60 self.timeout
61 }
62
63 #[must_use]
65 pub fn elapsed(&self) -> Duration {
66 self.started_at.elapsed()
67 }
68
69 #[must_use]
71 pub fn elapsed_ms(&self) -> u128 {
72 self.elapsed().as_millis()
73 }
74
75 #[must_use]
77 pub fn remaining(&self) -> Duration {
78 self.timeout.saturating_sub(self.elapsed())
79 }
80
81 #[must_use]
83 pub fn expired(&self) -> bool {
84 self.remaining().is_zero()
85 }
86
87 pub async fn sleep_at_most(&self, requested: Duration) {
89 let capped = requested.min(self.remaining());
90 if !capped.is_zero() {
91 tokio::time::sleep(capped).await;
92 }
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum DeadlineOutcome<T> {
103 Completed(T),
105 TimedOut,
107}
108
109impl<T> DeadlineOutcome<T> {
110 #[must_use]
112 pub fn timed_out(&self) -> bool {
113 matches!(self, Self::TimedOut)
114 }
115
116 #[must_use]
118 pub fn value(self) -> Option<T> {
119 match self {
120 Self::Completed(value) => Some(value),
121 Self::TimedOut => None,
122 }
123 }
124}
125
126pub async fn run_within_deadline<F, T>(deadline: &Deadline, operation: F) -> DeadlineOutcome<T>
133where
134 F: std::future::Future<Output = T>,
135{
136 let remaining = deadline.remaining();
137 if remaining.is_zero() {
138 return DeadlineOutcome::TimedOut;
141 }
142
143 match tokio::time::timeout(remaining, operation).await {
144 Ok(value) => DeadlineOutcome::Completed(value),
145 Err(_) => DeadlineOutcome::TimedOut,
146 }
147}
148
149#[derive(Debug, Clone, PartialEq)]
151pub struct CheckRecord {
152 pub name: String,
154 pub satisfied: bool,
156 pub skipped: bool,
158 pub started_at_ms: u128,
160 pub elapsed_ms: u128,
162 pub detail: Value,
164}
165
166impl CheckRecord {
167 #[must_use]
169 pub fn satisfied(name: impl Into<String>, detail: Value) -> Self {
170 Self {
171 name: name.into(),
172 satisfied: true,
173 skipped: false,
174 started_at_ms: 0,
175 elapsed_ms: 0,
176 detail,
177 }
178 }
179
180 #[must_use]
182 pub fn unsatisfied(name: impl Into<String>, detail: Value) -> Self {
183 Self {
184 name: name.into(),
185 satisfied: false,
186 skipped: false,
187 started_at_ms: 0,
188 elapsed_ms: 0,
189 detail,
190 }
191 }
192
193 #[must_use]
195 pub fn skipped(name: impl Into<String>, reason: impl Into<String>) -> Self {
196 Self {
197 name: name.into(),
198 satisfied: false,
199 skipped: true,
200 started_at_ms: 0,
201 elapsed_ms: 0,
202 detail: json!({ "reason": reason.into() }),
203 }
204 }
205
206 #[must_use]
208 pub fn with_timing(mut self, started_at_ms: u128, elapsed_ms: u128) -> Self {
209 self.started_at_ms = started_at_ms;
210 self.elapsed_ms = elapsed_ms;
211 self
212 }
213}
214
215#[derive(Debug, Clone, Default)]
217pub struct ReadinessOutcome {
218 pub status: ReadinessStatus,
220 pub ready: bool,
222 pub satisfied: Vec<String>,
224 pub failed: Vec<String>,
226 pub skipped: Vec<String>,
228 pub pending: Vec<String>,
230 pub evidence: Vec<CheckRecord>,
232 pub elapsed_ms: u128,
234 pub timeout_ms: u128,
236}
237
238impl ReadinessOutcome {
239 #[must_use]
241 pub fn new(deadline: &Deadline) -> Self {
242 Self {
243 status: ReadinessStatus::Ready,
244 ready: true,
245 timeout_ms: deadline.timeout().as_millis(),
246 ..Self::default()
247 }
248 }
249
250 pub fn record(&mut self, record: CheckRecord) {
252 if record.skipped {
253 self.skipped.push(record.name.clone());
254 } else if record.satisfied {
255 self.satisfied.push(record.name.clone());
256 } else {
257 self.failed.push(record.name.clone());
258 }
259 self.evidence.push(record);
260 }
261
262 pub fn defer(&mut self, name: impl Into<String>) {
264 self.pending.push(name.into());
265 }
266
267 #[must_use]
269 pub fn finish(mut self, deadline: &Deadline) -> Self {
270 self.ready = self.failed.is_empty() && self.pending.is_empty();
271 self.elapsed_ms = deadline.elapsed_ms();
272 self.status = if self.ready {
273 ReadinessStatus::Ready
274 } else if deadline.expired() {
275 ReadinessStatus::TimedOut
276 } else {
277 ReadinessStatus::Failed
278 };
279 self
280 }
281
282 #[must_use]
284 pub fn summary(&self) -> String {
285 if self.ready {
286 return format!("page ready after {}ms", self.elapsed_ms);
287 }
288 format!(
289 "page not ready ({}) after {}ms; failed=[{}] pending=[{}]",
290 self.status,
291 self.elapsed_ms,
292 self.failed.join(", "),
293 self.pending.join(", ")
294 )
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[tokio::test]
303 async fn run_within_deadline_returns_a_value_that_arrives_in_time() {
304 let deadline = Deadline::new(Duration::from_secs(1));
305
306 let outcome = run_within_deadline(&deadline, async { "done" }).await;
307
308 assert!(!outcome.timed_out());
309 assert_eq!(outcome.value(), Some("done"));
310 }
311
312 #[tokio::test]
313 async fn run_within_deadline_reports_expiry_instead_of_waiting_the_operation_out() {
314 let deadline = Deadline::new(Duration::from_millis(30));
315 let started = Instant::now();
316
317 let outcome = run_within_deadline(&deadline, async {
318 tokio::time::sleep(Duration::from_secs(5)).await;
319 "too late"
320 })
321 .await;
322
323 assert!(outcome.timed_out());
324 assert!(
325 started.elapsed() < Duration::from_secs(2),
326 "the budget, not the operation, has to end the wait"
327 );
328 }
329
330 #[tokio::test]
331 async fn run_within_deadline_does_not_start_an_operation_with_no_budget_left() {
332 let deadline = Deadline::new(Duration::ZERO);
333 let started = std::sync::atomic::AtomicBool::new(false);
334
335 let outcome = run_within_deadline(&deadline, async {
336 started.store(true, std::sync::atomic::Ordering::SeqCst);
337 })
338 .await;
339
340 assert!(outcome.timed_out());
341 assert!(
342 !started.load(std::sync::atomic::Ordering::SeqCst),
343 "an operation with no budget left only delays the answer we have"
344 );
345 }
346
347 #[test]
348 fn a_deadline_never_hands_out_more_budget_than_it_was_given() {
349 let deadline = Deadline::new(Duration::from_millis(10));
352
353 assert!(deadline.remaining() <= Duration::from_millis(10));
354 assert_eq!(deadline.timeout(), Duration::from_millis(10));
355 }
356
357 #[test]
358 fn remaining_saturates_at_zero() {
359 let deadline = Deadline::new(Duration::ZERO);
360
361 assert!(deadline.expired());
362 assert_eq!(deadline.remaining(), Duration::ZERO);
363 }
364
365 #[tokio::test]
366 async fn sleeping_is_capped_by_the_remaining_budget() {
367 let deadline = Deadline::new(Duration::from_millis(20));
368
369 deadline.sleep_at_most(Duration::from_secs(30)).await;
370
371 assert!(deadline.elapsed() < Duration::from_secs(1));
372 }
373
374 #[test]
375 fn an_unsatisfied_check_never_reports_ready() {
376 let deadline = Deadline::new(Duration::from_secs(5));
379 let mut outcome = ReadinessOutcome::new(&deadline);
380
381 outcome.record(CheckRecord::unsatisfied(
382 "url_stable_for",
383 json!({ "url": "https://example.com/" }),
384 ));
385 let outcome = outcome.finish(&deadline);
386
387 assert!(!outcome.ready);
388 assert_eq!(outcome.status, ReadinessStatus::Failed);
389 assert_eq!(outcome.failed, vec!["url_stable_for".to_string()]);
390 assert!(outcome.summary().contains("url_stable_for"));
391 }
392
393 #[test]
394 fn an_expired_budget_reports_timed_out() {
395 let deadline = Deadline::new(Duration::ZERO);
396 let mut outcome = ReadinessOutcome::new(&deadline);
397
398 outcome.record(CheckRecord::unsatisfied("url_stable_for", json!({})));
399 let outcome = outcome.finish(&deadline);
400
401 assert_eq!(outcome.status, ReadinessStatus::TimedOut);
402 }
403
404 #[test]
405 fn a_skipped_check_does_not_block_readiness() {
406 let deadline = Deadline::new(Duration::from_secs(5));
407 let mut outcome = ReadinessOutcome::new(&deadline);
408
409 outcome.record(CheckRecord::skipped("network_idle_for", "no tracker"));
410 let outcome = outcome.finish(&deadline);
411
412 assert!(outcome.ready);
413 assert_eq!(outcome.skipped, vec!["network_idle_for".to_string()]);
414 }
415
416 #[test]
417 fn checks_that_never_ran_are_pending_not_satisfied() {
418 let deadline = Deadline::new(Duration::from_secs(5));
419 let mut outcome = ReadinessOutcome::new(&deadline);
420
421 outcome.record(CheckRecord::satisfied("url_stable_for", json!({})));
422 outcome.defer("verify_navigation");
423 let outcome = outcome.finish(&deadline);
424
425 assert!(!outcome.ready);
426 assert_eq!(outcome.pending, vec!["verify_navigation".to_string()]);
427 }
428
429 #[test]
430 fn evidence_carries_the_timing_window_of_each_check() {
431 let record = CheckRecord::satisfied("url_stable_for", json!({})).with_timing(5, 12);
432
433 assert_eq!(record.started_at_ms, 5);
434 assert_eq!(record.elapsed_ms, 12);
435 }
436
437 #[test]
438 fn statuses_render_the_documented_names() {
439 assert_eq!(ReadinessStatus::Ready.to_string(), "ready");
440 assert_eq!(ReadinessStatus::TimedOut.to_string(), "timed_out");
441 assert_eq!(ReadinessStatus::Failed.to_string(), "failed");
442 }
443}