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::bus::{LogicalTime, QueryFailure, Subscriber};
use crate::participant::bus_log::{self, BusLogState};
use crate::participant::clock::{ClockSource, RealClock};
use crate::participant::context::{SetupContext, ShutdownContext, StepContext};
use crate::participant::emit::print_emit_apis;
use crate::participant::heartbeat::{self, HeartbeatPublisher};
use crate::participant::launch::{ClockMode, LaunchAction, ParticipantLaunch};
use crate::participant::managed::{ManagedTaskExit, ManagedTasks};
use crate::participant::scheduler::{
AnyStepScheduler, RealScheduler, SchedulerTick, SimulationClockHandle, SimulationScheduler,
StepScheduler, duration_to_nanos_saturating,
};
use crate::participant::spec::{ParticipantBehavior, StepSchedule};
use phoxal_api::y2026_1 as api;
use phoxal_bus::{Bus, BusConfig, IncomingQuery};
pub fn run<R: ParticipantBehavior>() -> 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: ParticipantBehavior>() -> crate::Result<()> {
let launch = match ParticipantLaunch::from_cli(R::ID, "robot")? {
LaunchAction::Run(launch) => launch,
LaunchAction::EmitApis => {
print_emit_apis::<R>();
return Ok(());
}
};
init_tracing();
let clock = launch_clock(&launch)?;
run_with::<R, _, _>(launch, clock, shutdown_signal()).await
}
fn launch_clock(launch: &ParticipantLaunch) -> crate::Result<RealClock> {
match launch.clock {
ClockMode::Real | ClockMode::Simulation => Ok(RealClock::new()),
}
}
fn step_scheduler_for(
clock_mode: ClockMode,
schedule: Option<StepSchedule>,
now: LogicalTime,
) -> (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());
match clock_mode {
ClockMode::Real => (
AnyStepScheduler::Real(RealScheduler::new(missed_tick, period, now)),
None,
),
ClockMode::Simulation => {
let (scheduler, handle) = SimulationScheduler::new(missed_tick, period, now);
(AnyStepScheduler::Simulation(scheduler), Some(handle))
}
}
}
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(received) = subscriber.recv().await {
let at = LogicalTime::new(received.metadata.epoch, received.metadata.produced_at_ns);
handle.advance(at);
if received.body.running {
handle.resume();
} else {
handle.pause();
}
}
}))
}
pub async fn run_with<R, C, S>(
launch: ParticipantLaunch,
clock: C,
shutdown: S,
) -> crate::Result<()>
where
R: ParticipantBehavior,
C: ClockSource,
S: Future<Output = ()>,
{
init_tracing();
let bus = Bus::open(BusConfig {
namespace: launch.namespace.clone(),
robot_id: launch.robot_id.clone(),
participant: launch.participant_id.clone(),
incarnation: 0,
connect_endpoints: launch.bus.connect_endpoints.clone(),
})
.await?;
let result = run_with_bus::<R, C, S>(&bus, launch, clock, 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, C, S>(
bus: &Bus,
launch: ParticipantLaunch,
clock: C,
shutdown: S,
) -> crate::Result<()>
where
R: ParticipantBehavior,
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: ParticipantBehavior,
C: ClockSource,
S: Future<Output = ()>,
{
let mut heartbeat = HeartbeatPublisher::attach(bus.clone(), launch.participant_id.clone());
heartbeat.publish(clock.now());
let result =
run_lifecycle_inner::<R, C, S>(bus, launch, &clock, shutdown, &mut heartbeat).await;
if result.is_err() {
heartbeat.set_readiness(api::presence::Readiness::Failed);
heartbeat.publish(clock.now());
}
result
}
async fn run_lifecycle_inner<R, C, S>(
bus: &Bus,
launch: ParticipantLaunch,
clock: &C,
shutdown: S,
heartbeat: &mut HeartbeatPublisher,
) -> crate::Result<()>
where
R: ParticipantBehavior,
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::v1::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 = match R::__setup(&mut ctx, config).await {
Ok(participant) => participant,
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 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 = bus.declare_server(topic).await?;
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 = bus.declare_server(topic).await?;
let committed = Arc::clone(&committed);
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 bus = bus.clone();
inflight.spawn(async move {
serve_snapshot_query::<R>(&bus, incoming, snapshot).await
});
}
Some(_) = inflight.join_next() => {}
}
}
}));
}
let schedule = R::__step_schedule();
let (scheduler, clock_handle) = step_scheduler_for(launch.clock, schedule, clock.now());
if let Some(handle) = clock_handle {
server_tasks.push(spawn_simulation_clock_feed(bus, handle)?);
}
let shutdown = pin!(shutdown);
heartbeat.set_readiness(api::presence::Readiness::Ready);
heartbeat.publish(clock.now());
tracing::info!(target: "phoxal.runtime", id = R::ID, participant = %launch.participant_id, "runtime ready");
super::sd_notify::ready();
let watchdog = super::sd_notify::Watchdog::start();
let fault = main_loop::<R, C, S>(
&mut participant,
bus,
clock,
&scheduler,
schedule,
&committed,
&mut excl_rx,
shutdown,
heartbeat,
&watchdog,
&mut managed_tasks,
)
.await;
watchdog.shutdown();
heartbeat.set_readiness(api::presence::Readiness::Degraded);
heartbeat.publish(clock.now());
drop(excl_tx);
for task in server_tasks {
task.abort();
}
let grace = Duration::from_millis(launch.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(ShutdownContext::new(grace)),
)
.await
{
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::warn!(target: "phoxal.runtime", error = %e, "shutdown hook returned error");
}
Err(_elapsed) => {
tracing::warn!(
target: "phoxal.runtime",
grace_ms = launch.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, launch.shutdown_grace_ms);
tracing::info!(target: "phoxal.runtime", id = R::ID, "runtime stopped");
if let Some(fault) = fault {
return Err(managed_task_fault_error(&fault));
}
Ok(())
}
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),
}
}
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,
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>,
heartbeat: &mut HeartbeatPublisher,
watchdog: &super::sd_notify::Watchdog,
managed_tasks: &mut ManagedTasks,
) -> Option<ManagedTaskExit>
where
R: ParticipantBehavior,
C: ClockSource,
S: Future<Output = ()>,
{
let period = schedule.map(|s| s.period());
let mut step_index: u64 = 0;
let mut last_time_ns = clock.now().time_ns();
let mut next_step_target = period.map(|period| {
let now = scheduler.now();
advance_logical_deadline(now, period, 0)
});
let mut next_heartbeat = tokio::time::Instant::now();
loop {
tokio::select! {
biased;
_ = &mut shutdown => return 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 Some(exit);
}
_ = heartbeat_tick(next_heartbeat) => {
heartbeat.publish(clock.now());
watchdog.feed();
advance_deadline(&mut next_heartbeat, heartbeat::HEARTBEAT_INTERVAL);
}
SchedulerTick { missed_ticks, .. } = step_tick(scheduler, next_step_target) => {
let (Some(period), Some(target)) = (period, next_step_target) else { continue };
next_step_target = Some(advance_logical_deadline(target, period, missed_ticks));
let now = clock.now();
let dt_ns = now.time_ns().saturating_sub(last_time_ns);
last_time_ns = now.time_ns();
let step = StepContext::new(now.epoch(), step_index, now.time_ns(), dt_ns, missed_ticks);
step_index += 1;
match participant.__step(step).await {
Ok(()) => commit_snapshot::<R>(participant, committed),
Err(e) => {
tracing::warn!(target: "phoxal.runtime", error = %e, "step returned error");
}
}
watchdog.feed();
}
Some(incoming) = excl_rx.recv() => {
if serve_exclusive_query::<R>(participant, bus, incoming).await {
commit_snapshot::<R>(participant, committed);
}
watchdog.feed();
}
}
}
}
fn advance_logical_deadline(
target: LogicalTime,
period: Duration,
missed_ticks: u32,
) -> LogicalTime {
let period_ns = duration_to_nanos_saturating(period);
let periods = u64::from(missed_ticks).saturating_add(1);
LogicalTime::new(
target.epoch(),
target
.time_ns()
.saturating_add(period_ns.saturating_mul(periods)),
)
}
async fn heartbeat_tick(next: tokio::time::Instant) {
tokio::time::sleep_until(next).await;
}
fn advance_deadline(next: &mut tokio::time::Instant, period: Duration) {
*next += period;
let now = tokio::time::Instant::now();
while *next <= now {
*next += period;
}
}
async fn step_tick(scheduler: &AnyStepScheduler, target: Option<LogicalTime>) -> SchedulerTick {
match target {
Some(target) => scheduler.wait_until(target).await,
None => std::future::pending().await,
}
}
fn commit_snapshot<R: ParticipantBehavior>(
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: ParticipantBehavior>(
participant: &mut R,
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(
&topic,
&metadata.api_version,
&metadata.schema_id,
&metadata.family,
&request,
)
.await
{
Ok(reply) => {
let _ = incoming
.reply(
bus,
reply.payload,
reply.family,
reply.api_version,
reply.schema_id,
)
.await;
true
}
Err(failure) => {
let _ = incoming.reply_err(&failure).await;
false
}
}
}
async fn serve_snapshot_query<R: ParticipantBehavior>(
bus: &Bus,
incoming: IncomingQuery,
snapshot: Option<Arc<R::Snapshot>>,
) {
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,
topic,
metadata.api_version,
metadata.schema_id,
metadata.family,
request,
)
.await
{
Ok(reply) => {
let _ = incoming
.reply(
bus,
reply.payload,
reply.family,
reply.api_version,
reply.schema_id,
)
.await;
}
Err(failure) => {
let _ = incoming.reply_err(&failure).await;
}
}
}
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");
}
}
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::*;
#[test]
fn simulation_clock_launch_is_accepted() {
let mut launch = ParticipantLaunch::local("participant", "robot");
launch.clock = ClockMode::Simulation;
launch_clock(&launch).expect("simulation clock launch should be accepted");
}
#[test]
fn step_scheduler_for_selects_real_or_simulation_by_clock_mode() {
let now = LogicalTime::new(0, 0);
let schedule = Some(StepSchedule::hz(100.0));
let (real, real_handle) = step_scheduler_for(ClockMode::Real, schedule, now);
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, now);
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"
);
}
#[test]
fn logical_step_deadline_skips_collapsed_ticks() {
let next = advance_logical_deadline(LogicalTime::new(0, 10), Duration::from_nanos(10), 3);
assert_eq!(
next,
LogicalTime::new(0, 50),
"target 10 plus the fired period and 3 collapsed periods should resume at 50"
);
}
#[test]
fn logical_step_deadline_saturates_instead_of_wrapping() {
let next = advance_logical_deadline(
LogicalTime::new(2, u64::MAX - 2),
Duration::from_nanos(10),
3,
);
assert_eq!(next, LogicalTime::new(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 start = LogicalTime::new(0, 0);
let (scheduler, handle) = step_scheduler_for(ClockMode::Simulation, Some(schedule), start);
let handle = handle.expect("simulation mode must hand back a driving handle");
let mut fired = Vec::new();
let mut target = LogicalTime::new(0, period_ns);
for _ in 0..3 {
handle.advance(target);
let tick = scheduler.wait_until(target).await;
fired.push(tick.fired_at.time_ns());
target = LogicalTime::new(0, target.time_ns() + period_ns);
}
assert_eq!(
fired,
vec![100_000_000, 200_000_000, 300_000_000],
"ticks fire in order at the logical times 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 = crate::bus::Publisher::<api::simulation::Clock>::new(
bus.clone(),
&api::topic::internal::new(crate::bus::OwnerCap::__mint())
.simulation()
.clock(),
)
.expect("clock publisher should attach");
let period = Duration::from_millis(10);
let (scheduler, handle) = SimulationScheduler::new(
crate::participant::spec::MissedTick::Collapse,
Some(period),
LogicalTime::new(0, 0),
);
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 = LogicalTime::new(0, period_ns);
let pending = tokio::time::timeout(
Duration::from_millis(100),
scheduler.wait_until(first_target),
)
.await;
assert!(
pending.is_err(),
"scheduler must not release before any simulation/clock sample arrives"
);
clock_publisher
.publish_at(
first_target,
api::simulation::Clock {
now_ns: period_ns,
running: true,
},
)
.await
.expect("clock sample should publish");
let tick = tokio::time::timeout(Duration::from_secs(2), 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 = LogicalTime::new(0, 2 * period_ns);
clock_publisher
.publish_at(
second_target,
api::simulation::Clock {
now_ns: 2 * period_ns,
running: false,
},
)
.await
.expect("paused clock sample should publish");
let still_pending = tokio::time::timeout(
Duration::from_millis(200),
scheduler.wait_until(second_target),
)
.await;
assert!(
still_pending.is_err(),
"running=false must withhold release even though logical time already reached the target"
);
clock_publisher
.publish_at(
second_target,
api::simulation::Clock {
now_ns: 2 * period_ns,
running: true,
},
)
.await
.expect("resume clock sample should publish");
let tick =
tokio::time::timeout(Duration::from_secs(2), scheduler.wait_until(second_target))
.await
.expect("scheduler should release once resumed");
assert_eq!(tick.fired_at, second_target);
feed_task.abort();
bus.close().await.expect("bus should close");
}
}