Skip to content

help(interop): interoperability with CycloneDDS #27

Open

Description

@Davide-1998

Hi, I am currently working on reading CycloneDDS data from ZeroDDS, but I cannot make them communicate.
I tried running the script tests/interop/cyclone_shapes_pub.py with both Python 3.13 and 3.10, using as CycloneDDS C backend the version 11.0.1 and 0.10.5 but neither worked.
During my tests I also tried setting the ZeroDDS DataReaderQos::data_representation to XCDR (the default for CycloneDDS) but nothing.
On CycloneDDS side, I tried enabling XCDR2 and yet nothing.

Can you help me figure out what is happening for them to not communicate?
Following are some snippets of code used for testing.

Thanks in advance for all the help

Robot.idl

struct Robot {
  uint32 id;
  uint32 label;
};
# Compiled using the following command, optionally with --cyclone flag
zerodds-idlc generate ./Robot.idl --rust [--cyclone] -o src/generated

Subscriber.rs

use zerodds_dcps::{DomainParticipantFactory, DataReaderQos,  DomainParticipantQos, SubscriberQos, TopicQos};
#[allow(clippy::all, clippy::pedantic, dead_code)]
mod generated {
    include!("generated/Robot.rs");
}

use generated::Robot;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let factory = DomainParticipantFactory::instance();

    let participant = factory.create_participant(100, DomainParticipantQos::default()).unwrap();
    let robot_topic = participant.create_topic::<Robot>("robot", TopicQos::default()).expect("create_topic");
    
    let subscriber = participant.create_subscriber(SubscriberQos::default());
    let reader = subscriber.create_datareader::<Robot>(&robot_topic, DataReaderQos::default()).expect("create_datawriter");

    let mut count: u64 = 0;
    loop {
        match reader.take() {
            Ok(samples) => {
                for item in samples {
                    println!("Read #{count}: id {} | label '{}'", item.id, item.label);
                    count += 1;
                }
            }

            Err(err) => {
                println!("error: {}", err)
            }
        }
    }
}

Writer CycloneDDS Python

import time
from dataclasses import dataclass

from cyclonedds.domain import DomainParticipant  # type: ignore[import-not-found]
from cyclonedds.idl import IdlStruct  # type: ignore[import-not-found]
from cyclonedds.idl.annotations import key  # type: ignore[import-not-found]
from cyclonedds.idl.types import bounded_str, int32, uint32  # type: ignore[import-not-found]
from cyclonedds.pub import DataWriter, Publisher  # type: ignore[import-not-found]
from cyclonedds.topic import Topic  # type: ignore[import-not-found]

@dataclass
class RobotType(IdlStruct, typename="Robot"):
    """Spec-kompatibel mit RTI/Cyclone/Fast-DDS.

    IDL:
        struct Robot {
            uint32 id;
            uint32 label;
        };
    """

    id: uint32 = 0
    label: uint32 = 0

def main() -> int:
    topic_name = "robot"
    domain_id = 100

    participant = DomainParticipant(domain_id=domain_id)
    topic = Topic(participant, topic_name, RobotType)
    publisher = Publisher(participant)
    writer = DataWriter(publisher, topic)

    print(
        f"[cyclone-pub] Topic={topic_name} Domain={domain_id} — Ctrl-C to stop",
        flush=True,
    )

    count = 0
    try:
        while True:
            sample = RobotType(id=1, label=count)
            writer.write(sample)
            print(f"  -> id={sample.id} label={sample.label}", flush=True)
            count += 1
            time.sleep(0.5)
    except KeyboardInterrupt:
        return 0


if __name__ == "__main__":
    raise SystemExit(main())

Writer CycloneDDS-CXX v11.0.1

#include <chrono>
#include <dds/dds.hpp>  // CycloneDDS-cxx
#include <print>
#include <thread>

#include "IDL/Robot.hpp"  // Compiled Robot.idl using cmake command `idlcxx_generate`

int main() {
    std::println("Start Robot writer");

    // Initialize DDS classes
    auto participant = ::dds::domain::DomainParticipant(100);
    auto topic = ::dds::topic::Topic<Robot>(participant, "robot", ::dds::topic::qos::TopicQos());
    auto publisher = ::dds::pub::Publisher(participant, participant.default_publisher_qos());
    auto writer = ::dds::pub::DataWriter<Robot>(publisher, topic, ::dds::pub::qos::DataWriterQos());

    std::jthread worker_thread([&]() {
        std::uint32_t num_packets = 0;

        while (!stop_thread.load()) {
            RobotIdl::Type data;
            data.id(num_packets);
            data.label(num_packets);

            writer.write(data);  // Write to DDS

            num_packets++;
            std::println("Written: id: {} | label: '{}'", num_packets, data.id(), data.label());

            std::this_thread::sleep_for(std::chrono::milliseconds(500));
        }
    });
}

