From 8f18fc94a32e76fb19c272aab0953cf304c2f2fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandra=20Ke=C3=9Fler?= Date: Fri, 31 Jul 2026 01:03:22 +0200 Subject: [PATCH 1/4] feat(idlc): --cyclone defaults un-annotated aggregates to @final --cyclone was accepted but a codegen no-op, so ZeroDDS still generated @appendable for un-annotated types while CycloneDDS defaults them to @final. The two then disagree on XCDR2 framing (DHEADER present vs not), and a forced-XCDR2 reader fails to decode the peer's samples (#27). --cyclone now resolves the default extensibility to `final` for un-annotated struct/union/enum, matching CycloneDDS' generator default. Precedence (highest first): explicit IDL annotation > explicit --default-extensibility > --cyclone (final) > global default (appendable). Resolved once after argument parsing, so order is irrelevant; --opendds stays a pure intent no-op. Help text and a precedence test added. --- tools/idlc/src/main.rs | 100 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/tools/idlc/src/main.rs b/tools/idlc/src/main.rs index 82f5748d..631d4f48 100644 --- a/tools/idlc/src/main.rs +++ b/tools/idlc/src/main.rs @@ -299,6 +299,14 @@ struct CliOptions { /// `--default-extensibility` — Extensibility fuer un-annotierte /// struct/union/enum. `None` = OMG-Default belassen (Emitter-Fallback). default_ext: Option, + /// `--cyclone` — CycloneDDS-Interop-Intent. Ueber den reinen + /// Intent-Marker hinaus setzt es den Default fuer un-annotierte + /// Aggregate auf `@final` (Cyclones Generator-Default), damit + /// ZeroDDS-Output mit einem un-annotierten Cyclone-Peer auf der Wire + /// interoperiert. Explizite IDL-Annotation und explizites + /// `--default-extensibility` haben Vorrang (Aufloesung nach dem Parsen, + /// argument-order-unabhaengig). `--opendds` bleibt reiner No-op. + cyclone: bool, /// `--default-nested true` — un-annotierte Typen als `@nested` /// markieren. `None`/`Some(false)` = OMG-Default belassen. default_nested: Option, @@ -441,7 +449,12 @@ fn run(args: &[String]) -> Result<(), CliError> { // Vendor-Eigenheiten sind #pragma-basiert (keylist, // DCPS_DATA_KEY) und werden ohnehin immer verarbeitet. // Die Flags sind als explizite Intent-Marker akzeptiert. - "--opendds" | "--cyclone" => {} + // `--opendds` is a pure intent marker (its vendor pragmas are + // processed unconditionally anyway). `--cyclone` additionally + // steers the default extensibility to `final` — resolved AFTER + // the whole loop so argument order never matters. + "--opendds" => {} + "--cyclone" => opts.cyclone = true, "-v" | "--verbose" => opts.verbose = opts.verbose.saturating_add(1), // Aggregierte Kurzform `-vv` / `-vvv`. other @@ -617,6 +630,20 @@ fn run(args: &[String]) -> Result<(), CliError> { )); } } + // `--cyclone`: adopt CycloneDDS' default extensibility (`final`) for + // un-annotated aggregates, so ZeroDDS output matches an un-annotated + // Cyclone peer on the wire (this is the concrete #27 interop path). + // Precedence, highest first: explicit IDL annotation (applied later, it + // only touches un-annotated types) > explicit `--default-extensibility` + // > `--cyclone` (final) > the global ZeroDDS default (`appendable`). + // Resolved here, after the entire argument loop, so order is irrelevant. + if opts.cyclone && opts.default_ext.is_none() { + opts.default_ext = Some(DefaultExt::Final); + opts.report( + 1, + "--cyclone: un-annotated aggregates default to @final (CycloneDDS-compatible)", + ); + } let dump_action = opts.parse_only || opts.dump_deps || opts.dump_typeobject; // Modes that produce no backend code: the dump/parse actions plus // `check` (semantic gate only). @@ -1497,7 +1524,9 @@ fn print_help() { \x20 --rti RTI Connext-Grammar-Delta beim Parse\n\ \x20 --opendds OpenDDS-Intent (Vendor-Pragmas werden\n\ \x20 ohnehin verarbeitet) — Kompat-Flag\n\ - \x20 --cyclone Cyclone-DDS-Intent — Kompat-Flag\n\ + \x20 --cyclone CycloneDDS-Interop: un-annotierte Aggregate\n\ + \x20 als @final (Cyclone-Default) generieren;\n\ + \x20 @-Annotation/--default-extensibility gewinnen\n\ \x20 --corba CORBA-Service-Code zusaetzlich emittieren\n\ \x20 (--cpp/--csharp/--java: Annex-A.1 inline;\n\ \x20 --rust: Two-File-Output via zerodds-corba-rust)\n\ @@ -2826,6 +2855,73 @@ mod tests { assert!(matches!(result, Err(CliError::Usage(_))), "got {result:?}"); } + /// `--cyclone` must make un-annotated aggregates `@final` (CycloneDDS' + /// generator default), with the correct precedence and independent of + /// argument order. Marker: the Rust emitter uses + /// `zerodds_cdr::struct_enc::encode_appendable` only for `@appendable` + /// (the DHEADER path); its absence ⇒ `@final`. The default extensibility + /// is resolved once in `run()` before backend dispatch, so every selected + /// backend sees the same resolved default (checked here via `--rust`). + #[test] + fn cyclone_flag_sets_final_with_correct_precedence() { + let work = unique_workdir("cyc-prec"); + std::fs::create_dir_all(&work).expect("mkdir"); + let mut n = 0; + let mut emit_rs = |idl_src: &str, extra: &[&str]| -> String { + n += 1; + let idl = work.join(format!("r{n}.idl")); + std::fs::write(&idl, idl_src).expect("write idl"); + let out = work.join(format!("o{n}")); + let mut args = vec!["--rust".to_string()]; + for e in extra { + args.push((*e).to_string()); + } + args.push("-o".to_string()); + args.push(out.to_string_lossy().to_string()); + args.push(idl.to_string_lossy().to_string()); + let r = run(&args); + assert!(r.is_ok(), "run failed for {extra:?}: {r:?}"); + std::fs::read_to_string(out.join(format!("r{n}.rs"))).expect("read gen") + }; + let plain = "struct Robot { unsigned long id; unsigned long label; };"; + let annotated = "@appendable\nstruct Robot { unsigned long id; unsigned long label; };"; + let is_final = |src: &str| !src.contains("encode_appendable"); + + // 1) --cyclone alone → final. + assert!( + is_final(&emit_rs(plain, &["--cyclone"])), + "--cyclone alone must default un-annotated aggregates to @final" + ); + // 2) no flag → the global ZeroDDS default stays @appendable (proves it + // is --cyclone, not something else, that flips the default). + assert!( + !is_final(&emit_rs(plain, &[])), + "without --cyclone the default must remain @appendable" + ); + // 3) explicit --default-extensibility wins over --cyclone. + assert!( + !is_final(&emit_rs( + plain, + &["--cyclone", "--default-extensibility", "appendable"] + )), + "explicit --default-extensibility must override --cyclone" + ); + // 4) argument order must not matter. + assert!( + !is_final(&emit_rs( + plain, + &["--default-extensibility", "appendable", "--cyclone"] + )), + "argument order must not change the resolved default" + ); + // 5) an explicit @appendable IDL annotation survives --cyclone. + assert!( + !is_final(&emit_rs(annotated, &["--cyclone"])), + "an explicit @appendable annotation must survive --cyclone" + ); + std::fs::remove_dir_all(&work).ok(); + } + #[test] fn run_default_nested_rejects_bad_value() { let result = run(&[ From 9acb07cc5eac5a515c7f5e29f79ca04a64d31506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandra=20Ke=C3=9Fler?= Date: Fri, 31 Jul 2026 01:30:20 +0200 Subject: [PATCH 2/4] test(interop): add CycloneDDS XTypes interop regression matrix (#27) A gated, live DCPS-over-UDP interop harness (domain 100, topic `robot`) pinning down the #27 finding and the --cyclone fix. Five cases, each reporting match / decoded-sample / decode-error counts SEPARATELY: final+XCDR1 -> ZeroDDS @appendable match, samples, 0 errors appendable+XCDR2 -> ZeroDDS @appendable match, samples, 0 errors final+XCDR2 -> ZeroDDS @appendable match, 0 samples, WireError (#27) reverse: ZeroDDS @appendable/XCDR2 -> Cyclone reader samples final+XCDR2 -> ZeroDDS @final (--cyclone) match, samples, 0 errors Case 3 shows the reporter's symptom is a SURFACED decode error (take() -> WireError), not a silent drop; case 5 shows generating the reader with `--cyclone` (default-final) fixes it. Opt-in / gated: loud-skips (exit 0) without a Python that can import cyclonedds. Reference vendor CycloneDDS 11.0.1. Verified end-to-end on the Linux test host (all five PASS). The reader lives in a standalone crate (own [workspace]); its src/robot.rs is regenerated per case by run_matrix.sh (git-ignored). --- interop/cyclone-xtypes-27/README.md | 55 +++++++++ interop/cyclone-xtypes-27/reader/.gitignore | 3 + interop/cyclone-xtypes-27/reader/Cargo.toml | 24 ++++ interop/cyclone-xtypes-27/reader/src/main.rs | 57 +++++++++ .../cyclone-xtypes-27/reader/src/writer.rs | 40 ++++++ interop/cyclone-xtypes-27/robot.idl | 4 + interop/cyclone-xtypes-27/run_matrix.sh | 116 ++++++++++++++++++ .../writers/cyclone_reader.py | 47 +++++++ .../writers/cyclone_writer.py | 51 ++++++++ 9 files changed, 397 insertions(+) create mode 100644 interop/cyclone-xtypes-27/README.md create mode 100644 interop/cyclone-xtypes-27/reader/.gitignore create mode 100644 interop/cyclone-xtypes-27/reader/Cargo.toml create mode 100644 interop/cyclone-xtypes-27/reader/src/main.rs create mode 100644 interop/cyclone-xtypes-27/reader/src/writer.rs create mode 100644 interop/cyclone-xtypes-27/robot.idl create mode 100755 interop/cyclone-xtypes-27/run_matrix.sh create mode 100755 interop/cyclone-xtypes-27/writers/cyclone_reader.py create mode 100755 interop/cyclone-xtypes-27/writers/cyclone_writer.py diff --git a/interop/cyclone-xtypes-27/README.md b/interop/cyclone-xtypes-27/README.md new file mode 100644 index 00000000..4026cc6d --- /dev/null +++ b/interop/cyclone-xtypes-27/README.md @@ -0,0 +1,55 @@ +# CycloneDDS ↔ ZeroDDS XTypes interop matrix (issue #27) + +A reproducible, live DCPS-over-UDP interop matrix between ZeroDDS and +CycloneDDS on domain 100, topic `robot` (`struct Robot { uint32 id; uint32 label; }`). + +It pins down issue #27: an un-annotated struct is `@final` under CycloneDDS' +generator default but `@appendable` under ZeroDDS' default. Under XCDR1 that is +invisible (an `@appendable` type emits no DHEADER, so the wire is identical to +`@final`); force XCDR2 and the framing differs (DHEADER present vs not), so a +`@final` writer and an `@appendable` reader stop understanding each other. + +## What it checks (separately: match, samples, decode errors) + +| Case | Writer (Cyclone) | Reader (ZeroDDS) | Expected | +|---|---|---|---| +| 1 | final, XCDR1 | `@appendable` (default) | match, samples > 0, 0 errors | +| 2 | appendable, XCDR2 | `@appendable` (default) | match, samples > 0, 0 errors | +| 3 | final, XCDR2 | `@appendable` (default) | **match, 0 samples, errors > 0** (the #27 symptom) | +| 4 | final, XCDR2 | `@final` (`--cyclone`) | match, samples > 0, 0 errors (the fix) | +| 5 | — (reverse) | ZeroDDS `@appendable`/XCDR2 writer → Cyclone reader | samples > 0 | + +Case 3 is the reporter's failure: the endpoints **match** (no incompatible +QoS), but every sample fails to decode. Crucially the decode error is *not* +silent — the ZeroDDS reader's `take()` returns `WireError`; the counters here +report `errors > 0`. Case 4 shows the fix: generating the ZeroDDS reader type +with `zerodds-idlc --cyclone` (which defaults un-annotated aggregates to +`@final`) makes the same Cyclone writer interoperate. + +## Requirements & running + +Needs a Python that can `import cyclonedds` plus the CycloneDDS C library. +The script **loud-skips (exit 0)** when they are absent, so it is safe to +invoke unconditionally in CI. + +``` +PYBIN=/path/to/venv/bin/python3 CYCLONEDDS_HOME=/path/to/cyclone \ + interop/cyclone-xtypes-27/run_matrix.sh +``` + +`PYBIN` must point at a Python that can `import cyclonedds`; `CYCLONEDDS_HOME` +at the matching CycloneDDS C install prefix. The runner exits non-zero if any +case deviates from the table above. + +**Reference vendor:** CycloneDDS 11.0.1. CycloneDDS 0.10.5 is a manual +compatibility check (same outcomes observed); it is not the CI reference. + +## Layout + +- `robot.idl` — the shared type. +- `reader/` — standalone ZeroDDS reader/writer crate (own `[workspace]`, so a + root `cargo build` ignores it). `src/robot.rs` is generated per case by the + runner (git-ignored) — `@appendable` by default, `@final` via `--cyclone`. +- `writers/cyclone_writer.py`, `writers/cyclone_reader.py` — CycloneDDS peers, + parameterized by extensibility and representation. +- `run_matrix.sh` — orchestrator; reports match / sample / error counts per case. diff --git a/interop/cyclone-xtypes-27/reader/.gitignore b/interop/cyclone-xtypes-27/reader/.gitignore new file mode 100644 index 00000000..ace1244f --- /dev/null +++ b/interop/cyclone-xtypes-27/reader/.gitignore @@ -0,0 +1,3 @@ +src/robot.rs +target/ +Cargo.lock diff --git a/interop/cyclone-xtypes-27/reader/Cargo.toml b/interop/cyclone-xtypes-27/reader/Cargo.toml new file mode 100644 index 00000000..2fb36b9d --- /dev/null +++ b/interop/cyclone-xtypes-27/reader/Cargo.toml @@ -0,0 +1,24 @@ +# Standalone reproducer crate for the CycloneDDS XTypes interop matrix (#27). +# Own `[workspace]` table so it is excluded from the parent workspace and +# never built by a normal `cargo build` at the repo root — it is driven only +# by `run_matrix.sh`, which regenerates `src/robot.rs` per case. +[package] +name = "cyclone27-reader" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +zerodds-dcps = { path = "../../../crates/dcps" } +zerodds-cdr = { path = "../../../crates/cdr" } +zerodds-types = { path = "../../../crates/types" } + +[[bin]] +name = "reader" +path = "src/main.rs" + +[[bin]] +name = "writer" +path = "src/writer.rs" + +[workspace] diff --git a/interop/cyclone-xtypes-27/reader/src/main.rs b/interop/cyclone-xtypes-27/reader/src/main.rs new file mode 100644 index 00000000..1e70eabf --- /dev/null +++ b/interop/cyclone-xtypes-27/reader/src/main.rs @@ -0,0 +1,57 @@ +//! ZeroDDS reader for the CycloneDDS XTypes interop matrix (#27). +//! +//! Reads topic `robot` on domain 100 for a fixed window and reports three +//! counters SEPARATELY, so a match without data (the #27 symptom) is visible: +//! * `matched` — highest matched_publication_count seen (0 or 1) +//! * `samples` — successfully decoded samples (`take()` Ok) +//! * errors — `take()` calls that returned WireError (decode failures) +//! +//! The reader's extensibility is whatever `src/robot.rs` was generated with +//! (`run_matrix.sh` regenerates it per case: default `@appendable`, or `@final` +//! via `zerodds-idlc --cyclone`). Output line: `RESULT matched=.. samples=.. errors=..`. +#![allow(clippy::unwrap_used, clippy::print_stdout, clippy::print_stderr)] + +#[path = "robot.rs"] +mod robot; +use robot::Robot; +use zerodds_dcps::{ + DataReaderQos, DomainParticipantFactory, DomainParticipantQos, SubscriberQos, TopicQos, +}; + +fn main() { + let secs: u64 = std::env::args() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(12); + let f = DomainParticipantFactory::instance(); + let p = f + .create_participant(100, DomainParticipantQos::default()) + .unwrap(); + let t = p + .create_topic::("robot", TopicQos::default()) + .expect("topic"); + let s = p.create_subscriber(SubscriberQos::default()); + let r = s + .create_datareader::(&t, DataReaderQos::default()) + .expect("reader"); + eprintln!("[zerodds reader] domain=100 topic=robot window={secs}s"); + + let start = std::time::Instant::now(); + let mut matched = 0usize; + let mut samples = 0u64; + let mut errors = 0u64; + while start.elapsed().as_secs() < secs { + matched = matched.max(r.matched_publication_count()); + match r.take() { + Ok(v) => samples += v.len() as u64, + Err(e) => { + errors += 1; + if errors <= 3 { + eprintln!("[zerodds reader] take error: {e:?}"); + } + } + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + println!("RESULT matched={matched} samples={samples} errors={errors}"); +} diff --git a/interop/cyclone-xtypes-27/reader/src/writer.rs b/interop/cyclone-xtypes-27/reader/src/writer.rs new file mode 100644 index 00000000..fcb0ad49 --- /dev/null +++ b/interop/cyclone-xtypes-27/reader/src/writer.rs @@ -0,0 +1,40 @@ +//! ZeroDDS writer for the reverse-direction leg of the #27 interop matrix +//! (ZeroDDS writer -> CycloneDDS reader). Writes topic `robot` on domain 100 +//! for a fixed window. Extensibility follows the generated `src/robot.rs`. +#![allow(clippy::unwrap_used, clippy::print_stderr)] + +#[path = "robot.rs"] +mod robot; +use robot::Robot; +use zerodds_dcps::{ + DataWriterQos, DomainParticipantFactory, DomainParticipantQos, PublisherQos, TopicQos, +}; + +fn main() { + let secs: u64 = std::env::args() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(20); + let f = DomainParticipantFactory::instance(); + let p = f + .create_participant(100, DomainParticipantQos::default()) + .unwrap(); + let t = p + .create_topic::("robot", TopicQos::default()) + .expect("topic"); + let pubr = p.create_publisher(PublisherQos::default()); + let w = pubr + .create_datawriter::(&t, DataWriterQos::default()) + .expect("writer"); + eprintln!("[zerodds writer] domain=100 topic=robot window={secs}s"); + let start = std::time::Instant::now(); + let mut c: u32 = 0; + while start.elapsed().as_secs() < secs { + let _ = w.write(&Robot { + id: 1, + label: c % 1000, + }); + c += 1; + std::thread::sleep(std::time::Duration::from_millis(300)); + } +} diff --git a/interop/cyclone-xtypes-27/robot.idl b/interop/cyclone-xtypes-27/robot.idl new file mode 100644 index 00000000..2556830f --- /dev/null +++ b/interop/cyclone-xtypes-27/robot.idl @@ -0,0 +1,4 @@ +struct Robot { + uint32 id; + uint32 label; +}; diff --git a/interop/cyclone-xtypes-27/run_matrix.sh b/interop/cyclone-xtypes-27/run_matrix.sh new file mode 100755 index 00000000..bed059e8 --- /dev/null +++ b/interop/cyclone-xtypes-27/run_matrix.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# ============================================================================ +# CycloneDDS <-> ZeroDDS XTypes interop regression matrix (issue #27). +# +# Live DCPS-over-UDP interop on domain 100, topic `robot`. Proves: +# * default (XCDR1) interop works both directions, +# * the reporter's forced-XCDR2 failure (Cyclone @final vs ZeroDDS +# @appendable framing) is a decode error SURFACED via take() -> WireError, +# * `zerodds-idlc --cyclone` (PR: default-final) fixes exactly that path. +# +# Reference vendor: CycloneDDS 11.0.1 (0.10.5 is a manual compat check). +# +# OPT-IN / GATED: needs a Python with `cyclonedds` importable and its C +# library. Configure via env, else the script LOUD-SKIPS (exit 0): +# PYBIN python that can `import cyclonedds` (e.g. a venv python) +# CYCLONEDDS_HOME CycloneDDS C install prefix (for the native lib) +# Example: PYBIN=/path/to/venv/bin/python3 CYCLONEDDS_HOME=/path/to/cyclone \ +# interop/cyclone-xtypes-27/run_matrix.sh +# ============================================================================ +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$HERE/../.." && pwd)" +READER_DIR="$HERE/reader" +PYBIN="${PYBIN:-python3}" +READ_WINDOW="${READ_WINDOW:-12}" +WRITE_WINDOW="${WRITE_WINDOW:-40}" + +log() { printf '%s\n' "$*" >&2; } + +# ---- gate ------------------------------------------------------------------ +if ! "$PYBIN" -c 'import cyclonedds' >/dev/null 2>&1; then + log "SKIP: '$PYBIN' cannot import cyclonedds (set PYBIN to a venv python + CYCLONEDDS_HOME)." + exit 0 +fi +export CYCLONEDDS_HOME="${CYCLONEDDS_HOME:-}" + +# ---- helpers --------------------------------------------------------------- +IDLC=(cargo run -q --manifest-path "$REPO_ROOT/Cargo.toml" -p zerodds-idlc --) + +# Regenerate the reader type with the requested extensibility, then build. +# $1 = appendable | final (final => generated via --cyclone) +build_reader() { + local ext="$1"; local extra=() + [ "$ext" = "final" ] && extra=(--cyclone) + rm -f "$READER_DIR/src/robot.rs" + "${IDLC[@]}" generate "$HERE/robot.idl" --rust "${extra[@]}" -o "$READER_DIR/src" >/dev/null 2>&1 + mv "$READER_DIR/src/Robot.rs" "$READER_DIR/src/robot.rs" 2>/dev/null || true + ( cd "$READER_DIR" && cargo build -q 2>&1 | tail -3 ) || { log "reader build failed ($ext)"; return 1; } +} + +FAILS=0 +declare -a ROWS + +# Forward: Cyclone writer -> ZeroDDS reader. +# $1 label $2 writer-ext $3 rep $4 expect (samples|nosamples) +forward() { + local label="$1" wext="$2" rep="$3" expect="$4" + "$PYBIN" "$HERE/writers/cyclone_writer.py" "$wext" "$rep" "$WRITE_WINDOW" \ + >/tmp/c27_w.log 2>&1 & + local wp=$! + sleep 3 + if ! kill -0 "$wp" 2>/dev/null; then log " writer died ($label): $(cat /tmp/c27_w.log)"; ROWS+=("$label|WRITER-DIED"); FAILS=$((FAILS+1)); return; fi + local out + out="$("$READER_DIR/target/debug/reader" "$READ_WINDOW" 2>/dev/null | grep '^RESULT')" + kill -9 "$wp" 2>/dev/null; sleep 1 + local matched samples errors + matched="$(sed -n 's/.*matched=\([0-9]*\).*/\1/p' <<<"$out")" + samples="$(sed -n 's/.*samples=\([0-9]*\).*/\1/p' <<<"$out")" + errors="$(sed -n 's/.*errors=\([0-9]*\).*/\1/p' <<<"$out")" + local verdict="PASS" + if [ "$expect" = "samples" ]; then + [ "${matched:-0}" = 1 ] && [ "${samples:-0}" -gt 0 ] && [ "${errors:-0}" = 0 ] || verdict="FAIL" + else # nosamples: matched but zero decoded + decode errors visible + [ "${matched:-0}" = 1 ] && [ "${samples:-0}" = 0 ] && [ "${errors:-0}" -gt 0 ] || verdict="FAIL" + fi + [ "$verdict" = FAIL ] && FAILS=$((FAILS+1)) + ROWS+=("$label|matched=${matched:-?} samples=${samples:-?} errors=${errors:-?}|$verdict") +} + +# Reverse: ZeroDDS writer -> Cyclone reader (one green config). +reverse() { + "$READER_DIR/target/debug/writer" "$WRITE_WINDOW" >/tmp/c27_zw.log 2>&1 & + local wp=$! + sleep 3 + local out + out="$("$PYBIN" "$HERE/writers/cyclone_reader.py" appendable "$READ_WINDOW" 2>/dev/null | grep '^CYCLONE_RESULT')" + kill -9 "$wp" 2>/dev/null; sleep 1 + local samples; samples="$(sed -n 's/.*samples=\([0-9]*\).*/\1/p' <<<"$out")" + local verdict="PASS"; [ "${samples:-0}" -gt 0 ] || { verdict="FAIL"; FAILS=$((FAILS+1)); } + ROWS+=("reverse: ZeroDDS@appendable/XCDR2 -> Cyclone|samples=${samples:-?}|$verdict") +} + +# ---- run ------------------------------------------------------------------- +log "=== building @appendable reader (ZeroDDS default = reporter's reader) ===" +build_reader appendable || exit 1 +forward "Cyclone final+XCDR1 -> ZeroDDS @appendable" final xcdr1 samples +forward "Cyclone appendable+XCDR2 -> ZeroDDS @appendable" appendable xcdr2 samples +forward "Cyclone final+XCDR2 -> ZeroDDS @appendable (#27)" final xcdr2 nosamples +reverse + +log "=== building @final reader via --cyclone (PR fix) ===" +build_reader final || exit 1 +forward "Cyclone final+XCDR2 -> ZeroDDS @final (--cyclone)" final xcdr2 samples + +# ---- report ---------------------------------------------------------------- +log "" +log "==================== #27 interop matrix ====================" +for row in "${ROWS[@]}"; do + IFS='|' read -r name data verdict <<<"$row" + printf ' [%-4s] %-52s %s\n' "${verdict:-?}" "$name" "$data" >&2 +done +log "===========================================================" +if [ "$FAILS" -ne 0 ]; then log "REGRESSION: $FAILS case(s) failed"; exit 1; fi +log "all cases as expected" +exit 0 diff --git a/interop/cyclone-xtypes-27/writers/cyclone_reader.py b/interop/cyclone-xtypes-27/writers/cyclone_reader.py new file mode 100755 index 00000000..f8f8702c --- /dev/null +++ b/interop/cyclone-xtypes-27/writers/cyclone_reader.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""CycloneDDS reader for the reverse leg of the #27 interop matrix +(ZeroDDS writer -> CycloneDDS reader). + +Usage: cyclone_reader.py [seconds] + +Counts successfully received samples on topic `robot`, domain 100, and prints +`CYCLONE_RESULT samples=`. +""" +import sys +import time +from dataclasses import dataclass + +from cyclonedds.domain import DomainParticipant +from cyclonedds.idl import IdlStruct +from cyclonedds.idl.annotations import appendable +from cyclonedds.idl.types import uint32 +from cyclonedds.sub import DataReader, Subscriber +from cyclonedds.topic import Topic + +ext = sys.argv[1] if len(sys.argv) > 1 else "appendable" +secs = float(sys.argv[2]) if len(sys.argv) > 2 else 12.0 + +if ext == "appendable": + @appendable + @dataclass + class RobotType(IdlStruct, typename="Robot"): + id: uint32 = 0 + label: uint32 = 0 +else: + @dataclass + class RobotType(IdlStruct, typename="Robot"): + id: uint32 = 0 + label: uint32 = 0 + +dp = DomainParticipant(domain_id=100) +tp = Topic(dp, "robot", RobotType) +r = DataReader(Subscriber(dp), tp) +print(f"[cyclone reader] ext={ext}", flush=True) +n = 0 +t0 = time.time() +while time.time() - t0 < secs: + for s in r.take(N=20): + if s.sample_info.valid_data: + n += 1 + time.sleep(0.1) +print(f"CYCLONE_RESULT samples={n}", flush=True) diff --git a/interop/cyclone-xtypes-27/writers/cyclone_writer.py b/interop/cyclone-xtypes-27/writers/cyclone_writer.py new file mode 100755 index 00000000..5015020f --- /dev/null +++ b/interop/cyclone-xtypes-27/writers/cyclone_writer.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""CycloneDDS writer for the #27 interop matrix. + +Usage: cyclone_writer.py [seconds] + +Extensibility is set on the type (default = final, matching Cyclone's own +generator default); representation is set via the DataRepresentation QoS. +Writes topic `robot` on domain 100. +""" +import sys +import time +from dataclasses import dataclass + +from cyclonedds.core import Policy, Qos +from cyclonedds.domain import DomainParticipant +from cyclonedds.idl import IdlStruct +from cyclonedds.idl.annotations import appendable +from cyclonedds.idl.types import uint32 +from cyclonedds.pub import DataWriter, Publisher +from cyclonedds.topic import Topic + +ext = sys.argv[1] if len(sys.argv) > 1 else "final" +rep = sys.argv[2] if len(sys.argv) > 2 else "xcdr1" +secs = float(sys.argv[3]) if len(sys.argv) > 3 else 30.0 + +if ext == "appendable": + @appendable + @dataclass + class RobotType(IdlStruct, typename="Robot"): + id: uint32 = 0 + label: uint32 = 0 +else: + @dataclass + class RobotType(IdlStruct, typename="Robot"): + id: uint32 = 0 + label: uint32 = 0 + +qos = Qos(Policy.DataRepresentation( + use_cdrv0_representation=(rep == "xcdr1"), + use_xcdrv2_representation=(rep == "xcdr2"), +)) +dp = DomainParticipant(domain_id=100) +tp = Topic(dp, "robot", RobotType) +w = DataWriter(Publisher(dp), tp, qos=qos) +print(f"[cyclone writer] ext={ext} rep={rep}", flush=True) +t0 = time.time() +c = 0 +while time.time() - t0 < secs: + w.write(RobotType(id=1, label=c % 1000)) + c += 1 + time.sleep(0.3) From e87c5ade74c20642212d3f4a6f9e761d4a9b78c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandra=20Ke=C3=9Fler?= Date: Fri, 31 Jul 2026 01:33:16 +0200 Subject: [PATCH 3/4] docs(dcps): correct DataRepresentation default-policy spec attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comments on `reader_accept_repr` and its regression test claimed the XTypes 1.3 §7.6.2 default (empty) DataRepresentation policy "accepts both XCDR1 and XCDR2". The spec default is XCDR1 only. Advertising both is ZeroDDS' own deliberate interop choice (so an XCDR1-defaulting CycloneDDS/ RTI/OpenDDS writer still matches), not the meaning of the spec default. Comment-only; the advertise-both behaviour is unchanged. --- crates/dcps/src/runtime.rs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/crates/dcps/src/runtime.rs b/crates/dcps/src/runtime.rs index 790136f8..c442f82a 100644 --- a/crates/dcps/src/runtime.rs +++ b/crates/dcps/src/runtime.rs @@ -2564,11 +2564,13 @@ fn build_publication_data( } /// The `DataRepresentation` set a **DataReader** announces (PID_DATA_REPRESENTATION -/// in its SEDP subscription). Per OMG XTypes 1.3 §7.6.2, a reader with the -/// default (empty) policy accepts **both** XCDR1 and XCDR2 — and ZeroDDS decodes -/// both (the read path dispatches on the per-sample encapsulation id). So the -/// reader advertises every representation it can decode, not just the writer's -/// preferred one. +/// in its SEDP subscription). Per OMG XTypes 1.3 §7.6.2 the *default* (empty) +/// policy is **XCDR1 only** — a reader left at that default would announce, and +/// accept, only XCDR1. ZeroDDS deliberately does not rely on the default: it +/// advertises **both** XCDR1 and XCDR2 (it can decode either — the read path +/// dispatches on the per-sample encapsulation id), so the reader accepts every +/// representation it can decode rather than a single preferred one. This is +/// ZeroDDS' own interop choice, not what the spec's default policy means. /// /// This matters cross-vendor: CycloneDDS (and legacy RTI / OpenDDS < 3.16) /// default their *writers* to **XCDR1** for `@final` types (non-XTypes backward @@ -11905,12 +11907,12 @@ mod tests { assert_ne!(parse_data_repr_offer_str("XCDR1"), Some(vec![dr::XML])); } - /// A DataReader announces every representation it can decode (XCDR2 + XCDR1) - /// — XTypes 1.3 §7.6.2: the default reader policy accepts both. CycloneDDS - /// (and legacy RTI / OpenDDS < 3.16) default their writers to XCDR1 for - /// `@final` types; without XCDR1 in the reader's announced set those writers - /// fail the DataRepresentation RxO check and never deliver. Regression for - /// Bug DR1. + /// A DataReader announces every representation it can decode (XCDR2 + XCDR1). + /// The spec default (empty policy, XTypes 1.3 §7.6.2) is XCDR1 only; ZeroDDS + /// deliberately advertises both instead. CycloneDDS (and legacy RTI / + /// OpenDDS < 3.16) default their writers to XCDR1 for `@final` types; without + /// XCDR1 in the reader's announced set those writers fail the + /// DataRepresentation RxO check and never deliver. Regression for Bug DR1. #[test] fn reader_accept_repr_always_includes_both_representations() { use zerodds_rtps::publication_data::data_representation as dr; From dd24b8bba0110949492bcb8a1696ff0840660106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandra=20Ke=C3=9Fler?= Date: Fri, 31 Jul 2026 01:39:20 +0200 Subject: [PATCH 4/4] feat(dcps): enrich sample-decode WireError with framing diagnostics (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A decode failure at take() now reports the received encapsulation (representation + byte order) and this reader type's own extensibility, and names an extensibility/framing mismatch as a *plausible* cause: in XCDR2 @appendable/@mutable carry a DHEADER length prefix and @final does not, so a peer with a different extensibility fails to decode. Flagged as plausible, not asserted — the remote type is not available at the decode site to confirm it. Applied at the five representation-dispatched decode_for_encap call sites via a shared decode_wire_error helper. Test added; the advertise/decode behaviour is otherwise unchanged. --- crates/dcps/src/subscriber.rs | 93 +++++++++++++++++++++++++---------- 1 file changed, 68 insertions(+), 25 deletions(-) diff --git a/crates/dcps/src/subscriber.rs b/crates/dcps/src/subscriber.rs index ad77a617..d5cd5511 100644 --- a/crates/dcps/src/subscriber.rs +++ b/crates/dcps/src/subscriber.rs @@ -16,7 +16,6 @@ extern crate alloc; use alloc::boxed::Box; -use alloc::string::ToString; use alloc::sync::Arc; use alloc::vec::Vec; use core::marker::PhantomData; @@ -80,6 +79,45 @@ fn decode_for_encap( } } +/// Builds a diagnostic `WireError` for a failed sample decode. Beyond the inner +/// error it records the received encapsulation (representation + byte order) and +/// this reader type's own extensibility, and — because the most common +/// cross-vendor cause is an extensibility/framing mismatch — names that as a +/// *plausible* cause. It is deliberately not asserted: without the remote type +/// the true cause cannot be confirmed here. See issue #27. +fn decode_wire_error( + inner: &crate::dds_type::DecodeError, + representation: u8, + big_endian: bool, +) -> DdsError { + use crate::dds_type::Extensibility; + let repr = if representation == 0 { + "XCDR1" + } else { + "XCDR2" + }; + let endian = if big_endian { + "big-endian" + } else { + "little-endian" + }; + let ext = match T::EXTENSIBILITY { + Extensibility::Final => "final", + Extensibility::Appendable => "appendable", + Extensibility::Mutable => "mutable", + }; + DdsError::WireError { + message: alloc::format!( + "decode error: {inner} (received {repr} {endian}; this reader's type '{}' is @{ext}. \ + A plausible cross-vendor cause is an extensibility mismatch — in XCDR2 \ + @appendable/@mutable carry a DHEADER length prefix and @final does not, so a peer \ + whose type has a different extensibility fails to decode. Not confirmed: the remote \ + type is not available here to verify.)", + T::TYPE_NAME, + ), + } +} + /// Subscriber — entity group for DataReaders. #[derive(Debug)] pub struct Subscriber { @@ -748,9 +786,7 @@ impl DataReader { .. } => { let sample = decode_for_encap::(&bytes, representation, big_endian) - .map_err(|e| DdsError::WireError { - message: e.to_string(), - })?; + .map_err(|e| decode_wire_error::(&e, representation, big_endian))?; if !self.sample_passes_filter(&sample) { continue; } @@ -785,9 +821,7 @@ impl DataReader { .. } => { let sample = decode_for_encap::(&bytes, representation, big_endian) - .map_err(|e| DdsError::WireError { - message: e.to_string(), - })?; + .map_err(|e| decode_wire_error::(&e, representation, big_endian))?; if !self.sample_passes_filter(&sample) { continue; } @@ -844,12 +878,8 @@ impl DataReader { else { continue; }; - let sample = - decode_for_encap::(&bytes, representation, big_endian).map_err(|e| { - DdsError::WireError { - message: e.to_string(), - } - })?; + let sample = decode_for_encap::(&bytes, representation, big_endian) + .map_err(|e| decode_wire_error::(&e, representation, big_endian))?; if !self.sample_passes_filter(&sample) { continue; } @@ -983,12 +1013,8 @@ impl DataReader { else { continue; }; - let sample = - decode_for_encap::(&bytes, representation, big_endian).map_err(|e| { - DdsError::WireError { - message: e.to_string(), - } - })?; + let sample = decode_for_encap::(&bytes, representation, big_endian) + .map_err(|e| decode_wire_error::(&e, representation, big_endian))?; if !self.sample_passes_filter(&sample) { continue; } @@ -1739,12 +1765,8 @@ impl DataReader { let sample_source_ts = src_ts.map_or(now, crate::time::he_timestamp_to_time); // Decode T to (a) evaluate the filter and (b) compute the // KeyHash. - let sample = - decode_for_encap::(&bytes, representation, big_endian).map_err(|e| { - DdsError::WireError { - message: alloc::string::ToString::to_string(&e), - } - })?; + let sample = decode_for_encap::(&bytes, representation, big_endian) + .map_err(|e| decode_wire_error::(&e, representation, big_endian))?; if !self.sample_passes_filter(&sample) { continue; } @@ -2384,6 +2406,27 @@ mod tests { assert_eq!(decode_for_encap::(&[], 0, true).unwrap(), Probe(20)); } + /// A failed decode carries diagnostic context (encapsulation + this reader's + /// extensibility) and flags the extensibility/framing mismatch as a + /// *plausible* — not asserted — cause (issue #27). + #[test] + fn decode_wire_error_carries_diagnostic_context() { + use crate::dds_type::DecodeError; + let inner = DecodeError::Invalid { what: "boom" }; + // RawBytes is @final by default; received XCDR2 little-endian. + let msg = match decode_wire_error::(&inner, 1, false) { + DdsError::WireError { message } => message, + _ => alloc::string::String::new(), + }; + assert!(!msg.is_empty(), "expected a WireError variant"); + assert!(msg.contains("XCDR2"), "{msg}"); + assert!(msg.contains("little-endian"), "{msg}"); + assert!(msg.contains("@final"), "{msg}"); + assert!(msg.contains("DHEADER"), "{msg}"); + assert!(msg.to_lowercase().contains("plausible"), "{msg}"); + assert!(msg.contains("Not confirmed"), "{msg}"); + } + #[test] fn subscriber_creates_datareader_for_matching_type() { let s = Subscriber::new(SubscriberQos::default(), None);