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; 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); 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) 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(&[