use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use tokio::sync::{Notify, mpsc};
use tokio::task::JoinHandle;
use zenoh::bytes::{Encoding, ZBytes};
use zenoh::key_expr::OwnedKeyExpr;
use crate::bus::error::{BusError, Result};
const OUTBOUND_CAPACITY: usize = 1024;
const OUTBOUND_MAX_BYTES: usize = 16 * 1024 * 1024;
#[derive(Clone, Debug)]
pub struct BusConfig {
pub namespace: String,
pub robot_id: String,
pub participant: String,
pub incarnation: u64,
pub connect_endpoints: Vec<String>,
}
impl BusConfig {
pub fn in_process(namespace: impl Into<String>, robot_id: impl Into<String>) -> Self {
BusConfig {
namespace: namespace.into(),
robot_id: robot_id.into(),
participant: "local".to_string(),
incarnation: 0,
connect_endpoints: Vec::new(),
}
}
}
#[derive(Debug, Default)]
pub struct BusHealth {
pub outbound_drops: AtomicU64,
pub inbound_drops: AtomicU64,
pub api_mismatches: AtomicU64,
pub decode_errors: AtomicU64,
}
struct Outbound {
key: String,
encoding: String,
attachment: Vec<u8>,
payload: Vec<u8>,
bytes: usize,
}
struct BusInner {
session: zenoh::Session,
root: String,
participant: String,
incarnation: u64,
seq: AtomicU64,
outbound: mpsc::Sender<Outbound>,
queued_bytes: AtomicUsize,
closing: AtomicBool,
shutdown: Notify,
drain: std::sync::Mutex<Option<JoinHandle<()>>>,
health: BusHealth,
}
#[derive(Clone)]
pub struct Bus {
inner: Arc<BusInner>,
}
impl std::fmt::Debug for Bus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Bus")
.field("root", &self.inner.root)
.field("participant", &self.inner.participant)
.finish_non_exhaustive()
}
}
impl Bus {
pub async fn open(config: BusConfig) -> Result<Self> {
validate_segment(&config.namespace, "identity.namespace", true)?;
validate_segment(&config.robot_id, "identity.id", false)?;
if config.participant.is_empty() {
return Err(BusError::Namespace(
"participant id must not be empty".to_string(),
));
}
let root = format!("{}/robots/{}", config.namespace, config.robot_id);
OwnedKeyExpr::new(root.clone())
.map_err(|e| BusError::Namespace(format!("invalid key root '{root}': {e}")))?;
let session = zenoh::open(zenoh_config(&config.connect_endpoints))
.await
.map_err(|e| BusError::Transport(e.to_string()))?;
let (tx, rx) = mpsc::channel::<Outbound>(OUTBOUND_CAPACITY);
let drain_session = session.clone();
let inner = Arc::new(BusInner {
session,
root,
participant: config.participant,
incarnation: config.incarnation,
seq: AtomicU64::new(0),
outbound: tx,
queued_bytes: AtomicUsize::new(0),
closing: AtomicBool::new(false),
shutdown: Notify::new(),
drain: std::sync::Mutex::new(None),
health: BusHealth::default(),
});
let drain = tokio::spawn(drain_loop(drain_session, rx, Arc::clone(&inner)));
*inner.drain.lock().expect("drain mutex poisoned") = Some(drain);
Ok(Bus { inner })
}
pub fn root(&self) -> &str {
&self.inner.root
}
pub fn participant(&self) -> &str {
&self.inner.participant
}
pub fn incarnation(&self) -> u64 {
self.inner.incarnation
}
pub fn health(&self) -> &BusHealth {
&self.inner.health
}
pub fn session(&self) -> &zenoh::Session {
&self.inner.session
}
pub fn full_key(&self, topic_key: &str) -> String {
format!("{}/{}", self.inner.root, topic_key)
}
pub fn next_sequence(&self) -> u64 {
self.inner.seq.fetch_add(1, Ordering::Relaxed)
}
pub(crate) fn enqueue(
&self,
key: String,
encoding: String,
attachment: Vec<u8>,
payload: Vec<u8>,
) -> Result<()> {
if self.inner.closing.load(Ordering::Acquire) {
return Err(BusError::Closed);
}
let bytes = key.len() + encoding.len() + attachment.len() + payload.len();
let prev = self.inner.queued_bytes.fetch_add(bytes, Ordering::AcqRel);
if prev + bytes > OUTBOUND_MAX_BYTES {
self.inner.queued_bytes.fetch_sub(bytes, Ordering::AcqRel);
return Err(self.dropped(&key, "byte bound"));
}
let outbound = Outbound {
key,
encoding,
attachment,
payload,
bytes,
};
match self.inner.outbound.try_send(outbound) {
Ok(()) => Ok(()),
Err(mpsc::error::TrySendError::Full(out)) => {
self.inner.queued_bytes.fetch_sub(bytes, Ordering::AcqRel);
Err(self.dropped(&out.key, "sample bound"))
}
Err(mpsc::error::TrySendError::Closed(_)) => {
self.inner.queued_bytes.fetch_sub(bytes, Ordering::AcqRel);
Err(BusError::Closed)
}
}
}
fn dropped(&self, key: &str, detail: &str) -> BusError {
self.inner
.health
.outbound_drops
.fetch_add(1, Ordering::Relaxed);
tracing::warn!(
target: "phoxal.bus",
participant = %self.inner.participant,
key,
detail,
"outbound queue saturated; dropped sample (publish never blocks the step loop)"
);
BusError::Saturated {
topic: key.to_string(),
detail: detail.to_string(),
}
}
pub async fn close(&self) -> Result<()> {
self.inner.closing.store(true, Ordering::Release);
self.inner.shutdown.notify_one();
let handle = self
.inner
.drain
.lock()
.expect("drain mutex poisoned")
.take();
if let Some(handle) = handle {
let _ = handle.await;
}
self.inner
.session
.close()
.await
.map_err(|e| BusError::Transport(e.to_string()))?;
Ok(())
}
}
async fn drain_loop(
session: zenoh::Session,
mut rx: mpsc::Receiver<Outbound>,
inner: Arc<BusInner>,
) {
loop {
tokio::select! {
biased;
_ = inner.shutdown.notified() => {
while let Ok(out) = rx.try_recv() {
inner.queued_bytes.fetch_sub(out.bytes, Ordering::AcqRel);
put(&session, out).await;
}
break;
}
msg = rx.recv() => match msg {
Some(out) => {
inner.queued_bytes.fetch_sub(out.bytes, Ordering::AcqRel);
put(&session, out).await;
}
None => break,
},
}
}
}
async fn put(session: &zenoh::Session, out: Outbound) {
let key = match OwnedKeyExpr::new(out.key.clone()) {
Ok(key) => key,
Err(e) => {
tracing::error!(target: "phoxal.bus", key = %out.key, error = %e, "invalid publish key");
return;
}
};
if let Err(e) = session
.put(key, ZBytes::from(out.payload))
.encoding(Encoding::from(out.encoding))
.attachment(out.attachment)
.await
{
tracing::warn!(target: "phoxal.bus", key = %out.key, error = %e, "publish failed");
}
}
fn validate_segment(value: &str, what: &str, multi: bool) -> Result<()> {
if value.is_empty() {
return Err(BusError::Namespace(format!("{what} must not be empty")));
}
if !multi && value.contains('/') {
return Err(BusError::Namespace(format!(
"{what} must be a single key segment, got '{value}'"
)));
}
for seg in value.split('/') {
if seg.is_empty() {
return Err(BusError::Namespace(format!(
"{what} '{value}' has an empty segment"
)));
}
if seg.contains('*') {
return Err(BusError::Namespace(format!(
"{what} '{value}' must be a concrete (non-wildcard) path"
)));
}
}
Ok(())
}
fn zenoh_config(connect_endpoints: &[String]) -> zenoh::Config {
let mut config = zenoh::Config::default();
let _ = config.insert_json5("scouting/multicast/enabled", "false");
if connect_endpoints.is_empty() {
let _ = config.insert_json5("listen/endpoints", "[]");
} else {
let _ = config.insert_json5("mode", "\"client\"");
if let Ok(json) = serde_json::to_string(connect_endpoints) {
let _ = config.insert_json5("connect/endpoints", &json);
}
}
config
}