From 64da3702c363dab4119833e1cd7afdeb03faee1b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 06:22:45 +0000 Subject: [PATCH 1/7] feat: add Linux x64 support (keep .NET 8) Add cross-platform native build and packaging so the bindings run on Linux x64 alongside the existing Windows x64 support, without changing the target framework (stays net8.0). Native build & CI - build/native-linux.sh: compile the CycloneDDS submodule + idlc on Linux, stage libddsc/libcycloneddsidl* .so and idlc into artifacts/native/linux-x64, and rewrite RPATH to $ORIGIN via patchelf so the flat NuGet tools/ layout resolves its .so dependencies. - CI: add a linux-build job that builds the native libs, then builds the managed solution (exercising build-time idlc codegen on Linux) and runs the runtime tests; the Windows job downloads the linux-x64 native artifacts and packs a cross-platform NuGet via the existing pack.ps1. - .gitignore: ignore build/native-linux/. Cross-platform code generator - IdlcRunner.FindIdlc/RunIdlc are now platform-aware: try idlc/idlc.exe and the win-x64/linux-x64 RID folders, set LD_LIBRARY_PATH to the idlc directory on Linux, and restore the Unix execute bit (NuGet does not preserve it) before launching idlc. Packaging & project files - Runtime.csproj: ship libddsc.so under runtimes/linux-x64/native and idlc + its .so deps under tools/ (Exists-guarded so a Windows-only pack still succeeds); update the package description. - Core.csproj and the native-consuming test projects + FeatureDemo: OS-guard the win-x64 native copy and add a linux-x64 copy via MSBuild IsOSPlatform conditions. Tests - ErrorHandlingTests: resolve the idlc path by RID/OS at runtime. - IdlcRunnerTests: use the platform idlc name and skip the Windows batch-file plumbing test on non-Windows. Native fork must be built from the pjanec/cyclonedds submodule; DllImport ("ddsc") resolves to libddsc.so on Linux with no source change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N7RoW2a2tBB2T8tDbNHB11 --- .github/workflows/ci.yml | 63 +++++++- .gitignore | 1 + build/native-linux.sh | 150 ++++++++++++++++++ examples/FeatureDemo/FeatureDemo.csproj | 6 +- src/CycloneDDS.Core/CycloneDDS.Core.csproj | 9 +- .../CycloneDDS.Runtime.csproj | 23 ++- .../CycloneDDS.CodeGen.Tests.csproj | 6 +- .../ErrorHandlingTests.cs | 9 +- .../IdlcRunnerTests.cs | 13 +- .../CycloneDDS.Core.Tests.csproj | 6 +- .../CycloneDDS.Native.Verify.csproj | 6 +- .../CycloneDDS.Runtime.Tests.csproj | 6 +- .../CycloneDDS.Schema.Tests.csproj | 6 +- .../DdsMonitor.Engine.Tests.csproj | 6 +- .../CycloneDDS.Compiler.Common/IdlcRunner.cs | 126 +++++++++++---- 15 files changed, 385 insertions(+), 51 deletions(-) create mode 100755 build/native-linux.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b066dd..026df53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,64 @@ on: workflow_dispatch: jobs: + # --------------------------------------------------------------------------- + # Linux: build the native CycloneDDS libraries + idlc, then build and test the + # managed code against them. Building the managed solution exercises the + # build-time IDL code generator (idlc) on Linux end-to-end, and the runtime + # tests exercise the DllImport("ddsc") -> libddsc.so path. The native artifacts + # are uploaded for the Windows pack job to fold into the cross-platform package. + # --------------------------------------------------------------------------- + linux-build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 # Required for Nerdbank.GitVersioning to determine version + submodules: recursive + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.0.x + + - name: Install native build prerequisites + run: sudo apt-get update && sudo apt-get install -y cmake build-essential patchelf + + - name: Display Tools Versions + run: | + dotnet --version + cmake --version + gcc --version + + - name: Build Native (linux-x64) + shell: bash + run: build/native-linux.sh Release + + - name: Build Managed Code + run: dotnet build CycloneDDS.NET.Core.slnf -c Release + + - name: Run Runtime Tests + run: dotnet test tests/CycloneDDS.Runtime.Tests/CycloneDDS.Runtime.Tests.csproj -c Release --no-build --logger "console;verbosity=normal" + + - name: Upload linux-x64 Native Artifacts + uses: actions/upload-artifact@v4 + with: + name: native-linux-x64 + path: artifacts/native/linux-x64/* + if-no-files-found: error + + # --------------------------------------------------------------------------- + # Windows: full build, test and pack via the existing pack.ps1 pipeline + # (builds win-x64 native, builds core, runs runtime tests, packs both the + # CycloneDDS.NET and DdsMonitor packages, builds the examples). The linux-x64 + # native artifacts produced above are downloaded first so the packed NuGet + # carries both win-x64 and linux-x64 runtime assets. + # --------------------------------------------------------------------------- build: runs-on: windows-latest - + needs: [ linux-build ] + env: # This ensures the build script can find CMake if not in default path (though it usually is on runners) CMAKE_GENERATOR: "Visual Studio 17 2022" @@ -32,6 +87,12 @@ jobs: dotnet --version cmake --version + - name: Download linux-x64 Native Artifacts + uses: actions/download-artifact@v4 + with: + name: native-linux-x64 + path: artifacts/native/linux-x64/ + - name: Build, Test, and Pack shell: pwsh run: .\build\pack.ps1 diff --git a/.gitignore b/.gitignore index 738e33f..ebc21e8 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ nunit-*.xml cyclone-compiled/ cyclone-compiled-debug/ build/native/ +build/native-linux/ test-extract/ test-output/ **/Generated/* diff --git a/build/native-linux.sh b/build/native-linux.sh new file mode 100755 index 0000000..b86fac3 --- /dev/null +++ b/build/native-linux.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -euo pipefail + +# =================================================================================== +# build/native-linux.sh +# +# PURPOSE: +# Compiles the native (C/C++) Cyclone DDS submodule for Linux and copies the +# resulting binaries (.so libraries + idlc) to the local 'artifacts' directory. +# This is a prerequisite for running managed tests or packing the NuGet package +# with linux-x64 support. It is the Linux counterpart of build/native-win.ps1. +# +# USAGE: +# ./build/native-linux.sh [Release|Debug] +# Default: Release +# =================================================================================== + +CONFIG="${1:-Release}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(dirname "$SCRIPT_DIR")" +SOURCE_DIR="$REPO_ROOT/cyclonedds" +BUILD_DIR="$REPO_ROOT/build/native-linux" +INSTALL_DIR="$REPO_ROOT/artifacts/native-install-linux" +ARTIFACTS_DIR="$REPO_ROOT/artifacts/native/linux-x64" + +echo "============================================================" +echo " Building Native CycloneDDS for Linux ($CONFIG)" +echo "============================================================" + +# ---------------------------------------------------------------- +# Prerequisites +# ---------------------------------------------------------------- +if ! command -v cmake &> /dev/null; then + echo "ERROR: cmake is not installed or not in PATH." >&2 + exit 1 +fi + +if ! command -v gcc &> /dev/null && ! command -v cc &> /dev/null; then + echo "ERROR: no C compiler (gcc/cc) found in PATH." >&2 + exit 1 +fi + +if [ ! -d "$SOURCE_DIR" ] || [ -z "$(ls -A "$SOURCE_DIR" 2>/dev/null)" ]; then + echo "ERROR: Native source directory is missing or empty: $SOURCE_DIR" >&2 + echo " Run: git submodule update --init --recursive" >&2 + exit 1 +fi + +mkdir -p "$BUILD_DIR" "$INSTALL_DIR" "$ARTIFACTS_DIR" + +# ---------------------------------------------------------------- +# [1/3] CMake Configure +# ---------------------------------------------------------------- +echo "" +echo "[1/3] Configuring CMake..." + +cmake -S "$SOURCE_DIR" -B "$BUILD_DIR" \ + -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" \ + -DCMAKE_BUILD_TYPE="$CONFIG" \ + -DBUILD_IDLC=ON \ + -DBUILD_TESTING=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DENABLE_SSL=OFF \ + -DENABLE_SHM=OFF \ + -DENABLE_SECURITY=OFF + +# ---------------------------------------------------------------- +# [2/3] Build & Install +# ---------------------------------------------------------------- +echo "" +echo "[2/3] Building & Installing..." + +NPROC="${NPROC:-$(nproc 2>/dev/null || echo 4)}" +cmake --build "$BUILD_DIR" --config "$CONFIG" -j "$NPROC" +cmake --install "$BUILD_DIR" --config "$CONFIG" + +# ---------------------------------------------------------------- +# [3/3] Copy Artifacts & Fix RPATH +# ---------------------------------------------------------------- +echo "" +echo "[3/3] Copying artifacts to $ARTIFACTS_DIR..." + +LIB_DIR="$INSTALL_DIR/lib" +BIN_DIR="$INSTALL_DIR/bin" +# Some distros install shared libs to lib64. +[ -d "$LIB_DIR" ] || LIB_DIR="$INSTALL_DIR/lib64" + +# Copy a shared library, resolving the SONAME symlink chain to the real file so +# we don't have to hard-code the version suffix. The file is staged as both +# .so (linker/convention name) and .so.0 (the runtime SONAME). +copy_lib() { + local base="$1" + local real="" + if [ -e "$LIB_DIR/${base}.so" ]; then + real="$(readlink -f "$LIB_DIR/${base}.so")" + elif [ -e "$LIB_DIR/${base}.so.0" ]; then + real="$(readlink -f "$LIB_DIR/${base}.so.0")" + else + # Last resort: pick the most specific versioned file available. + real="$(ls -1 "$LIB_DIR/${base}.so".* 2>/dev/null | sort | tail -n1 || true)" + fi + + if [ -z "$real" ] || [ ! -e "$real" ]; then + echo " [-] Missing ${base}.so* in $LIB_DIR" >&2 + return 1 + fi + + cp -f "$real" "$ARTIFACTS_DIR/${base}.so" + cp -f "$real" "$ARTIFACTS_DIR/${base}.so.0" + echo " [+] ${base}.so / ${base}.so.0" +} + +# Runtime library and IDL-compiler support libraries. +copy_lib libddsc +copy_lib libcycloneddsidl +copy_lib libcycloneddsidlc +copy_lib libcycloneddsidljson + +# IDL compiler executable. +if [ -f "$BIN_DIR/idlc" ]; then + cp -f "$BIN_DIR/idlc" "$ARTIFACTS_DIR/" + chmod +x "$ARTIFACTS_DIR/idlc" + echo " [+] idlc" +else + echo " [-] Missing idlc in $BIN_DIR" >&2 + exit 1 +fi + +# Fix RPATH: CMake sets RPATH to $ORIGIN/../lib (bin/ -> lib/), but in the flat +# NuGet tools/ directory every file sits side by side. Rewrite RPATH to $ORIGIN/ +# so the dynamic linker finds the .so dependencies next to the executable. +if command -v patchelf &> /dev/null; then + echo "" + echo " [+] Fixing RPATH to \$ORIGIN/..." + chmod +w "$ARTIFACTS_DIR/"*.so* "$ARTIFACTS_DIR/idlc" 2>/dev/null || true + for f in "$ARTIFACTS_DIR/"*.so* "$ARTIFACTS_DIR/idlc"; do + [ -e "$f" ] || continue + patchelf --set-rpath '$ORIGIN/' "$f" 2>/dev/null || true + done + echo " [+] RPATH fixed." +else + echo " [!] patchelf not found. Run: sudo apt-get install patchelf" + echo " [!] Without patchelf, LD_LIBRARY_PATH must point at the idlc directory at runtime." + echo " [!] (IdlcRunner sets this automatically; DllImport('ddsc') resolves via the co-located .so.)" +fi + +echo "" +echo "Native build complete." +echo "Artifacts staged at: $ARTIFACTS_DIR" +ls -la "$ARTIFACTS_DIR" diff --git a/examples/FeatureDemo/FeatureDemo.csproj b/examples/FeatureDemo/FeatureDemo.csproj index 67ea7d5..9ad25f3 100644 --- a/examples/FeatureDemo/FeatureDemo.csproj +++ b/examples/FeatureDemo/FeatureDemo.csproj @@ -21,7 +21,11 @@ - + + PreserveNewest + %(Filename)%(Extension) + + PreserveNewest %(Filename)%(Extension) diff --git a/src/CycloneDDS.Core/CycloneDDS.Core.csproj b/src/CycloneDDS.Core/CycloneDDS.Core.csproj index 452b585..2fed15b 100644 --- a/src/CycloneDDS.Core/CycloneDDS.Core.csproj +++ b/src/CycloneDDS.Core/CycloneDDS.Core.csproj @@ -9,7 +9,7 @@ false - + PreserveNewest %(RecursiveDir)%(Filename)%(Extension) @@ -19,4 +19,11 @@ %(RecursiveDir)%(Filename)%(Extension) + + + + PreserveNewest + %(RecursiveDir)%(Filename)%(Extension) + + \ No newline at end of file diff --git a/src/CycloneDDS.Runtime/CycloneDDS.Runtime.csproj b/src/CycloneDDS.Runtime/CycloneDDS.Runtime.csproj index ae63d4b..ca1a998 100644 --- a/src/CycloneDDS.Runtime/CycloneDDS.Runtime.csproj +++ b/src/CycloneDDS.Runtime/CycloneDDS.Runtime.csproj @@ -7,14 +7,23 @@ true CS8600;CS8601;CS8603;CS8604 CycloneDDS.NET - Cyclone DDS Runtime bindings for .NET (Win64) + Cyclone DDS Runtime bindings for .NET (Windows x64, Linux x64) true - + - + + + + + + + + + diff --git a/tests/CycloneDDS.CodeGen.Tests/CycloneDDS.CodeGen.Tests.csproj b/tests/CycloneDDS.CodeGen.Tests/CycloneDDS.CodeGen.Tests.csproj index 5bf4f22..29d226a 100644 --- a/tests/CycloneDDS.CodeGen.Tests/CycloneDDS.CodeGen.Tests.csproj +++ b/tests/CycloneDDS.CodeGen.Tests/CycloneDDS.CodeGen.Tests.csproj @@ -28,7 +28,11 @@ - + + PreserveNewest + %(Filename)%(Extension) + + PreserveNewest %(Filename)%(Extension) diff --git a/tests/CycloneDDS.CodeGen.Tests/ErrorHandlingTests.cs b/tests/CycloneDDS.CodeGen.Tests/ErrorHandlingTests.cs index c6cd57a..995e43b 100644 --- a/tests/CycloneDDS.CodeGen.Tests/ErrorHandlingTests.cs +++ b/tests/CycloneDDS.CodeGen.Tests/ErrorHandlingTests.cs @@ -128,8 +128,13 @@ string field2 // Missing semicolons var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); // Traverse up 5 levels: net8.0 -> Debug -> bin -> CycloneDDS.CodeGen.Tests -> tests -> RepoRoot var repoRoot = Path.GetFullPath(Path.Combine(assemblyDir, "..", "..", "..", "..", "..")); - var idlcPath = Path.Combine(repoRoot, "artifacts", "native", "win-x64", "idlc.exe"); - + + // Native artifacts are laid out per RID; the executable name differs by OS. + bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + string rid = isWindows ? "win-x64" : "linux-x64"; + string idlcName = isWindows ? "idlc.exe" : "idlc"; + var idlcPath = Path.Combine(repoRoot, "artifacts", "native", rid, idlcName); + runner.IdlcPathOverride = idlcPath; var result = runner.RunIdlc(tempIdl, Path.GetTempPath()); Assert.NotEqual(0, result.ExitCode); diff --git a/tests/CycloneDDS.CodeGen.Tests/IdlcRunnerTests.cs b/tests/CycloneDDS.CodeGen.Tests/IdlcRunnerTests.cs index aa910e5..3654bd6 100644 --- a/tests/CycloneDDS.CodeGen.Tests/IdlcRunnerTests.cs +++ b/tests/CycloneDDS.CodeGen.Tests/IdlcRunnerTests.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Runtime.InteropServices; using Xunit; using CycloneDDS.CodeGen; using CycloneDDS.Compiler.Common; @@ -67,9 +68,11 @@ public void FindIdlc_ReturnsOverride_WhenExists() [Fact] public void FindIdlc_ChecksCurrentDirectory() { - // Create a dummy idlc in the current directory (where tests run) + // Create a dummy idlc in the current directory (where tests run). + // The executable name is platform-specific (idlc.exe on Windows, idlc elsewhere). var currentDir = AppDomain.CurrentDomain.BaseDirectory; - var dummyIdlc = Path.Combine(currentDir, "idlc.exe"); + var idlcName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "idlc.exe" : "idlc"; + var dummyIdlc = Path.Combine(currentDir, idlcName); // Only run this test if idlc doesn't already exist there (to avoid messing up environment) if (!File.Exists(dummyIdlc)) @@ -91,6 +94,12 @@ public void FindIdlc_ChecksCurrentDirectory() [Fact] public void RunIdlc_ExecutesProcess_AndFindsFiles() { + // The mock idlc is a Windows batch file, so this plumbing test only + // runs on Windows. Real idlc invocation is exercised on Linux by the + // integration tests that point at the built native idlc. + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return; + // Setup var idlPath = Path.Combine(_tempDir, "TestTopic.idl"); File.WriteAllText(idlPath, "struct TestTopic {};"); diff --git a/tests/CycloneDDS.Core.Tests/CycloneDDS.Core.Tests.csproj b/tests/CycloneDDS.Core.Tests/CycloneDDS.Core.Tests.csproj index bfd96ee..8b3f157 100644 --- a/tests/CycloneDDS.Core.Tests/CycloneDDS.Core.Tests.csproj +++ b/tests/CycloneDDS.Core.Tests/CycloneDDS.Core.Tests.csproj @@ -27,7 +27,11 @@ - + + PreserveNewest + %(Filename)%(Extension) + + PreserveNewest %(Filename)%(Extension) diff --git a/tests/CycloneDDS.Native.Verify/CycloneDDS.Native.Verify.csproj b/tests/CycloneDDS.Native.Verify/CycloneDDS.Native.Verify.csproj index 2f2907b..bc9cf96 100644 --- a/tests/CycloneDDS.Native.Verify/CycloneDDS.Native.Verify.csproj +++ b/tests/CycloneDDS.Native.Verify/CycloneDDS.Native.Verify.csproj @@ -28,7 +28,11 @@ - + + PreserveNewest + %(Filename)%(Extension) + + PreserveNewest %(Filename)%(Extension) diff --git a/tests/CycloneDDS.Runtime.Tests/CycloneDDS.Runtime.Tests.csproj b/tests/CycloneDDS.Runtime.Tests/CycloneDDS.Runtime.Tests.csproj index aa188f7..635b21e 100644 --- a/tests/CycloneDDS.Runtime.Tests/CycloneDDS.Runtime.Tests.csproj +++ b/tests/CycloneDDS.Runtime.Tests/CycloneDDS.Runtime.Tests.csproj @@ -31,7 +31,11 @@ - + + PreserveNewest + %(Filename)%(Extension) + + PreserveNewest %(Filename)%(Extension) diff --git a/tests/CycloneDDS.Schema.Tests/CycloneDDS.Schema.Tests.csproj b/tests/CycloneDDS.Schema.Tests/CycloneDDS.Schema.Tests.csproj index 3fbe5a8..499a67e 100644 --- a/tests/CycloneDDS.Schema.Tests/CycloneDDS.Schema.Tests.csproj +++ b/tests/CycloneDDS.Schema.Tests/CycloneDDS.Schema.Tests.csproj @@ -27,7 +27,11 @@ - + + PreserveNewest + %(Filename)%(Extension) + + PreserveNewest %(Filename)%(Extension) diff --git a/tests/DdsMonitor.Engine.Tests/DdsMonitor.Engine.Tests.csproj b/tests/DdsMonitor.Engine.Tests/DdsMonitor.Engine.Tests.csproj index 863bb2a..98edf76 100644 --- a/tests/DdsMonitor.Engine.Tests/DdsMonitor.Engine.Tests.csproj +++ b/tests/DdsMonitor.Engine.Tests/DdsMonitor.Engine.Tests.csproj @@ -28,7 +28,11 @@ - + + PreserveNewest + %(Filename)%(Extension) + + PreserveNewest %(Filename)%(Extension) diff --git a/tools/CycloneDDS.Compiler.Common/IdlcRunner.cs b/tools/CycloneDDS.Compiler.Common/IdlcRunner.cs index 5be03e3..fcc1e26 100644 --- a/tools/CycloneDDS.Compiler.Common/IdlcRunner.cs +++ b/tools/CycloneDDS.Compiler.Common/IdlcRunner.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics; using System.IO; +using System.Runtime.InteropServices; namespace CycloneDDS.Compiler.Common { @@ -9,42 +10,66 @@ public class IdlcRunner public string? IdlcPathOverride { get; set; } public string? IdlcExtraArgs { get; set; } + // The idlc executable and the native RID sub-directory both differ per + // platform: idlc.exe under win-x64 on Windows, idlc under linux-x64 on + // Linux. Every lookup below iterates these so the same search logic works + // on either OS. + private static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + private static string[] IdlcNames => IsWindows ? new[] { "idlc.exe" } : new[] { "idlc" }; + private static string[] NativeRids => IsWindows ? new[] { "win-x64" } : new[] { "linux-x64" }; + public string FindIdlc() { if (!string.IsNullOrEmpty(IdlcPathOverride)) { if (File.Exists(IdlcPathOverride)) return IdlcPathOverride; - throw new FileNotFoundException($"idlc.exe not found at override path: {IdlcPathOverride}"); + throw new FileNotFoundException($"idlc not found at override path: {IdlcPathOverride}"); } - // Check current directory (where DLLs are) + // Check current directory (where DLLs / .so files are copied) string currentDir = AppDomain.CurrentDomain.BaseDirectory; - string localIdlc = Path.Combine(currentDir, "idlc.exe"); - if (File.Exists(localIdlc)) return localIdlc; + foreach (var name in IdlcNames) + { + string localIdlc = Path.Combine(currentDir, name); + if (File.Exists(localIdlc)) return localIdlc; + } - // Check NuGet package location relative to tools/ (tools/ -> ../runtimes/win-x64/native/) - try + // Check NuGet package location relative to tools/ (tools/ -> ../runtimes/{rid}/native/) + foreach (var rid in NativeRids) { - string nugetNativePath = Path.Combine(currentDir, "..", "runtimes", "win-x64", "native", "idlc.exe"); - if (File.Exists(nugetNativePath)) return Path.GetFullPath(nugetNativePath); + foreach (var name in IdlcNames) + { + try + { + string nugetNativePath = Path.Combine(currentDir, "..", "runtimes", rid, "native", name); + if (File.Exists(nugetNativePath)) return Path.GetFullPath(nugetNativePath); + } + catch { } + } } - catch {} // DEV: Check workspace location (for tests/dev) - // Iterate up 6 levels looking for cyclonedds/install/bin/idlc.exe OR cyclone-compiled/bin/idlc.exe + // Iterate up 6 levels looking for cyclonedds/install/bin, cyclone-compiled/bin, + // or artifacts/native/{rid} — trying each platform's executable name. var searchDir = new DirectoryInfo(currentDir); for (int i = 0; i < 6; i++) { if (searchDir == null) break; - - string checkPath = Path.Combine(searchDir.FullName, "cyclonedds", "install", "bin", "idlc.exe"); - if (File.Exists(checkPath)) return checkPath; - - string repoPath = Path.Combine(searchDir.FullName, "cyclone-compiled", "bin", "idlc.exe"); - if (File.Exists(repoPath)) return repoPath; - repoPath = Path.Combine(searchDir.FullName, "artifacts", "native", "win-x64", "idlc.exe"); - if (File.Exists(repoPath)) return repoPath; + foreach (var name in IdlcNames) + { + string checkPath = Path.Combine(searchDir.FullName, "cyclonedds", "install", "bin", name); + if (File.Exists(checkPath)) return checkPath; + + string repoPath = Path.Combine(searchDir.FullName, "cyclone-compiled", "bin", name); + if (File.Exists(repoPath)) return repoPath; + + foreach (var rid in NativeRids) + { + string artifactPath = Path.Combine(searchDir.FullName, "artifacts", "native", rid, name); + if (File.Exists(artifactPath)) return artifactPath; + } + } searchDir = searchDir.Parent; } @@ -53,33 +78,39 @@ public string FindIdlc() string? cycloneHome = Environment.GetEnvironmentVariable("CYCLONEDDS_HOME"); if (!string.IsNullOrEmpty(cycloneHome)) { - string path = Path.Combine(cycloneHome, "bin", "idlc.exe"); - if (File.Exists(path)) - return path; - - // Try without bin? - path = Path.Combine(cycloneHome, "idlc.exe"); - if (File.Exists(path)) - return path; + foreach (var name in IdlcNames) + { + string path = Path.Combine(cycloneHome, "bin", name); + if (File.Exists(path)) + return path; + + // Try without bin? + path = Path.Combine(cycloneHome, name); + if (File.Exists(path)) + return path; + } } - + // Check PATH string? pathEnv = Environment.GetEnvironmentVariable("PATH"); if (pathEnv != null) { foreach (var dir in pathEnv.Split(Path.PathSeparator)) { - try + foreach (var name in IdlcNames) { - string path = Path.Combine(dir, "idlc.exe"); - if (File.Exists(path)) - return path; + try + { + string path = Path.Combine(dir, name); + if (File.Exists(path)) + return path; + } + catch { /* Ignore invalid paths in PATH */ } } - catch { /* Ignore invalid paths in PATH */ } } } - - throw new FileNotFoundException("idlc.exe not found. Set CYCLONEDDS_HOME or add to PATH."); + + throw new FileNotFoundException("idlc executable not found. Set CYCLONEDDS_HOME or add to PATH."); } public IdlcResult RunIdlc(string idlFilePath, string outputDir, string? includePath = null) @@ -100,7 +131,32 @@ public IdlcResult RunIdlc(string idlFilePath, string outputDir, string? includeP RedirectStandardError = true, CreateNoWindow = true }; - + + if (!IsWindows) + { + // NuGet packages do not preserve the Unix execute bit, so an idlc + // restored from the package may not be runnable. Restore it (best effort). + try + { + var mode = File.GetUnixFileMode(idlcPath); + File.SetUnixFileMode(idlcPath, + mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); + } + catch { /* best effort — may already be executable, or FS may not support it */ } + + // idlc depends on the libcycloneddsidl*.so libraries shipped alongside + // it. The native build normally rewrites RPATH to $ORIGIN so they resolve, + // but prepend the idlc directory to LD_LIBRARY_PATH as a fallback (e.g. + // when patchelf was unavailable at native build time). + string? idlcDir = Path.GetDirectoryName(Path.GetFullPath(idlcPath)); + if (!string.IsNullOrEmpty(idlcDir)) + { + string existing = Environment.GetEnvironmentVariable("LD_LIBRARY_PATH") ?? string.Empty; + startInfo.Environment["LD_LIBRARY_PATH"] = + existing.Length == 0 ? idlcDir : idlcDir + Path.PathSeparator + existing; + } + } + if (!string.IsNullOrWhiteSpace(IdlcExtraArgs)) { // Simple split by whitespace is sufficient for most compiler flags like "-Werror" From ecc11ad2c2ddc42e579664b2622160fb8df59b80 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 06:50:53 +0000 Subject: [PATCH 2/7] fix: make Linux build and DDS event listeners actually work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by building and running the full test suite on Linux with the .NET SDK (net8.0 target, .NET 10 SDK toolchain). Case-sensitive project references - Four ProjectReference paths used "..\..\Src\..." (capital S). Windows' case-insensitive filesystem hid this; on Linux the references failed and CycloneDDS.CodeGen/Schema.Tests could not resolve their dependencies. Corrected to lowercase "src". Listener callback ABI (the real fix) - DdsOnPublicationMatched / DdsOnSubscriptionMatched passed the 24-byte status struct by `ref`. That matches the Windows x64 ABI (>16-byte structs passed by implicit pointer) but not the Linux System V ABI (passed by value on the stack), which shifted `arg` into the wrong register — GCHandle.FromIntPtr then threw and the callback was silently dropped. As a result PublicationMatched/SubscriptionMatched events and WaitForReaderAsync never fired on Linux. Declaring the status by value lets the .NET marshaller emit the correct per-platform ABI; the matching handler signatures in DdsWriter/DdsReader are updated to match. CI toolchain - The code uses C# 13 features (ref structs in async methods) that require the .NET 10 SDK compiler, so CI now installs both the 8.0 and 10.0 SDKs (10.0 builds, 8.0 runs the net8.0 tests) instead of pinning 8.0.x. Cleanup - Guard the idlc chmod with OperatingSystem.IsWindows() to silence CA1416. Verified on Linux: native build, full managed build, and tests all green — Runtime 145/1 skip, CodeGen 199, Core 19, Schema 12, IdlImporter 28, Compiler.Common 8. The two FeatureDemo example-test failures are pre-existing and environmental (Spectre.Console needs a TTY; the StockPublisher timing test the upstream author already ignores in CI) and are not run by the pack/CI pipeline. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N7RoW2a2tBB2T8tDbNHB11 --- .github/workflows/ci.yml | 16 ++++++++++++++-- src/CycloneDDS.Runtime/DdsReader.cs | 2 +- src/CycloneDDS.Runtime/DdsWriter.cs | 2 +- src/CycloneDDS.Runtime/Interop/DdsApi.cs | 13 +++++++++++-- .../CycloneDDS.Runtime.Tests.csproj | 4 ++-- .../CycloneDDS.Schema.Tests.csproj | 2 +- .../CycloneDDS.CodeGen/CycloneDDS.CodeGen.csproj | 4 ++-- tools/CycloneDDS.Compiler.Common/IdlcRunner.cs | 10 +++++++--- 8 files changed, 39 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 026df53..825bb43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,13 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 8.0.x + # net8.0 is the target framework, but the code uses C# 13 language + # features (e.g. ref structs in async methods), which require the .NET 10 + # SDK's compiler. Install both: the 10.0 SDK builds, the 8.0 runtime runs + # the net8.0 test assemblies. + dotnet-version: | + 8.0.x + 10.0.x - name: Install native build prerequisites run: sudo apt-get update && sudo apt-get install -y cmake build-essential patchelf @@ -80,7 +86,13 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 8.0.x + # net8.0 is the target framework, but the code uses C# 13 language + # features (e.g. ref structs in async methods), which require the .NET 10 + # SDK's compiler. Install both: the 10.0 SDK builds, the 8.0 runtime runs + # the net8.0 test assemblies. + dotnet-version: | + 8.0.x + 10.0.x - name: Display Tools Versions run: | diff --git a/src/CycloneDDS.Runtime/DdsReader.cs b/src/CycloneDDS.Runtime/DdsReader.cs index 1e8a462..f7f6b4e 100644 --- a/src/CycloneDDS.Runtime/DdsReader.cs +++ b/src/CycloneDDS.Runtime/DdsReader.cs @@ -261,7 +261,7 @@ private void EnsureListenerAttached() } } - private static void OnSubscriptionMatched(int reader, ref DdsApi.DdsSubscriptionMatchedStatus status, IntPtr arg) + private static void OnSubscriptionMatched(int reader, DdsApi.DdsSubscriptionMatchedStatus status, IntPtr arg) { if (arg == IntPtr.Zero) return; try diff --git a/src/CycloneDDS.Runtime/DdsWriter.cs b/src/CycloneDDS.Runtime/DdsWriter.cs index d802429..7f8815f 100644 --- a/src/CycloneDDS.Runtime/DdsWriter.cs +++ b/src/CycloneDDS.Runtime/DdsWriter.cs @@ -330,7 +330,7 @@ private void EnsureListenerAttached() } // [MonoPInvokeCallback(typeof(DdsApi.DdsOnPublicationMatched))] - private static void OnPublicationMatched(int writer, ref DdsApi.DdsPublicationMatchedStatus status, IntPtr arg) + private static void OnPublicationMatched(int writer, DdsApi.DdsPublicationMatchedStatus status, IntPtr arg) { if (arg == IntPtr.Zero) return; try diff --git a/src/CycloneDDS.Runtime/Interop/DdsApi.cs b/src/CycloneDDS.Runtime/Interop/DdsApi.cs index 98e0a82..c73e63f 100644 --- a/src/CycloneDDS.Runtime/Interop/DdsApi.cs +++ b/src/CycloneDDS.Runtime/Interop/DdsApi.cs @@ -401,11 +401,20 @@ public struct DdsSubscriptionMatchedStatus public long LastPublicationHandle; } + // NOTE: status is passed BY VALUE, matching the C signature + // void (*)(dds_entity_t, const dds_..._matched_status_t status, void *arg) + // These 24-byte structs are >16 bytes, so the Windows x64 ABI passes them by an + // implicit pointer (which a `ref` parameter happened to match) while the Linux + // System V ABI passes them by value on the stack. Declaring the parameter `ref` + // therefore worked only on Windows; on Linux it shifted `arg` into the wrong + // register, so GCHandle.FromIntPtr(arg) threw and the callback was silently + // dropped. Declaring it by value lets the .NET marshaller emit the correct + // per-platform ABI on both. [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - public delegate void DdsOnPublicationMatched(int writer, ref DdsPublicationMatchedStatus status, IntPtr arg); + public delegate void DdsOnPublicationMatched(int writer, DdsPublicationMatchedStatus status, IntPtr arg); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - public delegate void DdsOnSubscriptionMatched(int reader, ref DdsSubscriptionMatchedStatus status, IntPtr arg); + public delegate void DdsOnSubscriptionMatched(int reader, DdsSubscriptionMatchedStatus status, IntPtr arg); [DllImport(DLL_NAME)] public extern static void dds_lset_publication_matched(IntPtr listener, DdsOnPublicationMatched callback); diff --git a/tests/CycloneDDS.Runtime.Tests/CycloneDDS.Runtime.Tests.csproj b/tests/CycloneDDS.Runtime.Tests/CycloneDDS.Runtime.Tests.csproj index 635b21e..088a9e9 100644 --- a/tests/CycloneDDS.Runtime.Tests/CycloneDDS.Runtime.Tests.csproj +++ b/tests/CycloneDDS.Runtime.Tests/CycloneDDS.Runtime.Tests.csproj @@ -24,8 +24,8 @@ - - + + diff --git a/tests/CycloneDDS.Schema.Tests/CycloneDDS.Schema.Tests.csproj b/tests/CycloneDDS.Schema.Tests/CycloneDDS.Schema.Tests.csproj index 499a67e..777f5ee 100644 --- a/tests/CycloneDDS.Schema.Tests/CycloneDDS.Schema.Tests.csproj +++ b/tests/CycloneDDS.Schema.Tests/CycloneDDS.Schema.Tests.csproj @@ -22,7 +22,7 @@ - + diff --git a/tools/CycloneDDS.CodeGen/CycloneDDS.CodeGen.csproj b/tools/CycloneDDS.CodeGen/CycloneDDS.CodeGen.csproj index 73ee5ab..5b4f82a 100644 --- a/tools/CycloneDDS.CodeGen/CycloneDDS.CodeGen.csproj +++ b/tools/CycloneDDS.CodeGen/CycloneDDS.CodeGen.csproj @@ -16,7 +16,7 @@ - - + + diff --git a/tools/CycloneDDS.Compiler.Common/IdlcRunner.cs b/tools/CycloneDDS.Compiler.Common/IdlcRunner.cs index fcc1e26..7680f2d 100644 --- a/tools/CycloneDDS.Compiler.Common/IdlcRunner.cs +++ b/tools/CycloneDDS.Compiler.Common/IdlcRunner.cs @@ -136,11 +136,15 @@ public IdlcResult RunIdlc(string idlFilePath, string outputDir, string? includeP { // NuGet packages do not preserve the Unix execute bit, so an idlc // restored from the package may not be runnable. Restore it (best effort). + // The inner OperatingSystem guard is what the CA1416 analyzer recognizes. try { - var mode = File.GetUnixFileMode(idlcPath); - File.SetUnixFileMode(idlcPath, - mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); + if (!OperatingSystem.IsWindows()) + { + var mode = File.GetUnixFileMode(idlcPath); + File.SetUnixFileMode(idlcPath, + mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); + } } catch { /* best effort — may already be executable, or FS may not support it */ } From 8319b4c6dcf0ab60e3c34528152b3a5f9e4b46b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 06:57:54 +0000 Subject: [PATCH 3/7] chore: bump minor to 0.3 and document Linux support in changelog Adding a new supported platform (Linux x64) is a feature, and the listener-callback ABI fix changes a public interop delegate signature, so under SemVer (pre-1.0) this is a minor bump. Nerdbank.GitVersioning derives the patch component from git height automatically. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N7RoW2a2tBB2T8tDbNHB11 --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ version.json | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7d5f3c..ea8aad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## unreleased nothing yet +## 0.3.0 + +### Added +- **Linux x64 support.** The package now ships native assets for `linux-x64` + (`libddsc.so`) alongside `win-x64`, and bundles the Linux `idlc` plus its + `.so` dependencies under `tools/`. A new `build/native-linux.sh` compiles the + native CycloneDDS libraries and `idlc` and rewrites their RPATH to `$ORIGIN` + so they resolve in the flat NuGet `tools/` layout. `IdlcRunner` is now + platform-aware — it locates `idlc`/`idlc.exe` under the correct RID, sets + `LD_LIBRARY_PATH`, and restores the Unix execute bit that NuGet does not + preserve — and the MSBuild native-asset copies are guarded by + `IsOSPlatform`. The target framework remains `net8.0`. + +### Fixed +- **DDS matched-status events never fired on Linux.** The publication- and + subscription-matched listener callbacks passed their 24-byte status struct by + `ref`, which matches the Windows x64 ABI but not the Linux System V ABI (where + structs larger than 16 bytes are passed by value on the stack), so `arg` was + read from the wrong register and every callback was silently dropped. The + status is now passed by value, letting the marshaller emit the correct ABI on + both platforms, so `PublicationMatched`, `SubscriptionMatched` and + `WaitForReaderAsync` work on Linux. +- **Mis-cased project references.** Several `ProjectReference` paths used + `..\..\Src\...` (capital `S`), which resolved only on case-insensitive + (Windows) filesystems; corrected to `src` so the solution builds on Linux. + +### Changed +- CI builds native libraries on both Windows and Linux and produces a single + cross-platform package. It installs both the .NET 8 and .NET 10 SDKs: the code + uses C# 13 language features that require the newer compiler, while the target + framework stays `net8.0`. +- Package description updated from "Win64" to "Windows x64, Linux x64". + ## 0.2.3 ### Fixed diff --git a/version.json b/version.json index cd39102..db804ce 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "0.2", + "version": "0.3", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/master$", From 2aec2638259c86d14caf5ffe5e5752cd459bbb69 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 08:09:49 +0000 Subject: [PATCH 4/7] docs: stamp changelog with the 0.3.1 release version Matches the Nerdbank.GitVersioning output on main after a squash merge (single 0.3 commit -> git height 1 -> 0.3.1). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N7RoW2a2tBB2T8tDbNHB11 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea8aad2..77d9665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## unreleased nothing yet -## 0.3.0 +## 0.3.1 ### Added - **Linux x64 support.** The package now ships native assets for `linux-x64` From 8d7082221da162f00f34197c7c8394c0d6b1b98c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 08:31:05 +0000 Subject: [PATCH 5/7] fix: make the ddsmonitor global tool cross-platform (Linux + Windows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DdsMonitor tool bundled its native ddsc library only via the transitive Content copy from CycloneDDS.Core, which is OS-guarded — so a tool packed on Windows (as CI does) carried only ddsc.dll and threw DllNotFoundException when installed on Linux (and vice-versa). Bundle BOTH platforms' runtime native into the tool payload: the transitive copy still provides the pack host's native, and new Exists/OS-guarded Content items add the other platform's (linux-x64 libddsc.so when packing on Windows, win-x64 ddsc.dll when packing on Linux). Native libs sit flat in the tool's app-base dir, so DllImport("ddsc") resolves the right file per OS. Pack="false" keeps them in the tool payload (via CopyToOutputDirectory) without duplicating them into content/ and contentFiles/. Verified by packing on Linux with a win-x64 stub: the tool payload contains both tools/net8.0/any/ddsc.dll and tools/net8.0/any/libddsc.so(.0), and a normal Linux build (no other-platform native present) is unaffected. IdlImporter was already cross-platform via the package's tools/ idlc; confirmed by importing IDL to C# end-to-end on Linux. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N7RoW2a2tBB2T8tDbNHB11 --- CHANGELOG.md | 6 ++++ .../DdsMonitor.Blazor/DdsMonitor.csproj | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77d9665..73f4470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ nothing yet `LD_LIBRARY_PATH`, and restores the Unix execute bit that NuGet does not preserve — and the MSBuild native-asset copies are guarded by `IsOSPlatform`. The target framework remains `net8.0`. +- **`ddsmonitor` global tool runs on Linux.** Its tool payload now bundles both + the win-x64 (`ddsc.dll`) and linux-x64 (`libddsc.so`) native runtime, so + `dotnet tool install -g CycloneDDS.NET.DdsMonitor` works on either OS. + Previously the tool carried only the pack host's native and would fail with a + `DllNotFoundException` on the other platform. The bundled `IdlImporter` also + works on Linux via the platform-aware `idlc` shipped in the package's `tools/`. ### Fixed - **DDS matched-status events never fired on Linux.** The publication- and diff --git a/tools/DdsMonitor/DdsMonitor.Blazor/DdsMonitor.csproj b/tools/DdsMonitor/DdsMonitor.Blazor/DdsMonitor.csproj index 919f620..2631ba1 100644 --- a/tools/DdsMonitor/DdsMonitor.Blazor/DdsMonitor.csproj +++ b/tools/DdsMonitor/DdsMonitor.Blazor/DdsMonitor.csproj @@ -27,6 +27,40 @@ + + + + + PreserveNewest + libddsc.so + + + PreserveNewest + libddsc.so.0 + + + + PreserveNewest + ddsc.dll + + +