From 22593d9acc161fc7ceb990669a57a580c4fa3290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandra=20Theresa=20Ke=C3=9Fler?= Date: Mon, 3 Aug 2026 22:16:38 +0200 Subject: [PATCH 1/4] test(interop): cross-vendor typed-data CI gate harness (Refs #28) Adds tests/interop/ci/: a vendor-agnostic runner that exchanges typed Robot samples over live DDS/RTPS between ZeroDDS and CycloneDDS / Fast DDS in both directions, asserting endpoint match AND decoded delivery (SPDP discovery alone is never a pass). The classifier distinguishes PASS, EXPECTED_NEGATIVE, PRODUCT_FAIL, TIMEOUT and SETUP_FAIL; machine-readable JSON + human summary + always-uploaded per-cell logs. - Pinned vendor installs (install_cyclone.sh / install_fastdds.sh); no floating latest images. - CycloneDDS cells retain the #29 XTypes semantics incl. the visible final-vs-appendable negative case. - Fast DDS client (fastdds_robot) generated from robot.idl by Fast-DDS-Gen. - Harness self-tests (classifier, exact-PID cleanup) run vendor-free in the interop-harness job. - ZeroDDS reader/writer + Cyclone client reused from #29, domain parameterised via ZERODDS_DOMAIN for per-cell isolation. Base main 80663c06. Vendor-live cells are validated by the public Linux CI in this PR. --- .github/workflows/ci.yml | 65 ++++++ interop/cyclone-xtypes-27/reader/src/main.rs | 11 +- .../cyclone-xtypes-27/reader/src/writer.rs | 9 +- .../writers/cyclone_reader.py | 3 +- .../writers/cyclone_writer.py | 3 +- tests/interop/ci/README.md | 88 ++++++++ tests/interop/ci/fastdds/CMakeLists.txt | 50 +++++ tests/interop/ci/fastdds/fastdds_robot.cpp | 196 ++++++++++++++++ tests/interop/ci/fastdds/robot.idl | 9 + tests/interop/ci/install_cyclone.sh | 31 +++ tests/interop/ci/install_fastdds.sh | 55 +++++ tests/interop/ci/interop_result.py | 209 ++++++++++++++++++ tests/interop/ci/lib.sh | 30 +++ tests/interop/ci/run_vendor.sh | 203 +++++++++++++++++ tests/interop/ci/tests/test_cleanup.sh | 42 ++++ tests/interop/ci/tests/test_interop_result.py | 128 +++++++++++ 16 files changed, 1126 insertions(+), 6 deletions(-) create mode 100644 tests/interop/ci/README.md create mode 100644 tests/interop/ci/fastdds/CMakeLists.txt create mode 100644 tests/interop/ci/fastdds/fastdds_robot.cpp create mode 100644 tests/interop/ci/fastdds/robot.idl create mode 100755 tests/interop/ci/install_cyclone.sh create mode 100755 tests/interop/ci/install_fastdds.sh create mode 100755 tests/interop/ci/interop_result.py create mode 100755 tests/interop/ci/lib.sh create mode 100755 tests/interop/ci/run_vendor.sh create mode 100755 tests/interop/ci/tests/test_cleanup.sh create mode 100644 tests/interop/ci/tests/test_interop_result.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd6136a3..b32b4558 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -261,3 +261,68 @@ jobs: name: lcov path: lcov.info retention-days: 30 + + # --------------------------------------------------------------------------- + # interop-harness — deterministic, vendor-free tests of the interop gate + # harness itself (result classifier, exact-PID cleanup, shell lint). Runs + # everywhere and blocks merges; no DDS stack required. See tests/interop/ci/. + # --------------------------------------------------------------------------- + interop-harness: + name: interop harness self-tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install pytest + run: python -m pip install --user pytest + - name: Result classifier unit tests + run: python -m pytest tests/interop/ci/tests/test_interop_result.py -q + - name: Exact-PID cleanup contract test + run: bash tests/interop/ci/tests/test_cleanup.sh + - name: Shell lint the runner and libs + run: | + sudo apt-get update && sudo apt-get install -y --no-install-recommends shellcheck + shellcheck -S warning tests/interop/ci/run_vendor.sh \ + tests/interop/ci/lib.sh \ + tests/interop/ci/install_cyclone.sh \ + tests/interop/ci/install_fastdds.sh \ + tests/interop/ci/tests/test_cleanup.sh + + # --------------------------------------------------------------------------- + # interop — REQUIRED cross-vendor gate: typed `Robot` samples exchanged over + # live DDS/RTPS between ZeroDDS and each open-source vendor, BOTH directions. + # SPDP discovery alone is never a pass. Separate matrix cell per vendor with + # fail-fast: false so one vendor's logs survive the other's failure. Vendor + # versions are pinned (see the install_* scripts). ubuntu-24.04 is pinned so + # the apt-provided CycloneDDS version is deterministic. + # --------------------------------------------------------------------------- + interop: + name: interop (${{ matrix.vendor }}) + needs: [fmt, clippy, interop-harness] + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + vendor: [cyclone, fastdds] + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install vendor (${{ matrix.vendor }}) + run: bash tests/interop/ci/install_${{ matrix.vendor }}.sh + - name: Run interop gate (${{ matrix.vendor }}) + run: | + mkdir -p interop-artifacts + bash tests/interop/ci/run_vendor.sh ${{ matrix.vendor }} interop-artifacts + - name: Upload interop artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: interop-${{ matrix.vendor }} + path: interop-artifacts + retention-days: 30 diff --git a/interop/cyclone-xtypes-27/reader/src/main.rs b/interop/cyclone-xtypes-27/reader/src/main.rs index 3a84b930..9da03e7b 100644 --- a/interop/cyclone-xtypes-27/reader/src/main.rs +++ b/interop/cyclone-xtypes-27/reader/src/main.rs @@ -23,9 +23,16 @@ fn main() { .nth(1) .and_then(|s| s.parse().ok()) .unwrap_or(12); + // Domain is configurable (ZERODDS_DOMAIN) so the #28 interop CI gate can + // give each vendor/direction cell a unique domain and avoid cross-job + // multicast contamination. Defaults to 100 (the #27/#29 matrix domain). + let domain: u32 = std::env::var("ZERODDS_DOMAIN") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(100); let f = DomainParticipantFactory::instance(); let p = f - .create_participant(100, DomainParticipantQos::default()) + .create_participant(domain, DomainParticipantQos::default()) .unwrap(); let t = p .create_topic::("robot", TopicQos::default()) @@ -34,7 +41,7 @@ fn main() { let r = s .create_datareader::(&t, DataReaderQos::default()) .expect("reader"); - eprintln!("[zerodds reader] domain=100 topic=robot window={secs}s"); + eprintln!("[zerodds reader] domain={domain} topic=robot window={secs}s"); let start = std::time::Instant::now(); let mut matched = 0usize; diff --git a/interop/cyclone-xtypes-27/reader/src/writer.rs b/interop/cyclone-xtypes-27/reader/src/writer.rs index fcb0ad49..e47ef3bb 100644 --- a/interop/cyclone-xtypes-27/reader/src/writer.rs +++ b/interop/cyclone-xtypes-27/reader/src/writer.rs @@ -15,9 +15,14 @@ fn main() { .nth(1) .and_then(|s| s.parse().ok()) .unwrap_or(20); + // Configurable domain (ZERODDS_DOMAIN), default 100 — see reader main.rs. + let domain: u32 = std::env::var("ZERODDS_DOMAIN") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(100); let f = DomainParticipantFactory::instance(); let p = f - .create_participant(100, DomainParticipantQos::default()) + .create_participant(domain, DomainParticipantQos::default()) .unwrap(); let t = p .create_topic::("robot", TopicQos::default()) @@ -26,7 +31,7 @@ fn main() { let w = pubr .create_datawriter::(&t, DataWriterQos::default()) .expect("writer"); - eprintln!("[zerodds writer] domain=100 topic=robot window={secs}s"); + eprintln!("[zerodds writer] domain={domain} topic=robot window={secs}s"); let start = std::time::Instant::now(); let mut c: u32 = 0; while start.elapsed().as_secs() < secs { diff --git a/interop/cyclone-xtypes-27/writers/cyclone_reader.py b/interop/cyclone-xtypes-27/writers/cyclone_reader.py index f8f8702c..8b7ecde6 100755 --- a/interop/cyclone-xtypes-27/writers/cyclone_reader.py +++ b/interop/cyclone-xtypes-27/writers/cyclone_reader.py @@ -7,6 +7,7 @@ Counts successfully received samples on topic `robot`, domain 100, and prints `CYCLONE_RESULT samples=`. """ +import os import sys import time from dataclasses import dataclass @@ -33,7 +34,7 @@ class RobotType(IdlStruct, typename="Robot"): id: uint32 = 0 label: uint32 = 0 -dp = DomainParticipant(domain_id=100) +dp = DomainParticipant(domain_id=int(os.environ.get("ZERODDS_DOMAIN", "100"))) tp = Topic(dp, "robot", RobotType) r = DataReader(Subscriber(dp), tp) print(f"[cyclone reader] ext={ext}", flush=True) diff --git a/interop/cyclone-xtypes-27/writers/cyclone_writer.py b/interop/cyclone-xtypes-27/writers/cyclone_writer.py index 5015020f..ab34dbd1 100755 --- a/interop/cyclone-xtypes-27/writers/cyclone_writer.py +++ b/interop/cyclone-xtypes-27/writers/cyclone_writer.py @@ -7,6 +7,7 @@ generator default); representation is set via the DataRepresentation QoS. Writes topic `robot` on domain 100. """ +import os import sys import time from dataclasses import dataclass @@ -39,7 +40,7 @@ class RobotType(IdlStruct, typename="Robot"): use_cdrv0_representation=(rep == "xcdr1"), use_xcdrv2_representation=(rep == "xcdr2"), )) -dp = DomainParticipant(domain_id=100) +dp = DomainParticipant(domain_id=int(os.environ.get("ZERODDS_DOMAIN", "100"))) tp = Topic(dp, "robot", RobotType) w = DataWriter(Publisher(dp), tp, qos=qos) print(f"[cyclone writer] ext={ext} rep={rep}", flush=True) diff --git a/tests/interop/ci/README.md b/tests/interop/ci/README.md new file mode 100644 index 00000000..d8e6d339 --- /dev/null +++ b/tests/interop/ci/README.md @@ -0,0 +1,88 @@ +# Cross-vendor interop CI gate (issue #28) + +A small, deterministic, **required** public-CI gate that exchanges typed +`Robot` samples over live DDS/RTPS between ZeroDDS and the two widely used +open-source DDS implementations — Eclipse **CycloneDDS** and eProsima +**Fast DDS** — in **both directions**. Endpoint matching *and* actual decoded +sample delivery are asserted; SPDP participant discovery alone is never a pass. + +## Required matrix + +| Vendor | Direction | Assertion | +|---|---|---| +| CycloneDDS | vendor writer → ZeroDDS reader | matched; samples > 0; decode errors = 0 | +| CycloneDDS | ZeroDDS writer → vendor reader | matched; samples > 0 | +| Fast DDS | vendor writer → ZeroDDS reader | matched; samples > 0; decode errors = 0 | +| Fast DDS | ZeroDDS writer → vendor reader | matched; samples > 0 | + +The CycloneDDS cells also retain the issue #29 XTypes regression semantics: a +compatible final/XCDR1 case, a compatible appendable/XCDR2 case, the +final-vs-appendable XCDR2 **negative** case (which must surface a *visible* +decode error, not a silent timeout), and the `--cyclone` compatible-generation +case. + +## Pieces + +| File | Role | +|---|---| +| `interop_result.py` | Pure result classifier: counts → `PASS` / `EXPECTED_NEGATIVE` / `PRODUCT_FAIL` / `TIMEOUT` / `SETUP_FAIL`. | +| `run_vendor.sh` | Vendor-agnostic runner: orchestrates every cell, unique domain per cell, exact-PID cleanup, bounded timeouts, machine-readable `-result.json` + human summary. | +| `lib.sh` | Shared exact-PID process tracking/cleanup (unit tested). | +| `install_cyclone.sh` / `install_fastdds.sh` | Pinned vendor installation for the hosted Ubuntu runner. | +| `fastdds/` | Fast DDS `Robot` pub/sub client (`fastdds_robot`), type generated by Fast-DDS-Gen from `fastdds/robot.idl`. | +| `tests/` | Harness self-tests (classifier, cleanup) runnable on any host. | + +The ZeroDDS reader/writer and the CycloneDDS Python client are reused from +`interop/cyclone-xtypes-27/` (issue #29), parameterised by the `ZERODDS_DOMAIN` +environment variable so each cell gets an isolated domain. + +## One-command local reproduction (Linux) + +Build the ZeroDDS `Robot` reader/writer once (the runner rebuilds per +extensibility as needed): + +```bash +( cd interop/cyclone-xtypes-27/reader && cargo build ) +``` + +### CycloneDDS + +```bash +# Cyclone C library + Python binding (pinned): +tests/interop/ci/install_cyclone.sh +# Run both directions + the #29 XTypes cases: +tests/interop/ci/run_vendor.sh cyclone /tmp/interop-cyclone +``` + +### Fast DDS + +```bash +# Fast DDS + Fast-DDS-Gen (pinned) and build the client: +tests/interop/ci/install_fastdds.sh +# Run both directions: +tests/interop/ci/run_vendor.sh fastdds /tmp/interop-fastdds +``` + +Each run writes `-result.json` and per-cell logs to the output +directory and exits non-zero unless every cell is green (`PASS` or +`EXPECTED_NEGATIVE`). + +## Harness self-tests (no DDS stack required) + +```bash +python -m pytest tests/interop/ci/tests/test_interop_result.py -q +bash tests/interop/ci/tests/test_cleanup.sh +``` + +## Pinned versions + +Vendor versions are pinned in the `install_*` scripts (overridable via the +`*_VERSION` environment variables) and must never float to `latest` in the +required job. CycloneDDS is pinned by the `ubuntu-24.04` runner image's apt +archive plus a matching `cyclonedds` Python release; Fast DDS and Fast-DDS-Gen +are pinned to official versioned GitHub releases. + +## Non-goals + +RTI Connext (licensed), the full pairwise vendor×vendor matrix, DDS Security, +SHM, TCP, TSN, WAN, soak and latency benchmarking. See issue #28. diff --git a/tests/interop/ci/fastdds/CMakeLists.txt b/tests/interop/ci/fastdds/CMakeLists.txt new file mode 100644 index 00000000..70ef730a --- /dev/null +++ b/tests/interop/ci/fastdds/CMakeLists.txt @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 ZeroDDS Contributors +# +# Builds `fastdds_robot`, the Fast DDS interop client for the #28 CI gate. +# The Robot type support is generated from robot.idl by Fast-DDS-Gen at +# configure time, then compiled against the installed Fast DDS SDK. +# +# Requires: an installed Fast DDS (find_package(fastdds)) + fastcdr, and the +# `fastddsgen` code generator on PATH (or passed via -DFASTDDSGEN=/path). +cmake_minimum_required(VERSION 3.16) +project(fastdds_robot CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(fastcdr REQUIRED) +find_package(fastdds REQUIRED) + +# ---- code generation (configure time, so the sources exist for the build) -- +find_program(FASTDDSGEN NAMES fastddsgen fast-dds-gen) +if(NOT FASTDDSGEN) + message(FATAL_ERROR "fastddsgen not found; install Fast-DDS-Gen or pass -DFASTDDSGEN=/path/to/fastddsgen") +endif() + +set(GEN_DIR "${CMAKE_CURRENT_BINARY_DIR}/gen") +file(MAKE_DIRECTORY "${GEN_DIR}") +execute_process( + COMMAND "${FASTDDSGEN}" -replace -d "${GEN_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}/robot.idl" + RESULT_VARIABLE GEN_RC + OUTPUT_VARIABLE GEN_OUT + ERROR_VARIABLE GEN_ERR) +if(NOT GEN_RC EQUAL 0) + message(FATAL_ERROR "fastddsgen failed (${GEN_RC}):\n${GEN_OUT}\n${GEN_ERR}") +endif() + +file(GLOB GEN_SRCS "${GEN_DIR}/*.cxx") +if(GEN_SRCS STREQUAL "") + message(FATAL_ERROR "fastddsgen produced no .cxx sources in ${GEN_DIR}") +endif() + +# ---- executable ------------------------------------------------------------ +add_executable(fastdds_robot fastdds_robot.cpp ${GEN_SRCS}) +target_include_directories(fastdds_robot PRIVATE "${GEN_DIR}") +target_link_libraries(fastdds_robot PRIVATE fastdds fastcdr) + +# Surface the Fast DDS version to the binary's `version` command if the SDK +# exports it (best-effort; harmless when absent). +if(DEFINED fastdds_VERSION) + target_compile_definitions(fastdds_robot PRIVATE FASTDDS_VERSION_STR="${fastdds_VERSION}") +endif() diff --git a/tests/interop/ci/fastdds/fastdds_robot.cpp b/tests/interop/ci/fastdds/fastdds_robot.cpp new file mode 100644 index 00000000..624a1237 --- /dev/null +++ b/tests/interop/ci/fastdds/fastdds_robot.cpp @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 ZeroDDS Contributors +// +// Fast DDS <-> ZeroDDS interop client for the #28 CI gate. +// +// Typed `Robot` pub/sub over live DDS/RTPS. The `Robot` type + PubSubType are +// generated from robot.idl by Fast-DDS-Gen at build time (see CMakeLists.txt) — +// same structure as the ZeroDDS/Cyclone `Robot`, @final so the wire form +// (XCDR1, no DHEADER) matches the ZeroDDS @final endpoints. +// +// Usage: +// fastdds_robot pub [seconds] write Robot samples on domain/topic +// fastdds_robot sub [seconds] count decoded samples, print result +// fastdds_robot version print the Fast DDS version +// +// Domain id comes from ZERODDS_DOMAIN (default 100), matching the runner's +// per-cell domain. Topic is "robot". +// +// The subscriber prints exactly one machine-readable line to stdout: +// FASTDDS_RESULT matched=<0|1> samples= + +#include "RobotPubSubTypes.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace eprosima::fastdds::dds; + +namespace { + +constexpr const char* kTopic = "robot"; + +int resolve_domain() +{ + const char* env = std::getenv("ZERODDS_DOMAIN"); + if (env != nullptr) + { + char* end = nullptr; + long v = std::strtol(env, &end, 10); + if (end != env && v >= 0 && v < 233) + { + return static_cast(v); + } + } + return 100; +} + +class MatchWriterListener : public DataWriterListener +{ +public: + std::atomic matched{0}; + void on_publication_matched(DataWriter*, const PublicationMatchedStatus& s) override + { + matched.store(s.current_count); + } +}; + +class CountReaderListener : public DataReaderListener +{ +public: + std::atomic matched{0}; + std::atomic samples{0}; + + void on_subscription_matched(DataReader*, const SubscriptionMatchedStatus& s) override + { + matched.store(s.current_count); + } + + void on_data_available(DataReader* reader) override + { + Robot sample; + SampleInfo info; + while (reader->take_next_sample(&sample, &info) == eprosima::fastdds::dds::RETCODE_OK) + { + if (info.valid_data) + { + samples.fetch_add(1); + } + } + } +}; + +DomainParticipant* make_participant() +{ + return DomainParticipantFactory::get_instance()->create_participant( + resolve_domain(), PARTICIPANT_QOS_DEFAULT); +} + +int run_pub(double seconds) +{ + DomainParticipant* dp = make_participant(); + if (dp == nullptr) { std::cerr << "fastdds: create_participant failed\n"; return 2; } + + TypeSupport type_support(new RobotPubSubType()); + type_support.register_type(dp); + + Topic* topic = dp->create_topic(kTopic, type_support.get_type_name(), TOPIC_QOS_DEFAULT); + Publisher* pub = dp->create_publisher(PUBLISHER_QOS_DEFAULT); + MatchWriterListener wl; + DataWriter* writer = pub->create_datawriter(topic, DATAWRITER_QOS_DEFAULT, &wl); + if (writer == nullptr) { std::cerr << "fastdds: create_datawriter failed\n"; return 2; } + + std::cerr << "[fastdds pub] domain=" << resolve_domain() << " topic=" << kTopic << "\n"; + auto t0 = std::chrono::steady_clock::now(); + std::uint32_t c = 0; + while (std::chrono::duration(std::chrono::steady_clock::now() - t0).count() < seconds) + { + Robot sample; + sample.id(1); + sample.label(c % 1000); + writer->write(&sample); + ++c; + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + } + + DomainParticipantFactory::get_instance()->delete_participant(dp); + return 0; +} + +int run_sub(double seconds) +{ + DomainParticipant* dp = make_participant(); + if (dp == nullptr) { std::cerr << "fastdds: create_participant failed\n"; return 2; } + + TypeSupport type_support(new RobotPubSubType()); + type_support.register_type(dp); + + Topic* topic = dp->create_topic(kTopic, type_support.get_type_name(), TOPIC_QOS_DEFAULT); + Subscriber* sub = dp->create_subscriber(SUBSCRIBER_QOS_DEFAULT); + CountReaderListener rl; + DataReader* reader = sub->create_datareader(topic, DATAREADER_QOS_DEFAULT, &rl); + if (reader == nullptr) { std::cerr << "fastdds: create_datareader failed\n"; return 2; } + + std::cerr << "[fastdds sub] domain=" << resolve_domain() << " topic=" << kTopic << "\n"; + auto t0 = std::chrono::steady_clock::now(); + while (std::chrono::duration(std::chrono::steady_clock::now() - t0).count() < seconds) + { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + int matched = rl.matched.load() > 0 ? 1 : 0; + std::cout << "FASTDDS_RESULT matched=" << matched << " samples=" << rl.samples.load() + << std::endl; + + DomainParticipantFactory::get_instance()->delete_participant(dp); + return 0; +} + +} // namespace + +int main(int argc, char** argv) +{ + std::string mode = argc > 1 ? argv[1] : ""; + double seconds = argc > 2 ? std::strtod(argv[2], nullptr) : 12.0; + + if (mode == "version") + { + std::cout << "fastdds " << +#ifdef FASTDDS_VERSION_STR + FASTDDS_VERSION_STR +#else + "unknown" +#endif + << std::endl; + return 0; + } + if (mode == "pub") + { + return run_pub(seconds); + } + if (mode == "sub") + { + return run_sub(seconds); + } + std::cerr << "usage: fastdds_robot [seconds]\n"; + return 2; +} diff --git a/tests/interop/ci/fastdds/robot.idl b/tests/interop/ci/fastdds/robot.idl new file mode 100644 index 00000000..16f2d610 --- /dev/null +++ b/tests/interop/ci/fastdds/robot.idl @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +// Robot type for the #28 cross-vendor interop gate — identical structure to +// interop/cyclone-xtypes-27/robot.idl. @final so the FastDDS wire form (XCDR1, +// no DHEADER) matches the ZeroDDS @final reader/writer in the FastDDS cells. +@extensibility(FINAL) +struct Robot { + uint32 id; + uint32 label; +}; diff --git a/tests/interop/ci/install_cyclone.sh b/tests/interop/ci/install_cyclone.sh new file mode 100755 index 00000000..a540c26e --- /dev/null +++ b/tests/interop/ci/install_cyclone.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 ZeroDDS Contributors +# +# Install a pinned CycloneDDS + Python binding for the #28 interop gate on a +# hosted Ubuntu runner. The CycloneDDS C library comes from the runner image's +# apt archive (deterministic for a pinned `runs-on: ubuntu-24.04` image), the +# Python binding is pinned to a matching release. +# +# Prints the resolved versions so the CI artifact states exactly what ran. +set -euo pipefail + +CYCLONEDDS_PY_VERSION="${CYCLONEDDS_PY_VERSION:-0.10.5}" + +echo "=== install_cyclone: apt CycloneDDS C library ===" +sudo apt-get update +sudo apt-get install -y --no-install-recommends \ + libcyclonedds-dev cyclonedds-tools python3-pip + +# The Python binding builds against an installed ddsc; apt puts it under /usr. +export CYCLONEDDS_HOME=/usr +echo "CYCLONEDDS_HOME=$CYCLONEDDS_HOME" >>"${GITHUB_ENV:-/dev/null}" + +echo "=== install_cyclone: Python binding cyclonedds==${CYCLONEDDS_PY_VERSION} ===" +python3 -m pip install --user --upgrade pip +CYCLONEDDS_HOME=/usr python3 -m pip install --user "cyclonedds==${CYCLONEDDS_PY_VERSION}" + +echo "=== install_cyclone: versions ===" +dpkg -s libcyclonedds-dev 2>/dev/null | sed -n 's/^Version: / libcyclonedds-dev /p' || true +python3 -c 'import cyclonedds; print(" cyclonedds-python", getattr(cyclonedds, "__version__", "?"))' +python3 -c 'from cyclonedds.domain import DomainParticipant; DomainParticipant(0); print(" cyclonedds runtime OK")' diff --git a/tests/interop/ci/install_fastdds.sh b/tests/interop/ci/install_fastdds.sh new file mode 100755 index 00000000..4c273466 --- /dev/null +++ b/tests/interop/ci/install_fastdds.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 ZeroDDS Contributors +# +# Install a pinned eProsima Fast DDS + Fast-DDS-Gen and build the fastdds_robot +# interop client for the #28 gate on a hosted Ubuntu runner. +# +# Fast DDS is installed from the official versioned .deb bundle published on the +# eProsima GitHub release; Fast-DDS-Gen from its official versioned release. +# Both are pinned by tag — never `latest`, never an unverified curl|sh. +# +# Prints the resolved versions so the CI artifact states exactly what ran. +set -euo pipefail + +FASTDDS_VERSION="${FASTDDS_VERSION:-3.1.0}" +FASTDDSGEN_VERSION="${FASTDDSGEN_VERSION:-4.0.1}" +HERE="$(cd "$(dirname "$0")" && pwd)" +WORK="${WORK:-$HOME/fastdds-install}" +mkdir -p "$WORK" + +echo "=== install_fastdds: build deps ===" +sudo apt-get update +sudo apt-get install -y --no-install-recommends \ + cmake g++ default-jre wget ca-certificates + +echo "=== install_fastdds: Fast DDS ${FASTDDS_VERSION} (.deb bundle) ===" +DEB_TGZ="ubuntu-fastdds-${FASTDDS_VERSION}.tgz" +DEB_URL="https://github.com/eProsima/Fast-DDS/releases/download/v${FASTDDS_VERSION}/${DEB_TGZ}" +wget -q -O "$WORK/$DEB_TGZ" "$DEB_URL" +tar -xzf "$WORK/$DEB_TGZ" -C "$WORK" +# The bundle unpacks a set of .deb packages (fastcdr, fastdds, foonathan_memory). +sudo apt-get install -y "$WORK"/*.deb || { + # Fall back to dpkg + fix-broken if apt cannot resolve the local files. + sudo dpkg -i "$WORK"/*.deb || true + sudo apt-get install -y -f +} +sudo ldconfig + +echo "=== install_fastdds: Fast-DDS-Gen ${FASTDDSGEN_VERSION} ===" +GEN_URL="https://github.com/eProsima/Fast-DDS-Gen/releases/download/v${FASTDDSGEN_VERSION}/fastddsgen.tar.gz" +wget -q -O "$WORK/fastddsgen.tar.gz" "$GEN_URL" +tar -xzf "$WORK/fastddsgen.tar.gz" -C "$WORK" +GEN_BIN="$(find "$WORK" -type f -name fastddsgen | head -1)" +[ -n "$GEN_BIN" ] || { echo "fastddsgen binary not found after extract" >&2; exit 1; } +chmod +x "$GEN_BIN" +sudo ln -sf "$GEN_BIN" /usr/local/bin/fastddsgen + +echo "=== install_fastdds: build fastdds_robot ===" +cmake -S "$HERE/fastdds" -B "$HERE/fastdds/build" -DCMAKE_BUILD_TYPE=Release +cmake --build "$HERE/fastdds/build" --parallel + +echo "=== install_fastdds: versions ===" +echo " fast-dds .deb bundle v${FASTDDS_VERSION}" +fastddsgen -version 2>/dev/null | sed 's/^/ /' || echo " fastddsgen (version query unsupported)" +"$HERE/fastdds/build/fastdds_robot" version | sed 's/^/ /' || true diff --git a/tests/interop/ci/interop_result.py b/tests/interop/ci/interop_result.py new file mode 100755 index 00000000..edd8092d --- /dev/null +++ b/tests/interop/ci/interop_result.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 ZeroDDS Contributors +"""Result model + classifier for the cross-vendor interop CI gate (issue #28). + +A *cell* is one directed interop attempt: a vendor and a direction +(vendor -> ZeroDDS, or ZeroDDS -> vendor) exchanging typed `Robot` samples +on a unique domain. The classifier turns the observed reader-side counts +into exactly one status so the CI gate can distinguish a real product +failure from an expected-negative case, a timeout, or a setup problem. + +The classifier is intentionally pure and side-effect free so it can be unit +tested against small committed fixtures without any DDS stack present. + +Statuses +-------- +PASS correctness assertion for the cell was met. +EXPECTED_NEGATIVE a negative case produced the expected VISIBLE decode + error (match + zero decoded samples + decode errors > 0), + not a silent timeout. Counts as a green cell. +PRODUCT_FAIL the cell ran to completion but the assertion failed + (e.g. matched but zero samples in a positive case). +TIMEOUT the cell exceeded its bounded wall-clock budget without + reaching a terminal result. +SETUP_FAIL the environment was not usable (missing vendor binary, + vendor process died at startup, build failure). +""" +from __future__ import annotations + +import json +import re +import sys +from dataclasses import asdict, dataclass, field +from typing import Optional + +PASS = "PASS" +EXPECTED_NEGATIVE = "EXPECTED_NEGATIVE" +PRODUCT_FAIL = "PRODUCT_FAIL" +TIMEOUT = "TIMEOUT" +SETUP_FAIL = "SETUP_FAIL" + +# A cell is green (does not fail the gate) iff its status is one of these. +GREEN_STATUSES = frozenset({PASS, EXPECTED_NEGATIVE}) + +# Expectation kinds a cell can declare. +EXPECT_SAMPLES = "samples" # positive: expect matched + decoded samples, no errors +EXPECT_NEGATIVE = "negative" # expect a visible decode error, zero samples + + +@dataclass +class Observed: + """Reader-side observation of one cell. + + `matched` and `errors` are `None` when the reader side is a vendor that + does not report those counts (e.g. the reverse direction, where only the + delivered sample count is available). `samples` is always required for a + terminal (non-setup, non-timeout) verdict. + """ + + samples: Optional[int] = None + matched: Optional[int] = None + errors: Optional[int] = None + discovered: Optional[int] = None + timed_out: bool = False + setup_ok: bool = True + + +def classify(expect: str, obs: Observed) -> str: + """Return exactly one status for a cell. Pure function.""" + if expect not in (EXPECT_SAMPLES, EXPECT_NEGATIVE): + raise ValueError(f"unknown expectation {expect!r}") + # Setup problems dominate: nothing meaningful ran. + if not obs.setup_ok: + return SETUP_FAIL + # A timeout is only a timeout if we did not already have a terminal signal. + # For a positive case, an explicit match with zero samples inside the + # window is a product failure, not a timeout — surface it as such. + if expect == EXPECT_NEGATIVE: + # Expected visible framing/decode error: matched, nothing decoded, + # and decode errors actually surfaced (not a silent timeout). + if obs.matched == 1 and (obs.samples or 0) == 0 and (obs.errors or 0) > 0: + return EXPECTED_NEGATIVE + # No match at all within the budget → timeout, not a product bug. + if obs.timed_out and not obs.matched: + return TIMEOUT + return PRODUCT_FAIL + # Positive case: require decoded samples, no decode errors, and — where the + # reader reports it — an endpoint match. SPDP discovery alone is not a pass. + matched_ok = obs.matched is None or obs.matched >= 1 + errors_ok = obs.errors is None or obs.errors == 0 + if matched_ok and (obs.samples or 0) > 0 and errors_ok: + return PASS + # Distinguish "never matched, ran out of time" (timeout) from "matched but + # broken" (product failure). + if obs.timed_out and not matched_ok and (obs.samples or 0) == 0: + return TIMEOUT + return PRODUCT_FAIL + + +@dataclass +class CellResult: + vendor: str + direction: str # "vendor_to_zerodds" | "zerodds_to_vendor" + case: str # human-readable case id + expect: str + status: str + observed: Observed + detail: str = "" + + +@dataclass +class RunResult: + vendor: str + base_sha: str = "" + versions: dict = field(default_factory=dict) + cells: list = field(default_factory=list) + + @property + def ok(self) -> bool: + return bool(self.cells) and all( + c["status"] in GREEN_STATUSES for c in self.cells + ) + + +def _field(line: str, name: str) -> Optional[int]: + """Return the integer value of ``name=`` in *line*, or None.""" + m = re.search(rf"\b{name}=(\d+)", line) + return int(m.group(1)) if m else None + + +def parse_result_line(line: str) -> Observed: + """Extract counts from a ZeroDDS/vendor reader RESULT line. + + Recognises the ZeroDDS reader form + ``RESULT discovered=.. matched=.. samples=.. errors=..`` and the vendor + reader form ``*_RESULT samples=..``. Each field is matched independently, + so field order and absent fields are both handled. Missing fields stay + `None`. + """ + return Observed( + samples=_field(line, "samples"), + matched=_field(line, "matched"), + errors=_field(line, "errors"), + discovered=_field(line, "discovered"), + ) + + +def _cell_to_jsonable(c: CellResult) -> dict: + d = asdict(c) + d["observed"] = asdict(c.observed) + d["green"] = c.status in GREEN_STATUSES + return d + + +def main(argv: list) -> int: + """CLI: classify one cell and print its JSON object. + + Usage: + interop_result.py --vendor V --direction D --case C --expect E \ + [--result-line "RESULT ..."] [--samples N] [--matched N] \ + [--errors N] [--timed-out] [--setup-failed] [--detail TEXT] + + Prints the cell JSON to stdout and exits 0 if the cell is green, 1 if not. + """ + import argparse + + ap = argparse.ArgumentParser() + ap.add_argument("--vendor", required=True) + ap.add_argument("--direction", required=True) + ap.add_argument("--case", required=True) + ap.add_argument("--expect", required=True, choices=[EXPECT_SAMPLES, EXPECT_NEGATIVE]) + ap.add_argument("--result-line", default="") + ap.add_argument("--samples", type=int) + ap.add_argument("--matched", type=int) + ap.add_argument("--errors", type=int) + ap.add_argument("--discovered", type=int) + ap.add_argument("--timed-out", action="store_true") + ap.add_argument("--setup-failed", action="store_true") + ap.add_argument("--detail", default="") + a = ap.parse_args(argv) + + if a.result_line: + obs = parse_result_line(a.result_line) + else: + obs = Observed() + # Explicit flags/counts override parsed values. + for name in ("samples", "matched", "errors", "discovered"): + v = getattr(a, name) + if v is not None: + setattr(obs, name, v) + obs.timed_out = a.timed_out + obs.setup_ok = not a.setup_failed + + status = classify(a.expect, obs) + cell = CellResult( + vendor=a.vendor, + direction=a.direction, + case=a.case, + expect=a.expect, + status=status, + observed=obs, + detail=a.detail, + ) + print(json.dumps(_cell_to_jsonable(cell))) + return 0 if status in GREEN_STATUSES else 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/interop/ci/lib.sh b/tests/interop/ci/lib.sh new file mode 100755 index 00000000..2f5a0bfc --- /dev/null +++ b/tests/interop/ci/lib.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 ZeroDDS Contributors +# +# Shared helpers for the #28 interop gate runner. Kept separate so the +# exact-PID cleanup contract can be unit tested without any DDS stack. + +# Exact-PID process tracking. The gate NEVER uses a broad `pkill` or a +# container-name wildcard — it kills only the specific child PIDs it started, +# so a concurrent unrelated process on the runner is never touched. +CHILD_PIDS=() + +# track : register a child PID for cleanup. +track() { CHILD_PIDS+=("$1"); } + +# kill_pid : terminate exactly this PID and reap it. No-op on empty. +kill_pid() { + local p="${1:-}" + [ -n "$p" ] || return 0 + kill -9 "$p" 2>/dev/null || true + wait "$p" 2>/dev/null || true +} + +# cleanup: kill every tracked child PID, nothing else. +cleanup() { + local p + for p in "${CHILD_PIDS[@]:-}"; do + kill_pid "$p" + done +} diff --git a/tests/interop/ci/run_vendor.sh b/tests/interop/ci/run_vendor.sh new file mode 100755 index 00000000..8caf4598 --- /dev/null +++ b/tests/interop/ci/run_vendor.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 ZeroDDS Contributors +# ============================================================================ +# Cross-vendor DDS interop CI gate runner (issue #28). +# +# Exchanges typed `Robot` samples over live DDS/RTPS between ZeroDDS and one +# open-source vendor (CycloneDDS or Fast DDS), in BOTH directions, and asserts +# endpoint matching AND actual decoded sample delivery — SPDP discovery alone +# is never a pass. +# +# Usage: +# run_vendor.sh +# +# Vendor client configuration (all optional, sensible CI defaults): +# PYBIN python with `cyclonedds` importable (cyclone) +# FASTDDS_ROBOT path to the fastdds_robot pub/sub binary (fastdds) +# READ_WINDOW reader observation window, seconds (default 12) +# WRITE_WINDOW writer lifetime, seconds (default 30) +# START_DELAY bounded writer warm-up before the reader (default 3) +# DOMAIN_BASE first DDS domain id used (per vendor) +# +# Exit status: 0 iff every cell is green (PASS or EXPECTED_NEGATIVE). +# Writes /-result.json (machine-readable) and prints a human +# summary. Per-cell logs are kept in /. +# ============================================================================ +set -uo pipefail + +VENDOR="${1:?usage: run_vendor.sh }" +OUTDIR="${2:?usage: run_vendor.sh }" + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$HERE/../../.." && pwd)" +READER_DIR="$REPO_ROOT/interop/cyclone-xtypes-27/reader" +ZR="$READER_DIR/target/debug/reader" +ZW="$READER_DIR/target/debug/writer" +CYC_DIR="$REPO_ROOT/interop/cyclone-xtypes-27/writers" +RESULT_PY=("python3" "$HERE/interop_result.py") + +PYBIN="${PYBIN:-python3}" +FASTDDS_ROBOT="${FASTDDS_ROBOT:-$HERE/fastdds/build/fastdds_robot}" +READ_WINDOW="${READ_WINDOW:-12}" +WRITE_WINDOW="${WRITE_WINDOW:-30}" +START_DELAY="${START_DELAY:-3}" + +mkdir -p "$OUTDIR" +log() { printf '%s\n' "$*" >&2; } + +# ---- exact-PID cleanup (never a broad pkill) ------------------------------- +# shellcheck source=tests/interop/ci/lib.sh +. "$HERE/lib.sh" +trap cleanup EXIT INT TERM + +# ---- per-vendor domain base (isolate cells within a job) ------------------- +case "$VENDOR" in + cyclone) DOMAIN_BASE="${DOMAIN_BASE:-121}";; + fastdds) DOMAIN_BASE="${DOMAIN_BASE:-141}";; + *) log "unknown vendor: $VENDOR"; exit 2;; +esac +CELL_IDX=0 +next_domain() { echo $((DOMAIN_BASE + CELL_IDX)); CELL_IDX=$((CELL_IDX + 1)); } + +# ---- ZeroDDS reader-type build (per extensibility) ------------------------- +IDLC=(cargo run -q --manifest-path "$REPO_ROOT/Cargo.toml" -p zerodds-idlc --) +BUILT_EXT="" +build_zerodds() { # $1 = appendable|final + local ext="$1"; [ "$ext" = "$BUILT_EXT" ] && return 0 + local extra=(); [ "$ext" = "final" ] && extra=(--cyclone) + rm -f "$READER_DIR/src/robot.rs" + "${IDLC[@]}" generate "$REPO_ROOT/interop/cyclone-xtypes-27/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 "ZeroDDS reader/writer build failed ($ext)"; return 1; } + BUILT_EXT="$ext" +} + +# ---- vendor client commands ------------------------------------------------ +# vendor_writer -> starts bg, echoes PID +vendor_writer() { + local ext="$1" rep="$2" secs="$3" logf="$4" + case "$VENDOR" in + cyclone) "$PYBIN" "$CYC_DIR/cyclone_writer.py" "$ext" "$rep" "$secs" >"$logf" 2>&1 & echo $!;; + fastdds) "$FASTDDS_ROBOT" pub "$secs" >"$logf" 2>&1 & echo $!;; + esac +} +# vendor_reader -> prints the vendor RESULT line. +# Hard-bounded by `timeout` on top of the client's own internal window. +vendor_reader() { + local ext="$1" secs="$2" logf="$3" hard=$(( $2 + 15 )) + case "$VENDOR" in + cyclone) timeout -k 5 "$hard" "$PYBIN" "$CYC_DIR/cyclone_reader.py" "$ext" "$secs" 2>"$logf" | grep -E '^CYCLONE_RESULT';; + fastdds) timeout -k 5 "$hard" "$FASTDDS_ROBOT" sub "$secs" 2>"$logf" | grep -E '^FASTDDS_RESULT';; + esac +} + +# ---- cell runners ---------------------------------------------------------- +CELLS_JSON=() +record() { CELLS_JSON+=("$1"); } + +# forward: vendor writer -> ZeroDDS reader +# $1 case $2 writer-ext $3 rep $4 zerodds-ext $5 expect(samples|negative) +forward() { + local case="$1" wext="$2" rep="$3" zext="$4" expect="$5" + local dom; dom="$(next_domain)" + local wlog="$OUTDIR/${VENDOR}-fwd-${CELL_IDX}-writer.log" + local rlog="$OUTDIR/${VENDOR}-fwd-${CELL_IDX}-reader.log" + build_zerodds "$zext" || { record "$("${RESULT_PY[@]}" --vendor "$VENDOR" --direction vendor_to_zerodds --case "$case" --expect "$expect" --setup-failed --detail 'zerodds build failed')"; return; } + local wp; wp="$(ZERODDS_DOMAIN="$dom" vendor_writer "$wext" "$rep" "$WRITE_WINDOW" "$wlog")"; track "$wp" + sleep "$START_DELAY" + if ! kill -0 "$wp" 2>/dev/null; then + record "$("${RESULT_PY[@]}" --vendor "$VENDOR" --direction vendor_to_zerodds --case "$case" --expect "$expect" --setup-failed --detail "vendor writer died: $(tail -1 "$wlog" 2>/dev/null)")" + return + fi + local out timed=0 + out="$(ZERODDS_DOMAIN="$dom" timeout -k 5 "$((READ_WINDOW + 20))" "$ZR" "$READ_WINDOW" 2>"$rlog" | grep -E '^RESULT')" || timed=1 + kill_pid "$wp" + local flags=(); [ "$timed" = 1 ] && flags+=(--timed-out) + record "$("${RESULT_PY[@]}" --vendor "$VENDOR" --direction vendor_to_zerodds --case "$case" --expect "$expect" --result-line "${out:-no-result}" "${flags[@]}")" +} + +# reverse: ZeroDDS writer -> vendor reader +# $1 case $2 zerodds-ext $3 vendor-ext +reverse() { + local case="$1" zext="$2" vext="$3" + local dom; dom="$(next_domain)" + local wlog="$OUTDIR/${VENDOR}-rev-${CELL_IDX}-writer.log" + local rlog="$OUTDIR/${VENDOR}-rev-${CELL_IDX}-reader.log" + build_zerodds "$zext" || { record "$("${RESULT_PY[@]}" --vendor "$VENDOR" --direction zerodds_to_vendor --case "$case" --expect samples --setup-failed --detail 'zerodds build failed')"; return; } + local wp; wp="$(ZERODDS_DOMAIN="$dom" "$ZW" "$WRITE_WINDOW" >"$wlog" 2>&1 & echo $!)"; track "$wp" + sleep "$START_DELAY" + if ! kill -0 "$wp" 2>/dev/null; then + record "$("${RESULT_PY[@]}" --vendor "$VENDOR" --direction zerodds_to_vendor --case "$case" --expect samples --setup-failed --detail "zerodds writer died: $(tail -1 "$wlog" 2>/dev/null)")" + return + fi + local out timed=0 + out="$(ZERODDS_DOMAIN="$dom" vendor_reader "$vext" "$READ_WINDOW" "$rlog")" || timed=1 + kill_pid "$wp" + local flags=(); [ "$timed" = 1 ] && flags+=(--timed-out) + record "$("${RESULT_PY[@]}" --vendor "$VENDOR" --direction zerodds_to_vendor --case "$case" --expect samples --result-line "${out:-no-result}" "${flags[@]}")" +} + +# ---- preflight ------------------------------------------------------------- +preflight() { + log "=== preflight: $VENDOR ===" + case "$VENDOR" in + cyclone) + if ! "$PYBIN" -c 'import cyclonedds' 2>/dev/null; then + log "SETUP: '$PYBIN' cannot import cyclonedds"; return 1 + fi + "$PYBIN" -c 'import cyclonedds, sys; print("cyclonedds", getattr(cyclonedds,"__version__","?"))' >&2 2>/dev/null || true + ;; + fastdds) + if [ ! -x "$FASTDDS_ROBOT" ]; then + log "SETUP: fastdds_robot binary not found/executable at $FASTDDS_ROBOT"; return 1 + fi + "$FASTDDS_ROBOT" version >&2 2>/dev/null || true + ;; + esac + return 0 +} + +# ---- run ------------------------------------------------------------------- +if ! preflight; then + # Emit a single SETUP_FAIL cell so the artifact states what happened. + record "$("${RESULT_PY[@]}" --vendor "$VENDOR" --direction preflight --case "vendor available" --expect samples --setup-failed --detail 'vendor preflight failed')" +else + case "$VENDOR" in + cyclone) + # Retain the #29 XTypes regression semantics. + forward "final+XCDR1 compatible" final xcdr1 appendable samples + forward "appendable+XCDR2 compatible" appendable xcdr2 appendable samples + forward "final+XCDR2 vs appendable (neg #27)" final xcdr2 appendable negative + reverse "ZeroDDS@appendable -> Cyclone" appendable appendable + forward "final+XCDR2 -> ZeroDDS@final (--cyclone)" final xcdr2 final samples + ;; + fastdds) + # Real data both directions (shared wire path; no XTypes negative dup). + forward "FastDDS writer -> ZeroDDS reader" final xcdr1 final samples + reverse "ZeroDDS writer -> FastDDS reader" final final + ;; + esac +fi + +# ---- aggregate + report ---------------------------------------------------- +RUN_JSON="$OUTDIR/${VENDOR}-result.json" +python3 - "$VENDOR" "$RUN_JSON" "${CELLS_JSON[@]}" <<'PY' +import json, sys +vendor, out = sys.argv[1], sys.argv[2] +cells = [json.loads(x) for x in sys.argv[3:] if x.strip()] +green = {"PASS", "EXPECTED_NEGATIVE"} +ok = bool(cells) and all(c["status"] in green for c in cells) +doc = {"vendor": vendor, "ok": ok, "cells": cells} +with open(out, "w") as f: + json.dump(doc, f, indent=2) +print(f"\n==================== {vendor} interop gate ====================", file=sys.stderr) +for c in cells: + print(f" [{c['status']:<17}] {c['direction']:<18} {c['case']}" + + (f" {c['observed']}" if c.get('observed') else ""), file=sys.stderr) +print("=" * 62, file=sys.stderr) +print(("PASS" if ok else "FAIL") + f": {vendor} interop gate", file=sys.stderr) +sys.exit(0 if ok else 1) +PY diff --git a/tests/interop/ci/tests/test_cleanup.sh b/tests/interop/ci/tests/test_cleanup.sh new file mode 100755 index 00000000..101f45e7 --- /dev/null +++ b/tests/interop/ci/tests/test_cleanup.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 ZeroDDS Contributors +# +# Verifies the exact-PID cleanup contract of the #28 interop runner: a failed +# cell removes ONLY the children it started, never an unrelated process. +# Pure shell, no DDS stack — runnable on any host including macOS. +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=tests/interop/ci/lib.sh +. "$HERE/../lib.sh" + +fail() { echo "FAIL: $1" >&2; exit 1; } + +# An unrelated "bystander" process the runner must never touch. +sleep 30 & +BYSTANDER=$! + +# Two tracked children (as a cell would start). +sleep 30 & +track $! +CHILD_A=$! +sleep 30 & +track $! +CHILD_B=$! + +# Trigger the same cleanup the runner's EXIT trap uses. +cleanup + +# Give the kernel a moment to reap. +sleep 0.3 + +kill -0 "$CHILD_A" 2>/dev/null && fail "tracked child A ($CHILD_A) survived cleanup" +kill -0 "$CHILD_B" 2>/dev/null && fail "tracked child B ($CHILD_B) survived cleanup" +kill -0 "$BYSTANDER" 2>/dev/null || fail "bystander ($BYSTANDER) was killed — cleanup was not exact-PID" + +# Clean up the bystander ourselves (exact PID, of course). +kill -9 "$BYSTANDER" 2>/dev/null || true +wait "$BYSTANDER" 2>/dev/null || true + +echo "PASS: cleanup killed only the tracked children, spared the bystander" diff --git a/tests/interop/ci/tests/test_interop_result.py b/tests/interop/ci/tests/test_interop_result.py new file mode 100644 index 00000000..313d8d08 --- /dev/null +++ b/tests/interop/ci/tests/test_interop_result.py @@ -0,0 +1,128 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 ZeroDDS Contributors +"""Unit tests for the interop result classifier (issue #28). + +Pure-logic tests — no DDS stack required, runnable on any host including +macOS. Covers the five required scenarios (success, zero samples, decode +error, timeout, missing vendor binary) plus the result-line parser. +""" +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from interop_result import ( # noqa: E402 + EXPECT_NEGATIVE, + EXPECT_SAMPLES, + EXPECTED_NEGATIVE, + PASS, + PRODUCT_FAIL, + SETUP_FAIL, + TIMEOUT, + Observed, + classify, + parse_result_line, +) + + +# ---- positive (samples) case ------------------------------------------------ + +def test_success_matched_samples_no_errors(): + obs = Observed(matched=1, samples=40, errors=0, discovered=1) + assert classify(EXPECT_SAMPLES, obs) == PASS + + +def test_reverse_direction_no_match_count_but_samples(): + # Vendor reader reports only sample count (matched/errors unknown). + obs = Observed(samples=39, matched=None, errors=None) + assert classify(EXPECT_SAMPLES, obs) == PASS + + +def test_zero_samples_but_matched_is_product_fail(): + # Endpoint matched (discovery worked) but nothing decoded -> NOT a pass. + # This is exactly the "SPDP alone is not interop" guard. + obs = Observed(matched=1, samples=0, errors=0, discovered=1) + assert classify(EXPECT_SAMPLES, obs) == PRODUCT_FAIL + + +def test_samples_with_decode_errors_is_product_fail(): + obs = Observed(matched=1, samples=10, errors=3) + assert classify(EXPECT_SAMPLES, obs) == PRODUCT_FAIL + + +def test_timeout_never_matched_positive_case(): + obs = Observed(matched=0, samples=0, errors=0, discovered=0, timed_out=True) + assert classify(EXPECT_SAMPLES, obs) == TIMEOUT + + +def test_matched_zero_samples_not_timed_out_is_product_fail(): + # Ran to completion, matched, zero samples, but the window simply ended: + # product failure, not timeout, because a match was seen. + obs = Observed(matched=1, samples=0, errors=0, timed_out=True) + assert classify(EXPECT_SAMPLES, obs) == PRODUCT_FAIL + + +# ---- negative (expected decode error) case ---------------------------------- + +def test_expected_negative_visible_decode_error(): + # #27/#29 case: Cyclone @final XCDR2 vs ZeroDDS @appendable — matched, + # zero decoded, decode errors surfaced via take() -> WireError. + obs = Observed(matched=1, samples=0, errors=40, discovered=1) + assert classify(EXPECT_NEGATIVE, obs) == EXPECTED_NEGATIVE + + +def test_negative_case_silent_timeout_is_timeout_not_pass(): + # A negative case that NEVER matched and just timed out must NOT be + # rewarded as an expected-negative pass. + obs = Observed(matched=0, samples=0, errors=0, timed_out=True) + assert classify(EXPECT_NEGATIVE, obs) == TIMEOUT + + +def test_negative_case_but_samples_flowed_is_product_fail(): + # If the "incompatible" case actually delivered data, the negative + # expectation is wrong -> product failure (regression signal). + obs = Observed(matched=1, samples=20, errors=0) + assert classify(EXPECT_NEGATIVE, obs) == PRODUCT_FAIL + + +# ---- setup failure ---------------------------------------------------------- + +def test_missing_vendor_binary_is_setup_fail(): + obs = Observed(setup_ok=False) + assert classify(EXPECT_SAMPLES, obs) == SETUP_FAIL + + +def test_setup_fail_dominates_even_with_counts(): + obs = Observed(matched=1, samples=40, errors=0, setup_ok=False) + assert classify(EXPECT_SAMPLES, obs) == SETUP_FAIL + + +def test_unknown_expectation_raises(): + with pytest.raises(ValueError): + classify("bogus", Observed(samples=1)) + + +# ---- result-line parser ----------------------------------------------------- + +def test_parse_zerodds_reader_line(): + obs = parse_result_line("RESULT discovered=1 matched=1 samples=40 errors=0") + assert (obs.discovered, obs.matched, obs.samples, obs.errors) == (1, 1, 40, 0) + + +def test_parse_vendor_reader_line_samples_only(): + obs = parse_result_line("CYCLONE_RESULT samples=39") + assert obs.samples == 39 + assert obs.matched is None + assert obs.errors is None + + +def test_parse_negative_line(): + obs = parse_result_line("RESULT discovered=1 matched=1 samples=0 errors=40") + assert (obs.matched, obs.samples, obs.errors) == (1, 0, 40) + + +def test_parse_missing_fields_are_none(): + obs = parse_result_line("nothing useful here") + assert obs.samples is None and obs.matched is None From cee9eafe6816e36f024d862b4c44eca5a3c52d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandra=20Theresa=20Ke=C3=9Fler?= Date: Mon, 3 Aug 2026 22:25:01 +0200 Subject: [PATCH 2/4] ci(interop): build CycloneDDS and Fast DDS from pinned source (Refs #28) Neither vendor is apt-installable on the hosted ubuntu-24.04 runner ("Unable to locate package libcyclonedds-dev") and eProsima publishes no binary GitHub release asset, so both are now built from pinned git tags: CycloneDDS 0.10.5 (+ matching cyclonedds-python 0.10.5), Fast CDR v2.2.4 + Fast DDS v3.1.0 (bundled foonathan_memory) + Fast-DDS-Gen v4.0.1. Deterministic, no floating latest, no unverified installer. --- tests/interop/ci/install_cyclone.sh | 42 +++++++++++----- tests/interop/ci/install_fastdds.sh | 77 ++++++++++++++++++----------- 2 files changed, 77 insertions(+), 42 deletions(-) diff --git a/tests/interop/ci/install_cyclone.sh b/tests/interop/ci/install_cyclone.sh index a540c26e..3a3c74dc 100755 --- a/tests/interop/ci/install_cyclone.sh +++ b/tests/interop/ci/install_cyclone.sh @@ -2,30 +2,48 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright 2026 ZeroDDS Contributors # -# Install a pinned CycloneDDS + Python binding for the #28 interop gate on a -# hosted Ubuntu runner. The CycloneDDS C library comes from the runner image's -# apt archive (deterministic for a pinned `runs-on: ubuntu-24.04` image), the -# Python binding is pinned to a matching release. +# Build + install a PINNED CycloneDDS and its Python binding for the #28 +# interop gate. Neither Eclipse nor Ubuntu ships an apt package for the hosted +# runner, so the C library is built from a pinned git tag and the Python +# binding from a matching PyPI release — fully deterministic, no floating +# `latest`, no unverified third-party installer. # # Prints the resolved versions so the CI artifact states exactly what ran. set -euo pipefail -CYCLONEDDS_PY_VERSION="${CYCLONEDDS_PY_VERSION:-0.10.5}" +CYCLONEDDS_VERSION="${CYCLONEDDS_VERSION:-0.10.5}" # C library git tag +CYCLONEDDS_PY_VERSION="${CYCLONEDDS_PY_VERSION:-0.10.5}" # matching PyPI release +PREFIX="${CYCLONEDDS_HOME:-$HOME/cyclonedds-install}" +WORK="${WORK:-$HOME/cyclonedds-src}" -echo "=== install_cyclone: apt CycloneDDS C library ===" +echo "=== install_cyclone: build deps ===" sudo apt-get update sudo apt-get install -y --no-install-recommends \ - libcyclonedds-dev cyclonedds-tools python3-pip + git cmake g++ python3-dev python3-pip -# The Python binding builds against an installed ddsc; apt puts it under /usr. -export CYCLONEDDS_HOME=/usr -echo "CYCLONEDDS_HOME=$CYCLONEDDS_HOME" >>"${GITHUB_ENV:-/dev/null}" +echo "=== install_cyclone: build CycloneDDS ${CYCLONEDDS_VERSION} (source) ===" +if [ ! -x "$PREFIX/bin/idlc" ]; then + rm -rf "$WORK" + git clone --depth 1 --branch "$CYCLONEDDS_VERSION" \ + https://github.com/eclipse-cyclonedds/cyclonedds.git "$WORK" + cmake -S "$WORK" -B "$WORK/build" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$PREFIX" \ + -DBUILD_IDLC=ON -DBUILD_EXAMPLES=OFF -DBUILD_TESTING=OFF + cmake --build "$WORK/build" --target install --parallel +fi +export CYCLONEDDS_HOME="$PREFIX" +{ + echo "CYCLONEDDS_HOME=$PREFIX" + echo "LD_LIBRARY_PATH=$PREFIX/lib:${LD_LIBRARY_PATH:-}" +} >>"${GITHUB_ENV:-/dev/null}" +export LD_LIBRARY_PATH="$PREFIX/lib:${LD_LIBRARY_PATH:-}" echo "=== install_cyclone: Python binding cyclonedds==${CYCLONEDDS_PY_VERSION} ===" python3 -m pip install --user --upgrade pip -CYCLONEDDS_HOME=/usr python3 -m pip install --user "cyclonedds==${CYCLONEDDS_PY_VERSION}" +CYCLONEDDS_HOME="$PREFIX" python3 -m pip install --user "cyclonedds==${CYCLONEDDS_PY_VERSION}" echo "=== install_cyclone: versions ===" -dpkg -s libcyclonedds-dev 2>/dev/null | sed -n 's/^Version: / libcyclonedds-dev /p' || true +echo " cyclonedds-c ${CYCLONEDDS_VERSION} (prefix $PREFIX)" python3 -c 'import cyclonedds; print(" cyclonedds-python", getattr(cyclonedds, "__version__", "?"))' python3 -c 'from cyclonedds.domain import DomainParticipant; DomainParticipant(0); print(" cyclonedds runtime OK")' diff --git a/tests/interop/ci/install_fastdds.sh b/tests/interop/ci/install_fastdds.sh index 4c273466..530557be 100755 --- a/tests/interop/ci/install_fastdds.sh +++ b/tests/interop/ci/install_fastdds.sh @@ -2,54 +2,71 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright 2026 ZeroDDS Contributors # -# Install a pinned eProsima Fast DDS + Fast-DDS-Gen and build the fastdds_robot -# interop client for the #28 gate on a hosted Ubuntu runner. -# -# Fast DDS is installed from the official versioned .deb bundle published on the -# eProsima GitHub release; Fast-DDS-Gen from its official versioned release. -# Both are pinned by tag — never `latest`, never an unverified curl|sh. +# Build + install a PINNED eProsima Fast DDS stack (Fast CDR + Fast DDS + +# Fast-DDS-Gen) from source and build the fastdds_robot interop client for +# the #28 gate. eProsima publishes no apt package for the hosted runner and no +# binary GitHub release asset, so everything is built from pinned git tags — +# fully deterministic, no floating `latest`, no unverified installer. # # Prints the resolved versions so the CI artifact states exactly what ran. set -euo pipefail -FASTDDS_VERSION="${FASTDDS_VERSION:-3.1.0}" -FASTDDSGEN_VERSION="${FASTDDSGEN_VERSION:-4.0.1}" +FASTCDR_VERSION="${FASTCDR_VERSION:-v2.2.4}" +FASTDDS_VERSION="${FASTDDS_VERSION:-v3.1.0}" +FASTDDSGEN_VERSION="${FASTDDSGEN_VERSION:-v4.0.1}" +PREFIX="${FASTDDS_PREFIX:-$HOME/fastdds-install}" +WORK="${WORK:-$HOME/fastdds-src}" HERE="$(cd "$(dirname "$0")" && pwd)" -WORK="${WORK:-$HOME/fastdds-install}" mkdir -p "$WORK" echo "=== install_fastdds: build deps ===" sudo apt-get update sudo apt-get install -y --no-install-recommends \ - cmake g++ default-jre wget ca-certificates - -echo "=== install_fastdds: Fast DDS ${FASTDDS_VERSION} (.deb bundle) ===" -DEB_TGZ="ubuntu-fastdds-${FASTDDS_VERSION}.tgz" -DEB_URL="https://github.com/eProsima/Fast-DDS/releases/download/v${FASTDDS_VERSION}/${DEB_TGZ}" -wget -q -O "$WORK/$DEB_TGZ" "$DEB_URL" -tar -xzf "$WORK/$DEB_TGZ" -C "$WORK" -# The bundle unpacks a set of .deb packages (fastcdr, fastdds, foonathan_memory). -sudo apt-get install -y "$WORK"/*.deb || { - # Fall back to dpkg + fix-broken if apt cannot resolve the local files. - sudo dpkg -i "$WORK"/*.deb || true - sudo apt-get install -y -f + git cmake g++ default-jdk libssl-dev libasio-dev libtinyxml2-dev + +clone_pinned() { # + [ -d "$WORK/$3" ] && return 0 + git clone --depth 1 --branch "$2" "https://github.com/eProsima/$1.git" "$WORK/$3" +} +cmake_install() { # [extra cmake args...] + local d="$1"; shift + cmake -S "$WORK/$d" -B "$WORK/$d/build" \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$PREFIX" \ + -DCMAKE_PREFIX_PATH="$PREFIX" "$@" + cmake --build "$WORK/$d/build" --target install --parallel } -sudo ldconfig + +if [ ! -d "$PREFIX/include/fastdds" ]; then + echo "=== install_fastdds: Fast CDR ${FASTCDR_VERSION} ===" + clone_pinned Fast-CDR "$FASTCDR_VERSION" fastcdr + cmake_install fastcdr + + echo "=== install_fastdds: Fast DDS ${FASTDDS_VERSION} (with bundled foonathan_memory) ===" + clone_pinned Fast-DDS "$FASTDDS_VERSION" fastdds + cmake_install fastdds -DTHIRDPARTY=ON -DCOMPILE_EXAMPLES=OFF -DBUILD_TESTING=OFF +fi +export LD_LIBRARY_PATH="$PREFIX/lib:${LD_LIBRARY_PATH:-}" +echo "LD_LIBRARY_PATH=$PREFIX/lib:${LD_LIBRARY_PATH:-}" >>"${GITHUB_ENV:-/dev/null}" echo "=== install_fastdds: Fast-DDS-Gen ${FASTDDSGEN_VERSION} ===" -GEN_URL="https://github.com/eProsima/Fast-DDS-Gen/releases/download/v${FASTDDSGEN_VERSION}/fastddsgen.tar.gz" -wget -q -O "$WORK/fastddsgen.tar.gz" "$GEN_URL" -tar -xzf "$WORK/fastddsgen.tar.gz" -C "$WORK" -GEN_BIN="$(find "$WORK" -type f -name fastddsgen | head -1)" -[ -n "$GEN_BIN" ] || { echo "fastddsgen binary not found after extract" >&2; exit 1; } +GEN_BIN="$PREFIX/share/fastddsgen/scripts/fastddsgen" +if [ ! -x "$GEN_BIN" ]; then + git clone --recurse-submodules --depth 1 --branch "$FASTDDSGEN_VERSION" \ + https://github.com/eProsima/Fast-DDS-Gen.git "$WORK/fastddsgen" + ( cd "$WORK/fastddsgen" && ./gradlew assemble ) + mkdir -p "$PREFIX/share/fastddsgen" + cp -r "$WORK/fastddsgen/scripts" "$WORK/fastddsgen/share" "$PREFIX/share/fastddsgen/" 2>/dev/null || \ + cp -r "$WORK/fastddsgen/." "$PREFIX/share/fastddsgen/" + GEN_BIN="$(find "$PREFIX/share/fastddsgen" "$WORK/fastddsgen" -name fastddsgen -type f | head -1)" +fi chmod +x "$GEN_BIN" sudo ln -sf "$GEN_BIN" /usr/local/bin/fastddsgen echo "=== install_fastdds: build fastdds_robot ===" -cmake -S "$HERE/fastdds" -B "$HERE/fastdds/build" -DCMAKE_BUILD_TYPE=Release +cmake -S "$HERE/fastdds" -B "$HERE/fastdds/build" \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$PREFIX" cmake --build "$HERE/fastdds/build" --parallel echo "=== install_fastdds: versions ===" -echo " fast-dds .deb bundle v${FASTDDS_VERSION}" -fastddsgen -version 2>/dev/null | sed 's/^/ /' || echo " fastddsgen (version query unsupported)" +echo " fastcdr ${FASTCDR_VERSION} fastdds ${FASTDDS_VERSION} gen ${FASTDDSGEN_VERSION}" "$HERE/fastdds/build/fastdds_robot" version | sed 's/^/ /' || true From 1813352968c742c93e60766e3aa4058c8b809ef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandra=20Theresa=20Ke=C3=9Fler?= Date: Mon, 3 Aug 2026 22:33:27 +0200 Subject: [PATCH 3/4] fix(interop): ZeroDDS domain is i32 (DomainId); build foonathan_memory for Fast DDS (Refs #28) - The ZeroDDS interop reader/writer domain must be i32 (DomainParticipantFactory::create_participant takes DomainId = i32); the ZERODDS_DOMAIN parse used u32 and failed to compile (E0308), turning every Cyclone cell into SETUP_FAIL. - Fast DDS: build foonathan_memory_vendor + Fast CDR as separate pinned installs instead of -DTHIRDPARTY (a --depth 1 clone has no submodules, so find_package(foonathan_memory) failed). --- interop/cyclone-xtypes-27/reader/src/main.rs | 2 +- interop/cyclone-xtypes-27/reader/src/writer.rs | 2 +- tests/interop/ci/install_fastdds.sh | 9 +++++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/interop/cyclone-xtypes-27/reader/src/main.rs b/interop/cyclone-xtypes-27/reader/src/main.rs index 9da03e7b..216b5e07 100644 --- a/interop/cyclone-xtypes-27/reader/src/main.rs +++ b/interop/cyclone-xtypes-27/reader/src/main.rs @@ -26,7 +26,7 @@ fn main() { // Domain is configurable (ZERODDS_DOMAIN) so the #28 interop CI gate can // give each vendor/direction cell a unique domain and avoid cross-job // multicast contamination. Defaults to 100 (the #27/#29 matrix domain). - let domain: u32 = std::env::var("ZERODDS_DOMAIN") + let domain: i32 = std::env::var("ZERODDS_DOMAIN") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(100); diff --git a/interop/cyclone-xtypes-27/reader/src/writer.rs b/interop/cyclone-xtypes-27/reader/src/writer.rs index e47ef3bb..562c933d 100644 --- a/interop/cyclone-xtypes-27/reader/src/writer.rs +++ b/interop/cyclone-xtypes-27/reader/src/writer.rs @@ -16,7 +16,7 @@ fn main() { .and_then(|s| s.parse().ok()) .unwrap_or(20); // Configurable domain (ZERODDS_DOMAIN), default 100 — see reader main.rs. - let domain: u32 = std::env::var("ZERODDS_DOMAIN") + let domain: i32 = std::env::var("ZERODDS_DOMAIN") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(100); diff --git a/tests/interop/ci/install_fastdds.sh b/tests/interop/ci/install_fastdds.sh index 530557be..9aad44f1 100755 --- a/tests/interop/ci/install_fastdds.sh +++ b/tests/interop/ci/install_fastdds.sh @@ -37,13 +37,18 @@ cmake_install() { # [extra cmake args...] } if [ ! -d "$PREFIX/include/fastdds" ]; then + echo "=== install_fastdds: foonathan_memory (vendor) ===" + clone_pinned foonathan_memory_vendor master foonathan + cmake_install foonathan -DBUILD_SHARED_LIBS=ON + echo "=== install_fastdds: Fast CDR ${FASTCDR_VERSION} ===" clone_pinned Fast-CDR "$FASTCDR_VERSION" fastcdr cmake_install fastcdr - echo "=== install_fastdds: Fast DDS ${FASTDDS_VERSION} (with bundled foonathan_memory) ===" + echo "=== install_fastdds: Fast DDS ${FASTDDS_VERSION} ===" clone_pinned Fast-DDS "$FASTDDS_VERSION" fastdds - cmake_install fastdds -DTHIRDPARTY=ON -DCOMPILE_EXAMPLES=OFF -DBUILD_TESTING=OFF + # foonathan + fastcdr from PREFIX; asio + tinyxml2 + openssl from the system. + cmake_install fastdds -DCOMPILE_EXAMPLES=OFF -DBUILD_TESTING=OFF fi export LD_LIBRARY_PATH="$PREFIX/lib:${LD_LIBRARY_PATH:-}" echo "LD_LIBRARY_PATH=$PREFIX/lib:${LD_LIBRARY_PATH:-}" >>"${GITHUB_ENV:-/dev/null}" From f4656c0c8e95e8fbd34bbff7f414c3bccce0a84a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandra=20Theresa=20Ke=C3=9Fler?= Date: Mon, 3 Aug 2026 22:43:11 +0200 Subject: [PATCH 4/4] ci(interop): bound Fast DDS build parallelism + cache vendor SDK (Refs #28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The all-core Fast DDS source build (XTypes/TypeObject TUs are memory-heavy) peaked past the hosted-runner limit and the runner was SIGTERM'd twice at ~54% (exit 143) on the unchanged SHA — a resource limit, not transient infra. Cap the build to 2 jobs (CMAKE_BUILD_PARALLEL_LEVEL + gradle --max-workers) so the peak fits, add actions/cache keyed on the pinned versions so the stack builds once, and set a 60-minute job timeout for a clean failure mode. Cyclone cell is already green (both directions + the #29 XTypes cases). --- .github/workflows/ci.yml | 11 +++++++++++ tests/interop/ci/install_fastdds.sh | 7 ++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b32b4558..40a4ff29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -302,6 +302,7 @@ jobs: name: interop (${{ matrix.vendor }}) needs: [fmt, clippy, interop-harness] runs-on: ubuntu-24.04 + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -313,6 +314,16 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" + # Cache the built vendor SDK prefix, keyed on the pinned versions in the + # install scripts. The first run builds from source (bounded parallelism + # to fit the runner); later runs restore and skip the build. + - name: Cache vendor SDK (${{ matrix.vendor }}) + uses: actions/cache@v4 + with: + path: | + ~/cyclonedds-install + ~/fastdds-install + key: interop-${{ matrix.vendor }}-${{ runner.os }}-${{ hashFiles(format('tests/interop/ci/install_{0}.sh', matrix.vendor)) }} - name: Install vendor (${{ matrix.vendor }}) run: bash tests/interop/ci/install_${{ matrix.vendor }}.sh - name: Run interop gate (${{ matrix.vendor }}) diff --git a/tests/interop/ci/install_fastdds.sh b/tests/interop/ci/install_fastdds.sh index 9aad44f1..9f05be41 100755 --- a/tests/interop/ci/install_fastdds.sh +++ b/tests/interop/ci/install_fastdds.sh @@ -19,6 +19,11 @@ WORK="${WORK:-$HOME/fastdds-src}" HERE="$(cd "$(dirname "$0")" && pwd)" mkdir -p "$WORK" +# Fast DDS' XTypes/TypeObject translation units are memory-heavy; an all-core +# build on a hosted runner peaks past the VM limit and the runner is +# SIGTERM'd (exit 143). Cap parallelism to keep the peak bounded. +export CMAKE_BUILD_PARALLEL_LEVEL="${BUILD_JOBS:-2}" + echo "=== install_fastdds: build deps ===" sudo apt-get update sudo apt-get install -y --no-install-recommends \ @@ -58,7 +63,7 @@ GEN_BIN="$PREFIX/share/fastddsgen/scripts/fastddsgen" if [ ! -x "$GEN_BIN" ]; then git clone --recurse-submodules --depth 1 --branch "$FASTDDSGEN_VERSION" \ https://github.com/eProsima/Fast-DDS-Gen.git "$WORK/fastddsgen" - ( cd "$WORK/fastddsgen" && ./gradlew assemble ) + ( cd "$WORK/fastddsgen" && ./gradlew assemble --max-workers=2 --no-daemon ) mkdir -p "$PREFIX/share/fastddsgen" cp -r "$WORK/fastddsgen/scripts" "$WORK/fastddsgen/share" "$PREFIX/share/fastddsgen/" 2>/dev/null || \ cp -r "$WORK/fastddsgen/." "$PREFIX/share/fastddsgen/"