From c4cb5c1e7e07a888cd126c8607fa4b3c518555fc Mon Sep 17 00:00:00 2001 From: Yutaka Kondo Date: Wed, 5 Aug 2026 22:51:10 -0700 Subject: [PATCH 1/2] fix(dds): send unicast SPDP to the configured peer port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DDSTransportSession and RclTransportSession mapped only DDSPeer.address into the CycloneDDS list, dropping DDSPeer.port. Without a port, add_addresses_to_addrset_1() patches in the *participant* unicast discovery port (7400 + 250 * domain + 10) and then adds one locator per participant index up to MaxAutoParticipantIndex — 7410, 7412, ... 7426 on domain 0. A remote running the default ParticipantIndex ("none") binds ephemeral unicast ports, so nothing listens there: SPDP went to dead ports, discovery never completed, and no error was raised. The failure is indistinguishable from a firewall block or AP isolation, which is what it gets misdiagnosed as. Both call sites now emit DDSPeer.discoveryAddress — host:port, with a bare IPv6 address bracketed because ddsi_ipaddr_from_string only reads a port off a bracketed IPv6 address (fe80::1:7400 would otherwise parse as a different address entirely). Because the port is now load-bearing, the port: 7400 default is only correct on domain 0; the docs point at DDSPeer.peer(address:domainId:) for other domains. Reported and diagnosed by @peichunhuang-1. Fixes #176 Signed-off-by: Yutaka Kondo --- .../Articles/GettingStartedDDS.md | 10 ++++++++++ .../DDSTransportSession.swift | 2 +- .../RclTransportSession.swift | 7 ++++--- .../SwiftROS2Transport/TransportConfig.swift | 19 +++++++++++++++++++ .../DiscoveryConfigXMLTests.swift | 11 +++++++++++ .../DDSTransportSessionTests.swift | 10 ++++++++-- .../RclTransportSessionTests.swift | 5 ++++- .../TransportConfigTests.swift | 19 +++++++++++++++++++ docs/PARITY.md | 2 +- docs/parity-matrix.json | 2 +- 10 files changed, 78 insertions(+), 9 deletions(-) diff --git a/Sources/SwiftROS2/Documentation.docc/Articles/GettingStartedDDS.md b/Sources/SwiftROS2/Documentation.docc/Articles/GettingStartedDDS.md index 7703d35..252600f 100644 --- a/Sources/SwiftROS2/Documentation.docc/Articles/GettingStartedDDS.md +++ b/Sources/SwiftROS2/Documentation.docc/Articles/GettingStartedDDS.md @@ -29,3 +29,13 @@ let ctx = try await ROS2Context(transport: .ddsUnicast(peers: peers, domainId: 0 CycloneDDS computes the discovery port as `7400 + domainId * 250`. SwiftROS2 exposes the same formula via `DDSPeer.discoveryPort(forDomain:)`. + +The port is sent to CycloneDDS as part of the peer (`DDSPeer.discoveryAddress` +renders `host:port`), so it has to match the domain you are joining. On any +domain other than 0 the `port: 7400` default is wrong — build peers with the +factory instead, which applies the formula for you: + +```swift +let peers = [DDSPeer.peer(address: "192.168.1.10", domainId: 5)] +let ctx = try await ROS2Context(transport: .ddsUnicast(peers: peers, domainId: 5)) +``` diff --git a/Sources/SwiftROS2Transport/DDSTransportSession.swift b/Sources/SwiftROS2Transport/DDSTransportSession.swift index b78ba3a..3d0bd6f 100644 --- a/Sources/SwiftROS2Transport/DDSTransportSession.swift +++ b/Sources/SwiftROS2Transport/DDSTransportSession.swift @@ -67,7 +67,7 @@ public final class DDSTransportSession: TransportSession, @unchecked Sendable { let discoveryConfig = DDSBridgeDiscoveryConfig( mode: discoveryMode, - unicastPeers: config.ddsUnicastPeers.map { $0.address }, + unicastPeers: config.ddsUnicastPeers.map { $0.discoveryAddress }, networkInterface: config.ddsNetworkInterface ) diff --git a/Sources/SwiftROS2Transport/RclTransportSession.swift b/Sources/SwiftROS2Transport/RclTransportSession.swift index 5f9f9d2..9870ac3 100644 --- a/Sources/SwiftROS2Transport/RclTransportSession.swift +++ b/Sources/SwiftROS2Transport/RclTransportSession.swift @@ -60,12 +60,13 @@ public final class RclTransportSession: TransportSession, @unchecked Sendable { throw TransportError.unsupportedFeature( "RCL transport not available (CRos2Jazzy not built)") } - // DDS discovery (bare host addresses) on the `.rcl` path; the router - // locator on the `.zenoh` path. Only one is set per build variant. + // DDS discovery (`host:port` peer addresses — the port is load-bearing, + // see `DDSPeer.discoveryAddress`) on the `.rcl` path; the router locator + // on the `.zenoh` path. Only one is set per build variant. try client.createContext( domainId: Int32(config.domainId), transportType: config.type, - unicastPeerAddresses: config.ddsUnicastPeers.map { $0.address }, + unicastPeerAddresses: config.ddsUnicastPeers.map { $0.discoveryAddress }, networkInterface: config.ddsNetworkInterface, zenohRouterLocator: config.type == .zenoh ? config.zenohLocator : nil) lock.lock() diff --git a/Sources/SwiftROS2Transport/TransportConfig.swift b/Sources/SwiftROS2Transport/TransportConfig.swift index b90d0ea..441455d 100644 --- a/Sources/SwiftROS2Transport/TransportConfig.swift +++ b/Sources/SwiftROS2Transport/TransportConfig.swift @@ -64,6 +64,25 @@ public struct DDSPeer: Codable, Equatable, Sendable { "udp/\(address):\(port)" } + /// The peer as CycloneDDS consumes it in `` — always + /// `host:port`, with a bare IPv6 address bracketed. + /// + /// The port is not optional decoration. CycloneDDS only sends SPDP to the + /// exact port when the peer string carries one; a bare host makes it patch + /// in the *participant* unicast discovery port (`7400 + 250 * domain + 10`) + /// and probe participant indices up to `MaxAutoParticipantIndex` — 7410, + /// 7412, ... 7426 on domain 0. Nothing is bound there when the remote runs + /// the default `ParticipantIndex` ("none", which leaves its unicast ports + /// ephemeral), so discovery silently never completes (issue #176). + /// + /// Bracketing matters for the same reason: `ddsi_ipaddr_from_string` only + /// reads a port off an IPv6 address when the address part is bracketed, so + /// `fe80::1:7400` would parse as a *different* address with no port. + public var discoveryAddress: String { + let host = address.contains(":") && !address.hasPrefix("[") ? "[\(address)]" : address + return "\(host):\(port)" + } + public static func discoveryPort(forDomain domainId: Int) -> UInt16 { UInt16(7400 + domainId * 250) } diff --git a/Tests/SwiftROS2DDSTests/DiscoveryConfigXMLTests.swift b/Tests/SwiftROS2DDSTests/DiscoveryConfigXMLTests.swift index 95d42da..5ec7dbf 100644 --- a/Tests/SwiftROS2DDSTests/DiscoveryConfigXMLTests.swift +++ b/Tests/SwiftROS2DDSTests/DiscoveryConfigXMLTests.swift @@ -61,6 +61,17 @@ final class DiscoveryConfigXMLTests: XCTestCase { + "CycloneDDS builds without topic-discovery support, failing rmw_create_node (issue #149)") } + /// The `host:port` peer form the transport sessions build must reach the + /// XML attribute byte-for-byte. CycloneDDS only sends SPDP to the exact + /// port when the attribute carries one — strip it here and the caller's + /// port is lost just as it was in issue #176, one layer lower. + func testUnicastXMLPreservesPeerPort() { + let xml = buildXML(peers: ["192.168.1.85:7400", "[fe80::1]:7650"], interface: nil) + XCTAssertNotNil(xml) + XCTAssertTrue(xml!.contains("")) + XCTAssertTrue(xml!.contains("")) + } + /// Multicast/default discovery emits no block and therefore never /// reaches the offending element either. func testMulticastXMLHasNoPeersOrTopicDiscoveryElement() { diff --git a/Tests/SwiftROS2TransportTests/DDSTransportSessionTests.swift b/Tests/SwiftROS2TransportTests/DDSTransportSessionTests.swift index 551f1d6..c201d00 100644 --- a/Tests/SwiftROS2TransportTests/DDSTransportSessionTests.swift +++ b/Tests/SwiftROS2TransportTests/DDSTransportSessionTests.swift @@ -49,10 +49,16 @@ final class DDSTransportSessionTests: XCTestCase { func testOpenForwardsUnicastPeers() async throws { let client = MockDDSClient() let session = DDSTransportSession(client: client) - let peers = [DDSPeer(address: "10.0.0.5"), DDSPeer(address: "10.0.0.6")] + let peers = [ + DDSPeer.peer(address: "10.0.0.5", domainId: 1), + DDSPeer.peer(address: "10.0.0.6", domainId: 1), + ] try await session.open(config: TransportConfig.ddsUnicast(peers: peers, domainId: 1)) XCTAssertEqual(client.sessionCreations[0].config.mode, .unicast) - XCTAssertEqual(client.sessionCreations[0].config.unicastPeers, ["10.0.0.5", "10.0.0.6"]) + // The configured port must survive into the CycloneDDS peer list — + // dropping it makes SPDP probe participant-index ports nothing is + // bound to (issue #176). + XCTAssertEqual(client.sessionCreations[0].config.unicastPeers, ["10.0.0.5:7650", "10.0.0.6:7650"]) } func testCloseDestroysSession() async throws { diff --git a/Tests/SwiftROS2TransportTests/RclTransportSessionTests.swift b/Tests/SwiftROS2TransportTests/RclTransportSessionTests.swift index 777aa44..d654cde 100644 --- a/Tests/SwiftROS2TransportTests/RclTransportSessionTests.swift +++ b/Tests/SwiftROS2TransportTests/RclTransportSessionTests.swift @@ -28,7 +28,10 @@ final class RclTransportSessionTests: XCTestCase { let peer = DDSPeer(address: "192.168.1.85", port: DDSPeer.discoveryPort(forDomain: 0)) try await session.open( config: .rclUnicast(peers: [peer], domainId: 0, interface: "en0")) - XCTAssertEqual(client.lastUnicastPeerAddresses, [peer.address]) + // Port included: the RCL path feeds the same CycloneDDS discovery XML + // as the wire DDS path, so a bare host there loses SPDP the same way + // (issue #176). + XCTAssertEqual(client.lastUnicastPeerAddresses, ["192.168.1.85:7400"]) XCTAssertEqual(client.lastNetworkInterface, "en0") } diff --git a/Tests/SwiftROS2TransportTests/TransportConfigTests.swift b/Tests/SwiftROS2TransportTests/TransportConfigTests.swift index 37f4f42..dca09b6 100644 --- a/Tests/SwiftROS2TransportTests/TransportConfigTests.swift +++ b/Tests/SwiftROS2TransportTests/TransportConfigTests.swift @@ -32,6 +32,25 @@ final class TransportConfigTests: XCTestCase { XCTAssertEqual(peer.locator, "udp/192.168.1.10:7400") } + // The `` form CycloneDDS consumes MUST carry the port. + // Without one, add_addresses_to_addrset_1() patches in the *participant* + // unicast discovery port (7400 + 250*domain + 10) and then probes indices + // 1...MaxAutoParticipantIndex (7412, 7414, ... 7426 on domain 0) — ports + // nothing listens on when the remote runs the default ParticipantIndex + // ("none", so its unicast ports are ephemeral). Issue #176. + func testDDSPeerDiscoveryAddressCarriesPort() { + XCTAssertEqual(DDSPeer(address: "192.168.1.10", port: 7400).discoveryAddress, "192.168.1.10:7400") + XCTAssertEqual(DDSPeer.peer(address: "10.0.0.1", domainId: 1).discoveryAddress, "10.0.0.1:7650") + } + + // A bare IPv6 address must be bracketed or CycloneDDS reads the trailing + // ":7400" as part of the address (ddsi_ipaddr_from_string only honors a + // port when the address is bracketed), silently targeting a different host. + func testDDSPeerDiscoveryAddressBracketsIPv6() { + XCTAssertEqual(DDSPeer(address: "fe80::1", port: 7400).discoveryAddress, "[fe80::1]:7400") + XCTAssertEqual(DDSPeer(address: "[fe80::1]", port: 7400).discoveryAddress, "[fe80::1]:7400") + } + func testDDSPeerDiscoveryPortFormula() { XCTAssertEqual(DDSPeer.discoveryPort(forDomain: 0), 7400) XCTAssertEqual(DDSPeer.discoveryPort(forDomain: 1), 7650) diff --git a/docs/PARITY.md b/docs/PARITY.md index 00fc39f..85882a4 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -32,7 +32,7 @@ | action.server | supported | partial | minor | iOS/Catalyst/macOS/visionOS | bundled | na | na | pass | na | rcl_action_server.c:212-220 crcl_action_server_create gated on crcl_action_registry_lookup; registry holds 1 entry (example_interfaces/action/Fibonacci). Wire path serves any action Non-bundled (arbitrary-type) actions deferred-by-decision — no consumer (Conduit is a publisher); the pure-Swift wire path serves any action; a route-b fallback would require the full goal state machine (3 services + 2 topics) for no current need (design 2026-06-13 §9 non-goal). Bundled types (Fibonacci) fully supported. | | action.client | supported | partial | minor | iOS/Catalyst/macOS/visionOS | bundled | na | na | pass | na | rcl_action_client.c crcl_action_client_create gated on crcl_action_registry (CRCL_ACTION_REGISTRY_ENTRY_COUNT 1, Fibonacci only) Non-bundled (arbitrary-type) actions deferred-by-decision — no consumer (Conduit is a publisher); the pure-Swift wire path serves any action; a route-b fallback would require the full goal state machine (3 services + 2 topics) for no current need (design 2026-06-13 §9 non-goal). Bundled types (Fibonacci) fully supported. | | qos.profiles | supported | supported | n-a-by-design | iOS/Catalyst/macOS/visionOS | n-a | na | na | pass | na | makeCrclQoS maps reliability/durability/history/depth -> rmw_qos_profile; same knob set as pure-Swift TransportQoS (deadline/lifespan/liveliness absent on both = parity) | -| transport.dds | supported | supported | n-a-by-design | iOS/Catalyst/macOS/visionOS/Linux | n-a | na | na | pass | na | RclTransportSession.open forwards ddsUnicastPeers (bare host) + ddsNetworkInterface; RclClient.makeDiscoveryURIXML builds the SAME CycloneDDS discovery XML as the wire DDS path via the shared dds_bridge_build_domain_config_xml, exported as CYCLONEDDS_URI around context creation (restored on teardown). .rclUnicast factory + validate() peer check close the config hole. RclDiscoveryEnvTests asserts the XML shape; RclUnicastIntegrationTests is the LAN proof | +| transport.dds | supported | supported | n-a-by-design | iOS/Catalyst/macOS/visionOS/Linux | n-a | na | na | pass | na | RclTransportSession.open forwards ddsUnicastPeers (host:port via DDSPeer.discoveryAddress) + ddsNetworkInterface; RclClient.makeDiscoveryURIXML builds the SAME CycloneDDS discovery XML as the wire DDS path via the shared dds_bridge_build_domain_config_xml, exported as CYCLONEDDS_URI around context creation (restored on teardown). .rclUnicast factory + validate() peer check close the config hole. RclDiscoveryEnvTests asserts the XML shape; RclUnicastIntegrationTests is the LAN proof | | transport.zenoh | supported | supported | n-a-by-design | iOS/Catalyst/macOS/visionOS/Linux | n-a | pass | pass | pass | pass | MZ1: .zenoh(locator:) now routes to RclTransportSession in the zenoh-rmw variant via makeDefaultSession #elseif SWIFT_ROS2_RCL_RMW_ZENOH (zenoh-pico carved out). The router locator is injected into rmw_zenoh_cpp through RclClient.makeZenohSessionConfigJSON5 + applyZenohSessionEnv (ZENOH_SESSION_CONFIG_URI + ZENOH_ROUTER_CHECK_ATTEMPTS, a save/restore mirror of the DDS CYCLONEDDS_URI path), threaded through the createContext seam and RclTransportSession.open. RclZenohSessionEnvTests covers config shape, env apply/restore, and open() locator forwarding; the api-stability gate is green (createContext is a package requirement, public surface unchanged). MZ3 verified: end-to-end vs a live rmw_zenohd router (Docker, jazzy 0.2.9) — latency/soak/correctness recorded on this row; resource recorded in the MZ4 iPhone batch (PASS, documented binary-size divergence — see docs/verification-runbook.md). Build-time variant origin: M9 #128. visionOS caveat: the zenoh variant ships no xros/xrsimulator slices (zenoh-c dep pnet_sys lacks visionOS support) — visionOS stays on the wire path or .dds. | | publish.typed.sensor_msgs/CameraInfo | supported | supported | n-a-by-design | iOS/Catalyst/macOS/visionOS | bundled | na | na | pass | na | crcl_marshal_registry typed marshal for sensor_msgs/CameraInfo (#154); byte-parity vs pure-Swift CDR (CrossBackendBytesTests) | | publish.serialized.tf2_msgs/TFMessage | supported | supported | n-a-by-design | iOS/Catalyst/macOS/visionOS | bundled | na | na | pass | na | tf2_msgs bundled into both xcframeworks (#158, PKGS_UP_TO + registry-only entry) — Conduit /tf_static publishes route-a over the rmw serialized seam with transient-local durability; MarshalRegistryResolveTests | diff --git a/docs/parity-matrix.json b/docs/parity-matrix.json index a0a35cf..2f51d73 100644 --- a/docs/parity-matrix.json +++ b/docs/parity-matrix.json @@ -926,7 +926,7 @@ }, { "apiSymbol" : "TransportConfig.type=.dds", - "evidence" : "RclTransportSession.open forwards ddsUnicastPeers (bare host) + ddsNetworkInterface; RclClient.makeDiscoveryURIXML builds the SAME CycloneDDS discovery XML as the wire DDS path via the shared dds_bridge_build_domain_config_xml, exported as CYCLONEDDS_URI around context creation (restored on teardown). .rclUnicast factory + validate() peer check close the config hole. RclDiscoveryEnvTests asserts the XML shape; RclUnicastIntegrationTests is the LAN proof", + "evidence" : "RclTransportSession.open forwards ddsUnicastPeers (host:port via DDSPeer.discoveryAddress) + ddsNetworkInterface; RclClient.makeDiscoveryURIXML builds the SAME CycloneDDS discovery XML as the wire DDS path via the shared dds_bridge_build_domain_config_xml, exported as CYCLONEDDS_URI around context creation (restored on teardown). .rclUnicast factory + validate() peer check close the config hole. RclDiscoveryEnvTests asserts the XML shape; RclUnicastIntegrationTests is the LAN proof", "id" : "transport.dds", "platforms" : [ "iOS", From 8475c94566994e238d1749959cf9995ffa7d6867 Mon Sep 17 00:00:00 2001 From: Yutaka Kondo Date: Wed, 5 Aug 2026 22:51:29 -0700 Subject: [PATCH 2/2] fix(dds): fail loudly when CycloneDDS rejects the discovery config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dds_bridge_create_session ignored a failed dds_create_domain and carried on to dds_create_participant, which creates an *implicit* domain on the DEFAULT configuration: multicast SPDP, no , no network-interface pin. The session then reported itself connected while discovering nothing — the same "looks like a firewall block" symptom that made #176 so hard to pin down, and it hides exactly the peer misconfiguration the previous commit makes visible. The rejection now surfaces as DDSError.sessionCreationFailed. DDS_RETCODE_PRECONDITION_NOT_MET stays tolerated: it means the domain already exists (another session, or rmw), which is the documented process-lifetime limitation where the first session's config wins. Verified against the shipping CycloneDDS build: a peer of 192.0.2.10:7400 creates the domain (so the new host:port attribute form is accepted), while an unresolvable network interface now throws instead of silently falling back to default discovery. Signed-off-by: Yutaka Kondo --- CHANGELOG.md | 5 +++ Sources/CDDSBridge/dds_bridge.c | 18 +++++++++ .../DiscoveryConfigFailLoudTests.swift | 37 +++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 Tests/SwiftROS2DDSTests/DiscoveryConfigFailLoudTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9faa611..833ef55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +### Fixed + +- **Unicast DDS discovery now reaches the configured port.** `DDSTransportSession` and `RclTransportSession` passed only `DDSPeer.address` into the CycloneDDS `` list, dropping `DDSPeer.port`. Without a port CycloneDDS patches in the *participant* unicast discovery port (`7400 + 250 * domain + 10`) and probes participant indices up to `MaxAutoParticipantIndex` — 7410, 7412, … 7426 on domain 0 — none of which a remote running the default `ParticipantIndex` ("none", ephemeral unicast ports) listens on. SPDP went to dead ports, discovery never completed, and nothing was reported: the failure looked exactly like a firewall or AP-isolation problem. Both call sites now emit the new `DDSPeer.discoveryAddress` (`host:port`, IPv6 bracketed), so SPDP lands on the port the caller configured. Thanks to @peichunhuang-1 for the diagnosis (#176). +- **A discovery config CycloneDDS rejects now fails the session instead of degrading silently.** `dds_bridge_create_session` ignored a failed `dds_create_domain` and went on to `dds_create_participant`, which creates an *implicit* domain on the default configuration — multicast SPDP, no `` — so the session reported itself connected while discovering nothing, the same "looks like a firewall" symptom as #176. The rejection is now surfaced as `DDSError.sessionCreationFailed`. "Domain already exists" (`DDS_RETCODE_PRECONDITION_NOT_MET`) stays tolerated: that is the documented process-lifetime limitation, where the config of the first session on a domain wins (#176). + ## [1.2.0] - 2026-06-06 ### Added diff --git a/Sources/CDDSBridge/dds_bridge.c b/Sources/CDDSBridge/dds_bridge.c index 384f1b0..a1387c4 100644 --- a/Sources/CDDSBridge/dds_bridge.c +++ b/Sources/CDDSBridge/dds_bridge.c @@ -315,6 +315,24 @@ bridge_dds_session_t* dds_bridge_create_session( session->domain = domain; g_domain_created = true; g_domain_id = domain_id; + } else if (domain != DDS_RETCODE_PRECONDITION_NOT_MET) { + // CycloneDDS rejected the discovery config (bad peer address, + // unsupported element, ...). Falling through would create the + // participant on an implicit DEFAULT-configured domain: no + // , multicast SPDP, and a session that reports itself + // connected while discovering nothing. That silent degradation + // is indistinguishable from a firewall block and is exactly the + // class of failure issue #176 was misdiagnosed as. Fail loudly. + // + // PRECONDITION_NOT_MET is the one tolerated case: the domain + // already exists (created by another session or by rmw), so the + // config cannot be applied but the existing one is valid. That + // is the documented process-lifetime limitation above. + set_error("CycloneDDS rejected the discovery configuration: %s", + dds_strretcode(domain)); + free(config_xml); + free(session); + return NULL; } } diff --git a/Tests/SwiftROS2DDSTests/DiscoveryConfigFailLoudTests.swift b/Tests/SwiftROS2DDSTests/DiscoveryConfigFailLoudTests.swift new file mode 100644 index 0000000..c92017e --- /dev/null +++ b/Tests/SwiftROS2DDSTests/DiscoveryConfigFailLoudTests.swift @@ -0,0 +1,37 @@ +import XCTest + +@testable import SwiftROS2DDS +@testable import SwiftROS2Transport + +/// `dds_bridge_create_session` used to ignore a failed `dds_create_domain` and +/// carry on to `dds_create_participant`, which then created an *implicit* +/// domain on the DEFAULT configuration — no ``, multicast SPDP — while +/// reporting the session as connected. A rejected discovery config therefore +/// looked exactly like a firewall block: nothing discovered, nothing logged. +/// That silent degradation is the same failure class as issue #176, so the +/// bridge now surfaces the rejection. +final class DiscoveryConfigFailLoudTests: XCTestCase { + func testUnusableDiscoveryConfigSurfacesAsError() async throws { + let session = DDSTransportSession(client: DDSClient(wireFallback: ())) + // An interface name no NIC can have: CycloneDDS rejects the config at + // domain creation. Domain 42 keeps this off the domains other tests + // touch — the bridge only applies a config on first create per domain. + let config = TransportConfig( + type: .dds, domainId: 42, ddsDiscoveryMode: .unicast, + ddsUnicastPeers: [DDSPeer(address: "192.0.2.10", port: 7400)], + ddsNetworkInterface: "definitely-not-a-nic0") + do { + try await session.open(config: config) + try? session.close() + XCTFail("expected a rejected discovery config to throw, not to fall back to defaults") + } catch let error as DDSError { + guard case .sessionCreationFailed(let message) = error else { + return XCTFail("expected .sessionCreationFailed, got \(error)") + } + XCTAssertTrue( + message.contains("discovery configuration"), + "expected the CycloneDDS rejection to be named in the error, got: \(message)") + } + XCTAssertFalse(session.isConnected) + } +}