diff --git a/src/exec/budget.rs b/src/exec/budget.rs index 8f5a4eea..64e4de8f 100644 --- a/src/exec/budget.rs +++ b/src/exec/budget.rs @@ -1107,6 +1107,36 @@ mod tests { } } + /// Spin until the budget's monotonic clock has advanced past creation, so a + /// zero-second deadline is genuinely elapsed (`elapsed() > 0`). `Instant` + /// resolution is coarse on some platforms (notably Windows), where the first + /// charge can otherwise land within the same tick and read `elapsed() == 0`. + /// The wait is bounded by an iteration cap — not a wall-clock timeout, which + /// would depend on the very clock we suspect — so a pathologically frozen + /// clock fails loudly instead of hanging the suite. + fn wait_for_clock_to_advance(budget: &ExecutionBudget) { + const MAX_SPINS: u64 = 100_000_000; + for i in 0..MAX_SPINS { + if budget.elapsed() != Duration::ZERO { + return; + } + // Cheap CPU hint most iterations, but yield to the scheduler + // periodically so we don't monopolise a core while waiting for the + // next timer tick (which can be ~15ms on Windows) on a loaded CI + // runner. Yielding also lets the wall clock advance sooner under + // contention, so the wait usually exits earlier. + if i % 1024 == 0 { + std::thread::yield_now(); + } else { + std::hint::spin_loop(); + } + } + panic!( + "monotonic clock did not advance past budget creation within {MAX_SPINS} spins; \ + cannot exercise the zero-second deadline" + ); + } + #[test] fn operation_ceiling_trips_after_limit() { let budget = ExecutionBudget::new(tiny_limits()); @@ -1148,7 +1178,11 @@ mod tests { let mut limits = tiny_limits(); limits.max_duration = Some(Duration::from_secs(0)); let budget = ExecutionBudget::new(limits); - // A zero-second deadline is already elapsed at the first sample point. + // The deadline trips when `elapsed() > limit`, so a zero-second deadline + // is only "elapsed" once the monotonic clock has advanced past creation. + // Wait for that (bounded) so the assertion is deterministic rather than + // racing the timer resolution. + wait_for_clock_to_advance(&budget); assert_eq!( budget.charge_operation(true), Err(BudgetExceeded::Deadline { limit_secs: 0 }) @@ -1244,6 +1278,11 @@ mod tests { let mut limits = tiny_limits(); limits.max_duration = Some(Duration::from_secs(0)); let budget = Arc::new(ExecutionBudget::new(limits)); + // See `deadline_trips_when_elapsed`: the zero-second deadline only trips + // once the monotonic clock has moved past creation. Wait (bounded) so the + // post-main-loop assertion below is deterministic on coarse-resolution + // timers (e.g. Windows). + wait_for_clock_to_advance(&budget); // ONE meter reused across a main-loop boundary (the VM-reuse case); // `reset()` runs per top-level op, so each op's first charge (index 0) is // a sample point.