use std::future::Future;
use std::pin::pin;
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration;
use arc_swap::ArcSwapOption;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use crate::api;
use crate::bus::Subscriber;
use crate::bus::{LocalInstant, QueryFailure, RobotInstant, StepToken, TimelineId};
use crate::participant::api::{ParticipantApi, ParticipantLifecycle};
use crate::participant::bus_log::{self, BusLogState};
use crate::participant::clock::{ClockReading, ClockSource, RealClock, TimeUnsynchronized};
use crate::participant::context::{ResetContext, SetupContext, ShutdownContext, StepContext};
use crate::participant::launch::{ClockMode, ParticipantLaunch, ParticipantLaunchPolicy};
use crate::participant::managed::{ManagedTaskExit, ManagedTasks};
use crate::participant::runtime_performance::{RuntimePerformance, RuntimePerformancePublisher};
use crate::participant::scheduler::{
AnyStepScheduler, RealScheduler, SchedulerTick, SimulationClockAdvance, SimulationClockHandle,
SimulationScheduler, StepScheduler, duration_to_nanos_saturating,
};
use crate::participant::spec::StepSchedule;
use anyhow::Context as _;
use phoxal_bus::{Bus, BusConfig, IncomingQuery};
const RUNTIME_PERFORMANCE_TICK_INTERVAL: Duration = Duration::from_secs(1);
pub fn run<R: ParticipantLifecycle>() -> crate::Result<()> {
let tokio_runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
tokio_runtime.block_on(run_async::<R>())
}
pub async fn run_async<R: ParticipantLifecycle>() -> crate::Result<()> {
let launch = R::LaunchPolicy::from_cli(R::ID, "robot")?;
init_tracing();
run_with::<R, _>(launch, shutdown_signal()).await
}
pub(crate) fn step_scheduler_for(
clock_mode: ClockMode,
schedule: Option<StepSchedule>,
now: Option<RobotInstant>,
) -> crate::Result<(AnyStepScheduler, Option<SimulationClockHandle>)> {
let missed_tick = schedule
.map(|s| s.missed_tick)
.unwrap_or(crate::participant::spec::MissedTick::Collapse);
let period = schedule.map(|s| s.period());
Ok(match clock_mode {
ClockMode::Real => {
let now = now.context(
"a real participant cannot anchor its cadence without a synchronized clock",
)?;
(
AnyStepScheduler::Real(
RealScheduler::new(missed_tick, period, now)
.context("the host boot clock could not be read to anchor cadence")?,
),
None,
)
}
ClockMode::Simulation => {
let (scheduler, handle) = SimulationScheduler::new(missed_tick, period);
(AnyStepScheduler::Simulation(scheduler), Some(handle))
}
ClockMode::Clockless => (AnyStepScheduler::Clockless, None),
})
}
pub(crate) fn spawn_simulation_clock_feed(
bus: &Bus,
handle: SimulationClockHandle,
) -> crate::Result<JoinHandle<()>> {
let bus = bus.clone();
Ok(tokio::spawn(async move {
let topic = api::topic::new().simulation().clock();
let subscriber = match Subscriber::<api::simulation::Clock>::new(&bus, &topic, 1).await {
Ok(subscriber) => subscriber,
Err(error) => {
tracing::error!(
target: "phoxal.runtime",
error = %error,
"failed to subscribe simulation/clock; simulation-mode steps will never advance"
);
return;
}
};
tracing::info!(
target: "phoxal.runtime",
topic = topic.key(),
"subscribed the live simulation/clock feed; driving the simulation scheduler from it"
);
while let Ok(observed) = subscriber.recv().await {
let Some(at) = observed.metadata.produced_exactly_at() else {
tracing::warn!(
target: "phoxal.runtime",
"discarding a simulation clock with no exact production instant"
);
continue;
};
match handle.advance(at) {
SimulationClockAdvance::Advanced | SimulationClockAdvance::DuplicateOrBackward => {}
SimulationClockAdvance::RetiredTimeline => {
tracing::warn!(
target: "phoxal.runtime",
timeline = %at.timeline(),
ticks = at.ticks(),
"ignoring late simulation clock from a retired world history"
);
}
}
}
}))
}
pub async fn run_with<R, S>(launch: ParticipantLaunch, shutdown: S) -> crate::Result<()>
where
R: ParticipantLifecycle,
S: Future<Output = ()>,
{
init_tracing();
let bus = Bus::open(BusConfig {
namespace: launch.namespace.clone(),
robot_id: launch.robot_id.clone(),
execution: launch.execution,
participant: launch.participant_id.clone(),
producer: launch.producer,
connect_endpoints: launch.bus.connect_endpoints.clone(),
})
.await?;
let result = run_with_bus::<R, S>(&bus, launch, shutdown).await;
if let Err(e) = bus.close().await {
tracing::warn!(target: "phoxal.runtime", error = %e, "bus close failed");
}
result
}
pub async fn run_with_bus<R, S>(
bus: &Bus,
launch: ParticipantLaunch,
shutdown: S,
) -> crate::Result<()>
where
R: ParticipantLifecycle,
S: Future<Output = ()>,
{
let clock = match launch.execution_origin {
Some(origin) => RealClock::new(origin),
None => RealClock::without_origin(),
};
run_with_bus_inner::<R, RealClock, S>(bus, launch, clock, shutdown).await
}
#[doc(hidden)]
pub async fn run_with_bus_clock<R, C, S>(
bus: &Bus,
launch: ParticipantLaunch,
clock: C,
shutdown: S,
) -> crate::Result<()>
where
R: ParticipantLifecycle<LaunchPolicy = crate::participant::launch::ClockedParticipantLaunch>
+ crate::participant::TypedGraphSurface,
C: ClockSource,
S: Future<Output = ()>,
{
run_with_bus_inner::<R, C, S>(bus, launch, clock, shutdown).await
}
async fn run_with_bus_inner<R, C, S>(
bus: &Bus,
launch: ParticipantLaunch,
clock: C,
shutdown: S,
) -> crate::Result<()>
where
R: ParticipantLifecycle,
C: ClockSource,
S: Future<Output = ()>,
{
init_tracing();
let participant_id = launch.participant_id.clone();
let bus_logs = bus_log::attach(bus.clone(), &participant_id);
let result = run_lifecycle::<R, C, S>(bus, launch, clock, shutdown).await;
bus_logs.shutdown().await;
result
}
async fn run_lifecycle<R, C, S>(
bus: &Bus,
launch: ParticipantLaunch,
clock: C,
shutdown: S,
) -> crate::Result<()>
where
R: ParticipantLifecycle,
C: ClockSource,
S: Future<Output = ()>,
{
let schedule = R::__step_schedule();
let clock_mode = R::LaunchPolicy::clock_mode(&launch);
let reading = clock.read();
if clock_mode == ClockMode::Real
&& let ClockReading::Unsynchronized(reason) = reading
{
return Err(clock_discipline_error(reason));
}
let (scheduler, clock_handle) = step_scheduler_for(clock_mode, schedule, reading.instant())?;
let effective_clock = Arc::new(match &scheduler {
AnyStepScheduler::Simulation(sim) => RunnerClock::Simulation(sim.simulation_clock()),
AnyStepScheduler::Real(_) | AnyStepScheduler::Clockless => RunnerClock::Delegated(clock),
});
let clock_feed = clock_handle
.map(|handle| spawn_simulation_clock_feed(bus, handle))
.transpose()?;
let result = run_lifecycle_inner::<R, C, S>(
bus,
launch,
Arc::clone(&effective_clock),
scheduler,
schedule,
shutdown,
)
.await;
if let Some(task) = clock_feed {
task.abort();
}
result
}
enum RunnerClock<C: ClockSource> {
Delegated(C),
Simulation(crate::participant::clock::SimulationClock),
}
impl<C: ClockSource> ClockSource for RunnerClock<C> {
fn read(&self) -> ClockReading {
match self {
RunnerClock::Delegated(clock) => clock.read(),
RunnerClock::Simulation(clock) => clock.read(),
}
}
}
#[allow(clippy::too_many_arguments)]
async fn run_lifecycle_inner<R, C, S>(
bus: &Bus,
launch: ParticipantLaunch,
clock: Arc<RunnerClock<C>>,
scheduler: AnyStepScheduler,
schedule: Option<StepSchedule>,
shutdown: S,
) -> crate::Result<()>
where
R: ParticipantLifecycle,
C: ClockSource,
S: Future<Output = ()>,
{
R::__validate_server_topics().map_err(anyhow::Error::msg)?;
let config: R::Config = match &launch.config {
Some(value) => serde_json::from_value(value.clone())?,
None => serde_json::from_value(serde_json::Value::Null)?,
};
let robot = match &launch.robot_root {
Some(root) => Some(Arc::new(crate::model::v0::Robot::read_from_dir(root)?)),
None => None,
};
let mut ctx = SetupContext::<R>::new(
bus.clone(),
::phoxal_bus::OwnerCap::__mint(),
robot,
launch.robot_root.clone(),
launch.component_instance.clone(),
);
let (mut participant, api) = match R::__setup(&mut ctx, config).await {
Ok(pair) => pair,
Err(error) => {
let grace = Duration::from_millis(launch.shutdown_grace_ms);
let unjoined = ctx.take_managed_tasks().shutdown_within(grace).await;
log_unjoined_managed_tasks(unjoined, launch.shutdown_grace_ms);
return Err(error);
}
};
let mut managed_tasks = ctx.take_managed_tasks();
let api_shared: Arc<R::Api> = Arc::new(api.clone());
let mut api = api;
let committed: Arc<ArcSwapOption<R::Snapshot>> = Arc::new(ArcSwapOption::empty());
commit_snapshot::<R>(&participant, &committed);
let (excl_tx, mut excl_rx) = mpsc::channel::<IncomingQuery>(64);
let mut server_tasks: Vec<JoinHandle<()>> = Vec::new();
for topic in R::__exclusive_server_topics() {
let queryable = match bus.declare_server(topic).await {
Ok(queryable) => queryable,
Err(error) => {
teardown_lifecycle(
&mut participant,
&mut api,
server_tasks,
managed_tasks,
launch.shutdown_grace_ms,
)
.await;
return Err(error.into());
}
};
let tx = excl_tx.clone();
server_tasks.push(tokio::spawn(async move {
while let Ok(incoming) = queryable.recv().await {
if tx.send(incoming).await.is_err() {
break;
}
}
}));
}
for topic in R::__snapshot_server_topics() {
let queryable = match bus.declare_server(topic).await {
Ok(queryable) => queryable,
Err(error) => {
teardown_lifecycle(
&mut participant,
&mut api,
server_tasks,
managed_tasks,
launch.shutdown_grace_ms,
)
.await;
return Err(error.into());
}
};
let committed = Arc::clone(&committed);
let api_shared = Arc::clone(&api_shared);
let bus = bus.clone();
server_tasks.push(tokio::spawn(async move {
let mut inflight = tokio::task::JoinSet::new();
loop {
tokio::select! {
incoming = queryable.recv() => {
let Ok(incoming) = incoming else { break };
let snapshot = committed.load_full();
let api = Arc::clone(&api_shared);
let bus = bus.clone();
inflight.spawn(async move {
serve_snapshot_query::<R>(&bus, incoming, snapshot, api).await
});
}
Some(_) = inflight.join_next() => {}
}
}
}));
}
let runtime_performance_publisher = RuntimePerformancePublisher::attach(bus.clone());
let mut runtime_performance = RuntimePerformance::new(schedule);
let shutdown = pin!(shutdown);
let liveliness = match bus.declare_participant_liveliness().await {
Ok(token) => token,
Err(error) => {
teardown_lifecycle(
&mut participant,
&mut api,
server_tasks,
managed_tasks,
launch.shutdown_grace_ms,
)
.await;
return Err(error.into());
}
};
tracing::info!(target: "phoxal.runtime", id = R::ID, participant = %launch.participant_id, "runtime ready");
let loop_result = main_loop::<R, _, S>(
&mut participant,
&mut api,
bus,
clock.as_ref(),
&scheduler,
schedule,
&committed,
&mut excl_rx,
shutdown,
&runtime_performance_publisher,
&mut runtime_performance,
&mut managed_tasks,
)
.await;
drop(excl_tx);
teardown_lifecycle(
&mut participant,
&mut api,
server_tasks,
managed_tasks,
launch.shutdown_grace_ms,
)
.await;
drop(liveliness);
let fault = loop_result?;
if let Some(fault) = fault {
return Err(fault.into_error());
}
Ok(())
}
async fn teardown_lifecycle<R>(
participant: &mut R,
api: &mut R::Api,
server_tasks: Vec<JoinHandle<()>>,
mut managed_tasks: ManagedTasks,
shutdown_grace_ms: u64,
) where
R: ParticipantLifecycle,
{
for task in server_tasks {
task.abort();
}
let grace = Duration::from_millis(shutdown_grace_ms);
let shutdown_deadline = tokio::time::Instant::now() + grace;
managed_tasks.cancel();
let shutdown_remaining =
shutdown_deadline.saturating_duration_since(tokio::time::Instant::now());
match tokio::time::timeout(
shutdown_remaining,
participant.__shutdown(api, ShutdownContext::new(grace)),
)
.await
{
Ok(Ok(())) => {}
Ok(Err(error)) => {
tracing::warn!(target: "phoxal.runtime", error = %error, "shutdown hook returned error");
}
Err(_elapsed) => {
tracing::warn!(
target: "phoxal.runtime",
grace_ms = shutdown_grace_ms,
"shutdown hook exceeded the grace deadline; proceeding to bus close"
);
}
}
let unjoined = managed_tasks.join_until(shutdown_deadline).await;
log_unjoined_managed_tasks(unjoined, shutdown_grace_ms);
tracing::info!(target: "phoxal.runtime", id = R::ID, "runtime stopped");
}
pub(crate) enum LoopFault {
ManagedTask(ManagedTaskExit),
ClockDiscipline(TimeUnsynchronized),
}
impl LoopFault {
fn into_error(self) -> anyhow::Error {
match self {
LoopFault::ManagedTask(exit) => managed_task_fault_error(&exit),
LoopFault::ClockDiscipline(reason) => clock_discipline_error(reason),
}
}
}
pub(crate) fn clock_discipline_error(reason: TimeUnsynchronized) -> anyhow::Error {
anyhow::anyhow!("clock discipline lost: {reason}")
}
pub(crate) fn managed_task_fault_error(exit: &ManagedTaskExit) -> anyhow::Error {
match &exit.panic_message {
Some(message) => anyhow::anyhow!("managed task \"{}\" panicked: {message}", exit.name),
None => anyhow::anyhow!("managed task \"{}\" exited unexpectedly", exit.name),
}
}
pub(crate) fn log_unjoined_managed_tasks(unjoined: Vec<String>, grace_ms: u64) {
if !unjoined.is_empty() {
tracing::warn!(
target: "phoxal.runtime",
tasks = ?unjoined,
grace_ms,
"managed tasks were still running at the shutdown grace deadline"
);
}
}
#[allow(clippy::too_many_arguments)]
async fn main_loop<R, C, S>(
participant: &mut R,
api: &mut R::Api,
bus: &Bus,
clock: &C,
scheduler: &AnyStepScheduler,
schedule: Option<StepSchedule>,
committed: &Arc<ArcSwapOption<R::Snapshot>>,
excl_rx: &mut mpsc::Receiver<IncomingQuery>,
mut shutdown: std::pin::Pin<&mut S>,
runtime_performance_publisher: &RuntimePerformancePublisher,
runtime_performance: &mut RuntimePerformance,
managed_tasks: &mut ManagedTasks,
) -> crate::Result<Option<LoopFault>>
where
R: ParticipantLifecycle,
C: ClockSource,
S: Future<Output = ()>,
{
let period = schedule.map(|s| s.period());
let mut step_index: u64 = 0;
let mut active_timeline: Option<TimelineId> = None;
let mut simulation_time_rx = scheduler.simulation_time_receiver();
let initial_time = scheduler.now();
if let Some(initial_time) = initial_time.filter(|_| simulation_time_rx.is_some()) {
active_timeline = Some(initial_time.timeline());
api.__retain_timeline(initial_time.timeline());
}
let mut last_step_at = initial_time;
let mut next_step_target =
initial_time.and_then(|at| period.map(|period| advance_step_deadline(at, period, 0)));
let mut next_runtime_performance_tick = tokio::time::Instant::now();
loop {
tokio::select! {
biased;
_ = &mut shutdown => return Ok(None),
exit = managed_tasks.next_unexpected_exit() => {
tracing::error!(
target: "phoxal.runtime",
task = %exit.name,
panic = exit.panic_message.as_deref(),
"managed task exited unexpectedly; faulting the participant"
);
return Ok(Some(LoopFault::ManagedTask(exit)));
}
fired_at = simulation_time_change(&mut simulation_time_rx) => {
if active_timeline == Some(fired_at.timeline()) {
continue;
}
let previous_timeline = active_timeline.replace(fired_at.timeline());
api.__retain_timeline(fired_at.timeline());
if let Some(previous_timeline) = previous_timeline {
participant
.__reset(
api,
ResetContext::new(previous_timeline, fired_at.timeline()),
)
.await?;
commit_snapshot::<R>(participant, committed);
}
next_step_target =
period.map(|period| advance_step_deadline(fired_at, period, 0));
step_index = 0;
last_step_at = Some(fired_at);
runtime_performance.reset(schedule);
}
_ = runtime_performance_tick(next_runtime_performance_tick) => {
advance_deadline(
&mut next_runtime_performance_tick,
RUNTIME_PERFORMANCE_TICK_INTERVAL,
);
let faulted = LocalInstant::clock_faulted()
.then_some(TimeUnsynchronized::ClockFault)
.or_else(|| match (period, scheduler) {
(None, AnyStepScheduler::Real(_)) => match clock.read() {
ClockReading::Unsynchronized(reason) => Some(reason),
ClockReading::Synchronized(_) => None,
},
_ => None,
});
if let Some(reason) = faulted {
tracing::error!(
target: "phoxal.runtime",
error = %reason,
"clock discipline lost; failing the participant"
);
return Ok(Some(LoopFault::ClockDiscipline(reason)));
}
if let Some(rollup) = runtime_performance.take_rollup(bus) {
runtime_performance_publisher.publish(rollup);
}
}
SchedulerTick { fired_at, missed_ticks } = step_tick(scheduler, next_step_target) => {
let (Some(period), Some(target)) = (period, next_step_target) else { continue };
if LocalInstant::clock_faulted() {
tracing::error!(
target: "phoxal.runtime",
error = %TimeUnsynchronized::ClockFault,
"clock discipline lost; failing the participant"
);
return Ok(Some(LoopFault::ClockDiscipline(TimeUnsynchronized::ClockFault)));
}
if fired_at.timeline() != target.timeline() {
next_step_target = Some(advance_step_deadline(fired_at, period, 0));
continue;
}
active_timeline.get_or_insert(fired_at.timeline());
next_step_target = Some(advance_step_deadline(target, period, missed_ticks));
let now = match clock.read() {
ClockReading::Synchronized(now) if now.timeline() == target.timeline() => now,
ClockReading::Synchronized(_) => {
continue;
}
ClockReading::Unsynchronized(reason) => {
tracing::error!(
target: "phoxal.runtime",
error = %reason,
"clock discipline lost; failing the participant"
);
return Ok(Some(LoopFault::ClockDiscipline(reason)));
}
};
let dt = last_step_at
.and_then(|last| now.duration_since(last).ok())
.unwrap_or_default();
last_step_at = Some(now);
let step = StepContext::new(
StepToken::__mint(now),
step_index,
dt,
missed_ticks,
);
step_index += 1;
let observation = runtime_performance.begin_step(target, fired_at, missed_ticks);
let success = match participant.__step(api, step).await {
Ok(()) => {
commit_snapshot::<R>(participant, committed);
true
}
Err(e) => {
tracing::warn!(target: "phoxal.runtime", error = %e, "step returned error");
false
}
};
runtime_performance.finish_step(observation, success);
}
Some(incoming) = excl_rx.recv() => {
if serve_exclusive_query::<R>(participant, api, bus, incoming).await {
commit_snapshot::<R>(participant, committed);
}
}
}
}
}
pub(crate) async fn simulation_time_change(
receiver: &mut Option<tokio::sync::watch::Receiver<Option<RobotInstant>>>,
) -> RobotInstant {
let Some(receiver) = receiver else {
return std::future::pending().await;
};
loop {
if receiver.changed().await.is_ok() {
if let Some(at) = *receiver.borrow_and_update() {
return at;
}
continue;
}
std::future::pending::<()>().await;
}
}
pub(crate) fn advance_step_deadline(
target: RobotInstant,
period: Duration,
missed_ticks: u32,
) -> RobotInstant {
let period_ns = duration_to_nanos_saturating(period);
let periods = u64::from(missed_ticks).saturating_add(1);
target.saturating_add(Duration::from_nanos(period_ns.saturating_mul(periods)))
}
pub(crate) async fn runtime_performance_tick(next: tokio::time::Instant) {
tokio::time::sleep_until(next).await;
}
pub(crate) fn advance_deadline(next: &mut tokio::time::Instant, period: Duration) {
*next += period;
let now = tokio::time::Instant::now();
while *next <= now {
*next += period;
}
}
pub(crate) async fn step_tick(
scheduler: &AnyStepScheduler,
target: Option<RobotInstant>,
) -> SchedulerTick {
match target {
Some(target) => scheduler.wait_until(target).await,
None => std::future::pending().await,
}
}
fn commit_snapshot<R: ParticipantLifecycle>(
participant: &R,
committed: &Arc<ArcSwapOption<R::Snapshot>>,
) {
if R::HAS_SNAPSHOT {
committed.store(Some(Arc::new(participant.__take_snapshot())));
}
}
async fn serve_exclusive_query<R: ParticipantLifecycle>(
participant: &mut R,
api: &mut R::Api,
bus: &Bus,
incoming: IncomingQuery,
) -> bool {
let topic = incoming.topic_key().to_string();
let metadata = match incoming.request_metadata() {
Ok(m) => m,
Err(e) => {
let _ = incoming
.reply_err(&QueryFailure::invalid_argument(e.to_string()))
.await;
return false;
}
};
if metadata.codec_id().is_none() {
let _ = incoming
.reply_err(&QueryFailure::invalid_argument(format!(
"unsupported request codec id {}",
metadata.codec
)))
.await;
return false;
}
let request = match incoming.request_bytes() {
Ok(bytes) => bytes,
Err(e) => {
let _ = incoming
.reply_err(&QueryFailure::invalid_argument(e.to_string()))
.await;
return false;
}
};
match participant.__serve_exclusive(api, &topic, &request).await {
Ok(reply) => {
let _ = incoming.reply(bus, reply.payload).await;
true
}
Err(failure) => {
let _ = incoming.reply_err(&failure).await;
false
}
}
}
async fn serve_snapshot_query<R: ParticipantLifecycle>(
bus: &Bus,
incoming: IncomingQuery,
snapshot: Option<Arc<R::Snapshot>>,
api: Arc<R::Api>,
) {
let topic = incoming.topic_key().to_string();
let metadata = match incoming.request_metadata() {
Ok(m) => m,
Err(e) => {
let _ = incoming
.reply_err(&QueryFailure::invalid_argument(e.to_string()))
.await;
return;
}
};
if metadata.codec_id().is_none() {
let _ = incoming
.reply_err(&QueryFailure::invalid_argument(format!(
"unsupported request codec id {}",
metadata.codec
)))
.await;
return;
}
let request = match incoming.request_bytes() {
Ok(bytes) => bytes,
Err(e) => {
let _ = incoming
.reply_err(&QueryFailure::invalid_argument(e.to_string()))
.await;
return;
}
};
let Some(snapshot) = snapshot else {
let _ = incoming
.reply_err(&QueryFailure::unavailable("no committed snapshot yet"))
.await;
return;
};
match R::__serve_snapshot(snapshot, api, topic, request).await {
Ok(reply) => {
let _ = incoming.reply(bus, reply.payload).await;
}
Err(failure) => {
let _ = incoming.reply_err(&failure).await;
}
}
}
pub(crate) async fn shutdown_signal() {
if let Err(e) = tokio::signal::ctrl_c().await {
tracing::warn!(target: "phoxal.runtime", error = %e, "failed to listen for ctrl-c");
}
}
pub(crate) fn init_tracing() {
static INIT: OnceLock<()> = OnceLock::new();
INIT.get_or_init(|| {
use tracing_subscriber::EnvFilter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let bus_layer = bus_log::BusLogLayer::new(bus_log_state());
let _ = tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer().with_target(true))
.with(bus_layer)
.try_init();
});
}
pub(crate) fn bus_log_state() -> Arc<BusLogState> {
static STATE: OnceLock<Arc<BusLogState>> = OnceLock::new();
Arc::clone(STATE.get_or_init(bus_log::new_state_from_env))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bus::{StatePublisher, TimelineAuthority};
fn line(value: u64) -> TimelineId {
TimelineId::from_raw(value).expect("test timeline must be nonzero")
}
fn at(timeline: u64, ticks: u64) -> RobotInstant {
RobotInstant::new(line(timeline), ticks)
}
#[test]
fn step_scheduler_for_selects_real_or_simulation_by_clock_mode() {
let schedule = Some(StepSchedule::hz(100.0));
let (real, real_handle) =
step_scheduler_for(ClockMode::Real, schedule, Some(at(1, 0))).expect("real scheduler");
assert!(matches!(real, AnyStepScheduler::Real(_)));
assert!(
real_handle.is_none(),
"real mode has no simulation clock handle to drive"
);
let (simulation, simulation_handle) =
step_scheduler_for(ClockMode::Simulation, schedule, None)
.expect("simulation scheduler");
assert!(matches!(simulation, AnyStepScheduler::Simulation(_)));
assert!(
simulation_handle.is_some(),
"simulation mode must hand back the driving handle so the caller can wire the live feed"
);
assert_eq!(
simulation.now(),
None,
"simulation mode starts with no world history, not with an invented zero"
);
}
#[test]
fn a_real_participant_with_no_trustworthy_clock_does_not_get_a_cadence_at_all() {
assert!(step_scheduler_for(ClockMode::Real, Some(StepSchedule::hz(50.0)), None).is_err());
}
#[test]
fn step_deadlines_skip_collapsed_ticks_and_saturate_instead_of_wrapping() {
assert_eq!(
advance_step_deadline(at(1, 10), Duration::from_nanos(10), 3),
at(1, 50),
"target 10 plus the fired period and 3 collapsed periods should resume at 50"
);
assert_eq!(
advance_step_deadline(at(2, u64::MAX - 2), Duration::from_nanos(10), 3),
at(2, u64::MAX)
);
}
#[tokio::test]
async fn simulation_scheduler_selected_by_the_runner_schedules_deterministically() {
let schedule = StepSchedule::hz(10.0); let period_ns = duration_to_nanos_saturating(schedule.period());
let (scheduler, handle) = step_scheduler_for(ClockMode::Simulation, Some(schedule), None)
.expect("simulation scheduler");
let handle = handle.expect("simulation mode must hand back a driving handle");
let mut fired = Vec::new();
let mut target = at(1, period_ns);
for _ in 0..3 {
handle.advance(target);
let tick = scheduler.wait_until(target).await;
fired.push(tick.fired_at.ticks());
target = at(1, target.ticks() + period_ns);
}
assert_eq!(
fired,
vec![100_000_000, 200_000_000, 300_000_000],
"ticks fire in order at the instants the handle advanced to, with no real sleeping"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn simulation_clock_feed_drives_the_scheduler_from_published_samples() {
let bus_config = BusConfig::in_process("test/sim-clock-feed-unit", "robot");
let bus = Bus::open(bus_config).await.expect("bus should open");
let clock_publisher = StatePublisher::<api::simulation::Clock>::new(
bus.clone(),
&api::topic::internal::new(crate::bus::OwnerCap::__mint())
.simulation()
.clock(),
)
.expect("clock publisher should attach");
let mut authority =
TimelineAuthority::__mint(line(11)).expect("the world authority should mint");
let period = Duration::from_millis(10);
let release_guard = Duration::from_secs(10);
let (scheduler, handle) =
SimulationScheduler::new(crate::participant::spec::MissedTick::Collapse, Some(period));
let feed_task = spawn_simulation_clock_feed(&bus, handle).expect("feed task should spawn");
let period_ns = duration_to_nanos_saturating(period);
let first_target = at(11, period_ns);
assert!(
tokio::time::timeout(
Duration::from_millis(100),
scheduler.wait_until(first_target),
)
.await
.is_err(),
"scheduler must not release before any simulation/clock sample arrives"
);
clock_publisher
.publish(
&authority.completed_step(period_ns),
api::simulation::Clock { step: 1 },
)
.expect("clock sample should publish");
let tick = tokio::time::timeout(release_guard, scheduler.wait_until(first_target))
.await
.expect("scheduler should release once the feed advances past the target");
assert_eq!(tick.fired_at, first_target);
let second_target = at(11, 2 * period_ns);
assert!(
tokio::time::timeout(
Duration::from_millis(200),
scheduler.wait_until(second_target),
)
.await
.is_err(),
"the scheduler must remain still while no new clock sample arrives"
);
clock_publisher
.publish(
&authority.completed_step(2 * period_ns),
api::simulation::Clock { step: 2 },
)
.expect("second clock sample should publish");
let tick = tokio::time::timeout(release_guard, scheduler.wait_until(second_target))
.await
.expect("scheduler should release on the next clock sample");
assert_eq!(tick.fired_at, second_target);
authority.replace_timeline(line(12));
clock_publisher
.publish(
&authority.completed_step(0),
api::simulation::Clock { step: 0 },
)
.expect("replacement clock should publish");
let tick = tokio::time::timeout(release_guard, scheduler.wait_until(at(11, 3 * period_ns)))
.await
.expect("replacement timeline should reach the scheduler");
assert_eq!(tick.fired_at, at(12, 0));
authority.replace_timeline(line(11));
clock_publisher
.publish(
&authority.completed_step(3 * period_ns),
api::simulation::Clock { step: 3 },
)
.expect("late retired clock should publish at the bus layer");
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(
scheduler.now(),
Some(at(12, 0)),
"a retired clock must not roll the scheduler back to an old world history"
);
feed_task.abort();
bus.close().await.expect("bus should close");
}
}