use std::time::Duration;
use tokio::sync::watch;
use crate::bus::{LocalInstant, RobotInstant, TimelineId};
use crate::participant::spec::MissedTick;
use phoxal_bus::RetiredTimelines;
pub(crate) fn duration_to_nanos_saturating(duration: Duration) -> u64 {
u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SchedulerTick {
pub fired_at: RobotInstant,
pub missed_ticks: u32,
}
#[allow(async_fn_in_trait)]
pub trait StepScheduler: Send + Sync + 'static {
async fn wait_until(&self, target: RobotInstant) -> SchedulerTick;
fn now(&self) -> Option<RobotInstant>;
}
pub struct RealScheduler {
missed_tick: MissedTick,
period: Option<Duration>,
timeline: TimelineId,
started_boot: LocalInstant,
started_timer: tokio::time::Instant,
started_ticks: u64,
}
impl RealScheduler {
pub fn new(
missed_tick: MissedTick,
period: Option<Duration>,
now: RobotInstant,
) -> Option<Self> {
let started_boot = LocalInstant::try_now()?;
Some(RealScheduler {
missed_tick,
period,
timeline: now.timeline(),
started_boot,
started_timer: tokio::time::Instant::now(),
started_ticks: now.ticks(),
})
}
fn timer_deadline_for(&self, target: RobotInstant) -> tokio::time::Instant {
let delta_ns = target.ticks().saturating_sub(self.started_ticks);
self.started_timer + Duration::from_nanos(delta_ns)
}
fn boot_elapsed(&self) -> Option<Duration> {
Some(LocalInstant::try_now()?.saturating_duration_since(self.started_boot))
}
}
fn resolve_tick(
started_ticks: u64,
elapsed: Duration,
target_ticks: u64,
period: Option<Duration>,
missed_tick: MissedTick,
) -> (u64, u32) {
let fired_ticks = started_ticks
.saturating_add(duration_to_nanos_saturating(elapsed))
.max(target_ticks);
let mut missed_ticks = 0u32;
if missed_tick == MissedTick::Collapse
&& let Some(period) = period.filter(|period| !period.is_zero())
{
let period_ns = duration_to_nanos_saturating(period);
let overrun = fired_ticks.saturating_sub(target_ticks);
missed_ticks = u32::try_from(overrun / period_ns).unwrap_or(u32::MAX);
}
(fired_ticks, missed_ticks)
}
impl StepScheduler for RealScheduler {
async fn wait_until(&self, target: RobotInstant) -> SchedulerTick {
tokio::time::sleep_until(self.timer_deadline_for(target)).await;
let Some(elapsed) = self.boot_elapsed() else {
return SchedulerTick {
fired_at: target,
missed_ticks: 0,
};
};
let (fired_ticks, missed_ticks) = resolve_tick(
self.started_ticks,
elapsed,
target.ticks(),
self.period,
self.missed_tick,
);
SchedulerTick {
fired_at: RobotInstant::new(self.timeline, fired_ticks),
missed_ticks,
}
}
fn now(&self) -> Option<RobotInstant> {
Some(RobotInstant::new(
self.timeline,
self.started_ticks
.saturating_add(duration_to_nanos_saturating(self.boot_elapsed()?)),
))
}
}
pub struct SimulationScheduler {
missed_tick: MissedTick,
period: Option<Duration>,
_tx_keepalive: watch::Sender<Option<RobotInstant>>,
rx: watch::Receiver<Option<RobotInstant>>,
}
struct SimulationClockState {
current: Option<RobotInstant>,
retired_timelines: RetiredTimelines,
}
#[doc(hidden)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SimulationClockAdvance {
Advanced,
DuplicateOrBackward,
RetiredTimeline,
}
#[derive(Clone)]
pub struct SimulationClockHandle {
tx: watch::Sender<Option<RobotInstant>>,
state: std::sync::Arc<std::sync::Mutex<SimulationClockState>>,
}
impl SimulationClockHandle {
pub fn advance(&self, at: RobotInstant) -> SimulationClockAdvance {
let mut state = self.state.lock().expect("simulation clock mutex poisoned");
match state.current {
Some(current) if current.timeline() == at.timeline() => {
if at.ticks() <= current.ticks() {
return SimulationClockAdvance::DuplicateOrBackward;
}
}
current => {
if state.retired_timelines.contains(at.timeline()) {
return SimulationClockAdvance::RetiredTimeline;
}
if let Some(previous) = current {
state.retired_timelines.retire(previous.timeline());
}
state.retired_timelines.activate(at.timeline());
}
}
state.current = Some(at);
self.tx.send_replace(Some(at));
SimulationClockAdvance::Advanced
}
}
impl SimulationScheduler {
pub fn new(missed_tick: MissedTick, period: Option<Duration>) -> (Self, SimulationClockHandle) {
let (tx, rx) = watch::channel(None);
let scheduler = SimulationScheduler {
missed_tick,
period,
_tx_keepalive: tx.clone(),
rx,
};
let handle = SimulationClockHandle {
tx,
state: std::sync::Arc::new(std::sync::Mutex::new(SimulationClockState {
current: None,
retired_timelines: RetiredTimelines::default(),
})),
};
(scheduler, handle)
}
pub(crate) fn simulation_clock(&self) -> crate::participant::clock::SimulationClock {
crate::participant::clock::SimulationClock::from_receiver(self.rx.clone())
}
fn missed_ticks(&self, target: RobotInstant, current: RobotInstant) -> u32 {
let (MissedTick::Collapse, Some(period)) = (self.missed_tick, self.period) else {
return 0;
};
let Ok(overrun) = current.duration_since(target) else {
return 0;
};
let period_ns = duration_to_nanos_saturating(period);
if period_ns == 0 {
return 0;
}
u32::try_from(duration_to_nanos_saturating(overrun) / period_ns).unwrap_or(u32::MAX)
}
}
impl StepScheduler for SimulationScheduler {
async fn wait_until(&self, target: RobotInstant) -> SchedulerTick {
let mut rx = self.rx.clone();
loop {
if let Some(current) = *rx.borrow_and_update() {
let reached = current.timeline() != target.timeline()
|| current
.checked_cmp(target)
.is_ok_and(|order| order != std::cmp::Ordering::Less);
if reached {
return SchedulerTick {
fired_at: current,
missed_ticks: self.missed_ticks(target, current),
};
}
}
if rx.changed().await.is_err() {
return std::future::pending().await;
}
}
}
fn now(&self) -> Option<RobotInstant> {
*self.rx.borrow()
}
}
pub enum AnyStepScheduler {
Real(RealScheduler),
Simulation(SimulationScheduler),
Clockless,
}
impl AnyStepScheduler {
pub(crate) fn simulation_time_receiver(&self) -> Option<watch::Receiver<Option<RobotInstant>>> {
match self {
AnyStepScheduler::Real(_) | AnyStepScheduler::Clockless => None,
AnyStepScheduler::Simulation(scheduler) => Some(scheduler.rx.clone()),
}
}
}
impl StepScheduler for AnyStepScheduler {
async fn wait_until(&self, target: RobotInstant) -> SchedulerTick {
match self {
AnyStepScheduler::Real(scheduler) => scheduler.wait_until(target).await,
AnyStepScheduler::Simulation(scheduler) => scheduler.wait_until(target).await,
AnyStepScheduler::Clockless => std::future::pending().await,
}
}
fn now(&self) -> Option<RobotInstant> {
match self {
AnyStepScheduler::Real(scheduler) => scheduler.now(),
AnyStepScheduler::Simulation(scheduler) => scheduler.now(),
AnyStepScheduler::Clockless => None,
}
}
}
#[cfg(test)]
mod tests {
use std::future::Future;
use super::*;
fn line() -> TimelineId {
TimelineId::from_raw(1).expect("test timeline must be nonzero")
}
fn lt(ticks: u64) -> RobotInstant {
RobotInstant::new(line(), ticks)
}
fn other(ticks: u64) -> RobotInstant {
RobotInstant::new(
TimelineId::from_raw(2).expect("test timeline must be nonzero"),
ticks,
)
}
const PERIOD: Duration = Duration::from_millis(10);
const SIM_PERIOD: Duration = Duration::from_nanos(10);
#[tokio::test]
async fn real_scheduler_wakes_at_target_and_reports_no_miss_when_on_time() {
let start = tokio::time::Instant::now();
let scheduler =
RealScheduler::new(MissedTick::Collapse, Some(PERIOD), lt(0)).expect("test host clock");
let period_ns = PERIOD.as_nanos() as u64;
let tick = scheduler.wait_until(lt(period_ns)).await;
assert_eq!(tick.missed_ticks, 0);
assert!(tick.fired_at.ticks() >= period_ns);
assert!(start.elapsed() >= PERIOD);
}
#[test]
fn real_cadence_collapses_a_missed_tick_instead_of_bursting() {
let period_ns = PERIOD.as_nanos() as u64;
let (fired, missed) = resolve_tick(
0,
Duration::from_millis(50),
period_ns,
Some(PERIOD),
MissedTick::Collapse,
);
assert_eq!(fired, 50_000_000);
assert_eq!(
missed, 4,
"a multi-period overrun collapses to one tick, reporting the skipped count"
);
}
#[test]
fn a_host_suspend_counts_as_missed_periods_rather_than_time_that_never_happened() {
let period_ns = PERIOD.as_nanos() as u64;
let (fired, missed) = resolve_tick(
0,
Duration::from_secs(1),
period_ns,
Some(PERIOD),
MissedTick::Collapse,
);
assert_eq!(
fired, 1_000_000_000,
"the released tick is where the host is"
);
assert_eq!(
missed, 99,
"one second of suspend is 99 further 10ms periods"
);
}
#[test]
fn a_tick_that_fires_on_time_reports_no_miss_and_no_period_means_no_collapse() {
let period_ns = PERIOD.as_nanos() as u64;
assert_eq!(
resolve_tick(0, PERIOD, period_ns, Some(PERIOD), MissedTick::Collapse),
(period_ns, 0)
);
assert_eq!(
resolve_tick(
0,
Duration::from_millis(50),
period_ns,
None,
MissedTick::Collapse
),
(50_000_000, 0),
"a step-less participant has no period to collapse against"
);
assert_eq!(
resolve_tick(
0,
Duration::from_millis(50),
period_ns,
Some(PERIOD),
MissedTick::CatchUp
),
(50_000_000, 0),
"catch-up replays each tick elsewhere; it never collapses here"
);
}
#[tokio::test]
async fn simulation_scheduler_releases_ticks_in_order_deterministically() {
let (scheduler, handle) = SimulationScheduler::new(MissedTick::Collapse, Some(SIM_PERIOD));
let mut fired = Vec::new();
for step in 1..=5u64 {
let target = lt(step * 10);
let handle = handle.clone();
let advancer = tokio::spawn(async move { handle.advance(target) });
let tick = scheduler.wait_until(target).await;
advancer.await.unwrap();
fired.push(tick.fired_at.ticks());
}
assert_eq!(fired, vec![10, 20, 30, 40, 50]);
}
#[test]
fn a_scheduler_with_no_world_history_yet_reports_no_time_rather_than_zero() {
let (scheduler, handle) = SimulationScheduler::new(MissedTick::Collapse, Some(SIM_PERIOD));
assert_eq!(
scheduler.now(),
None,
"before the authority publishes, there is no world history at all"
);
handle.advance(lt(10));
assert_eq!(scheduler.now(), Some(lt(10)));
}
#[test]
fn timeline_replacement_is_equality_only_with_no_generation_order() {
let (scheduler, handle) = SimulationScheduler::new(MissedTick::Collapse, Some(SIM_PERIOD));
assert_eq!(handle.advance(lt(100)), SimulationClockAdvance::Advanced);
assert_eq!(handle.advance(other(0)), SimulationClockAdvance::Advanced);
assert_eq!(scheduler.now(), Some(other(0)));
assert_eq!(
handle.advance(lt(101)),
SimulationClockAdvance::RetiredTimeline,
"a late clock from the retired world must not reactivate it"
);
assert_eq!(
handle.advance(other(0)),
SimulationClockAdvance::DuplicateOrBackward
);
assert_eq!(
scheduler.now(),
Some(other(0)),
"duplicate and same-timeline non-forward samples are ignored"
);
}
#[tokio::test]
async fn simulation_scheduler_never_sleeps_on_a_wall_clock_timer() {
let (scheduler, handle) = SimulationScheduler::new(MissedTick::Collapse, Some(SIM_PERIOD));
handle.advance(lt(100));
let started = std::time::Instant::now();
let tick = scheduler.wait_until(lt(100)).await;
let elapsed = started.elapsed();
assert_eq!(tick.fired_at, lt(100));
assert!(
elapsed < Duration::from_millis(50),
"simulation scheduler should resolve immediately once time is already due, took {elapsed:?}"
);
}
#[tokio::test]
async fn simulation_scheduler_releases_when_advance_happened_before_wait() {
let (scheduler, handle) = SimulationScheduler::new(MissedTick::Collapse, Some(SIM_PERIOD));
handle.advance(lt(30));
let tick = tokio::time::timeout(Duration::from_millis(50), scheduler.wait_until(lt(20)))
.await
.expect("advance-before-wait should release without waiting for another change");
assert_eq!(tick.fired_at, lt(30));
assert_eq!(tick.missed_ticks, 1);
}
#[tokio::test]
async fn simulation_scheduler_does_not_miss_racing_advance_after_pending_poll() {
let (scheduler, handle) = SimulationScheduler::new(MissedTick::Collapse, Some(SIM_PERIOD));
let wait = scheduler.wait_until(lt(10));
tokio::pin!(wait);
assert!(
poll_once(wait.as_mut()).is_none(),
"wait should pend before robot time reaches the target"
);
handle.advance(lt(10));
let tick = tokio::time::timeout(Duration::from_secs(1), &mut wait)
.await
.expect("advance after a pending poll should wake the waiter");
assert_eq!(tick.fired_at, lt(10));
assert_eq!(tick.missed_ticks, 0);
}
#[tokio::test]
async fn simulation_scheduler_keeps_waiting_if_external_handle_is_dropped() {
let (scheduler, handle) = SimulationScheduler::new(MissedTick::Collapse, Some(SIM_PERIOD));
drop(handle);
let wait = scheduler.wait_until(lt(10));
tokio::pin!(wait);
assert!(
poll_once(wait.as_mut()).is_none(),
"dropping the external handle must not close the scheduler feed and release a stale tick"
);
}
#[tokio::test]
async fn simulation_scheduler_collapses_a_multi_period_jump() {
let (scheduler, handle) = SimulationScheduler::new(MissedTick::Collapse, Some(SIM_PERIOD));
handle.advance(lt(40));
let tick = scheduler.wait_until(lt(10)).await;
assert_eq!(
tick.missed_ticks, 3,
"a jump past the target collapses to one released tick, reporting all 3 skipped periods"
);
assert_eq!(tick.fired_at, lt(40));
}
fn poll_once<F: Future>(fut: std::pin::Pin<&mut F>) -> Option<F::Output> {
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
fn noop(_: *const ()) {}
fn clone(_: *const ()) -> RawWaker {
RawWaker::new(std::ptr::null(), &VTABLE)
}
static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop);
let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
let mut cx = Context::from_waker(&waker);
match fut.poll(&mut cx) {
Poll::Ready(output) => Some(output),
Poll::Pending => None,
}
}
}