Activity

  1. adriano-tumino commented on Jul 30, 2026

    @adriano-tumino

    I have the same problem!

  2. self-assigned this
    on Jul 30, 2026
  3. added theissue type on Jul 30, 2026
  4. added this to the 1.0.0-rc.7 milestone on Jul 30, 2026
  5. SandraK82 commented on Aug 1, 2026

    @SandraK82
    Contributor

    Thanks for the detailed report — I dug into this properly.

    Short version: with the default configuration, ZeroDDS ↔ CycloneDDS interop for an un-annotated struct Robot works for me in both directions, same-host and cross-host, on both CycloneDDS 11.0.1 and 0.10.5. I could not reproduce a failure on the default (XCDR1) path. The failure I can reproduce is the one that appears once XCDR2 is forced — and that one has a concrete cause and fix.

    What I tested

    A minimal struct Robot { uint32 id; uint32 label; }; (no annotations), domain 100, ZeroDDS on one side and CycloneDDS on the other:

    Writer (Cyclone) Reader (ZeroDDS) Result
    default (final, XCDR1) default match, samples delivered (53 received)
    forced XCDR2 (still final) default match, but take() returns WireError, 0 samples
    @appendable, XCDR2 default match, samples delivered (53 received)

    Verified with both the Python and the C++ (ddscxx) bindings, so it is not a binding artifact, and on both CycloneDDS 11.0.1 and 0.10.5. I also checked the reverse direction for the working configuration (ZeroDDS @appendable/XCDR2 writer → CycloneDDS reader): samples delivered.

    The cause of the XCDR2 case

    It comes down to a default-extensibility difference for un-annotated types:

    • CycloneDDS treats an un-annotated aggregate as @final (its own idlc even warns about this: "No default extensibility provided … currently 'final' … may change to 'appendable' … XTypes spec").
    • ZeroDDS follows the XTypes 1.3 default and treats an un-annotated aggregate as @appendable.

    On the wire this is invisible under XCDR1: an @appendable type emits no DHEADER in XCDR1, so the bytes are identical to @final and everything interoperates. That is why the default path works.

    Force XCDR2 and the difference becomes real: @appendable in XCDR2 is framed with a DHEADER (a length prefix), @final is framed without one. So the ZeroDDS reader (which believes the type is @appendable) tries to read a DHEADER that the CycloneDDS @final writer never wrote, and the decode fails. That is your "I tried enabling XCDR2 and yet nothing": the endpoints still match (there is no incompatible-QoS), but every sample fails to decode.

    A note on why the endpoints still match despite the extensibility difference: complete standard TypeObject validation is not available to the cross-vendor matching path here, so with the default force_type_validation = false the matching falls back to the exact type name. This does not mean final and @appendable are assignable — they are not — only that the assignability check isn't reached in this cross-vendor case. So you get a match followed by a decode failure rather than a match-time rejection.

    Also worth flagging: this decode failure is not silent — DataReader::take() returns Err(WireError) for each undecodable sample. If your reader loop discards the Result (e.g. if let Ok(v) = reader.take()), the errors disappear into the floor. Logging the Err arm surfaces them.

    The fix

    Make the extensibility explicit and identical on both sides. Either annotate the type @final everywhere, or @appendable everywhere:

    @final          // or @appendable — just make both sides agree
    struct Robot {
        uint32 id;
        uint32 label;
    };

    If you'd rather not touch the IDL, tell the ZeroDDS generator to default un-annotated aggregates to final (matching CycloneDDS' default):

    zerodds-idlc generate Robot.idl --rust --default-extensibility final -o src/generated
    

    One important clarification, since I suspect you may have tried it: in the current release --cyclone does not change the generated code. It is accepted as an intent marker but is a no-op for extensibility today — it does not imply --default-extensibility final.

    I've opened a PR that makes --cyclone functional (#29): it defaults un-annotated aggregates to @final, matching CycloneDDS' own default, so zerodds-idlc generate … --cyclone produces a reader that interoperates with a CycloneDDS @final writer under XCDR2 out of the box. Until that lands in a release, the workaround today is to be explicit: --default-extensibility final, or the @final annotation on the type.

    With matching extensibility, XCDR1 and XCDR2 both interoperate.

    On your default-path failure

    Since I can't reproduce a failure on the plain default (XCDR1) path, could you share a bit more about that specific setup? In particular:

    • Are the two endpoints on the same host or different hosts / subnets? (Multicast discovery behaves very differently across some switches/APs and WLAN.)
    • The exact CycloneDDS config (CYCLONEDDS_URI) and any QoS you set.
    • Whether matched_publication_count / matched_subscription_count ever reaches 1.

    That will tell us whether you're seeing a discovery/transport issue rather than the XCDR2 framing one above.

  6. SandraK82 commented on Aug 1, 2026

    @SandraK82
    Contributor

    Update: the fix is merged — #29.

    zerodds-idlc --cyclone now defaults un-annotated aggregates to @final, matching CycloneDDS, so a reader generated with --cyclone interoperates with a CycloneDDS @final/XCDR2 writer without touching the IDL. take() also now surfaces the framing mismatch as a WireError rather than a generic error. A gated live interop regression matrix (interop/cyclone-xtypes-27/) covers the default XCDR1 path, the forced-XCDR2 failure, and the --cyclone fix against CycloneDDS 11.0.1.

    • For any released version, the workaround stays valid: annotate the type @final (or @appendable on both sides), or generate with --default-extensibility final.
    • On current main (or the next release), please re-test with --cyclone — it should now interoperate out of the box.

    I'm leaving this open on purpose:

    • Your original report was on the default (XCDR1) path, which I still can't reproduce here. If you can share whether the endpoints are same-host or cross-host, your CYCLONEDDS_URI/QoS, and whether matched_*_count reaches 1, that will tell us if it's a discovery/transport issue rather than the XCDR2 framing one.

    Separately, the deeper limitation behind the "matched but no data" behaviour — ZeroDDS not yet consuming a peer's standard PID_TYPE_INFORMATION, so cross-vendor assignability isn't checked at match time — is tracked in #30. That is not fixed by #29.

  7. adriano-tumino commented on Aug 3, 2026

    @adriano-tumino

    Hello,
    Thank you for the explanation, but it still doesn't work for me.
    I started from the examples provided earlier and added the missing pieces, but I am still unable to get it working.

    //build.rs
    use std::process::Command;
    
    fn main() {
        let idl_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("idl");
        println!("cargo:warning=idl_dir={:?}", idl_dir);
        let out_dir = std::env::var("OUT_DIR").unwrap();
        println!("cargo:warning=idl_dir={:?}", out_dir);
    
        println!("cargo:rerun-if-changed={}", idl_dir.display());
        for entry in std::fs::read_dir(&idl_dir).unwrap() {
            let path = entry.unwrap().path();
            if path.extension().and_then(|e| e.to_str()) != Some("idl") {
                continue;
            }
            println!("cargo:rerun-if-changed={}", path.display());
    
            let status = Command::new("zerodds-idlc")
                .arg("generate")
                .arg(&path)
                .arg("--rust")
                .arg("--default-extensibility")
                .arg("final")
                /* .arg("--cyclone") */
                .arg("-o")
                .arg("src/generated")
                .status()
                .expect("failed to run zerodds-idlc");
    
            assert!(
                status.success(),
                "zerodds-idlc failed for {}",
                path.display()
            );
        }
    }
    //main.rs
    
    use zerodds_dcps::{DomainParticipantFactory, DataReaderQos,  DomainParticipantQos, SubscriberQos, TopicQos};
    #[allow(clippy::all, clippy::pedantic, dead_code)]
    mod generated {
        include!("generated/Robot.rs");
    }
    
    use generated::Robot;
    
    fn main() -> Result<(), Box<dyn std::error::Error>> {
        let factory = DomainParticipantFactory::instance();
    
        let participant = factory.create_participant(100, DomainParticipantQos::default()).unwrap();
        let robot_topic = participant.create_topic::<Robot>("robot", TopicQos::default()).expect("create_topic");
        
        let subscriber = participant.create_subscriber(SubscriberQos::default());
        let reader = subscriber.create_datareader::<Robot>(&robot_topic, DataReaderQos::default()).expect("create_datawriter");
    
        let mut count: u64 = 0;
        loop {
            match reader.take() {
                Ok(samples) => {
                    for item in samples {
                        println!("Read #{count}: id {} | label '{}'", item.id, item.label);
                        count += 1;
                    }
                }
    
                Err(err) => {
                    println!("error: {}", err)
                }
            }
        }
    }
    //cyclone_main.cpp
    #include <atomic>
    #include <chrono>
    #include <dds/dds.hpp>  // CycloneDDS-cxx
    #include <print>
    #include <thread>
    
    #include "IDL/Robot.hpp"  // Compiled Robot.idl using cmake command `idlcxx_generate`
    
    int main() {
        std::println("Start Robot writer");
    
        // Initialize DDS classes
        auto participant = ::dds::domain::DomainParticipant(100);
        auto topic = ::dds::topic::Topic<Robot>(participant, "robot", ::dds::topic::qos::TopicQos());
        auto publisher = ::dds::pub::Publisher(participant, participant.default_publisher_qos());
        auto writer = ::dds::pub::DataWriter<Robot>(publisher, topic, ::dds::pub::qos::DataWriterQos());
    
        std::jthread worker_thread([&]() {
            std::uint32_t num_packets = 0;
    
            while(num_packets < 100) {
                Robot data;
                data.id(num_packets);
                data.label(num_packets);
    
                writer.write(data);  // Write to DDS
    
                num_packets++;
                std::println("Written: id: {} | label: '{}'", num_packets, data.id(), data.label());
    
                std::this_thread::sleep_for(std::chrono::milliseconds(500));
            }
        });
    }
    cargo run -p subscribe
    warning: profiles for the non root package will be ignored, specify profiles at the workspace root:
    package:   C:\Users\Tumino Adriano\Desktop\zero test\zero\subscribe\Cargo.toml
    workspace: C:\Users\Tumino Adriano\Desktop\zero test\zero\Cargo.toml
    warning: profiles for the non root package will be ignored, specify profiles at the workspace root:
    package:   C:\Users\Tumino Adriano\Desktop\zero test\zero\writer\Cargo.toml
    workspace: C:\Users\Tumino Adriano\Desktop\zero test\zero\Cargo.toml
    warning: subscribe@0.1.0: idl_dir="C:\\Users\\Tumino Adriano\\Desktop\\zero test\\zero\\subscribe\\idl"
    warning: subscribe@0.1.0: idl_dir="C:\\Users\\Tumino Adriano\\Desktop\\zero test\\zero\\target\\debug\\build\\subscribe-276b09b71aaa3efc\\out"
        Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.16s
         Running `target\debug\subscribe.exe`
    
    Start Robot writer
    Written: id: 1 | label: '0'
    Written: id: 2 | label: '1'
    Written: id: 3 | label: '2'
    Written: id: 4 | label: '3'
    Written: id: 5 | label: '4'
    Written: id: 6 | label: '5'
    Written: id: 7 | label: '6'
    Written: id: 8 | label: '7'
    Written: id: 9 | label: '8'
    Written: id: 10 | label: '9'
    ...
    

    I also tried switching between the --default-extensibility and --cyclone compilation flags, but in neither case does the subscriber receive any data.
    I used CycloneDDS Insight as well to verify that everything was working correctly, and the data is indeed being published. However, I noticed that the subscriber is not even detected by Insight.

    I am running the tests on Windows 11, with both the publisher and subscriber on the same machine, and I have not configured any environment variables.

    Image Image
  8. SandraK82 commented on Aug 3, 2026

    @SandraK82
    Contributor

    Thanks @adriano-tumino — your Windows details and the Insight screenshots gave us the missing discriminator.

    We have now reproduced the remaining failure on Windows 11.

    The problem was not XCDR or extensibility. ZeroDDS successfully discovered the CycloneDDS participant via multicast, but on a multi-adapter system it announced only one unicast locator. Windows could resolve that locator through a different, unreachable interface. The participant was therefore discovered, but SEDP endpoint discovery could not complete, leaving the ZeroDDS reader unmatched and invisible in Insight.

    The fix announces all eligible participant locators and retains and fans out to all usable peer locators:

    #35

    Our deterministic Windows reproduction changed from:

    discovered=1 matched=0 samples=0

    to:

    discovered=1 matched=1 samples=25/26

    without setting ZERODDS_INTERFACE.

    Until the fix is released, the existing workaround is to pin ZeroDDS to the interface used by CycloneDDS:

    $env:ZERODDS_INTERFACE="<your active IPv4 address>"

    Once the PR build is available, it would be very helpful if you could retry your original example without that environment variable and report the discovered and matched counts.

  9. adriano-tumino commented on Aug 4, 2026

    @adriano-tumino

    Hi,
    I tested the fixes implemented through the pull requests, and everything seems to be working correctly. I'm able to view everything as expected.
    I'll try some more complex IDLs to see if there are any other bugs that might surface.

    Thanks!!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions