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/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/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)
+ }
+}
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",