use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Duration;
use zenoh::{Session, Wait};
use super::attachment::AttachmentFormat;
use super::liveliness::{ADMIN_SPACE, Entity};
use super::names::NameError;
use super::node::Node;
use super::qos::QosProfile;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum RosError {
#[error("zenoh: {0}")]
Zenoh(String),
#[error("invalid zenoh configuration: {0}")]
Config(String),
#[error(transparent)]
Name(#[from] NameError),
#[error("CDR: {0}")]
Cdr(#[from] super::cdr::CdrError),
#[error("service call timed out")]
Timeout,
#[error("service replied with an error: {0}")]
ServiceError(String),
#[error("ROS entity id space exhausted")]
EntityIdExhausted,
}
impl RosError {
pub(crate) fn zenoh(e: impl std::fmt::Display) -> Self {
Self::Zenoh(e.to_string())
}
}
pub type Result<T> = std::result::Result<T, RosError>;
static NEXT_ENTITY_ID: AtomicU64 = AtomicU64::new(0);
fn allocate_entity_id(next: &AtomicU64) -> Result<u64> {
loop {
let id = next.load(Ordering::Relaxed);
let Some(successor) = id.checked_add(1) else {
return Err(RosError::EntityIdExhausted);
};
if next
.compare_exchange_weak(id, successor, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
return Ok(id);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum SessionConfig {
#[default]
RmwZenohDefault,
File(PathBuf),
Json5(String),
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize,
)]
pub enum RmwZenohCompat {
#[default]
#[serde(rename = "current")]
Current,
#[serde(rename = "humble")]
Humble,
#[serde(rename = "jazzy-0.2.3")]
Jazzy023,
#[serde(rename = "jazzy-0.2.2")]
Jazzy022,
#[serde(rename = "jazzy-0.2.0")]
Jazzy020,
}
pub const TYPE_HASH_NOT_SUPPORTED: &str = "TypeHashNotSupported";
impl RmwZenohCompat {
#[must_use]
pub fn type_hash_key(self, rihs01: String) -> String {
if self.type_hashes() {
rihs01
} else {
TYPE_HASH_NOT_SUPPORTED.to_owned()
}
}
#[must_use]
pub fn type_hashes(self) -> bool {
self != Self::Humble
}
#[must_use]
pub fn attachment_format(self) -> AttachmentFormat {
match self {
Self::Current | Self::Humble => AttachmentFormat::Current,
Self::Jazzy023 | Self::Jazzy022 | Self::Jazzy020 => AttachmentFormat::Legacy,
}
}
#[must_use]
pub fn qos_defaults(self) -> &'static QosProfile {
match self {
Self::Current | Self::Humble | Self::Jazzy023 => &QosProfile::RMW_ZENOH_DEFAULT,
Self::Jazzy022 | Self::Jazzy020 => &QosProfile::RMW_ZENOH_DEFAULT_BEFORE_0_2_3,
}
}
#[must_use]
pub fn advanced_pubsub(self) -> bool {
self != Self::Jazzy020
}
#[must_use]
pub fn sample_miss_recovery(self) -> bool {
matches!(self, Self::Current | Self::Humble)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextOptions {
pub domain_id: u32,
pub enclave: String,
pub config: SessionConfig,
pub overrides: Vec<(String, String)>,
pub router_check_attempts: Option<u64>,
pub compat: RmwZenohCompat,
}
impl Default for ContextOptions {
fn default() -> Self {
Self {
domain_id: 0,
enclave: "/".to_owned(),
config: SessionConfig::RmwZenohDefault,
overrides: Vec::new(),
router_check_attempts: Some(1),
compat: RmwZenohCompat::Current,
}
}
}
impl ContextOptions {
pub fn from_env() -> Result<Self> {
let mut opts = Self::default();
if let Ok(v) = std::env::var("ROS_DOMAIN_ID")
&& !v.trim().is_empty()
{
opts.domain_id = v
.trim()
.parse()
.map_err(|_| RosError::Config(format!("ROS_DOMAIN_ID={v:?} is not a number")))?;
}
if let Ok(v) = std::env::var("ZENOH_SESSION_CONFIG_URI")
&& !v.is_empty()
{
opts.config = SessionConfig::File(PathBuf::from(v));
}
if let Ok(v) = std::env::var("ZENOH_ROUTER_CHECK_ATTEMPTS")
&& !v.trim().is_empty()
{
let n: i64 = v.trim().parse().map_err(|_| {
RosError::Config(format!("ZENOH_ROUTER_CHECK_ATTEMPTS={v:?} is not a number"))
})?;
opts.router_check_attempts = u64::try_from(n).ok();
}
if let Ok(v) = std::env::var("ZENOH_CONFIG_OVERRIDE") {
opts.overrides.extend(parse_overrides(&v)?);
}
Ok(opts)
}
}
pub fn parse_overrides(s: &str) -> Result<Vec<(String, String)>> {
s.split(';')
.map(str::trim)
.filter(|e| !e.is_empty())
.map(|entry| {
entry
.split_once('=')
.map(|(k, v)| (k.trim().to_owned(), v.trim().to_owned()))
.ok_or_else(|| RosError::Config(format!("override {entry:?} is not key=value")))
})
.collect()
}
const RMW_ZENOH_DEFAULTS: &[(&str, &str)] = &[
("mode", r#""peer""#),
("connect/endpoints", r#"["tcp/localhost:7447"]"#),
("connect/timeout_ms", "{ router: -1, peer: -1, client: 0 }"),
(
"connect/exit_on_failure",
"{ router: false, peer: false, client: true }",
),
("listen/endpoints", r#"["tcp/localhost:0"]"#),
("scouting/multicast/enabled", "false"),
("scouting/gossip/enabled", "true"),
("scouting/gossip/multihop", "false"),
(
"scouting/gossip/autoconnect",
r#"{ router: [], peer: ["router", "peer"] }"#,
),
(
"timestamping/enabled",
"{ router: true, peer: true, client: true }",
),
("queries_default_timeout", "10000"),
("transport/unicast/max_links", "1"),
];
pub fn build_config(options: &ContextOptions) -> Result<zenoh::Config> {
let mut config = match &options.config {
SessionConfig::RmwZenohDefault => {
let mut c = zenoh::Config::default();
for (k, v) in RMW_ZENOH_DEFAULTS {
c.insert_json5(k, v)
.map_err(|e| RosError::Config(format!("{k}: {e}")))?;
}
c
}
SessionConfig::File(path) => zenoh::Config::from_file(path)
.map_err(|e| RosError::Config(format!("{}: {e}", path.display())))?,
SessionConfig::Json5(s) => {
zenoh::Config::from_json5(s).map_err(|e| RosError::Config(e.to_string()))?
}
};
for (k, v) in &options.overrides {
config
.insert_json5(k, v)
.map_err(|e| RosError::Config(format!("{k}={v}: {e}")))?;
}
Ok(config)
}
pub(crate) struct ContextInner {
pub(crate) session: Session,
pub(crate) domain_id: u32,
pub(crate) enclave: String,
pub(crate) zid: String,
pub(crate) compat: RmwZenohCompat,
}
#[derive(Clone)]
pub struct Context {
pub(crate) inner: Arc<ContextInner>,
}
impl std::fmt::Debug for Context {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Context")
.field("zid", &self.inner.zid)
.field("domain_id", &self.inner.domain_id)
.finish_non_exhaustive()
}
}
impl Context {
pub fn new(options: &ContextOptions) -> Result<Self> {
let config = build_config(options)?;
let session = zenoh::open(config).wait().map_err(RosError::zenoh)?;
Ok(Self::from_session(session, options))
}
#[must_use]
pub fn from_session(session: Session, options: &ContextOptions) -> Self {
let zid = session.zid().to_string();
let ctx = Self {
inner: Arc::new(ContextInner {
session,
domain_id: options.domain_id,
enclave: options.enclave.clone(),
zid,
compat: options.compat,
}),
};
if let Some(attempts) = options.router_check_attempts {
ctx.check_router(attempts);
}
ctx
}
pub fn check_router(&self, attempts: u64) -> bool {
self.wait_for_router(attempts, &AtomicBool::new(false))
}
pub fn wait_for_router(&self, attempts: u64, stop: &AtomicBool) -> bool {
let mut tried = 0_u64;
loop {
if self
.inner
.session
.info()
.routers_zid()
.wait()
.next()
.is_some()
{
return true;
}
tried += 1;
if attempts != 0 && tried >= attempts {
tracing::warn!(
"unable to connect to a zenoh router; ROS 2 nodes using rmw_zenoh_cpp will not \
see this bridge unless a router (`ros2 run rmw_zenoh_cpp rmw_zenohd`) is \
reachable or peers are configured"
);
return false;
}
for _ in 0..10 {
if stop.load(Ordering::Relaxed) {
return false;
}
std::thread::sleep(Duration::from_millis(100));
}
}
}
#[must_use]
pub fn session(&self) -> &Session {
&self.inner.session
}
#[must_use]
pub fn domain_id(&self) -> u32 {
self.inner.domain_id
}
#[must_use]
pub fn zid(&self) -> &str {
&self.inner.zid
}
#[must_use]
pub fn compat(&self) -> RmwZenohCompat {
self.inner.compat
}
pub(crate) fn next_entity_id() -> Result<u64> {
allocate_entity_id(&NEXT_ENTITY_ID)
}
pub fn create_node(&self, name: &str, namespace: &str) -> Result<Node> {
Node::new(self.clone(), name, namespace)
}
pub fn graph(&self, timeout: Duration) -> Result<Vec<Entity>> {
let replies = self
.inner
.session
.liveliness()
.get(format!("{ADMIN_SPACE}/{}/**", self.inner.domain_id))
.timeout(timeout)
.wait()
.map_err(RosError::zenoh)?;
let mut out = Vec::new();
while let Ok(reply) = replies.recv() {
if let Ok(sample) = reply.result()
&& let Some(entity) =
Entity::parse(sample.key_expr().as_str(), self.inner.compat.qos_defaults())
{
out.push(entity);
}
}
out.sort_by(|a, b| (&a.zid, a.id).cmp(&(&b.zid, b.id)));
out.dedup();
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_is_accepted() {
build_config(&ContextOptions::default()).unwrap();
}
#[test]
fn overrides() {
let o =
parse_overrides("mode=\"client\"; connect/endpoints=[\"tcp/1.2.3.4:7447\"]").unwrap();
assert_eq!(o.len(), 2);
assert_eq!(o[0], ("mode".to_owned(), "\"client\"".to_owned()));
let opts = ContextOptions {
overrides: o,
..ContextOptions::default()
};
build_config(&opts).unwrap();
assert!(parse_overrides("novalue").is_err());
let bad = ContextOptions {
overrides: vec![("no/such/key".into(), "1".into())],
..ContextOptions::default()
};
assert!(build_config(&bad).is_err());
}
#[test]
fn entity_id_allocator_reports_exhaustion_without_wrapping() {
let next = AtomicU64::new(u64::MAX - 1);
assert_eq!(allocate_entity_id(&next).unwrap(), u64::MAX - 1);
assert_eq!(
allocate_entity_id(&next).unwrap_err().to_string(),
"ROS entity id space exhausted"
);
assert_eq!(next.load(Ordering::Relaxed), u64::MAX);
}
#[test]
fn concurrent_entity_id_allocations_are_unique_and_contiguous() {
const THREADS: u64 = 8;
const ALLOCATIONS_PER_THREAD: u64 = 64;
let next = AtomicU64::new(0);
let barrier = std::sync::Barrier::new(THREADS as usize);
let mut ids = std::thread::scope(|scope| {
let workers: Vec<_> = (0..THREADS)
.map(|_| {
scope.spawn(|| {
barrier.wait();
(0..ALLOCATIONS_PER_THREAD)
.map(|_| allocate_entity_id(&next).unwrap())
.collect::<Vec<_>>()
})
})
.collect();
workers
.into_iter()
.flat_map(|worker| worker.join().unwrap())
.collect::<Vec<_>>()
});
ids.sort_unstable();
assert_eq!(
ids,
(0..THREADS * ALLOCATIONS_PER_THREAD).collect::<Vec<_>>()
);
assert_eq!(
next.load(Ordering::Relaxed),
THREADS * ALLOCATIONS_PER_THREAD
);
}
}