Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Peer address="..."/>` 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 `<Peers>` — 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
Expand Down
18 changes: 18 additions & 0 deletions Sources/CDDSBridge/dds_bridge.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <Peers>, 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;
}
}

Expand Down
10 changes: 10 additions & 0 deletions Sources/SwiftROS2/Documentation.docc/Articles/GettingStartedDDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
```
2 changes: 1 addition & 1 deletion Sources/SwiftROS2Transport/DDSTransportSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
7 changes: 4 additions & 3 deletions Sources/SwiftROS2Transport/RclTransportSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
19 changes: 19 additions & 0 deletions Sources/SwiftROS2Transport/TransportConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,25 @@ public struct DDSPeer: Codable, Equatable, Sendable {
"udp/\(address):\(port)"
}

/// The peer as CycloneDDS consumes it in `<Peer address="..."/>` — 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)
}
Expand Down
37 changes: 37 additions & 0 deletions Tests/SwiftROS2DDSTests/DiscoveryConfigFailLoudTests.swift
Original file line number Diff line number Diff line change
@@ -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 `<Peers>`, 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)
}
}
11 changes: 11 additions & 0 deletions Tests/SwiftROS2DDSTests/DiscoveryConfigXMLTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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("<Peer address=\"192.168.1.85:7400\"/>"))
XCTAssertTrue(xml!.contains("<Peer address=\"[fe80::1]:7650\"/>"))
}

/// Multicast/default discovery emits no <Peers> block and therefore never
/// reaches the offending element either.
func testMulticastXMLHasNoPeersOrTopicDiscoveryElement() {
Expand Down
10 changes: 8 additions & 2 deletions Tests/SwiftROS2TransportTests/DDSTransportSessionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion Tests/SwiftROS2TransportTests/RclTransportSessionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
19 changes: 19 additions & 0 deletions Tests/SwiftROS2TransportTests/TransportConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,25 @@ final class TransportConfigTests: XCTestCase {
XCTAssertEqual(peer.locator, "udp/192.168.1.10:7400")
}

// The `<Peer address="..."/>` 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)
Expand Down
2 changes: 1 addition & 1 deletion docs/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/parity-matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading