diff --git a/crates/hiroz/src/context.rs b/crates/hiroz/src/context.rs index ba6d02f22..cc94a6538 100644 --- a/crates/hiroz/src/context.rs +++ b/crates/hiroz/src/context.rs @@ -71,9 +71,8 @@ impl RemapRules { } } -#[derive(Default)] pub struct ZContextBuilder { - domain_id: usize, + domain_id: DomainId, namespace: String, enclave: String, zenoh_config: Option, @@ -86,10 +85,84 @@ pub struct ZContextBuilder { clock: Option, } +/// The builder's resolved (or pending) ROS domain id. +/// +/// `Invalid` only exists between `ZContextBuilder::default()` and +/// `.build()`/`.with_domain_id()` -- a live `ZContext` always carries a +/// concrete `usize`. Modeling "haven't resolved an invalid ROS_DOMAIN_ID +/// yet" as a variant, rather than a `usize` plus a side-channel error +/// field, makes it a state the type carries instead of an invariant call +/// sites have to remember to check (and that `with_domain_id()` has to +/// remember to clear). +#[derive(Debug, Clone, PartialEq, Eq)] +enum DomainId { + /// A concrete domain, either explicit or read from `ROS_DOMAIN_ID`. + Value(usize), + /// `ROS_DOMAIN_ID` was set but is not a valid non-negative integer. + /// `build()` rejects this unless `.with_domain_id()` overrides it + /// first. Falling back to domain 0 silently would put a node on the + /// wrong ROS graph after an operator typo; `rcl_get_default_domain_id` + /// treats this the same way, returning an error that aborts + /// `rcl_init` rather than defaulting. + Invalid(String), +} + +impl DomainId { + /// Matches `rclcpp`/`rclpy`: read `ROS_DOMAIN_ID` from the environment, + /// so the normal ROS 2 deployment story (set the env var, don't touch + /// source) works here too. `.with_domain_id()` called after + /// `default()` still overrides this, same precedence as every other + /// ROS 2 client library. + fn from_env() -> Self { + Self::parse(std::env::var("ROS_DOMAIN_ID").ok()) + } + + /// Pure parsing, taking the env var's value directly rather than + /// reading it -- so this is unit-testable without mutating (and + /// racing on) real process-global state. + fn parse(value: Option) -> Self { + match value { + Some(val) => match val.parse::() { + Ok(id) => Self::Value(id), + Err(_) => Self::Invalid(val), + }, + None => Self::Value(0), + } + } +} + +impl std::fmt::Display for DomainId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Value(id) => write!(f, "{id}"), + Self::Invalid(raw) => write!(f, ""), + } + } +} + +impl Default for ZContextBuilder { + fn default() -> Self { + Self { + domain_id: DomainId::from_env(), + namespace: String::default(), + enclave: String::default(), + zenoh_config: None, + config_file: None, + config_overrides: Vec::default(), + remap_rules: RemapRules::default(), + enable_logging: bool::default(), + shm_config: None, + keyexpr_format: hiroz_protocol::KeyExprFormat::default(), + clock: None, + } + } +} + impl ZContextBuilder { - /// Set the ROS domain ID + /// Set the ROS domain ID, overriding `ROS_DOMAIN_ID` (and any error + /// parsing it) with an explicit value. pub fn with_domain_id(mut self, domain_id: usize) -> Self { - self.domain_id = domain_id; + self.domain_id = DomainId::Value(domain_id); self } @@ -476,6 +549,15 @@ impl Builder for ZContextBuilder { // 4. **NEW DEFAULT**: ROS session config (connects to router at tcp/localhost:7447) // This matches rmw_zenoh_cpp behavior + let DomainId::Value(_) = &self.domain_id else { + return Err(format!( + "{}: not a valid non-negative integer; set ROS_DOMAIN_ID to a \ + valid domain or call .with_domain_id() explicitly", + self.domain_id + ) + .into()); + }; + debug!( "[CTX] Building context: domain_id={}, has_config={}", self.domain_id, @@ -550,7 +632,9 @@ impl Builder for ZContextBuilder { } } - let domain_id = builder.domain_id; + let DomainId::Value(domain_id) = builder.domain_id else { + unreachable!("build() already rejected a non-Value domain_id above") + }; let graph = Arc::new(Graph::new( &session, domain_id, @@ -667,6 +751,13 @@ impl ZContext { &self.graph } + /// The resolved ROS domain id this context was built with -- either + /// explicit via `.with_domain_id()`, from `ROS_DOMAIN_ID`, or the + /// default of 0. + pub fn domain_id(&self) -> usize { + self.domain_id + } + /// Access the context clock used by nodes and runtime helpers. pub fn clock(&self) -> &ZClock { &self.clock diff --git a/crates/hiroz/src/dynamic/type_description_service.rs b/crates/hiroz/src/dynamic/type_description_service.rs index f863c09b3..4de8a6fb2 100644 --- a/crates/hiroz/src/dynamic/type_description_service.rs +++ b/crates/hiroz/src/dynamic/type_description_service.rs @@ -435,7 +435,11 @@ pub struct TypeDescriptionService { } impl TypeDescriptionService { - /// Create a new TypeDescriptionService. + /// Create a domain-0 TypeDescriptionService using the default key-expression format. + /// + /// Nodes created by [`ZNodeBuilder`](crate::node::ZNodeBuilder) inherit their context via + /// [`Self::new_with_node`]. Use that constructor when creating this service manually for a + /// non-default domain or key-expression format. /// /// # Arguments /// @@ -455,6 +459,34 @@ impl TypeDescriptionService { node_id: usize, counter: &crate::context::GlobalCounter, clock: &crate::time::ZClock, + ) -> ZResult { + let node = crate::entity::NodeEntity::new( + 0, + session.zid(), + node_id, + node_name.to_string(), + namespace.to_string(), + String::new(), + ); + Self::new_with_node( + session, + node, + counter, + clock, + hiroz_protocol::KeyExprFormat::default(), + ) + } + + /// Create a TypeDescriptionService from an existing node identity and key-expression format. + /// + /// `node.z_id` must identify `session`. Passing the owning node's entity avoids silently + /// resetting its domain, namespace, enclave, or other discovery identity. + pub fn new_with_node( + session: Arc, + node: crate::entity::NodeEntity, + counter: &crate::context::GlobalCounter, + clock: &crate::time::ZClock, + keyexpr_format: hiroz_protocol::KeyExprFormat, ) -> ZResult { let schemas: Arc>> = Arc::new(RwLock::new(HashMap::new())); @@ -464,19 +496,12 @@ impl TypeDescriptionService { // which expands to /{namespace}/{node_name}/get_type_description let service_name = "~get_type_description"; - // Create the node entity for the service - let node_entity = crate::entity::NodeEntity::new( - 0, // domain_id - session.zid(), - node_id, - node_name.to_string(), - namespace.to_string(), - String::new(), // enclave (empty, normalized to "%" in liveliness token) - ); + let node_name = node.name.clone(); + let namespace = node.namespace.clone(); let entity = crate::entity::EndpointEntity { id: counter.increment(), - node: Some(node_entity), + node: Some(node), kind: crate::entity::EndpointKind::Service, topic: service_name.to_string(), type_info: Some(GetTypeDescription::service_type_info()), @@ -488,7 +513,7 @@ impl TypeDescriptionService { entity, session, clock: clock.clone(), - keyexpr_format: hiroz_protocol::KeyExprFormat::default(), + keyexpr_format, _phantom_data: Default::default(), }; diff --git a/crates/hiroz/src/node.rs b/crates/hiroz/src/node.rs index e640a2191..86fe8f5e3 100644 --- a/crates/hiroz/src/node.rs +++ b/crates/hiroz/src/node.rs @@ -241,13 +241,12 @@ impl Builder for ZNodeBuilder { // Create type description service if enabled let type_desc_service = if self.enable_type_desc_service { debug!("[NOD] Creating type description service"); - let service = TypeDescriptionService::new( + let service = TypeDescriptionService::new_with_node( self.session.clone(), - &self.name, - &self.namespace, - id, + node.clone(), &self.counter, &self.clock, + self.keyexpr_format.clone(), )?; info!("[NOD] TypeDescriptionService created (callback mode)"); @@ -263,8 +262,11 @@ impl Builder for ZNodeBuilder { let service = ParameterService::new(ParameterServiceConfig { session: self.session.clone(), graph: self.graph.clone(), + domain_id: self.domain_id, + keyexpr_format: self.keyexpr_format.clone(), node_name: &self.name, namespace: &self.namespace, + enclave: &node.enclave, node_id: id, counter: &self.counter, clock: &self.clock, diff --git a/crates/hiroz/src/parameter/service.rs b/crates/hiroz/src/parameter/service.rs index 15db88d81..23cf67edf 100644 --- a/crates/hiroz/src/parameter/service.rs +++ b/crates/hiroz/src/parameter/service.rs @@ -40,8 +40,11 @@ type BoxedServer = Arc; pub(crate) struct ParameterServiceConfig<'a> { pub session: Arc, pub graph: Arc, + pub domain_id: usize, + pub keyexpr_format: hiroz_protocol::KeyExprFormat, pub node_name: &'a str, pub namespace: &'a str, + pub enclave: &'a str, pub node_id: usize, pub counter: &'a GlobalCounter, pub clock: &'a crate::time::ZClock, @@ -284,8 +287,11 @@ impl ParameterService { let ParameterServiceConfig { session, graph, + domain_id, + keyexpr_format, node_name, namespace, + enclave, node_id, counter, clock, @@ -297,12 +303,12 @@ impl ParameterService { wire_types::register_parameter_schemas(tds); } let node_entity = NodeEntity::new( - 0, + domain_id, session.zid(), node_id, node_name.to_string(), namespace.to_string(), - String::new(), + enclave.to_string(), ); // Compute node fully-qualified name for parameter events @@ -323,8 +329,6 @@ impl ParameterService { qos: Default::default(), }; - let ke_format = hiroz_protocol::KeyExprFormat::default(); - // ── /parameter_events publisher ─────────────────────────────────────── let pub_entity = EndpointEntity { id: counter.increment(), @@ -351,7 +355,7 @@ impl ParameterService { clock: clock.clone(), with_attachment: true, shm_config: None, - keyexpr_format: ke_format.clone(), + keyexpr_format: keyexpr_format.clone(), dyn_schema: None, encoding: None, _phantom_data: Default::default(), @@ -380,7 +384,7 @@ impl ParameterService { entity, session: session.clone(), clock: clock.clone(), - keyexpr_format: ke_format.clone(), + keyexpr_format: keyexpr_format.clone(), _phantom_data: Default::default(), }; builder.build_with_callback(move |query| { @@ -400,7 +404,7 @@ impl ParameterService { entity, session: session.clone(), clock: clock.clone(), - keyexpr_format: ke_format.clone(), + keyexpr_format: keyexpr_format.clone(), _phantom_data: Default::default(), }; builder.build_with_callback(move |query| { @@ -420,7 +424,7 @@ impl ParameterService { entity, session: session.clone(), clock: clock.clone(), - keyexpr_format: ke_format.clone(), + keyexpr_format: keyexpr_format.clone(), _phantom_data: Default::default(), }; builder.build_with_callback(move |query| { @@ -440,7 +444,7 @@ impl ParameterService { entity, session: session.clone(), clock: clock.clone(), - keyexpr_format: ke_format.clone(), + keyexpr_format: keyexpr_format.clone(), _phantom_data: Default::default(), }; builder.build_with_callback(move |query| { @@ -460,7 +464,7 @@ impl ParameterService { entity, session: session.clone(), clock: clock.clone(), - keyexpr_format: ke_format.clone(), + keyexpr_format: keyexpr_format.clone(), _phantom_data: Default::default(), }; builder.build_with_callback(move |query| { @@ -480,7 +484,7 @@ impl ParameterService { entity, session: session.clone(), clock: clock.clone(), - keyexpr_format: ke_format.clone(), + keyexpr_format: keyexpr_format.clone(), _phantom_data: Default::default(), }; builder.build_with_callback(move |query| { diff --git a/crates/hiroz/tests/common/mod.rs b/crates/hiroz/tests/common/mod.rs new file mode 100644 index 000000000..43baaa865 --- /dev/null +++ b/crates/hiroz/tests/common/mod.rs @@ -0,0 +1,48 @@ +//! Shared integration-test helpers. +//! +//! `tests/*.rs` files are each compiled as a separate crate, so anything +//! reused across them belongs here (`mod common;` per Rust's own +//! `tests/common/mod.rs` convention -- naming it `common.rs` instead would +//! make cargo treat it as its own test binary). Add a helper here rather +//! than copying it into a new test file. + +use std::time::Duration; + +use zenoh::{Wait, config::WhatAmI}; + +/// A local zenoh router bound to an ephemeral port, for tests that need +/// real cross-session discovery rather than the default in-process/loopback +/// config. +pub struct TestRouter { + pub endpoint: String, + _session: zenoh::Session, +} + +impl TestRouter { + pub fn new() -> Self { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind test port"); + let port = listener.local_addr().expect("test address").port(); + drop(listener); + + let endpoint = format!("tcp/127.0.0.1:{port}"); + let mut config = zenoh::Config::default(); + config.set_mode(Some(WhatAmI::Router)).unwrap(); + config + .insert_json5("listen/endpoints", &format!("[\"{endpoint}\"]")) + .unwrap(); + config + .insert_json5("scouting/multicast/enabled", "false") + .unwrap(); + let session = zenoh::open(config).wait().expect("open test router"); + std::thread::sleep(Duration::from_millis(300)); + + Self { + endpoint, + _session: session, + } + } + + pub fn endpoint(&self) -> &str { + &self.endpoint + } +} diff --git a/crates/hiroz/tests/domain_id.rs b/crates/hiroz/tests/domain_id.rs new file mode 100644 index 000000000..244417657 --- /dev/null +++ b/crates/hiroz/tests/domain_id.rs @@ -0,0 +1,90 @@ +//! `ROS_DOMAIN_ID` resolution, exercised only through `ZContextBuilder`'s +//! public API -- no reach into `context.rs`'s private `DomainId` type. +//! +//! Each `tests/*.rs` file compiles as its own process, so mutating the +//! process-global `ROS_DOMAIN_ID` here cannot race with the rest of the +//! crate's tests in other files or in the `--lib` binary. `#[serial]` only +//! has to guard against races between the handful of tests in *this* file. + +use hiroz::{Builder, context::ZContextBuilder}; +use serial_test::serial; + +/// Restores the previous `ROS_DOMAIN_ID` (or its absence) on drop, so one +/// test's env mutation can't leak into the next. +struct EnvVarGuard { + key: &'static str, + previous: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var(key).ok(); + // SAFETY: serialized by #[serial] -- no other thread reads/writes + // process env vars while a guard is live. + unsafe { std::env::set_var(key, value) }; + Self { key, previous } + } + + fn unset(key: &'static str) -> Self { + let previous = std::env::var(key).ok(); + // SAFETY: see above. + unsafe { std::env::remove_var(key) }; + Self { key, previous } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + // SAFETY: see above. + unsafe { + match &self.previous { + Some(val) => std::env::set_var(self.key, val), + None => std::env::remove_var(self.key), + } + } + } +} + +#[test] +#[serial] +fn unset_env_defaults_to_domain_zero() { + let _guard = EnvVarGuard::unset("ROS_DOMAIN_ID"); + let ctx = ZContextBuilder::default() + .build() + .expect("default domain must build"); + assert_eq!(ctx.domain_id(), 0); +} + +#[test] +#[serial] +fn valid_env_is_used() { + let _guard = EnvVarGuard::set("ROS_DOMAIN_ID", "42"); + let ctx = ZContextBuilder::default() + .build() + .expect("a valid ROS_DOMAIN_ID must build"); + assert_eq!(ctx.domain_id(), 42); +} + +/// The behavior Copilot's review asked for: an invalid `ROS_DOMAIN_ID` +/// aborts `build()` with an error naming the bad value, matching +/// `rcl_init`, rather than silently producing a `ZContext` on domain 0. +#[test] +#[serial] +fn invalid_env_is_rejected_by_build() { + let _guard = EnvVarGuard::set("ROS_DOMAIN_ID", "not-a-number"); + let err = ZContextBuilder::default() + .build() + .expect_err("an invalid ROS_DOMAIN_ID must not silently build"); + assert!(err.to_string().contains("not-a-number")); +} + +#[test] +#[serial] +fn with_domain_id_overrides_an_invalid_env_value() { + let _guard = EnvVarGuard::set("ROS_DOMAIN_ID", "garbage"); + let ctx = ZContextBuilder::default() + .with_domain_id(7) + .build() + .expect("explicit with_domain_id must override a bad env value"); + assert_eq!(ctx.domain_id(), 7); +} diff --git a/crates/hiroz/tests/graph.rs b/crates/hiroz/tests/graph.rs index 518cb33ac..f16df2f1e 100644 --- a/crates/hiroz/tests/graph.rs +++ b/crates/hiroz/tests/graph.rs @@ -7,7 +7,7 @@ //! - Node discovery and information //! - Service availability checking -use std::time::Duration; +use std::{sync::Arc, time::Duration}; use hiroz::{ Builder, Result, @@ -15,6 +15,72 @@ use hiroz::{ entity::{EndpointKind, NodeKey}, }; use hiroz_msgs::{example_interfaces::srv::AddTwoInts, std_msgs::String as RosString}; +use hiroz_protocol::{ + EndpointEntity, Entity, KeyExprFormat, KeyExprFormatter, KeyExprFormatterAdapter, NodeEntity, + RmwZenohFormatter, + entity::{LivelinessKE, TopicKE}, + qos::QosProfile, +}; + +mod common; +use common::TestRouter; + +/// A pass-through `KeyExprFormatter` that delegates to `RmwZenohFormatter` for +/// everything except its own admin space, so a node using it is +/// distinguishable on the wire from a plain rmw_zenoh node without +/// reimplementing any encoding. +#[derive(Debug)] +struct TestKeyExprFormatter; + +impl TestKeyExprFormatter { + fn from_rmw_liveliness(key: LivelinessKE) -> hiroz::Result { + Ok(LivelinessKE::new( + key.as_str() + .replacen("@ros2_lv", Self::ADMIN_SPACE, 1) + .try_into()?, + )) + } +} + +impl KeyExprFormatter for TestKeyExprFormatter { + const ESCAPE_CHAR: char = '%'; + const ADMIN_SPACE: &'static str = "@hiroz_test_lv"; + + fn topic_key_expr(entity: &EndpointEntity) -> hiroz::Result { + let rmw = RmwZenohFormatter::topic_key_expr(entity)?; + Ok(TopicKE::new( + format!("hiroz_test/{}", rmw.as_str()).try_into()?, + )) + } + + fn liveliness_key_expr( + entity: &EndpointEntity, + zid: &zenoh::session::ZenohId, + ) -> hiroz::Result { + Self::from_rmw_liveliness(RmwZenohFormatter::liveliness_key_expr(entity, zid)?) + } + + fn node_liveliness_key_expr(entity: &NodeEntity) -> hiroz::Result { + Self::from_rmw_liveliness(RmwZenohFormatter::node_liveliness_key_expr(entity)?) + } + + fn parse_liveliness(key: &zenoh::key_expr::KeyExpr) -> hiroz::Result { + let rmw: zenoh::key_expr::KeyExpr<'static> = key + .as_str() + .replacen(Self::ADMIN_SPACE, "@ros2_lv", 1) + .try_into()?; + RmwZenohFormatter::parse_liveliness(&rmw) + } + + fn encode_qos(qos: &QosProfile, keyless: bool) -> String { + RmwZenohFormatter::encode_qos(qos, keyless) + } + + fn decode_qos(encoded: &str) -> hiroz::Result<(bool, QosProfile)> { + RmwZenohFormatter::decode_qos(encoded) + } +} + /// Helper to create a test context and node async fn setup_test_node( node_name: &str, @@ -74,6 +140,142 @@ async fn wait_for_subscribers( mod tests { use super::*; + /// Built-in services (type description + the six parameter services) must + /// announce the node's actual domain, not a hardcoded domain 0. + /// + /// Does not assert `enclave`: `format/rmw_zenoh.rs`'s liveliness + /// encoder hardcodes the enclave segment to the empty placeholder for + /// every entity type (`// Enclave (not supported yet)` on the decode + /// side), so no entity's enclave round-trips today -- not the node's + /// own token, not a plain `ZNode`'s, not these. That is a separate, + /// pre-existing wire-format gap this PR does not touch. This test only + /// covers what production `ParameterService`/`TypeDescriptionService` + /// construction actually controls: domain_id. + #[tokio::test(flavor = "multi_thread")] + async fn built_in_services_inherit_context_domain() -> Result<()> { + const DOMAIN_ID: usize = 123; + let router = TestRouter::new(); + let observer_ctx = ZContextBuilder::default() + .with_domain_id(DOMAIN_ID) + .disable_multicast_scouting() + .with_connect_endpoints([router.endpoint()]) + .with_mode("client") + .build()?; + let observer = observer_ctx + .create_node("domain_123_observer") + .without_parameters() + .build()?; + let producer_ctx = ZContextBuilder::default() + .with_domain_id(DOMAIN_ID) + .disable_multicast_scouting() + .with_connect_endpoints([router.endpoint()]) + .with_mode("client") + .build()?; + let _producer = producer_ctx + .create_node("domain_123_builtins") + .with_type_description_service() + .build()?; + let node_key: NodeKey = (String::new(), "domain_123_builtins".to_string()); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + let services = loop { + let services = observer + .graph() + .get_entities_by_node(EndpointKind::Service, node_key.clone()); + if services.len() >= 7 || tokio::time::Instant::now() >= deadline { + break services; + } + tokio::time::sleep(Duration::from_millis(10)).await; + }; + + assert_eq!( + services.len(), + 7, + "six parameter services and get_type_description must be discoverable" + ); + assert!(services.iter().all(|endpoint| { + endpoint + .node + .as_ref() + .is_some_and(|owner| owner.domain_id == DOMAIN_ID) + })); + + let parameter_events = observer + .graph() + .get_entities_by_node(EndpointKind::Publisher, node_key); + assert!(parameter_events.iter().any(|endpoint| { + endpoint.topic == "/parameter_events" + && endpoint + .node + .as_ref() + .is_some_and(|owner| owner.domain_id == DOMAIN_ID) + })); + + Ok(()) + } + + /// Built-in services must also inherit the context's `keyexpr_format`, + /// not the default rmw_zenoh format -- the other half of what + /// `ParameterServiceConfig`/`TypeDescriptionService::new_with_node` + /// thread through alongside `domain_id`. Uses a pass-through custom + /// formatter with its own admin space, so a node's built-in services are + /// only discoverable at all if they actually used it. + #[tokio::test(flavor = "multi_thread")] + async fn built_in_services_inherit_custom_keyexpr_format() -> Result<()> { + let router = TestRouter::new(); + let format = KeyExprFormat::Custom(Arc::new( + KeyExprFormatterAdapter::::new(), + )); + let observer_ctx = ZContextBuilder::default() + .keyexpr_format(format.clone()) + .disable_multicast_scouting() + .with_connect_endpoints([router.endpoint()]) + .with_mode("client") + .build()?; + let observer = observer_ctx + .create_node("custom_format_observer") + .without_parameters() + .build()?; + let producer_ctx = ZContextBuilder::default() + .keyexpr_format(format) + .disable_multicast_scouting() + .with_connect_endpoints([router.endpoint()]) + .with_mode("client") + .build()?; + let _producer = producer_ctx + .create_node("custom_format_builtins") + .with_type_description_service() + .build()?; + let node_key: NodeKey = (String::new(), "custom_format_builtins".to_string()); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + let services = loop { + let services = observer + .graph() + .get_entities_by_node(EndpointKind::Service, node_key.clone()); + if services.len() >= 7 || tokio::time::Instant::now() >= deadline { + break services; + } + tokio::time::sleep(Duration::from_millis(10)).await; + }; + + assert_eq!( + services.len(), + 7, + "built-in services must use the context's custom liveliness format" + ); + assert!( + observer + .graph() + .get_entities_by_node(EndpointKind::Publisher, node_key) + .iter() + .any(|endpoint| endpoint.topic == "/parameter_events"), + "parameter-events publisher must use the context's custom liveliness format" + ); + + Ok(()) + } + /// Tests getting topic names and types from the graph #[tokio::test(flavor = "multi_thread")] async fn test_get_topic_names_and_types() -> Result<()> { diff --git a/crates/hiroz/tests/message_type_info_derive.rs b/crates/hiroz/tests/message_type_info_derive.rs index f633260d9..c2777310d 100644 --- a/crates/hiroz/tests/message_type_info_derive.rs +++ b/crates/hiroz/tests/message_type_info_derive.rs @@ -6,7 +6,9 @@ use hiroz::{ dynamic::{FieldType, MessageSchemaTypeDescription}, }; use serde::{Deserialize, Serialize}; -use zenoh::{Wait, config::WhatAmI}; + +mod common; +use common::TestRouter; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, hiroz::MessageTypeInfo)] #[ros_msg(type_name = "custom_msgs/msg/Position2D")] @@ -29,45 +31,6 @@ impl hiroz::msg::ZMessage for RobotTelemetry { type Serdes = hiroz::msg::SerdeCdrSerdes; } -struct TestRouter { - endpoint: String, - _session: zenoh::Session, -} - -impl TestRouter { - fn new() -> Self { - let port = { - let listener = - std::net::TcpListener::bind("127.0.0.1:0").expect("failed to bind port 0"); - listener.local_addr().unwrap().port() - }; - - let endpoint = format!("tcp/127.0.0.1:{port}"); - let mut config = zenoh::Config::default(); - config.set_mode(Some(WhatAmI::Router)).unwrap(); - config - .insert_json5("listen/endpoints", &format!("[\"{endpoint}\"]")) - .unwrap(); - config - .insert_json5("scouting/multicast/enabled", "false") - .unwrap(); - - let session = zenoh::open(config) - .wait() - .expect("failed to open test router"); - std::thread::sleep(Duration::from_millis(300)); - - Self { - endpoint, - _session: session, - } - } - - fn endpoint(&self) -> &str { - &self.endpoint - } -} - fn create_context_with_router(router: &TestRouter) -> hiroz::Result { ZContextBuilder::default() .disable_multicast_scouting()