From 632bdc2649b305810bd328ec7b74d8b6b5d62120 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 04:04:17 +0000 Subject: [PATCH 1/3] feat: return exact timestamp when next action is required Extract each timer into a method on `Timers` that returns the exact `Instant` at which it is due, and use them to make `next_timer_update` report the precise next wake-up together with a reason for it. Callers can now drive the state machine purely by polling at the returned instant instead of on a fixed interval. `clear_all` now takes the current time instead of reading the wall clock, keeping the timer state pure so the reported instants stay deterministic after a connection is wiped. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XsRkNsA4r812CDfwPH8ehF --- boringtun/src/noise/timers.rs | 302 +++++++++++++++++++++++++--------- 1 file changed, 224 insertions(+), 78 deletions(-) diff --git a/boringtun/src/noise/timers.rs b/boringtun/src/noise/timers.rs index 56c5fc7d..1c8d50b3 100644 --- a/boringtun/src/noise/timers.rs +++ b/boringtun/src/noise/timers.rs @@ -58,14 +58,14 @@ pub struct Timers { /// Is the owner of the timer the initiator or the responder for the last handshake? is_initiator: bool, timers: [Instant; TimerName::Top as usize], - /// Did we receive data without sending anything back? + /// The last data packet we received without having sent a reply since. /// - /// If `Some`, tracks the timestamp when we want to send a keepalive. - want_passive_keepalive_at: Option, - /// Did we send data without hearing back? + /// If `Some`, the passive-keepalive deadline is derived from it on demand. + last_data_received_without_reply: Option, + /// The first data packet we sent without having heard back since. /// - /// If `Some`, tracks the timestamp when we want to initiate the new handshake. - want_handshake_at: Option, + /// If `Some`, the new-handshake deadline is derived from it on demand. + first_data_sent_without_reply: Option, persistent_keepalive: usize, /// Should this timer call reset rr function (if not a shared rr instance) pub(super) should_reset_rr: bool, @@ -89,8 +89,8 @@ impl Timers { Timers { is_initiator: false, timers: [now; TimerName::Top as usize], - want_passive_keepalive_at: Default::default(), - want_handshake_at: Default::default(), + last_data_received_without_reply: Default::default(), + first_data_sent_without_reply: Default::default(), persistent_keepalive: usize::from(persistent_keepalive.unwrap_or(0)), should_reset_rr: reset_rr, send_handshake_at: None, @@ -109,14 +109,89 @@ impl Timers { !self.is_initiator() } + /// After `REJECT_AFTER_TIME * 3` without a new handshake, all ephemeral and + /// symmetric key material is wiped and the connection is considered dead. + pub(crate) fn reject_after_time(&self) -> Instant { + self[TimeSessionEstablished] + REJECT_AFTER_TIME * 3 + } + + /// After `REKEY_ATTEMPT_TIME` of unsuccessfully trying to complete a + /// handshake, we give up. + pub(crate) fn rekey_attempt_time(&self) -> Instant { + self[TimeLastHandshakeStarted] + self.rekey_attempt_time + } + + /// As the initiator, we start a new handshake `REKEY_AFTER_TIME` after + /// establishing a session on which we have since sent data. + pub(crate) fn rekey_after_time_on_send(&self) -> Option { + if !self.is_initiator { + // If we aren't the initiator of the current session, this timer does not matter. + return None; + } + + let session_established = self[TimeSessionEstablished]; + + if session_established >= self[TimeLastDataPacketSent] { + // If we haven't sent any data yet, this timer doesn't matter. + return None; + } + + Some(session_established + REKEY_AFTER_TIME) + } + + /// As the initiator, we start a new handshake once a session on which we + /// have since received data gets close to its expiry. + pub(crate) fn reject_after_time_on_receive(&self) -> Option { + if !self.is_initiator { + // If we aren't the initiator of the current session, this timer does not matter. + return None; + } + + let session_established = self[TimeSessionEstablished]; + + if session_established >= self[TimeLastDataPacketReceived] { + // If we haven't received any data yet, this timer doesn't matter. + return None; + } + + Some(session_established + REJECT_AFTER_TIME - self.keepalive_timeout - self.rekey_timeout) + } + + /// If we sent data but have not heard back within `KEEPALIVE_TIMEOUT + + /// REKEY_TIMEOUT`, we assume the session is dead and start a new handshake. + pub(crate) fn rekey_after_time_without_response(&self) -> Option { + let first_packet_without_reply = self.first_data_sent_without_reply?; + + Some(first_packet_without_reply + self.keepalive_timeout + self.rekey_timeout) + } + + /// If we received data but have not sent anything back within + /// `KEEPALIVE_TIMEOUT`, we send a passive keepalive. + pub(crate) fn keepalive_after_time_without_send(&self) -> Option { + let last_data_received_without_reply = self.last_data_received_without_reply?; + + Some(last_data_received_without_reply + self.keepalive_timeout) + } + + /// If configured, we send a keepalive every `persistent_keepalive` seconds. + pub(crate) fn next_persistent_keepalive(&self) -> Option { + let keepalive = Duration::from_secs(self.persistent_keepalive as u64); + + if keepalive.is_zero() { + return None; + } + + Some(self[TimePersistentKeepalive] + keepalive) + } + // We don't really clear the timers, but we set them to the current time to // so the reference time frame is the same pub(super) fn clear(&mut self, now: Instant) { for t in &mut self.timers[..] { *t = now; } - self.want_handshake_at = None; - self.want_passive_keepalive_at = None; + self.first_data_sent_without_reply = None; + self.last_data_received_without_reply = None; } pub(crate) fn set_rekey_attempt_time(&mut self, rekey_attempt_time: Duration) { @@ -149,25 +224,24 @@ impl Tunn { pub(super) fn timer_tick(&mut self, timer_name: TimerName, now: Instant) { match timer_name { TimeLastPacketReceived => { - self.timers.want_handshake_at = None; + self.timers.first_data_sent_without_reply = None; } TimeLastPacketSent => { - self.timers.want_passive_keepalive_at = None; + self.timers.last_data_received_without_reply = None; } TimeLastDataPacketReceived => { - self.timers.want_passive_keepalive_at = Some(now + self.timers.keepalive_timeout); + self.timers.last_data_received_without_reply = Some(now); } TimeLastDataPacketSent => { - match self.timers.want_handshake_at { + match self.timers.first_data_sent_without_reply { Some(_) => { // This isn't the first timer tick (i.e. not the first packet) // we haven't received a response to. } None => { // We sent a packet and haven't heard back yet. - // Start a timer for when we want to make a new handshake. - self.timers.want_handshake_at = - Some(now + self.timers.keepalive_timeout + self.timers.rekey_timeout) + // Remember when, so we can derive the new-handshake deadline. + self.timers.first_data_sent_without_reply = Some(now) } } } @@ -184,7 +258,7 @@ impl Tunn { // We don't really clear the timers, but we set them to the current time to // so the reference time frame is the same - fn clear_all(&mut self) { + fn clear_all(&mut self, now: Instant) { for session in &mut self.sessions { *session = None; } @@ -192,7 +266,7 @@ impl Tunn { #[cfg(feature = "packet-queue")] self.packet_queue.clear(); - self.timers.clear(Instant::now()); + self.timers.clear(now); } fn expire_sessions(&mut self, now: Instant) { @@ -214,6 +288,26 @@ impl Tunn { } } + /// The earliest [`Instant`] at which one of our sessions expires. + fn next_expired_session(&self) -> Option { + self.sessions + .iter() + .flatten() + .map(|s| s.established_at() + REJECT_AFTER_TIME) + .min() + } + + /// The [`Instant`] at which we need to retransmit our handshake initiation, + /// together with the local index of the in-flight handshake. + /// + /// Returns `None` unless we are currently the initiator of an in-progress + /// handshake. + fn handshake_rekey_timeout(&self) -> Option<(Instant, super::Index)> { + let (time_sent, local_index) = self.handshake.timer()?; + + Some((time_sent + self.timers.rekey_timeout, local_index)) + } + #[deprecated(note = "Prefer `Timers::update_timers_at` to avoid time-impurity")] pub fn update_timers<'a>(&mut self, dst: &'a mut [u8]) -> TunnResult<'a> { self.update_timers_at(dst, Instant::now()) @@ -259,13 +353,6 @@ impl Tunn { handshake_initiation_required = true; } - // Load timers only once: - let session_established = self.timers[TimeSessionEstablished]; - let handshake_started = self.timers[TimeLastHandshakeStarted]; - let data_packet_received = self.timers[TimeLastDataPacketReceived]; - let data_packet_sent = self.timers[TimeLastDataPacketSent]; - let persistent_keepalive = self.timers.persistent_keepalive; - if self.handshake.is_expired() { return TunnResult::Err(WireGuardError::ConnectionExpired); } @@ -282,29 +369,27 @@ impl Tunn { // All ephemeral private keys and symmetric session keys are zeroed out after // (REJECT_AFTER_TIME * 3) ms if no new keys have been exchanged. - if now - session_established >= REJECT_AFTER_TIME * 3 { + if now >= self.timers.reject_after_time() { tracing::debug!("CONNECTION_EXPIRED(REJECT_AFTER_TIME * 3)"); self.handshake.set_expired(); - self.clear_all(); + self.clear_all(now); return TunnResult::Err(WireGuardError::ConnectionExpired); } - if let Some((time_init_sent, local_idx)) = self.handshake.timer() { + if let Some((rekey_timeout, local_idx)) = self.handshake_rekey_timeout() { // Handshake Initiation Retransmission - if now - handshake_started >= self.timers.rekey_attempt_time { + if now >= self.timers.rekey_attempt_time() { // After REKEY_ATTEMPT_TIME ms of trying to initiate a new handshake, // the retries give up and cease, and clear all existing packets queued // up to be sent. If a packet is explicitly queued up to be sent, then // this timer is reset. tracing::debug!(%local_idx, "CONNECTION_EXPIRED(REKEY_ATTEMPT_TIME)"); self.handshake.set_expired(); - self.clear_all(); + self.clear_all(now); return TunnResult::Err(WireGuardError::ConnectionExpired); } - if now.duration_since(time_init_sent) >= self.timers.rekey_timeout { - // We avoid using `time` here, because it can be earlier than `time_init_sent`. - // Once `checked_duration_since` is stable we can use that. + if now >= rekey_timeout { // A handshake initiation is retried after REKEY_TIMEOUT + jitter ms, // if a response has not been received, where jitter is some random // value between 0 and 333 ms (`MAX_JITTER`). @@ -312,34 +397,33 @@ impl Tunn { handshake_initiation_required = true; } } else { - if self.timers.is_initiator() { - // After sending a packet, if the sender was the original initiator - // of the handshake and if the current session key is REKEY_AFTER_TIME - // ms old, we initiate a new handshake. If the sender was the original - // responder of the handshake, it does not re-initiate a new handshake - // after REKEY_AFTER_TIME ms like the original initiator does. - if session_established < data_packet_sent - && now - session_established >= REKEY_AFTER_TIME - { - tracing::debug!("HANDSHAKE(REKEY_AFTER_TIME (on send))"); - handshake_initiation_required = true; - } + // After sending a packet, if the sender was the original initiator + // of the handshake and if the current session key is REKEY_AFTER_TIME + // ms old, we initiate a new handshake. If the sender was the original + // responder of the handshake, it does not re-initiate a new handshake + // after REKEY_AFTER_TIME ms like the original initiator does. + if self + .timers + .rekey_after_time_on_send() + .is_some_and(|deadline| now >= deadline) + { + tracing::debug!("HANDSHAKE(REKEY_AFTER_TIME (on send))"); + handshake_initiation_required = true; + } - // After receiving a packet, if the receiver was the original initiator - // of the handshake and if the current session key is REJECT_AFTER_TIME - // - KEEPALIVE_TIMEOUT - REKEY_TIMEOUT ms old, we initiate a new - // handshake. - if session_established < data_packet_received - && now - session_established - >= REJECT_AFTER_TIME - - self.timers.keepalive_timeout - - self.timers.rekey_timeout - { - tracing::debug!( - "HANDSHAKE(REJECT_AFTER_TIME - KEEPALIVE_TIMEOUT - REKEY_TIMEOUT (on receive))" - ); - handshake_initiation_required = true; - } + // After receiving a packet, if the receiver was the original initiator + // of the handshake and if the current session key is REJECT_AFTER_TIME + // - KEEPALIVE_TIMEOUT - REKEY_TIMEOUT ms old, we initiate a new + // handshake. + if self + .timers + .reject_after_time_on_receive() + .is_some_and(|deadline| now >= deadline) + { + tracing::debug!( + "HANDSHAKE(REJECT_AFTER_TIME - KEEPALIVE_TIMEOUT - REKEY_TIMEOUT (on receive))" + ); + handshake_initiation_required = true; } // If we have sent a packet to a given peer but have not received a @@ -347,8 +431,8 @@ impl Tunn { // we initiate a new handshake. if self .timers - .want_handshake_at - .is_some_and(|handshake_at| now >= handshake_at) + .rekey_after_time_without_response() + .is_some_and(|deadline| now >= deadline) { tracing::debug!("HANDSHAKE(KEEPALIVE + REKEY_TIMEOUT)"); handshake_initiation_required = true; @@ -359,17 +443,18 @@ impl Tunn { // to the given peer in KEEPALIVE ms, we send an empty packet. if self .timers - .want_passive_keepalive_at - .is_some_and(|keepalive_at| now >= keepalive_at) + .keepalive_after_time_without_send() + .is_some_and(|deadline| now >= deadline) { tracing::debug!("KEEPALIVE(KEEPALIVE_TIMEOUT)"); keepalive_required = true; } // Persistent KEEPALIVE - if persistent_keepalive > 0 - && (now - self.timers[TimePersistentKeepalive] - >= Duration::from_secs(persistent_keepalive as _)) + if self + .timers + .next_persistent_keepalive() + .is_some_and(|deadline| now >= deadline) { tracing::debug!("KEEPALIVE(PERSISTENT_KEEPALIVE)"); self.timer_tick(TimePersistentKeepalive, now); @@ -408,21 +493,82 @@ impl Tunn { TunnResult::Done } - /// Returns an [`Instant`] when [`Tunn::update_timers_at`] should be called again. + /// Returns the [`Instant`] at which [`Tunn::update_timers_at`] next needs to + /// be called, together with a human-readable reason for debugging. /// - /// Calling it earlier than the given [`Instant`] is safe but unlikely to have any effect. - pub fn next_timer_update(&self) -> Option { - let next = self.next_timer_update_internal()?; + /// Calling it earlier than the given [`Instant`] is safe but unlikely to + /// have any effect. Driving the state machine by repeatedly polling at the + /// returned [`Instant`] is sufficient: there is no need to call + /// [`Tunn::update_timers_at`] on a fixed interval. + pub fn next_timer_update(&self) -> Option<(Instant, &'static str)> { + let (next, reason) = self.next_timer_update_internal()?; let last_update = self.timers[TimeLastUpdate]; - Some(next.max(last_update)) + // Never announce an [`Instant`] in the past: a deadline may already have + // elapsed by the time the caller polls us. + Some((next.max(last_update), reason)) } - fn next_timer_update_internal(&self) -> Option { - iter::empty() - .chain(self.timers.send_handshake_at) - .chain(self.timers.want_passive_keepalive_at) - .min() + fn next_timer_update_internal(&self) -> Option<(Instant, &'static str)> { + // Mimic `update_timers_at`: if we have a handshake scheduled, no other timer matters. + if let Some(scheduled_handshake) = self.timers.send_handshake_at { + return Some((scheduled_handshake, "scheduled handshake")); + } + + let common_timers = iter::empty() + .chain( + self.next_expired_session() + .map(|instant| (instant, "next expired session")), + ) + .chain( + self.handshake + .cookie_expiration() + .map(|instant| (instant, "cookie expiration")), + ) + .chain(iter::once(( + self.timers.reject_after_time(), + "reject-after-time", + ))); + + if let Some((rekey_timeout, _)) = self.handshake_rekey_timeout() { + // While a handshake is in progress, only the handshake timers matter. + common_timers + .chain(iter::once((rekey_timeout, "rekey-timeout"))) + .chain(iter::once(( + self.timers.rekey_attempt_time(), + "rekey-attempt", + ))) + .min_by_key(|(instant, _)| *instant) + } else { + // Persistent keep-alive only makes sense if the current session is active. + let persistent_keepalive = self.sessions[self.current] + .as_ref() + .and_then(|_| self.timers.next_persistent_keepalive()); + + common_timers + .chain( + self.timers + .rekey_after_time_on_send() + .map(|instant| (instant, "rekey-after-time (on send)")), + ) + .chain( + self.timers + .reject_after_time_on_receive() + .map(|instant| (instant, "reject-after-time (on receive)")), + ) + .chain( + self.timers + .rekey_after_time_without_response() + .map(|instant| (instant, "rekey-after-time (without response)")), + ) + .chain( + self.timers + .keepalive_after_time_without_send() + .map(|instant| (instant, "keepalive-after-time (without send)")), + ) + .chain(persistent_keepalive.map(|instant| (instant, "persistent keep-alive"))) + .min_by_key(|(instant, _)| *instant) + } } #[deprecated(note = "Prefer `Tunn::time_since_last_handshake_at` to avoid time-impurity")] From cd246187d66100910021705741065d44d53eb910 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 04:04:23 +0000 Subject: [PATCH 2/3] test: drive timer suite via next_timer_update `Sim::advance` no longer polls once per tick. It jumps straight to the earliest instant either peer announces via `next_timer_update`, which asserts that the announced instants are complete: polling only at them reproduces the exact behaviour of polling once a second. A small nudge keeps forward progress across the strictly-after session-expiry boundary. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XsRkNsA4r812CDfwPH8ehF --- boringtun/tests/it/harness.rs | 28 ++++++++++++++++++++++++---- boringtun/tests/it/sans_io.rs | 16 +++++++++------- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/boringtun/tests/it/harness.rs b/boringtun/tests/it/harness.rs index 17b09285..2a3b461a 100644 --- a/boringtun/tests/it/harness.rs +++ b/boringtun/tests/it/harness.rs @@ -45,7 +45,9 @@ pub const COOKIE_REPLY_SIZE: usize = 64; /// (implementation-specific; the paper's reference window is 2000 packets). pub const REPLAY_WINDOW: u64 = 8192; -/// Granularity at which [`Sim::advance`] polls `update_timers_at`. +/// Minimum step [`Sim::advance`] takes when a timer's deadline lands on the +/// current instant, so it can nudge past a strictly-after boundary and keep +/// making progress. It also bounds how late an event may be observed. pub const TICK: Duration = Duration::from_millis(100); const BUF: usize = 4096; @@ -363,12 +365,30 @@ impl Sim { // --- Driving the tunnels --- - /// Advance the virtual clock, polling `update_timers_at` on both peers - /// every [`TICK`] and routing whatever they emit. + /// Advance the virtual clock, driving both peers purely by the instants they + /// announce via `next_timer_update` and routing whatever they emit. + /// + /// Rather than polling on a fixed interval, we jump straight to the earliest + /// instant either peer asked to be called back at. This asserts that + /// `next_timer_update` is *complete*: a caller that only ever polls at the + /// announced instants observes exactly the same behaviour as one polling + /// once a second. The [`TICK`] nudge below is the sole safety net for timers + /// whose deadline is reported inclusively but only fires strictly afterwards + /// (session expiry), and guarantees forward progress. pub fn advance(&mut self, duration: Duration) { let end = self.now + duration; while self.now < end { - self.now = std::cmp::min(self.now + TICK, end); + let next = [Peer::A, Peer::B] + .into_iter() + .filter_map(|peer| self.tunn(peer).next_timer_update()) + .map(|(at, _reason)| at) + .min() + // Never move backwards and always make progress, even when a + // deadline is reported for the current instant. + .map(|at| at.max(self.now + TICK)) + .unwrap_or(end); + + self.now = std::cmp::min(next, end); self.poll(Peer::A); self.poll(Peer::B); } diff --git a/boringtun/tests/it/sans_io.rs b/boringtun/tests/it/sans_io.rs index d6332d02..95609fe8 100644 --- a/boringtun/tests/it/sans_io.rs +++ b/boringtun/tests/it/sans_io.rs @@ -35,7 +35,7 @@ fn scheduled_handshake_honours_next_timer_update() { tunn.update_timers_at(&mut buf, deadline), TunnResult::Done )); - let wake = tunn.next_timer_update().expect("a scheduled handshake"); + let (wake, _reason) = tunn.next_timer_update().expect("a scheduled handshake"); assert!(wake >= deadline && wake <= deadline + MAX_JITTER); // ... and emitted exactly once when the announced instant is polled. @@ -59,7 +59,7 @@ fn next_timer_update_predicts_the_passive_keepalive() { let received_at = sim.now; let tunn = sim.tunn_mut(B); - let wake = tunn.next_timer_update().expect("a pending keepalive"); + let (wake, _reason) = tunn.next_timer_update().expect("a pending keepalive"); assert_eq!(wake, received_at + KEEPALIVE_TIMEOUT); let mut buf = [0u8; 256]; @@ -72,11 +72,12 @@ fn next_timer_update_predicts_the_passive_keepalive() { }; assert_eq!(packet.len(), KEEPALIVE_SIZE); - assert_eq!( - tunn.next_timer_update(), - None, - "nothing scheduled once the keepalive is out" - ); + // The passive keepalive has been answered, so it must not re-arm; the only + // thing left on the clock is the eventual session expiry. + let (_wake, reason) = tunn + .next_timer_update() + .expect("a session that still expires eventually"); + assert_eq!(reason, "next expired session"); } /// Callers may poll arbitrarily rarely; a single call after a long gap must @@ -129,6 +130,7 @@ fn different_seeds_produce_different_jitter() { tunn.next_timer_update() .expect("a scheduled retry") + .0 .duration_since(retry_due) } From 075138241d193da34d85254c75eb9bbabc1e51b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 05:07:50 +0000 Subject: [PATCH 3/3] test: assert next_timer_update never returns a past instant Cover the guarantee restored in #104: sending on a session older than REKEY_AFTER_TIME arms a timer whose deadline has already elapsed, and `next_timer_update` must clamp that to the last known now rather than hand the caller an instant it can never sleep until. The jump-based harness clamps its own step, so this contract was otherwise untested. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XsRkNsA4r812CDfwPH8ehF --- boringtun/tests/it/sans_io.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/boringtun/tests/it/sans_io.rs b/boringtun/tests/it/sans_io.rs index 95609fe8..124b515d 100644 --- a/boringtun/tests/it/sans_io.rs +++ b/boringtun/tests/it/sans_io.rs @@ -4,7 +4,7 @@ use crate::harness::{ classify, ipv4_packet, secs, Kind, Outcome as _, Peer::A, Peer::B, Sim, KEEPALIVE_SIZE, - KEEPALIVE_TIMEOUT, MAX_JITTER, REJECT_AFTER_TIME, REKEY_TIMEOUT, + KEEPALIVE_TIMEOUT, MAX_JITTER, REJECT_AFTER_TIME, REKEY_AFTER_TIME, REKEY_TIMEOUT, }; use boringtun::noise::errors::WireGuardError; use boringtun::noise::TunnResult; @@ -80,6 +80,31 @@ fn next_timer_update_predicts_the_passive_keepalive() { assert_eq!(reason, "next expired session"); } +/// `next_timer_update` must never announce an instant in the past. Timers are +/// anchored to the session start, so sending on a session already older than +/// `REKEY_AFTER_TIME` arms the rekey-on-send timer with a deadline that has +/// already elapsed; the caller must still be handed a usable (not past) instant. +#[test] +fn next_timer_update_never_points_into_the_past() { + let mut sim = Sim::connected(); + + // Age the session past REKEY_AFTER_TIME. With no traffic, nothing rekeys, so + // the session is still alive and its start is now well over REKEY_AFTER_TIME + // in the past. + sim.advance(REKEY_AFTER_TIME + secs(1)); + + // Sending now arms the rekey-on-send timer, whose deadline (session start + + // REKEY_AFTER_TIME) is already behind us. + sim.send_ip(A, &ipv4_packet(b"traffic after a long silence")); + + let (wake, _reason) = sim.tunn(A).next_timer_update().expect("a rekey is now due"); + assert!( + wake >= sim.now, + "next_timer_update returned an instant {:?} before now", + sim.now.duration_since(wake), + ); +} + /// Callers may poll arbitrarily rarely; a single call after a long gap must /// still observe the (by then) expired state instead of panicking or hanging. #[test]