diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cf7a47f..ff5f7ad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,42 @@ Versioning follows [Semantic Versioning 2.0](https://semver.org/). ## [Unreleased] -Reserved for changes after the `1.0.0-rc.6` workspace tag. +### Fixed + +- **Multi-homed discovery (#27).** On a host with more than one interface (VPN, + Docker, second NIC) ZeroDDS announced a single discovery locator resolved from + the default-route source-address probe. When that interface differs from the + one a peer shares, the announced unicast locator is unreachable: the peer + discovers the participant over multicast (`discovered=1`) but never matches an + endpoint (`matched=0`) and no data flows. Automatic mode now enumerates every + eligible unicast interface (`if-addrs`) and announces **all** of them, so a + peer on any shared segment finds a reachable locator. Metatraffic is fanned out + to every de-duplicated usable peer locator (UDP cannot detect a dead + destination). `ZERODDS_INTERFACE` keeps its deterministic single-interface + behaviour. Multi-interface multicast join/TX is intentionally deferred (see + `docs/OPEN-ITEMS.md`) — packet capture proved multicast was never the failing + operation. + +### Changed + +- **BREAKING (`zerodds-rtps`): `ParticipantBuiltinTopicData` locator fields are + now `Vec`, not `Option`.** Every field that is semantically a + locator list per DDSI-RTPS 2.5 §9.6.1 (`default_unicast_locators`, + `default_multicast_locators`, `metatraffic_unicast_locators`, + `metatraffic_multicast_locators`) changed type, and the four fields were + renamed to the plural. This is required for #27 (a `*_LOCATOR` PID may repeat; + the wire already allowed it — decode now retains all, in LE and BE). + + **Migration:** + - Construction: `default_unicast_locator: Some(loc)` → `default_unicast_locators: vec![loc]`; `…: None` → `…: Vec::new()`. + - Single-value reads: use `primary_default_unicast_locator()` / + `primary_metatraffic_unicast_locator()` (return `Option`, first of + the list), or `metatraffic_or_default_unicast_locators()` for the full + fan-out set. + - Decode no longer filters locators; routing/usability selection moved to + DCPS/transport (`locator_looks_routable` is a public predicate there). + - No wire-format change and no on-the-wire compatibility break — repeated + locator PIDs are standard RTPS and are emitted/parsed by Cyclone/FastDDS. ## [1.0.0-rc.6] — 2026-07-22 diff --git a/Cargo.lock b/Cargo.lock index 34a5cc5d..542816ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2624,6 +2624,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -6253,7 +6263,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -6352,6 +6362,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -8690,6 +8709,7 @@ name = "zerodds-transport-udp" version = "1.0.0-rc.7" dependencies = [ "criterion", + "if-addrs", "libc", "socket2 0.5.10", "zerodds-inspect-endpoint", diff --git a/crates/dcps/src/builtin_topics.rs b/crates/dcps/src/builtin_topics.rs index fb8949a3..5d6e7ec5 100644 --- a/crates/dcps/src/builtin_topics.rs +++ b/crates/dcps/src/builtin_topics.rs @@ -728,10 +728,10 @@ mod tests { guid: g, protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, - default_unicast_locator: None, - default_multicast_locator: None, - metatraffic_unicast_locator: None, - metatraffic_multicast_locator: None, + default_unicast_locators: Vec::new(), + default_multicast_locators: Vec::new(), + metatraffic_unicast_locators: Vec::new(), + metatraffic_multicast_locators: Vec::new(), domain_id: None, builtin_endpoint_set: 0, lease_duration: zerodds_qos::Duration::from_secs(100), diff --git a/crates/dcps/src/participant.rs b/crates/dcps/src/participant.rs index f70ce3ba..c21fd0f3 100644 --- a/crates/dcps/src/participant.rs +++ b/crates/dcps/src/participant.rs @@ -2226,10 +2226,10 @@ mod tests { guid: Guid::new(remote, EntityId::PARTICIPANT), protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, - default_unicast_locator: None, - default_multicast_locator: None, - metatraffic_unicast_locator: None, - metatraffic_multicast_locator: None, + default_unicast_locators: Vec::new(), + default_multicast_locators: Vec::new(), + metatraffic_unicast_locators: Vec::new(), + metatraffic_multicast_locators: Vec::new(), domain_id: Some(30), builtin_endpoint_set: 0, lease_duration: zerodds_rtps::participant_data::Duration::from_secs(100), diff --git a/crates/dcps/src/runtime.rs b/crates/dcps/src/runtime.rs index c442f82a..31be28cf 100644 --- a/crates/dcps/src/runtime.rs +++ b/crates/dcps/src/runtime.rs @@ -249,6 +249,57 @@ fn announce_locator(uc: &(dyn Transport + Send + Sync), hint: Ipv4Addr) -> Locat to_locator([127, 0, 0, 1]) } +/// Materializes the **full** set of unicast locators to announce for a +/// `0.0.0.0`-bound socket (#27). +/// +/// On a multi-homed host the single [`announce_locator`] probe resolves only +/// the default-route source address, which can diverge from the interface a +/// given peer actually shares — the peer then learns one unreachable locator +/// and discovery half-completes (participant discovered, endpoints never +/// match). Instead, in automatic mode this announces **every** eligible +/// unicast interface address at the socket's bound port, so a peer on any +/// shared segment finds a reachable one and can fan out to all of them. +/// +/// An explicit interface (`ZERODDS_INTERFACE` / `RuntimeConfig`) keeps its +/// deterministic single-interface behaviour. Non-UDPv4 locators, and the case +/// where interface enumeration yields nothing, fall back to the single +/// [`announce_locator`] result — the announced list is never empty. +#[cfg(feature = "std")] +fn announce_locators(uc: &(dyn Transport + Send + Sync), hint: Ipv4Addr) -> Vec { + let raw = uc.local_locator(); + let single = announce_locator(uc, hint); + // Explicit pin, or a non-UDPv4 locator (V6/SHM/TCP), or an already-concrete + // bound address → keep the single deterministic locator. + let ip = Ipv4Addr::new( + raw.address[12], + raw.address[13], + raw.address[14], + raw.address[15], + ); + if !hint.is_unspecified() || raw.kind != LocatorKind::UdpV4 || !ip.is_unspecified() { + return alloc::vec![single]; + } + // Automatic: one locator per eligible interface, deterministic order + // (already sorted by ip in the enumerator), deduplicated by address. + let port = raw.port; + let mut locs: Vec = Vec::new(); + for e in zerodds_transport_udp::eligible_ipv4_interfaces() { + let loc = Locator::udp_v4(e.ipv4.octets(), port); + if !locs.contains(&loc) { + locs.push(loc); + } + } + // Guarantee the probe-resolved address is present even if enumeration + // missed it, and never announce an empty list. + if locs.is_empty() { + return alloc::vec![single]; + } + if !locs.contains(&single) && zerodds_rtps::participant_data::locator_looks_routable(&single) { + locs.push(single); + } + locs +} + /// Converts a `core::time::Duration` (std) to a `zerodds_qos::Duration` /// (spec 2^-32 fraction encoding). Saturates on overflow — `i32::MAX` /// seconds suffices for over 60 years of lease. @@ -1061,21 +1112,25 @@ mod endpoint_attr_tests { } /// Unicast targets for the WLP heartbeat fan-out (M-2): per discovered peer the -/// `metatraffic_unicast_locator` (fallback `default_unicast_locator`), filtered +/// `metatraffic_unicast_locators` (fallback `default_unicast_locators`), filtered /// to routable kinds. WLP is metatraffic (DDSI-RTPS §8.4.13); in multicast- /// free environments (container/cloud) the pure multicast pulse never reaches the /// peer reader → the lease expires although the peer is alive. The additional /// unicast fan-out follows the SEDP locator model. fn wlp_unicast_targets(peers: &[zerodds_discovery::spdp::DiscoveredParticipant]) -> Vec { - peers - .iter() - .filter_map(|dp| { - dp.data - .metatraffic_unicast_locator - .or(dp.data.default_unicast_locator) - }) - .filter(is_routable_user_locator) - .collect() + // #27 fan-out: a peer may advertise several metatraffic locators (multi- + // homed). UDP cannot tell which one is reachable, so send the WLP pulse to + // every deduplicated usable one — the reachable locator delivers, the rest + // fall on the floor. + let mut out: Vec = Vec::new(); + for dp in peers { + for loc in dp.data.metatraffic_or_default_unicast_locators() { + if is_routable_user_locator(&loc) && !out.contains(&loc) { + out.push(loc); + } + } + } + out } /// Extracts the IPv4 address from a `Locator` (UDP-V4). @@ -2508,7 +2563,7 @@ fn build_publication_data( writer_eid: EntityId, cfg: &UserWriterConfig, runtime_offer: &[i16], - user_locator: Locator, + user_locators: &[Locator], ) -> zerodds_rtps::publication_data::PublicationBuiltinTopicData { use zerodds_qos::{ReliabilityKind, ReliabilityQosPolicy}; zerodds_rtps::publication_data::PublicationBuiltinTopicData { @@ -2558,7 +2613,7 @@ fn build_publication_data( // DDSI-RTPS 2.5 §8.5.3.3: endpoint locator. All user endpoints // share the one `user_unicast` socket — hence the // endpoint locator equals the resolved participant locator. - unicast_locators: alloc::vec![user_locator], + unicast_locators: user_locators.to_vec(), multicast_locators: Vec::new(), } } @@ -2596,7 +2651,7 @@ fn build_subscription_data( reader_eid: EntityId, cfg: &UserReaderConfig, runtime_offer: &[i16], - user_locator: Locator, + user_locators: &[Locator], ) -> zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData { use zerodds_qos::{ReliabilityKind, ReliabilityQosPolicy}; zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData { @@ -2639,7 +2694,7 @@ fn build_subscription_data( type_identifier: cfg.type_identifier.clone(), // DDSI-RTPS 2.5 §8.5.3.2: endpoint locator (see // build_publication_data). - unicast_locators: alloc::vec![user_locator], + unicast_locators: user_locators.to_vec(), multicast_locators: Vec::new(), } } @@ -2667,6 +2722,13 @@ pub struct DcpsRuntime { /// via `announce_locator`, so the endpoint and participant locators /// are guaranteed identical. pub user_announce_locator: Locator, + /// #27: the FULL set of user-unicast locators to announce — every eligible + /// interface address (automatic mode) or the single pinned one. Written as + /// the endpoint `PID_UNICAST_LOCATOR` list in EVERY SEDP pub/sub announce + /// so a peer on any shared segment can reach the endpoint. Mirrors the + /// participant `default_unicast_locators`. Never empty (falls back to + /// `[user_announce_locator]`). + pub user_announce_locators: Vec, /// Sender socket for the SPDP multicast announce (separate UdpSocket /// without SO_REUSE/SO_BIND_IP_MULTICAST, so send_to routes cleanly). spdp_mc_tx: Arc, @@ -3078,16 +3140,26 @@ impl DcpsRuntime { // (no traffic, just the routing table) and announce the // resulting local interface address — cross-host-capable // without an external crate dependency. - let user_locator = announce_locator(&*user_uc, config.multicast_interface); - let spdp_uc_locator = announce_locator(&spdp_uc, config.multicast_interface); + // #27: announce EVERY eligible interface address (automatic mode), so a + // peer on any shared segment finds a reachable locator. An explicit + // `ZERODDS_INTERFACE` collapses these to the single pinned address. + let user_locators = announce_locators(&*user_uc, config.multicast_interface); + let spdp_uc_locators = announce_locators(&spdp_uc, config.multicast_interface); + // Primary (first) locator for the single-locator API surface + // (`user_locator()`, endpoint-locator fallback). The full list drives + // the announced participant/endpoint locator sets. + let user_locator = user_locators + .first() + .copied() + .unwrap_or_else(|| announce_locator(&*user_uc, config.multicast_interface)); let participant_data = ParticipantBuiltinTopicData { guid: Guid::new(guid_prefix, EntityId::PARTICIPANT), protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, - default_unicast_locator: Some(user_locator), - default_multicast_locator: None, - metatraffic_unicast_locator: Some(spdp_uc_locator), - metatraffic_multicast_locator: Some(Locator { + default_unicast_locators: user_locators.clone(), + default_multicast_locators: Vec::new(), + metatraffic_unicast_locators: spdp_uc_locators, + metatraffic_multicast_locators: vec![Locator { kind: LocatorKind::UdpV4, port: u32::from(spdp_port), address: { @@ -3095,7 +3167,7 @@ impl DcpsRuntime { a[12..].copy_from_slice(&config.spdp_multicast_group.octets()); a }, - }), + }], domain_id: Some(domain_id as u32), // We announce the endpoints we actually // implement: SPDP (participant ann/det) + SEDP @@ -3175,6 +3247,7 @@ impl DcpsRuntime { spdp_unicast: Arc::new(spdp_uc), user_unicast: user_uc, user_announce_locator: user_locator, + user_announce_locators: user_locators, spdp_mc_tx: Arc::new(spdp_mc_tx), spdp_beacon: Mutex::new(beacon), participant_data, @@ -3526,8 +3599,8 @@ impl DcpsRuntime { return Ok(None); }; dp.data - .default_unicast_locator - .or(dp.data.metatraffic_unicast_locator) + .primary_default_unicast_locator() + .or_else(|| dp.data.primary_metatraffic_unicast_locator()) }; let Some(target) = target else { return Ok(None); @@ -3994,7 +4067,7 @@ impl DcpsRuntime { for t in dg.targets.iter() { if is_routable_user_locator(t) { // §8.3.7: unicast metatraffic (SEDP DATA to the remote - // metatraffic_unicast_locator) MUST go out from the metatraffic + // metatraffic_unicast_locators) MUST go out from the metatraffic // recv socket `spdp_unicast`, NOT from the ephemeral // `spdp_mc_tx` — otherwise the peer sees a foreign // source port and sends its reliable ACKNACK/resends @@ -4523,7 +4596,7 @@ impl DcpsRuntime { eid, &cfg, &self.config.data_representation_offer, - self.user_announce_locator, + &self.user_announce_locators, ); // FU2 cross-vendor: EndpointSecurityInfo from the governance // data_protection — otherwise cyclone/FastDDS reject the user endpoint @@ -4794,7 +4867,7 @@ impl DcpsRuntime { eid, &cfg, &reader_repr, - self.user_announce_locator, + &self.user_announce_locators, ); // FU2 cross-vendor: EndpointSecurityInfo from the governance (see writer). sub_data.security_info = self.user_endpoint_security_info(); @@ -6583,12 +6656,10 @@ impl DcpsRuntime { ) { continue; } - // Reader prefix → default_unicast_locator from discovery. + // Reader prefix → default unicast locators from discovery. if let Ok(cache) = discovered.lock() { if let Some(p) = cache.get(&reader.prefix) { - if let Some(loc) = p.data.default_unicast_locator { - skip.push(loc); - } + skip.extend(p.data.default_unicast_locators.iter().copied()); } } } @@ -9064,7 +9135,7 @@ fn remote_user_locators( match discovered.lock() { Ok(cache) => cache .get(&prefix) - .and_then(|p| p.data.default_unicast_locator) + .and_then(|p| p.data.primary_default_unicast_locator()) .into_iter() .collect(), Err(_) => Vec::new(), @@ -11487,8 +11558,8 @@ fn dispatch_type_lookup_datagram(rt: &Arc, bytes: &[u8], source: &L .and_then(|d| { d.get(&src_prefix).and_then(|dp| { dp.data - .default_unicast_locator - .or(dp.data.metatraffic_unicast_locator) + .primary_default_unicast_locator() + .or_else(|| dp.data.primary_metatraffic_unicast_locator()) }) }) .unwrap_or(*source); @@ -11677,7 +11748,7 @@ fn send_discovery_datagram(rt: &Arc, targets: &[Locator], bytes: &[ } // Send unicast metatraffic (SEDP responses, VolatileSecure, stateless auth) // from the **metatraffic recv socket** (`spdp_unicast`, = announced - // metatraffic_unicast_locator), NOT from the ephemeral `spdp_mc_tx`. + // metatraffic_unicast_locators), NOT from the ephemeral `spdp_mc_tx`. // Otherwise the peer sees a foreign source port and sends its // responses (e.g. cyclone's VolatileSecure ACKNACK to the source locator) // to a port ZeroDDS does not listen on → reliable resends stay @@ -12271,6 +12342,45 @@ mod tests { assert_ne!(auto.address[12..], [10, 11, 12, 13]); } + #[test] + fn announce_locators_pins_to_single_interface() { + // #27: an explicit interface keeps the deterministic SINGLE-locator + // behaviour — no multi-interface fan-out of the announced set. + let udp = UdpTransport::bind_v4(Ipv4Addr::UNSPECIFIED, 0).expect("bind"); + let pin = Ipv4Addr::new(10, 11, 12, 13); + let locs = super::announce_locators(&udp, pin); + assert_eq!( + locs.len(), + 1, + "pinned interface announces exactly one locator" + ); + assert_eq!(locs[0].address[12..], [10, 11, 12, 13]); + assert_eq!(locs[0], super::announce_locator(&udp, pin)); + } + + #[test] + fn announce_locators_automatic_is_non_empty_and_includes_probe() { + // Automatic mode enumerates eligible interfaces; the list is never + // empty and always contains at least the route-probe address (so the + // default-route peer stays reachable while extra segments are added). + let udp = UdpTransport::bind_v4(Ipv4Addr::UNSPECIFIED, 0).expect("bind"); + let locs = super::announce_locators(&udp, Ipv4Addr::UNSPECIFIED); + assert!(!locs.is_empty(), "automatic announce is never empty"); + let probe = super::announce_locator(&udp, Ipv4Addr::UNSPECIFIED); + // The probe address is a real interface (or the loopback fallback); + // either way it must be present in the announced set. + assert!( + locs.contains(&probe), + "announced set includes the route-probe locator" + ); + // No exact-duplicate locators in the announced set. + let mut seen = alloc::vec::Vec::new(); + for l in &locs { + assert!(!seen.contains(l), "no duplicate announced locators"); + seen.push(*l); + } + } + #[test] fn expand_initial_peer_ip_only_yields_well_known_port_range() { let m = super::INITIAL_PEER_MAX_PARTICIPANTS; @@ -14177,10 +14287,10 @@ mod tests { guid: Guid::new(remote_prefix, EntityId::PARTICIPANT), protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, - default_unicast_locator: None, - default_multicast_locator: None, - metatraffic_unicast_locator: None, - metatraffic_multicast_locator: None, + default_unicast_locators: Vec::new(), + default_multicast_locators: Vec::new(), + metatraffic_unicast_locators: Vec::new(), + metatraffic_multicast_locators: Vec::new(), domain_id: Some(0), builtin_endpoint_set: 0, lease_duration: QosDuration::from_secs(100), @@ -14669,10 +14779,10 @@ mod tests { guid: Guid::new(remote_prefix, EntityId::PARTICIPANT), protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, - default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7500)), - default_multicast_locator: None, - metatraffic_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7501)), - metatraffic_multicast_locator: None, + default_unicast_locators: vec![Locator::udp_v4([127, 0, 0, 99], 7500)], + default_multicast_locators: Vec::new(), + metatraffic_unicast_locators: vec![Locator::udp_v4([127, 0, 0, 99], 7501)], + metatraffic_multicast_locators: Vec::new(), domain_id: Some(0), builtin_endpoint_set: endpoint_set, lease_duration: QosDuration::from_secs(100), @@ -14704,10 +14814,10 @@ mod tests { guid: Guid::new(prefix, EntityId::PARTICIPANT), protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, - default_unicast_locator: default, - default_multicast_locator: None, - metatraffic_unicast_locator: metatraffic, - metatraffic_multicast_locator: None, + default_unicast_locators: default.into_iter().collect(), + default_multicast_locators: Vec::new(), + metatraffic_unicast_locators: metatraffic.into_iter().collect(), + metatraffic_multicast_locators: Vec::new(), domain_id: Some(0), builtin_endpoint_set: 0, lease_duration: QosDuration::from_secs(100), @@ -14757,10 +14867,10 @@ mod tests { guid: Guid::new(remote_prefix, EntityId::PARTICIPANT), protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, - default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7500)), - default_multicast_locator: None, - metatraffic_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7501)), - metatraffic_multicast_locator: None, + default_unicast_locators: vec![Locator::udp_v4([127, 0, 0, 99], 7500)], + default_multicast_locators: Vec::new(), + metatraffic_unicast_locators: vec![Locator::udp_v4([127, 0, 0, 99], 7501)], + metatraffic_multicast_locators: Vec::new(), domain_id: Some(0), builtin_endpoint_set: endpoint_set, lease_duration: QosDuration::from_secs(100), diff --git a/crates/discovery/examples/dump_spdp_beacon.rs b/crates/discovery/examples/dump_spdp_beacon.rs index c302e153..06ab5e3c 100644 --- a/crates/discovery/examples/dump_spdp_beacon.rs +++ b/crates/discovery/examples/dump_spdp_beacon.rs @@ -48,10 +48,10 @@ fn main() { protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, participant_security_info: None, - default_unicast_locator: Some(Locator::udp_v4(ip, u32::from(uc_port))), - default_multicast_locator: Some(Locator::udp_v4([239, 255, 0, 1], mc_port)), - metatraffic_unicast_locator: Some(Locator::udp_v4(ip, u32::from(uc_port))), - metatraffic_multicast_locator: Some(Locator::udp_v4([239, 255, 0, 1], mc_port)), + default_unicast_locators: vec![Locator::udp_v4(ip, u32::from(uc_port))], + default_multicast_locators: vec![Locator::udp_v4([239, 255, 0, 1], mc_port)], + metatraffic_unicast_locators: vec![Locator::udp_v4(ip, u32::from(uc_port))], + metatraffic_multicast_locators: vec![Locator::udp_v4([239, 255, 0, 1], mc_port)], domain_id: Some(domain), builtin_endpoint_set: flags, lease_duration: DdsDuration::from_secs(30), diff --git a/crates/discovery/examples/spdp_demo.rs b/crates/discovery/examples/spdp_demo.rs index 7a068702..725b7e1d 100644 --- a/crates/discovery/examples/spdp_demo.rs +++ b/crates/discovery/examples/spdp_demo.rs @@ -91,10 +91,10 @@ fn main() -> Result<(), Box> { protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, participant_security_info: None, - default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 1], 7410)), - default_multicast_locator: Some(Locator::udp_v4(SPDP_DEFAULT_MULTICAST_ADDRESS, port)), - metatraffic_unicast_locator: None, - metatraffic_multicast_locator: None, + default_unicast_locators: vec![Locator::udp_v4([127, 0, 0, 1], 7410)], + default_multicast_locators: vec![Locator::udp_v4(SPDP_DEFAULT_MULTICAST_ADDRESS, port)], + metatraffic_unicast_locators: Vec::new(), + metatraffic_multicast_locators: Vec::new(), domain_id: None, builtin_endpoint_set: endpoint_flag::PARTICIPANT_ANNOUNCER | endpoint_flag::PARTICIPANT_DETECTOR, @@ -147,8 +147,8 @@ fn main() -> Result<(), Box> { p.data.guid.prefix, p.sender_vendor, p.data.protocol_version, - p.data.default_unicast_locator, - p.data.default_multicast_locator, + p.data.default_unicast_locators, + p.data.default_multicast_locators, ); println!(" total known participants: {}", c.len()); } diff --git a/crates/discovery/src/security/stack.rs b/crates/discovery/src/security/stack.rs index 60a04da8..55f91305 100644 --- a/crates/discovery/src/security/stack.rs +++ b/crates/discovery/src/security/stack.rs @@ -238,12 +238,7 @@ impl SecurityBuiltinStack { if !caps.has_stateless_auth && !caps.has_volatile_secure { return; } - let unicast: Vec = peer - .data - .metatraffic_unicast_locator - .or(peer.data.default_unicast_locator) - .into_iter() - .collect(); + let unicast: Vec = peer.data.metatraffic_or_default_unicast_locators(); let remote_prefix = peer.sender_prefix; if caps.has_stateless_auth { @@ -727,6 +722,7 @@ impl PeerHandshake { #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; + use alloc::vec; use zerodds_rtps::participant_data::{ Duration as DdsDuration, ParticipantBuiltinTopicData, endpoint_flag, }; @@ -752,10 +748,10 @@ mod tests { guid: Guid::new(remote_prefix(), EntityId::PARTICIPANT), protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, - default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7411)), - default_multicast_locator: None, - metatraffic_unicast_locator: None, - metatraffic_multicast_locator: None, + default_unicast_locators: vec![Locator::udp_v4([127, 0, 0, 99], 7411)], + default_multicast_locators: Vec::new(), + metatraffic_unicast_locators: Vec::new(), + metatraffic_multicast_locators: Vec::new(), domain_id: None, builtin_endpoint_set: flags, lease_duration: DdsDuration::from_secs(30), diff --git a/crates/discovery/src/sedp/stack.rs b/crates/discovery/src/sedp/stack.rs index 62afbe9d..3d0eb529 100644 --- a/crates/discovery/src/sedp/stack.rs +++ b/crates/discovery/src/sedp/stack.rs @@ -275,12 +275,7 @@ impl SedpStack { // SEDP routes to metatraffic_unicast_locator (PID 0x0032) — // only if that is missing does it fall back to default_unicast_locator. // Cyclone/FastDDS announce both, but with strictly separate roles. - let unicast_locators: Vec<_> = p - .data - .metatraffic_unicast_locator - .or(p.data.default_unicast_locator) - .into_iter() - .collect(); + let unicast_locators: Vec<_> = p.data.metatraffic_or_default_unicast_locators(); let flags = p.data.builtin_endpoint_set; // Does the remote have a publications announcer (writer)? @@ -707,6 +702,7 @@ fn routes_to_sec_sub(reader_id: EntityId, writer_id: EntityId) -> bool { #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; + use alloc::vec; use zerodds_rtps::participant_data::{ Duration as DdsDuration, ParticipantBuiltinTopicData, endpoint_flag, }; @@ -721,10 +717,10 @@ mod tests { guid: Guid::new(prefix, EntityId::PARTICIPANT), protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, - default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7411)), - default_multicast_locator: None, - metatraffic_unicast_locator: None, - metatraffic_multicast_locator: None, + default_unicast_locators: vec![Locator::udp_v4([127, 0, 0, 99], 7411)], + default_multicast_locators: Vec::new(), + metatraffic_unicast_locators: Vec::new(), + metatraffic_multicast_locators: Vec::new(), domain_id: None, builtin_endpoint_set: endpoint_set, lease_duration: DdsDuration::from_secs(30), diff --git a/crates/discovery/src/spdp.rs b/crates/discovery/src/spdp.rs index 56795ad4..4af1f916 100644 --- a/crates/discovery/src/spdp.rs +++ b/crates/discovery/src/spdp.rs @@ -278,6 +278,7 @@ impl DiscoveredParticipantsCache { mod tests { #![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] use super::*; + use alloc::vec; use zerodds_rtps::participant_data::{Duration, endpoint_flag}; use zerodds_rtps::wire_types::{Guid, Locator, ProtocolVersion}; @@ -286,10 +287,10 @@ mod tests { guid: Guid::new(GuidPrefix::from_bytes([0xA; 12]), EntityId::PARTICIPANT), protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, - default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 1], 7410)), - default_multicast_locator: Some(Locator::udp_v4([239, 255, 0, 1], 7400)), - metatraffic_unicast_locator: None, - metatraffic_multicast_locator: None, + default_unicast_locators: vec![Locator::udp_v4([127, 0, 0, 1], 7410)], + default_multicast_locators: vec![Locator::udp_v4([239, 255, 0, 1], 7400)], + metatraffic_unicast_locators: Vec::new(), + metatraffic_multicast_locators: Vec::new(), domain_id: None, builtin_endpoint_set: endpoint_flag::PARTICIPANT_ANNOUNCER | endpoint_flag::PARTICIPANT_DETECTOR, diff --git a/crates/discovery/tests/cyclone_live_sedp.rs b/crates/discovery/tests/cyclone_live_sedp.rs index e6a8e73c..7bc1f0bd 100644 --- a/crates/discovery/tests/cyclone_live_sedp.rs +++ b/crates/discovery/tests/cyclone_live_sedp.rs @@ -189,22 +189,22 @@ fn build_local_participant(local_ip: Ipv4Addr, unicast_port: u16) -> Participant guid: Guid::new(GuidPrefix::from_bytes(LOCAL_PREFIX), EntityId::PARTICIPANT), protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, - default_unicast_locator: Some(Locator::udp_v4(local_ip.octets(), u32::from(unicast_port))), - default_multicast_locator: Some(Locator::udp_v4( + default_unicast_locators: vec![Locator::udp_v4(local_ip.octets(), u32::from(unicast_port))], + default_multicast_locators: vec![Locator::udp_v4( SPDP_MULTICAST_GROUP.octets(), u32::from(SPDP_MULTICAST_PORT), - )), + )], // Metatraffic locator = our unicast socket. Cyclone sends SEDP // publications exactly there after it matches our beacon. // Without this PID, Cyclone does not route SEDP back. - metatraffic_unicast_locator: Some(Locator::udp_v4( + metatraffic_unicast_locators: vec![Locator::udp_v4( local_ip.octets(), u32::from(unicast_port), - )), - metatraffic_multicast_locator: Some(Locator::udp_v4( + )], + metatraffic_multicast_locators: vec![Locator::udp_v4( SPDP_MULTICAST_GROUP.octets(), u32::from(SPDP_MULTICAST_PORT), - )), + )], // Domain 42 matching Cyclone's `-D 42`. Without DOMAIN_ID, // Cyclone counts the beacon as domain 0 → no match. domain_id: Some(CYCLONE_DOMAIN), @@ -345,7 +345,7 @@ fn cyclone_live_sedp_discovery() { p.sender_prefix, p.sender_vendor, p.data.builtin_endpoint_set, - p.data.default_unicast_locator + p.data.default_unicast_locators ); stack.on_participant_discovered(&p); cyclone_discovered = true; diff --git a/crates/discovery/tests/cyclone_sedp_replay.rs b/crates/discovery/tests/cyclone_sedp_replay.rs index fd95435e..46e89149 100644 --- a/crates/discovery/tests/cyclone_sedp_replay.rs +++ b/crates/discovery/tests/cyclone_sedp_replay.rs @@ -85,10 +85,10 @@ fn cyclone_participant() -> DiscoveredParticipant { ), protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId([0x01, 0x10]), - default_unicast_locator: Some(Locator::udp_v4([192, 168, 178, 60], 46133)), - default_multicast_locator: None, - metatraffic_unicast_locator: None, - metatraffic_multicast_locator: None, + default_unicast_locators: vec![Locator::udp_v4([192, 168, 178, 60], 46133)], + default_multicast_locators: Vec::new(), + metatraffic_unicast_locators: Vec::new(), + metatraffic_multicast_locators: Vec::new(), domain_id: None, builtin_endpoint_set: flags, lease_duration: DdsDuration::from_secs(30), diff --git a/crates/discovery/tests/fastdds_live_spdp.rs b/crates/discovery/tests/fastdds_live_spdp.rs index 5a91c3b4..e6548431 100644 --- a/crates/discovery/tests/fastdds_live_spdp.rs +++ b/crates/discovery/tests/fastdds_live_spdp.rs @@ -92,19 +92,19 @@ fn build_local_participant(local_ip: Ipv4Addr, unicast_port: u16) -> Participant guid: Guid::new(GuidPrefix::from_bytes(LOCAL_PREFIX), EntityId::PARTICIPANT), protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, - default_unicast_locator: Some(Locator::udp_v4(local_ip.octets(), u32::from(unicast_port))), - default_multicast_locator: Some(Locator::udp_v4( + default_unicast_locators: vec![Locator::udp_v4(local_ip.octets(), u32::from(unicast_port))], + default_multicast_locators: vec![Locator::udp_v4( SPDP_MULTICAST_GROUP.octets(), u32::from(SPDP_MULTICAST_PORT), - )), - metatraffic_unicast_locator: Some(Locator::udp_v4( + )], + metatraffic_unicast_locators: vec![Locator::udp_v4( local_ip.octets(), u32::from(unicast_port), - )), - metatraffic_multicast_locator: Some(Locator::udp_v4( + )], + metatraffic_multicast_locators: vec![Locator::udp_v4( SPDP_MULTICAST_GROUP.octets(), u32::from(SPDP_MULTICAST_PORT), - )), + )], domain_id: Some(FASTDDS_DOMAIN), builtin_endpoint_set: flags, lease_duration: DdsDuration::from_secs(30), diff --git a/crates/discovery/tests/fastdds_spdp_repro.rs b/crates/discovery/tests/fastdds_spdp_repro.rs index f6da5fe9..968ef384 100644 --- a/crates/discovery/tests/fastdds_spdp_repro.rs +++ b/crates/discovery/tests/fastdds_spdp_repro.rs @@ -44,7 +44,7 @@ fn zerodds_parses_real_fastdds_spdp() { ); eprintln!( " metatraffic_unicast={:?} default_unicast={:?}", - dp.data.metatraffic_unicast_locator, dp.data.default_unicast_locator + dp.data.metatraffic_unicast_locators, dp.data.default_unicast_locators ); } Err(e) => eprintln!("ERR: parse_datagram rejects FastDDS SPDP: {e:?}"), diff --git a/crates/discovery/tests/spdp_loopback_e2e.rs b/crates/discovery/tests/spdp_loopback_e2e.rs index 09991ac9..ce3c0f14 100644 --- a/crates/discovery/tests/spdp_loopback_e2e.rs +++ b/crates/discovery/tests/spdp_loopback_e2e.rs @@ -48,10 +48,10 @@ fn make_participant(prefix_byte: u8, port: u32) -> ParticipantBuiltinTopicData { ), protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, - default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 1], port)), - default_multicast_locator: Some(Locator::udp_v4(SPDP_DEFAULT_MULTICAST_ADDRESS, port)), - metatraffic_unicast_locator: None, - metatraffic_multicast_locator: None, + default_unicast_locators: vec![Locator::udp_v4([127, 0, 0, 1], port)], + default_multicast_locators: vec![Locator::udp_v4(SPDP_DEFAULT_MULTICAST_ADDRESS, port)], + metatraffic_unicast_locators: Vec::new(), + metatraffic_multicast_locators: Vec::new(), domain_id: None, builtin_endpoint_set: endpoint_flag::PARTICIPANT_ANNOUNCER | endpoint_flag::PARTICIPANT_DETECTOR, diff --git a/crates/rtps/CHANGELOG.md b/crates/rtps/CHANGELOG.md index f2581fa6..bfac3a4d 100644 --- a/crates/rtps/CHANGELOG.md +++ b/crates/rtps/CHANGELOG.md @@ -5,6 +5,19 @@ SemVer per [SemVer 2.0.0](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed + +- **BREAKING:** `ParticipantBuiltinTopicData` locator fields are now + `Vec` (renamed to plural: `default_unicast_locators`, + `default_multicast_locators`, `metatraffic_unicast_locators`, + `metatraffic_multicast_locators`), not `Option` — every field that is + a locator list per DDSI-RTPS 2.5 §9.6.1 (#27). Decode retains all repeated + locator PIDs in LE and BE (new `Locator::from_bytes_be`); no routing filtering + in decode. New helpers: `primary_default_unicast_locator`, + `primary_metatraffic_unicast_locator`, `metatraffic_or_default_unicast_locators`, + and the public `locator_looks_routable` predicate. See the workspace CHANGELOG + for migration notes. + ## [1.0.0-rc.1] — 2026-05-06 ### RC1 audit diff --git a/crates/rtps/src/participant_data.rs b/crates/rtps/src/participant_data.rs index 09033fa7..fa16bd0b 100644 --- a/crates/rtps/src/participant_data.rs +++ b/crates/rtps/src/participant_data.rs @@ -191,16 +191,21 @@ pub struct ParticipantBuiltinTopicData { pub protocol_version: ProtocolVersion, /// Vendor-Identifier. pub vendor_id: VendorId, - /// Default-Unicast-Locator — wohin Peers User-Daten schicken. - pub default_unicast_locator: Option, - /// Default multicast locator — user-data multicast. - pub default_multicast_locator: Option, - /// Metatraffic unicast locator — where peers send SEDP unicast. + /// Default unicast locators — where peers send user data. A list + /// (DDSI-RTPS 2.5 §9.6.1: `PID_DEFAULT_UNICAST_LOCATOR` may repeat); on a + /// multi-homed host every usable address is announced so a peer on any + /// shared segment finds a reachable one (#27). Empty = none announced. + /// Wire order preserved; exact duplicates removed on encode. + pub default_unicast_locators: Vec, + /// Default multicast locators — user-data multicast groups. + pub default_multicast_locators: Vec, + /// Metatraffic unicast locators — where peers send SEDP unicast. /// Indispensable for SEDP interop (e.g. Cyclone): Cyclone routes - /// publications/subscriptions to exactly this locator after a match. - pub metatraffic_unicast_locator: Option, - /// Metatraffic multicast locator — the SPDP/SEDP multicast group. - pub metatraffic_multicast_locator: Option, + /// publications/subscriptions to these locators after a match. Announced + /// as the full multi-homed set (#27). + pub metatraffic_unicast_locators: Vec, + /// Metatraffic multicast locators — the SPDP/SEDP multicast groups. + pub metatraffic_multicast_locators: Vec, /// DDS domain ID. Cyclone filters beacons from other domains; if the /// PID is missing, domain 0 is usually assumed. pub domain_id: Option, @@ -256,6 +261,37 @@ pub struct ParticipantBuiltinTopicData { } impl ParticipantBuiltinTopicData { + /// First announced default-unicast locator, if any. Ergonomic accessor + /// for call sites that only need one representative locator (e.g. logging + /// or a legacy single-destination path). Prefer iterating the full list + /// for actual transmission (fan-out). + #[must_use] + pub fn primary_default_unicast_locator(&self) -> Option { + self.default_unicast_locators.first().copied() + } + + /// First announced metatraffic-unicast locator, if any. See + /// [`Self::primary_default_unicast_locator`]. + #[must_use] + pub fn primary_metatraffic_unicast_locator(&self) -> Option { + self.metatraffic_unicast_locators.first().copied() + } + + /// The full set of unicast locators a peer should use to reach this + /// participant's metatraffic (SEDP): the announced metatraffic list, or — + /// if none was announced — the default-unicast list (DDSI-RTPS 2.5 + /// §8.5.4.2 fallback). Returns **all** of them so the caller can fan out + /// to every advertised destination (#27); the list is empty only when the + /// participant announced no unicast locator at all. + #[must_use] + pub fn metatraffic_or_default_unicast_locators(&self) -> Vec { + if self.metatraffic_unicast_locators.is_empty() { + self.default_unicast_locators.clone() + } else { + self.metatraffic_unicast_locators.clone() + } + } + /// Encodes to PL_CDR_LE bytes (with a 4-byte encapsulation header). /// The output is directly usable as the `serialized_payload` of a DATA /// submessage. @@ -281,36 +317,31 @@ impl ParticipantBuiltinTopicData { self.guid.to_bytes().to_vec(), )); - // DEFAULT_UNICAST_LOCATOR (optional): 24 byte - if let Some(loc) = self.default_unicast_locator { - params.push(Parameter::new( - pid::DEFAULT_UNICAST_LOCATOR, - loc.to_bytes_le().to_vec(), - )); - } - - // DEFAULT_MULTICAST_LOCATOR (optional): 24 byte - if let Some(loc) = self.default_multicast_locator { - params.push(Parameter::new( + // Locator lists: one PID per locator, wire order preserved, exact + // duplicates removed (24 byte each, LE). + for (id, locs) in [ + (pid::DEFAULT_UNICAST_LOCATOR, &self.default_unicast_locators), + ( pid::DEFAULT_MULTICAST_LOCATOR, - loc.to_bytes_le().to_vec(), - )); - } - - // METATRAFFIC_UNICAST_LOCATOR (optional): 24 byte - if let Some(loc) = self.metatraffic_unicast_locator { - params.push(Parameter::new( + &self.default_multicast_locators, + ), + ( pid::METATRAFFIC_UNICAST_LOCATOR, - loc.to_bytes_le().to_vec(), - )); - } - - // METATRAFFIC_MULTICAST_LOCATOR (optional): 24 byte - if let Some(loc) = self.metatraffic_multicast_locator { - params.push(Parameter::new( + &self.metatraffic_unicast_locators, + ), + ( pid::METATRAFFIC_MULTICAST_LOCATOR, - loc.to_bytes_le().to_vec(), - )); + &self.metatraffic_multicast_locators, + ), + ] { + let mut seen: Vec = Vec::new(); + for loc in locs { + if seen.contains(loc) { + continue; + } + seen.push(*loc); + params.push(Parameter::new(id, loc.to_bytes_le().to_vec())); + } } // DOMAIN_ID (optional): 4 byte u32 @@ -427,22 +458,29 @@ impl ParticipantBuiltinTopicData { self.guid.to_bytes().to_vec(), )); - for (id, loc) in [ - (pid::DEFAULT_UNICAST_LOCATOR, self.default_unicast_locator), + // One PID per locator, wire order preserved, exact duplicates removed + // (a repeated locator carries no information and only bloats the beacon). + for (id, locs) in [ + (pid::DEFAULT_UNICAST_LOCATOR, &self.default_unicast_locators), ( pid::DEFAULT_MULTICAST_LOCATOR, - self.default_multicast_locator, + &self.default_multicast_locators, ), ( pid::METATRAFFIC_UNICAST_LOCATOR, - self.metatraffic_unicast_locator, + &self.metatraffic_unicast_locators, ), ( pid::METATRAFFIC_MULTICAST_LOCATOR, - self.metatraffic_multicast_locator, + &self.metatraffic_multicast_locators, ), ] { - if let Some(loc) = loc { + let mut seen: Vec = Vec::new(); + for loc in locs { + if seen.contains(loc) { + continue; + } + seen.push(*loc); params.push(Parameter::new(id, loc.to_bytes_be().to_vec())); } } @@ -565,25 +603,18 @@ impl ParticipantBuiltinTopicData { }) .unwrap_or(VendorId::UNKNOWN); - // Unicast locators may be announced multiple times (multi-homed peer): - // decode all and prefer the routable one instead of blindly the first (M-1). - let default_unicast_locator = pick_routable_locator( - pl.find_all(pid::DEFAULT_UNICAST_LOCATOR) - .filter_map(|p| decode_locator(&p.value, little_endian)), - ); - - let default_multicast_locator = pl - .find(pid::DEFAULT_MULTICAST_LOCATOR) - .and_then(|p| decode_locator(&p.value, little_endian)); - - let metatraffic_unicast_locator = pick_routable_locator( - pl.find_all(pid::METATRAFFIC_UNICAST_LOCATOR) - .filter_map(|p| decode_locator(&p.value, little_endian)), - ); - - let metatraffic_multicast_locator = pl - .find(pid::METATRAFFIC_MULTICAST_LOCATOR) - .and_then(|p| decode_locator(&p.value, little_endian)); + // Locator PIDs may appear zero, one, or many times (multi-homed peer, + // DDSI-RTPS 2.5 §9.6.1). Retain ALL valid ones in wire order — no + // routing/usability filtering here; that belongs in DCPS/transport. + let decode_all = |id: u16| -> Vec { + pl.find_all(id) + .filter_map(|p| decode_locator(&p.value, little_endian)) + .collect() + }; + let default_unicast_locators = decode_all(pid::DEFAULT_UNICAST_LOCATOR); + let default_multicast_locators = decode_all(pid::DEFAULT_MULTICAST_LOCATOR); + let metatraffic_unicast_locators = decode_all(pid::METATRAFFIC_UNICAST_LOCATOR); + let metatraffic_multicast_locators = decode_all(pid::METATRAFFIC_MULTICAST_LOCATOR); let domain_id = pl.find(pid::DOMAIN_ID).and_then(|p| { if p.value.len() == 4 { @@ -695,10 +726,10 @@ impl ParticipantBuiltinTopicData { guid, protocol_version, vendor_id, - default_unicast_locator, - default_multicast_locator, - metatraffic_unicast_locator, - metatraffic_multicast_locator, + default_unicast_locators, + default_multicast_locators, + metatraffic_unicast_locators, + metatraffic_multicast_locators, domain_id, builtin_endpoint_set, lease_duration, @@ -719,7 +750,13 @@ impl ParticipantBuiltinTopicData { /// link-local (169.254.0.0/16) or unspecified (0.0.0.0). Loopback (127.0.0.0/8) /// counts as routable (same-host). Non-UDPv4 kinds (TCP/SHM/UDS/IPv6) are /// not heuristically downgraded. -fn locator_looks_routable(loc: &Locator) -> bool { +/// Heuristic: does this locator look like a routable destination? UDPv4 +/// unspecified (0.0.0.0) and link-local (169.254/16) are treated as +/// non-routable; every other kind/address is assumed routable. Used by +/// DCPS/transport for usability ordering of a peer's announced locator list +/// (the RTPS decode path itself performs **no** filtering — it retains all). +#[must_use] +pub fn locator_looks_routable(loc: &Locator) -> bool { if loc.kind != LocatorKind::UdpV4 { return true; } @@ -729,37 +766,17 @@ fn locator_looks_routable(loc: &Locator) -> bool { !(unspecified || link_local) } -/// Picks from several announced locators (multi-homed peer, DDSI-RTPS -/// §8.5.3.2 / §9.6.1.1: a `*_UNICAST_LOCATOR` PID may appear multiple times) -/// the most likely reachable one: a plausibly routable one ([`locator_looks_routable`]) -/// is preferred, otherwise the first announced. Fixes the misroute where a -/// non-routable FIRST locator (link-local listed first) sent the reverse SPDP/ -/// SEDP/VolatileSecure reply to an unreachable target. -fn pick_routable_locator(candidates: impl Iterator) -> Option { - let mut first = None; - let mut best = None; - for loc in candidates { - if first.is_none() { - first = Some(loc); - } - if best.is_none() && locator_looks_routable(&loc) { - best = Some(loc); - } - } - best.or(first) -} - fn decode_locator(value: &[u8], little_endian: bool) -> Option { if value.len() != Locator::WIRE_SIZE { return None; } - if !little_endian { - // Limitation: BE locator not implemented. - return None; - } let mut bs = [0u8; 24]; bs.copy_from_slice(value); - Locator::from_bytes_le(bs).ok() + if little_endian { + Locator::from_bytes_le(bs).ok() + } else { + Locator::from_bytes_be(bs).ok() + } } #[cfg(test)] @@ -770,35 +787,78 @@ mod tests { use alloc::vec; #[test] - fn pick_routable_prefers_non_link_local() { - // Regression M-1: a multi-homed peer may list the link-local - // locator FIRST. pick_routable_locator must still choose the - // routable one, otherwise the reverse-discovery reply goes to - // 169.254.x.x into the void. - let link_local = Locator::udp_v4([169, 254, 1, 5], 7410); - let routable = Locator::udp_v4([192, 168, 1, 10], 7410); - assert_eq!( - pick_routable_locator([link_local, routable].into_iter()), - Some(routable) - ); - // Only link-local → fall back to the first (better than nothing). - assert_eq!( - pick_routable_locator([link_local].into_iter()), - Some(link_local) - ); - // unspecified is downgraded too. - let unspec = Locator::udp_v4([0, 0, 0, 0], 7410); - assert_eq!( - pick_routable_locator([unspec, routable].into_iter()), - Some(routable) - ); + fn locator_looks_routable_classifies_addresses() { + // The routability heuristic is a pure predicate now (DCPS orders the + // retained list with it; the decode path itself never filters). + assert!(locator_looks_routable(&Locator::udp_v4( + [192, 168, 1, 10], + 7410 + ))); // Loopback counts as routable (same-host operation). - let loopback = Locator::udp_v4([127, 0, 0, 1], 7410); + assert!(locator_looks_routable(&Locator::udp_v4( + [127, 0, 0, 1], + 7410 + ))); + // link-local and unspecified are non-routable. + assert!(!locator_looks_routable(&Locator::udp_v4( + [169, 254, 1, 5], + 7410 + ))); + assert!(!locator_looks_routable(&Locator::udp_v4( + [0, 0, 0, 0], + 7410 + ))); + } + + #[test] + fn decode_retains_all_repeated_unicast_locators_in_order_le_and_be() { + // A multi-homed peer lists every usable unicast locator. Decode keeps + // ALL of them in wire order (no reduction), LE and BE alike (#27). + let a = Locator::udp_v4([169, 254, 1, 5], 7410); // link-local FIRST + let b = Locator::udp_v4([192, 168, 1, 10], 7410); + let c = Locator::udp_v4([10, 0, 0, 7], 7410); + for be in [false, true] { + let mut src = sample_data(); + src.default_unicast_locators = vec![a, b, c]; + src.metatraffic_unicast_locators = vec![b, c]; + let bytes = if be { + src.to_pl_cdr_be() + } else { + src.to_pl_cdr_le() + }; + let decoded = ParticipantBuiltinTopicData::from_pl_cdr_le(&bytes).unwrap(); + assert_eq!( + decoded.default_unicast_locators, + vec![a, b, c], + "be={be}: all three retained, wire order preserved" + ); + assert_eq!(decoded.metatraffic_unicast_locators, vec![b, c], "be={be}"); + // No routing filtering in decode: the link-local FIRST survives. + assert_eq!(decoded.default_unicast_locators[0], a, "be={be}"); + } + } + + #[test] + fn encode_dedups_exact_duplicate_locators() { + let b = Locator::udp_v4([192, 168, 1, 10], 7410); + let mut src = sample_data(); + src.default_unicast_locators = vec![b, b, b]; + let decoded = ParticipantBuiltinTopicData::from_pl_cdr_le(&src.to_pl_cdr_le()).unwrap(); assert_eq!( - pick_routable_locator([loopback].into_iter()), - Some(loopback) + decoded.default_unicast_locators, + vec![b], + "duplicates removed" ); - assert_eq!(pick_routable_locator(core::iter::empty()), None); + } + + #[test] + fn decode_zero_locators_yields_empty_lists() { + let mut src = sample_data(); + src.default_unicast_locators.clear(); + src.metatraffic_unicast_locators.clear(); + let decoded = ParticipantBuiltinTopicData::from_pl_cdr_le(&src.to_pl_cdr_le()).unwrap(); + assert!(decoded.default_unicast_locators.is_empty()); + assert!(decoded.metatraffic_unicast_locators.is_empty()); } fn sample_data() -> ParticipantBuiltinTopicData { @@ -809,10 +869,10 @@ mod tests { ), protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, - default_unicast_locator: Some(Locator::udp_v4([192, 168, 1, 100], 7410)), - default_multicast_locator: Some(Locator::udp_v4([239, 255, 0, 1], 7400)), - metatraffic_unicast_locator: None, - metatraffic_multicast_locator: None, + default_unicast_locators: vec![Locator::udp_v4([192, 168, 1, 100], 7410)], + default_multicast_locators: vec![Locator::udp_v4([239, 255, 0, 1], 7400)], + metatraffic_unicast_locators: Vec::new(), + metatraffic_multicast_locators: Vec::new(), domain_id: None, builtin_endpoint_set: endpoint_flag::PARTICIPANT_ANNOUNCER | endpoint_flag::PARTICIPANT_DETECTOR, @@ -1104,12 +1164,12 @@ mod tests { #[test] fn participant_data_without_optional_locators() { let mut d = sample_data(); - d.default_unicast_locator = None; - d.default_multicast_locator = None; + d.default_unicast_locators.clear(); + d.default_multicast_locators.clear(); let bytes = d.to_pl_cdr_le(); let decoded = ParticipantBuiltinTopicData::from_pl_cdr_le(&bytes).unwrap(); - assert!(decoded.default_unicast_locator.is_none()); - assert!(decoded.default_multicast_locator.is_none()); + assert!(decoded.default_unicast_locators.is_empty()); + assert!(decoded.default_multicast_locators.is_empty()); } #[test] diff --git a/crates/rtps/src/reliable_writer.rs b/crates/rtps/src/reliable_writer.rs index b64b5dfc..ea02b32c 100644 --- a/crates/rtps/src/reliable_writer.rs +++ b/crates/rtps/src/reliable_writer.rs @@ -1936,6 +1936,40 @@ mod tests { assert_ne!(dgs[0].targets, dgs[1].targets); } + #[test] + fn write_fans_out_to_every_unicast_locator_of_a_proxy() { + // #27: a multi-homed reader advertises several unicast locators. UDP + // cannot detect a dead destination, so the writer targets EVERY + // advertised locator — the reachable one delivers even when the first + // advertised locator is unreachable. + let mut w = make_writer(10, Duration::from_secs(10)); + let rguid = reader_guid(); + let unreachable = Locator::udp_v4([192, 0, 2, 9], 7411); // TEST-NET-1, FIRST + let reachable = Locator::udp_v4([127, 0, 0, 1], 7411); // loopback, SECOND + // Replace the default proxy with a two-unicast-locator one (no multicast, + // so `targets_for` uses the unicast list). + w.add_reader_proxy(ReaderProxy::new( + rguid, + alloc::vec![unreachable, reachable], + alloc::vec![], + true, + )); + assert_eq!(w.reader_proxy_count(), 1, "same guid replaces, not appends"); + let dgs = w.write(&alloc::vec![0xAA]).unwrap(); + let d = dgs + .iter() + .find(|d| d.targets.contains(&reachable)) + .expect("a datagram targets the reachable locator"); + assert!( + d.targets.contains(&unreachable), + "the unreachable FIRST locator is still targeted" + ); + assert!( + d.targets.contains(&reachable), + "the reachable SECOND locator is targeted — delivery succeeds despite the dead first" + ); + } + #[test] fn add_reader_proxy_is_idempotent_on_same_guid() { let mut w = make_writer(10, Duration::from_secs(10)); diff --git a/crates/rtps/src/wire_types.rs b/crates/rtps/src/wire_types.rs index eb1d7d1d..bd13674c 100644 --- a/crates/rtps/src/wire_types.rs +++ b/crates/rtps/src/wire_types.rs @@ -1002,6 +1002,27 @@ impl Locator { address, }) } + + /// BE decoder (for PL_CDR_BE peers). Mirrors [`Locator::from_bytes_le`]; + /// the 16-byte address is byte-order-neutral. + /// + /// # Errors + /// `WireError::InvalidLocatorKind` on an unknown kind. + pub fn from_bytes_be(bytes: [u8; 24]) -> Result { + let mut kind_bytes = [0u8; 4]; + kind_bytes.copy_from_slice(&bytes[..4]); + let kind = LocatorKind::from_i32(i32::from_be_bytes(kind_bytes))?; + let mut port_bytes = [0u8; 4]; + port_bytes.copy_from_slice(&bytes[4..8]); + let port = u32::from_be_bytes(port_bytes); + let mut address = [0u8; 16]; + address.copy_from_slice(&bytes[8..]); + Ok(Self { + kind, + port, + address, + }) + } } #[cfg(test)] diff --git a/crates/rtps/tests/user_data_propagation.rs b/crates/rtps/tests/user_data_propagation.rs index 9509b48f..414ce35f 100644 --- a/crates/rtps/tests/user_data_propagation.rs +++ b/crates/rtps/tests/user_data_propagation.rs @@ -169,10 +169,10 @@ fn participant_user_data_roundtrip() { protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, participant_security_info: None, - default_unicast_locator: None, - default_multicast_locator: None, - metatraffic_unicast_locator: None, - metatraffic_multicast_locator: None, + default_unicast_locators: Vec::new(), + default_multicast_locators: Vec::new(), + metatraffic_unicast_locators: Vec::new(), + metatraffic_multicast_locators: Vec::new(), domain_id: Some(7), builtin_endpoint_set: endpoint_flag::PARTICIPANT_ANNOUNCER, lease_duration: RtpsDuration::from_secs(100), @@ -201,10 +201,10 @@ fn user_data_large_payload_32kib() { protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, participant_security_info: None, - default_unicast_locator: None, - default_multicast_locator: None, - metatraffic_unicast_locator: None, - metatraffic_multicast_locator: None, + default_unicast_locators: Vec::new(), + default_multicast_locators: Vec::new(), + metatraffic_unicast_locators: Vec::new(), + metatraffic_multicast_locators: Vec::new(), domain_id: Some(7), builtin_endpoint_set: 0, lease_duration: RtpsDuration::from_secs(100), diff --git a/crates/security-runtime/tests/cyclone_live_security_caps.rs b/crates/security-runtime/tests/cyclone_live_security_caps.rs index 4cac2fb2..d977c8ec 100644 --- a/crates/security-runtime/tests/cyclone_live_security_caps.rs +++ b/crates/security-runtime/tests/cyclone_live_security_caps.rs @@ -153,19 +153,19 @@ fn build_secure_beacon_data(local_ip: Ipv4Addr, unicast_port: u16) -> Participan guid: Guid::new(GuidPrefix::from_bytes(LOCAL_PREFIX), EntityId::PARTICIPANT), protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, - default_unicast_locator: Some(Locator::udp_v4(local_ip.octets(), u32::from(unicast_port))), - default_multicast_locator: Some(Locator::udp_v4( + default_unicast_locators: vec![Locator::udp_v4(local_ip.octets(), u32::from(unicast_port))], + default_multicast_locators: vec![Locator::udp_v4( SPDP_MULTICAST_GROUP.octets(), u32::from(SPDP_MULTICAST_PORT), - )), - metatraffic_unicast_locator: Some(Locator::udp_v4( + )], + metatraffic_unicast_locators: vec![Locator::udp_v4( local_ip.octets(), u32::from(unicast_port), - )), - metatraffic_multicast_locator: Some(Locator::udp_v4( + )], + metatraffic_multicast_locators: vec![Locator::udp_v4( SPDP_MULTICAST_GROUP.octets(), u32::from(SPDP_MULTICAST_PORT), - )), + )], domain_id: Some(CYCLONE_DOMAIN), builtin_endpoint_set: flags, lease_duration: DdsDuration::from_secs(30), diff --git a/crates/security-runtime/tests/delegation_vehicle_mesh_e2e.rs b/crates/security-runtime/tests/delegation_vehicle_mesh_e2e.rs index b55c9401..acbf5a33 100644 --- a/crates/security-runtime/tests/delegation_vehicle_mesh_e2e.rs +++ b/crates/security-runtime/tests/delegation_vehicle_mesh_e2e.rs @@ -64,10 +64,10 @@ fn baseline_participant(prefix: u8) -> ParticipantBuiltinTopicData { guid: Guid::new(GuidPrefix::from_bytes([prefix; 12]), EntityId::PARTICIPANT), protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, - default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 1], 7410)), - default_multicast_locator: Some(Locator::udp_v4([239, 255, 0, 1], 7400)), - metatraffic_unicast_locator: None, - metatraffic_multicast_locator: None, + default_unicast_locators: vec![Locator::udp_v4([127, 0, 0, 1], 7410)], + default_multicast_locators: vec![Locator::udp_v4([239, 255, 0, 1], 7400)], + metatraffic_unicast_locators: Vec::new(), + metatraffic_multicast_locators: Vec::new(), domain_id: Some(0), builtin_endpoint_set: endpoint_flag::PARTICIPANT_ANNOUNCER | endpoint_flag::PARTICIPANT_DETECTOR, diff --git a/crates/security-runtime/tests/spdp_caps_e2e.rs b/crates/security-runtime/tests/spdp_caps_e2e.rs index 1c1ec925..dc564321 100644 --- a/crates/security-runtime/tests/spdp_caps_e2e.rs +++ b/crates/security-runtime/tests/spdp_caps_e2e.rs @@ -37,10 +37,10 @@ fn baseline_participant(prefix: u8) -> ParticipantBuiltinTopicData { guid: Guid::new(GuidPrefix::from_bytes([prefix; 12]), EntityId::PARTICIPANT), protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, - default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 1], 7410)), - default_multicast_locator: Some(Locator::udp_v4([239, 255, 0, 1], 7400)), - metatraffic_unicast_locator: None, - metatraffic_multicast_locator: None, + default_unicast_locators: vec![Locator::udp_v4([127, 0, 0, 1], 7410)], + default_multicast_locators: vec![Locator::udp_v4([239, 255, 0, 1], 7400)], + metatraffic_unicast_locators: Vec::new(), + metatraffic_multicast_locators: Vec::new(), domain_id: Some(0), builtin_endpoint_set: endpoint_flag::PARTICIPANT_ANNOUNCER | endpoint_flag::PARTICIPANT_DETECTOR, diff --git a/crates/security-runtime/tests/spdp_token_roundtrip.rs b/crates/security-runtime/tests/spdp_token_roundtrip.rs index e0f5cd31..9726cb4b 100644 --- a/crates/security-runtime/tests/spdp_token_roundtrip.rs +++ b/crates/security-runtime/tests/spdp_token_roundtrip.rs @@ -34,10 +34,10 @@ fn make_baseline(prefix: u8) -> ParticipantBuiltinTopicData { guid: Guid::new(GuidPrefix::from_bytes([prefix; 12]), EntityId::PARTICIPANT), protocol_version: ProtocolVersion::V2_5, vendor_id: VendorId::ZERODDS, - default_unicast_locator: None, - default_multicast_locator: None, - metatraffic_unicast_locator: None, - metatraffic_multicast_locator: None, + default_unicast_locators: Vec::new(), + default_multicast_locators: Vec::new(), + metatraffic_unicast_locators: Vec::new(), + metatraffic_multicast_locators: Vec::new(), domain_id: Some(7), builtin_endpoint_set: 0, lease_duration: Duration::from_secs(100), diff --git a/crates/transport-udp/Cargo.toml b/crates/transport-udp/Cargo.toml index ce907dec..218771d4 100644 --- a/crates/transport-udp/Cargo.toml +++ b/crates/transport-udp/Cargo.toml @@ -23,7 +23,7 @@ path = "src/lib.rs" [features] safety = [] # empty marker for safe-profile lints (internal/safety-flag-drift.md) default = ["std"] -std = ["alloc"] +std = ["alloc", "dep:if-addrs"] alloc = [] # PDE Reality Inspector hook-up — tap dispatch in the UDP send path. # Default OFF (R-034). @@ -41,6 +41,10 @@ zerodds-rtps = { version = "1.0.0-rc.7", path = "../rtps" } zerodds-transport = { version = "1.0.0-rc.7", path = "../transport" } zerodds-monitor = { version = "1.0.0-rc.7", path = "../monitor", default-features = false, features = ["std"] } socket2 = { workspace = true } +# #27: eligible interface enumeration, cross-platform. std-only (pulled by +# the `std` feature). Pcap-free (uses getifaddrs / GetAdaptersAddresses) — +# unlike pnet_datalink it needs no Npcap Packet.lib, so it links on Windows. +if-addrs = { version = "0.13", optional = true } [target.'cfg(unix)'.dependencies] # libc for setsockopt(SO_DONTROUTE) configuration (a Cyclone DDS diff --git a/crates/transport-udp/src/interfaces.rs b/crates/transport-udp/src/interfaces.rs new file mode 100644 index 00000000..1aee9a7e --- /dev/null +++ b/crates/transport-udp/src/interfaces.rs @@ -0,0 +1,206 @@ +//! Enumeration of eligible local IPv4 interfaces for RTPS discovery locators. +//! +//! #27: on a multi-homed host, announcing a single probed source address can +//! advertise a locator that is unreachable for a given peer (the OS default- +//! route probe and the peer's segment diverge). Discovery instead announces +//! *every* usable unicast address, so a peer on any shared segment always +//! finds a reachable one. This module produces the deterministic, filtered +//! set the DCPS announce path fans out over. +//! +//! Routing/usability filtering beyond "is this a routable unicast IPv4 on an +//! UP, multicast-capable, non-loopback interface" stays in DCPS — this module +//! only enumerates and orders. + +use std::net::{IpAddr, Ipv4Addr}; + +/// An eligible local IPv4 interface address usable as an announced unicast +/// discovery locator. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EligibleInterface { + /// OS interface name (diagnostic + deterministic tiebreak). + pub name: String, + /// OS interface index. + pub index: u32, + /// The unicast IPv4 address configured on the interface. + pub ipv4: Ipv4Addr, +} + +/// Returns the eligible IPv4 interfaces in deterministic order. +/// +/// Eligibility (DDSI-RTPS 2.5 §9.6.1 discovery locators): the interface is +/// operational (UP — `if-addrs` only returns configured, operational +/// interfaces) and not loopback; the address is a routable unicast IPv4 — not +/// loopback, not unspecified, not link-local (169.254/16), not broadcast, not +/// multicast. Exact duplicate `(name, index, ipv4)` tuples are removed. Order +/// is by IPv4 address ascending, then name, then index — stable and +/// independent of OS enumeration order. +/// +/// Multicast-capability is assumed here: this set drives **unicast** locator +/// announcement (a unicast SEDP destination does not require the interface to +/// be multicast-capable). The per-interface multicast join/TX that would need +/// the actual `IFF_MULTICAST` flag is deferred discovery-hardening. +#[must_use] +pub fn eligible_ipv4_interfaces() -> Vec { + let ifaces = match if_addrs::get_if_addrs() { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + collect_eligible(ifaces.into_iter().filter_map(|iface| { + let ipv4 = match iface.ip() { + IpAddr::V4(v4) => v4, + IpAddr::V6(_) => return None, + }; + Some(RawInterface { + name: iface.name.clone(), + index: iface.index.unwrap_or(0), + // get_if_addrs returns only operational (UP) interfaces; the + // multicast flag is not exposed, so assume it (see fn doc). + up: true, + multicast: true, + loopback: iface.is_loopback(), + v4_addrs: vec![ipv4], + }) + })) +} + +/// OS-agnostic view of one interface, so the filter/order logic is testable +/// without a live network stack. +#[derive(Debug, Clone)] +struct RawInterface { + name: String, + index: u32, + up: bool, + multicast: bool, + loopback: bool, + v4_addrs: Vec, +} + +/// Filter + flatten + deterministic order + exact-duplicate removal. Split +/// from [`eligible_ipv4_interfaces`] so tests drive it with synthetic input. +fn collect_eligible(raw: impl Iterator) -> Vec { + let mut out: Vec = raw + .filter(|r| r.up && r.multicast && !r.loopback) + .flat_map(|r| { + let RawInterface { + name, + index, + v4_addrs, + .. + } = r; + v4_addrs + .into_iter() + .filter(is_eligible_v4) + .map(move |ipv4| EligibleInterface { + name: name.clone(), + index, + ipv4, + }) + }) + .collect(); + out.sort_by(|a, b| { + a.ipv4 + .octets() + .cmp(&b.ipv4.octets()) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.index.cmp(&b.index)) + }); + out.dedup(); + out +} + +/// A routable unicast IPv4 usable as an announced locator. +fn is_eligible_v4(ip: &Ipv4Addr) -> bool { + !ip.is_loopback() + && !ip.is_unspecified() + && !ip.is_link_local() + && !ip.is_broadcast() + && !ip.is_multicast() +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + fn raw(name: &str, index: u32, up: bool, mc: bool, lo: bool, addrs: &[&str]) -> RawInterface { + RawInterface { + name: name.to_string(), + index, + up, + multicast: mc, + loopback: lo, + v4_addrs: addrs.iter().map(|s| s.parse().unwrap()).collect(), + } + } + + #[test] + fn keeps_only_up_multicast_non_loopback() { + let ifs = vec![ + raw("eth0", 2, true, true, false, &["192.168.1.10"]), + raw("down0", 3, false, true, false, &["10.0.0.5"]), // down + raw("nomc0", 4, true, false, false, &["10.0.0.6"]), // not multicast + raw("lo", 1, true, true, true, &["127.0.0.1"]), // loopback flag + ]; + let got = collect_eligible(ifs.into_iter()); + assert_eq!(got.len(), 1); + assert_eq!(got[0].ipv4, Ipv4Addr::new(192, 168, 1, 10)); + } + + #[test] + fn filters_loopback_linklocal_unspecified_multicast_broadcast_addrs() { + let ifs = vec![raw( + "eth0", + 2, + true, + true, + false, + &[ + "127.0.0.1", // loopback addr + "169.254.3.4", // link-local + "0.0.0.0", // unspecified + "239.255.0.1", // multicast + "255.255.255.255", // broadcast + "10.1.2.3", // the one keeper + ], + )]; + let got = collect_eligible(ifs.into_iter()); + assert_eq!(got.len(), 1); + assert_eq!(got[0].ipv4, Ipv4Addr::new(10, 1, 2, 3)); + } + + #[test] + fn deterministic_order_by_ip_then_name_then_index() { + // Input in scrambled order; output must be by ipv4 asc, then name, index. + let ifs = vec![ + raw("zeth", 9, true, true, false, &["192.168.1.20"]), + raw("aeth", 2, true, true, false, &["10.0.0.5"]), + raw("aeth", 2, true, true, false, &["192.168.1.20"]), + ]; + let got = collect_eligible(ifs.into_iter()); + let ips: Vec<_> = got.iter().map(|e| (e.ipv4, e.name.as_str())).collect(); + assert_eq!( + ips, + vec![ + (Ipv4Addr::new(10, 0, 0, 5), "aeth"), + (Ipv4Addr::new(192, 168, 1, 20), "aeth"), + (Ipv4Addr::new(192, 168, 1, 20), "zeth"), + ] + ); + } + + #[test] + fn removes_exact_duplicate_tuples() { + let ifs = vec![ + raw("eth0", 2, true, true, false, &["192.168.1.10"]), + raw("eth0", 2, true, true, false, &["192.168.1.10"]), // exact dup + ]; + let got = collect_eligible(ifs.into_iter()); + assert_eq!(got.len(), 1); + } + + #[test] + fn empty_when_only_loopback() { + let ifs = vec![raw("lo", 1, true, true, true, &["127.0.0.1"])]; + assert!(collect_eligible(ifs.into_iter()).is_empty()); + } +} diff --git a/crates/transport-udp/src/lib.rs b/crates/transport-udp/src/lib.rs index 090c6576..5ed07116 100644 --- a/crates/transport-udp/src/lib.rs +++ b/crates/transport-udp/src/lib.rs @@ -48,5 +48,12 @@ pub mod recv_batch; #[cfg(feature = "std")] mod udp_transport; +/// #27: eligible local IPv4 interface enumeration for multi-locator discovery +/// announcement. +#[cfg(feature = "std")] +pub mod interfaces; + +#[cfg(feature = "std")] +pub use interfaces::{EligibleInterface, eligible_ipv4_interfaces}; #[cfg(feature = "std")] pub use udp_transport::{MAX_DATAGRAM_SIZE, UdpTransport, UdpTransportError}; diff --git a/docs/OPEN-ITEMS.md b/docs/OPEN-ITEMS.md new file mode 100644 index 00000000..5670118b --- /dev/null +++ b/docs/OPEN-ITEMS.md @@ -0,0 +1,14 @@ +# Open Items + +Tracked follow-up work. Each entry links a `*-followup.md` with the detail. + +## Discovery + +- **Multi-interface multicast join/TX** — see + [discovery-multi-interface-multicast-followup.md](discovery-multi-interface-multicast-followup.md). + Deferred out of the #27 multi-locator fix. On a multi-homed host the SPDP + multicast socket joins/transmits on a single OS-selected interface. #27 is + resolved without changing this (the announced unicast locator, not multicast + membership, was the failing operation — proven by packet capture). Implement + only after an independent reproduction demonstrates a case where multicast + RX/TX itself selects the wrong interface. diff --git a/docs/discovery-multi-interface-multicast-followup.md b/docs/discovery-multi-interface-multicast-followup.md new file mode 100644 index 00000000..ab11ff18 --- /dev/null +++ b/docs/discovery-multi-interface-multicast-followup.md @@ -0,0 +1,38 @@ +# Follow-up: multi-interface multicast join/TX + +**Status:** deferred (not required for #27). + +## Context + +#27 (multi-homed discovery failure) is resolved by announcing every eligible +unicast interface address and fanning metatraffic out to every advertised peer +locator. That fix is entirely on the **unicast** path. + +The **multicast** path is unchanged: the SPDP socket binds `0.0.0.0` and the OS +selects one interface for the group join and for multicast transmission +(`IP_MULTICAST_IF` per the routing table). + +## Why it is deferred, not done + +Packet capture of the #27 reproduction showed SPDP multicast TX leaving on the +correct interface in **both** the failing and the passing run — 0 packets on the +misconfigured interface. The reader discovered the peer participant in the +failing run (`discovered=1`); only the endpoint match failed, because the +announced **unicast** locator was unreachable. Multicast membership/TX was never +the failing operation. + +## What full multi-interface multicast would add + +Independence from the OS single-interface multicast selection: + +- join the SPDP group on **every** eligible interface, +- transmit SPDP on **every** eligible interface (per-interface sockets — never a + shared socket whose outgoing interface is mutated concurrently), +- tolerate per-interface failure, require at least one usable interface, +- log the selected/joined interfaces at diagnostic level. + +## Gate to implement + +An independent, reproducible failure in which the OS selects the wrong interface +for multicast **join or transmission** (not merely for the unicast source-address +probe). Until such a reproduction exists, this is speculative hardening. diff --git a/interop/cyclone-xtypes-27/reader/src/main.rs b/interop/cyclone-xtypes-27/reader/src/main.rs index 1e70eabf..3a84b930 100644 --- a/interop/cyclone-xtypes-27/reader/src/main.rs +++ b/interop/cyclone-xtypes-27/reader/src/main.rs @@ -38,10 +38,12 @@ fn main() { let start = std::time::Instant::now(); let mut matched = 0usize; + let mut discovered = 0usize; let mut samples = 0u64; let mut errors = 0u64; while start.elapsed().as_secs() < secs { matched = matched.max(r.matched_publication_count()); + discovered = discovered.max(p.discovered_participants_count()); match r.take() { Ok(v) => samples += v.len() as u64, Err(e) => { @@ -53,5 +55,5 @@ fn main() { } std::thread::sleep(std::time::Duration::from_millis(100)); } - println!("RESULT matched={matched} samples={samples} errors={errors}"); + println!("RESULT discovered={discovered} matched={matched} samples={samples} errors={errors}"); }