From a305e5b0a97efab93b4507a8642350911fce8e58 Mon Sep 17 00:00:00 2001 From: Justin Kovacich Date: Wed, 29 Apr 2026 15:58:02 -0400 Subject: [PATCH] phase 20h: TX-direction SD wire-format conformance + CI gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two TX-direction tests covering simple-someip's SD emit path: `tx_announcement_loop_emits_wire_format_offer` (no docker, CI-gated): drives Server::announcement_loop and captures the emitted bytes on a second multicast socket joined to the SD group. Asserts every field of the SOME/IP envelope (service/method/message-type/protocol+iface versions, return code), the SD flags (unicast set), the OfferService entry body (service+instance+major+minor+TTL>0), and the IPv4 endpoint option (interface, port, UDP). Catches silent regressions in the emit path without requiring vsomeip up. `vsomeip_sees_simple_someip_offer_service` (full cross-impl, optional): keeps the existing docker-based subscriber test for cross-impl validation when run on a second host. Module docs now record the same-host caveat we hit: vsomeip's routing-host architecture binds both endpoints to 0.0.0.0:30490 with SO_REUSEPORT, and same-host multicast delivery between two such instances is non-deterministic (reproduced with vsomeip-offerer → vsomeip-subscriber on the same box). The docker test should be run on a second host sharing the multicast-capable network. CI: new step in the `test` job flips MULTICAST on `lo` and runs the no-docker test with `--ignored --exact` so only that test runs (the docker-dependent ignored tests stay skipped). Both vsomeip JSON configs aligned to multicast 239.255.0.255 to match simple-someip's hardcoded MULTICAST_IP. The subscriber.cpp / subscriber.json / entrypoint.sh role dispatcher round out the docker image so both offerer and subscriber roles ship in one container. Follow-up: simple-someip's SD MULTICAST_IP is hardcoded to the Luminar-internal 239.255.0.255; making it configurable would let us run vsomeip with its spec-default 224.0.23.0 for stricter conformance. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 14 + tests/data/vsomeip-offerer/CMakeLists.txt | 19 +- tests/data/vsomeip-offerer/Dockerfile | 21 +- tests/data/vsomeip-offerer/entrypoint.sh | 48 ++- tests/data/vsomeip-offerer/offerer.json | 3 +- tests/data/vsomeip-offerer/subscriber.cpp | 94 +++++ tests/data/vsomeip-offerer/subscriber.json | 35 ++ tests/vsomeip_sd_compat.rs | 455 ++++++++++++++++++++- 8 files changed, 656 insertions(+), 33 deletions(-) create mode 100644 tests/data/vsomeip-offerer/subscriber.cpp create mode 100644 tests/data/vsomeip-offerer/subscriber.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eab10af..789052c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,6 +141,20 @@ jobs: cargo doc --no-deps --all-features - name: No-alloc witness (explicit gate) run: cargo test --features client,bare_metal --test no_alloc_witness + - name: SD wire-format conformance (TX direction) + # `tx_announcement_loop_emits_wire_format_offer` is `#[ignore]`'d by + # default because it needs an interface with the `MULTICAST` link + # flag. CI's `lo` lacks it; flip it on, point the test at + # 127.0.0.1, and run just this one test (the rest of the file's + # ignored tests need an external vsomeip docker container — they + # stay skipped). + run: | + sudo ip link set lo multicast on + SIMPLE_SOMEIP_TEST_INTERFACE=127.0.0.1 \ + cargo test --features client-tokio,server-tokio \ + --test vsomeip_sd_compat \ + tx_announcement_loop_emits_wire_format_offer \ + -- --ignored --exact --nocapture - run: cargo llvm-cov nextest --all-features --lcov --output-path ./target/lcov.info - name: Upload Coverage report uses: codecov/codecov-action@v5 diff --git a/tests/data/vsomeip-offerer/CMakeLists.txt b/tests/data/vsomeip-offerer/CMakeLists.txt index f0f144a..1d5a64b 100644 --- a/tests/data/vsomeip-offerer/CMakeLists.txt +++ b/tests/data/vsomeip-offerer/CMakeLists.txt @@ -8,16 +8,13 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) # stage of the Dockerfile) provides the imported targets we need. find_package(vsomeip3 REQUIRED) -# Offerer binary: tiny, links libvsomeip3. +# Two binaries: offerer (advertises the service) and subscriber +# (consumes it via SD availability handler). The Dockerfile builds +# both; the entrypoint dispatches based on `VSOMEIP_ROLE`. add_executable(offerer offerer.cpp) +add_executable(subscriber subscriber.cpp) -target_link_libraries(offerer - PRIVATE - vsomeip3 -) - -# vsomeip publishes its headers under . -target_include_directories(offerer - PRIVATE - ${VSOMEIP_INCLUDE_DIRS} -) +foreach(target offerer subscriber) + target_link_libraries(${target} PRIVATE vsomeip3) + target_include_directories(${target} PRIVATE ${VSOMEIP_INCLUDE_DIRS}) +endforeach() diff --git a/tests/data/vsomeip-offerer/Dockerfile b/tests/data/vsomeip-offerer/Dockerfile index 2020c3c..1361c22 100644 --- a/tests/data/vsomeip-offerer/Dockerfile +++ b/tests/data/vsomeip-offerer/Dockerfile @@ -58,8 +58,10 @@ RUN mkdir build && cd build \ && make install \ && ldconfig -# Build our offerer. It links against the just-installed libvsomeip3. -COPY offerer.cpp /src/offerer/offerer.cpp +# Build our offerer + subscriber. They link against the just-installed +# libvsomeip3. +COPY offerer.cpp /src/offerer/offerer.cpp +COPY subscriber.cpp /src/offerer/subscriber.cpp COPY CMakeLists.txt /src/offerer/CMakeLists.txt WORKDIR /src/offerer @@ -78,17 +80,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libboost-log1.74.0 \ && rm -rf /var/lib/apt/lists/* -# Copy installed vsomeip libs + the offerer binary + the config + entrypoint. +# Copy installed vsomeip libs + both binaries + both configs + entrypoint. COPY --from=build /usr/local/lib/libvsomeip3*.so* /usr/local/lib/ COPY --from=build /src/offerer/build/offerer /usr/local/bin/offerer +COPY --from=build /src/offerer/build/subscriber /usr/local/bin/subscriber COPY offerer.json /etc/vsomeip-offerer.json +COPY subscriber.json /etc/vsomeip-subscriber.json COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN ldconfig && chmod +x /usr/local/bin/entrypoint.sh -# Entrypoint script templates VSOMEIP_UNICAST into the JSON config -# before launching the offerer. Caller MUST pass `-e VSOMEIP_UNICAST= -# ` on `docker run`; the script exits -# loudly otherwise. See entrypoint.sh for the rationale (lo's lack of -# MULTICAST flag is the gotcha). +# Entrypoint script templates VSOMEIP_UNICAST into the chosen +# role's JSON config and execs offerer or subscriber based on +# VSOMEIP_ROLE (default: offerer). Caller MUST pass +# `-e VSOMEIP_UNICAST=` on `docker run`; +# the script exits loudly otherwise. See entrypoint.sh for the +# rationale (lo's lack of MULTICAST flag is the gotcha). ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/tests/data/vsomeip-offerer/entrypoint.sh b/tests/data/vsomeip-offerer/entrypoint.sh index e652115..d209ea0 100755 --- a/tests/data/vsomeip-offerer/entrypoint.sh +++ b/tests/data/vsomeip-offerer/entrypoint.sh @@ -1,10 +1,14 @@ #!/bin/sh -# Templates VSOMEIP_UNICAST into /etc/vsomeip-offerer.json then exec -# the offerer. The env var MUST be set on docker run; vsomeip 3.4.10 -# does not honor a VSOMEIP_UNICAST_ADDRESS-style env var directly, -# and `unicast: 127.0.0.1` doesn't work on Linux (lo lacks the -# MULTICAST flag, so SD multicast never reaches the wire). Pick the -# IP of an actual multicast-capable interface on the host: +# Templates VSOMEIP_UNICAST into the role-specific JSON and execs +# the chosen vsomeip role. Two roles: +# VSOMEIP_ROLE=offerer (default) — advertises service 0x1234 +# VSOMEIP_ROLE=subscriber — requests + watches for it +# +# VSOMEIP_UNICAST is required (vsomeip 3.4.10 doesn't honor any +# unicast-override env var directly, and `unicast: 127.0.0.1` +# doesn't work on Linux — lo lacks the MULTICAST flag, so SD +# multicast never reaches the wire). Pick the IP of an actual +# multicast-capable interface on the host: # # ip route get 224.0.23.0 # @@ -20,12 +24,32 @@ if [ -z "${VSOMEIP_UNICAST:-}" ]; then exit 1 fi -# Templated config goes to a writable location since /etc/ in the -# image is read-only-ish from the build's COPY. +ROLE="${VSOMEIP_ROLE:-offerer}" +case "${ROLE}" in + offerer) + SRC_JSON=/etc/vsomeip-offerer.json + DST_JSON=/tmp/vsomeip-offerer.json + BINARY=/usr/local/bin/offerer + APP_NAME=offerer + ;; + subscriber) + SRC_JSON=/etc/vsomeip-subscriber.json + DST_JSON=/tmp/vsomeip-subscriber.json + BINARY=/usr/local/bin/subscriber + APP_NAME=subscriber + ;; + *) + echo "ERROR: VSOMEIP_ROLE='${ROLE}' invalid; use 'offerer' or 'subscriber'." 1>&2 + exit 1 + ;; +esac + +# Templated config goes to /tmp because /etc is read-only-ish from +# the image's COPY layer. sed "s/VSOMEIP_UNICAST_PLACEHOLDER/${VSOMEIP_UNICAST}/" \ - /etc/vsomeip-offerer.json > /tmp/vsomeip-offerer.json + "${SRC_JSON}" > "${DST_JSON}" -export VSOMEIP_CONFIGURATION=/tmp/vsomeip-offerer.json -export VSOMEIP_APPLICATION_NAME=offerer +export VSOMEIP_CONFIGURATION="${DST_JSON}" +export VSOMEIP_APPLICATION_NAME="${APP_NAME}" -exec /usr/local/bin/offerer +exec "${BINARY}" diff --git a/tests/data/vsomeip-offerer/offerer.json b/tests/data/vsomeip-offerer/offerer.json index 21008f5..11bc072 100644 --- a/tests/data/vsomeip-offerer/offerer.json +++ b/tests/data/vsomeip-offerer/offerer.json @@ -21,7 +21,8 @@ "routing": "offerer", "service-discovery": { "enable": "true", - "multicast": "224.0.23.0", + "_multicast_comment": "simple-someip's default SD multicast is hardcoded to 239.255.0.255 (see src/protocol/sd/mod.rs:MULTICAST_IP — Luminar-internal-network style, predates spec-default alignment). vsomeip's default would be 224.0.23.0 (the SOME/IP-SD spec value). For these conformance tests we match simple-someip; future work should make simple-someip's multicast group configurable so we can test against spec-default vsomeip too.", + "multicast": "239.255.0.255", "port": "30490", "protocol": "udp", "initial_delay_min": "10", diff --git a/tests/data/vsomeip-offerer/subscriber.cpp b/tests/data/vsomeip-offerer/subscriber.cpp new file mode 100644 index 0000000..3b83c14 --- /dev/null +++ b/tests/data/vsomeip-offerer/subscriber.cpp @@ -0,0 +1,94 @@ +// vsomeip subscriber for phase-20h's TX-direction conformance test. +// +// Reverse of `offerer.cpp`: registers as a *requester* of service +// 0x1234 instance 0x0001, sets up an availability handler, and +// prints a stable [subscriber] AVAILABLE / UNAVAILABLE marker +// whenever vsomeip's SD subsystem decides the service is on/off +// the wire. The Rust test (`tests/vsomeip_sd_compat.rs`) drives +// `Server::announcement_loop` and scrapes our docker logs for the +// AVAILABLE marker as the assertion. +// +// Same hardcoded service+instance as the offerer — change one, +// change the other (see tests/vsomeip_sd_compat.rs constants). + +#include + +#include +#include +#include +#include +#include + +namespace { + +constexpr vsomeip::service_t kServiceId = 0x1234; +constexpr vsomeip::instance_t kInstanceId = 0x0001; + +std::atomic g_shutdown{false}; + +void on_signal(int /*signum*/) { + g_shutdown.store(true, std::memory_order_release); +} + +} // namespace + +int main() { + std::signal(SIGINT, on_signal); + std::signal(SIGTERM, on_signal); + + auto runtime = vsomeip::runtime::get(); + if (!runtime) { + std::cerr << "[subscriber] vsomeip::runtime::get() returned null" << std::endl; + return 1; + } + + auto app = runtime->create_application("subscriber"); + if (!app) { + std::cerr << "[subscriber] runtime->create_application() returned null" << std::endl; + return 1; + } + + if (!app->init()) { + std::cerr << "[subscriber] application->init() failed; " + << "check VSOMEIP_CONFIGURATION and JSON validity" << std::endl; + return 1; + } + + // The availability handler fires whenever the routing manager's + // view of the service changes (offered <-> stopped). Print a + // distinct prefix so the Rust test can grep with low noise. + app->register_availability_handler( + kServiceId, kInstanceId, + [](vsomeip::service_t srv, vsomeip::instance_t inst, bool available) { + std::cout << "[subscriber] " + << (available ? "AVAILABLE" : "UNAVAILABLE") + << " service=0x" << std::hex << srv + << " instance=0x" << inst + << std::dec << std::endl + << std::flush; + }); + + // Drive vsomeip on a worker thread, the same shape as the offerer. + std::thread vsomeip_thread([&app]() { app->start(); }); + + // Brief warmup so vsomeip's SD subsystem is fully initialized + // before we issue the request. Without this the request can + // race past the SD-init code path on slower hosts and miss the + // first round of incoming offers. + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + + std::cout << "[subscriber] requesting service 0x" << std::hex << kServiceId + << " instance 0x" << kInstanceId << std::dec << std::endl; + + app->request_service(kServiceId, kInstanceId); + + while (!g_shutdown.load(std::memory_order_acquire)) { + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + } + + std::cout << "[subscriber] shutdown requested; stopping vsomeip" << std::endl; + app->release_service(kServiceId, kInstanceId); + app->stop(); + vsomeip_thread.join(); + return 0; +} diff --git a/tests/data/vsomeip-offerer/subscriber.json b/tests/data/vsomeip-offerer/subscriber.json new file mode 100644 index 0000000..03c2c25 --- /dev/null +++ b/tests/data/vsomeip-offerer/subscriber.json @@ -0,0 +1,35 @@ +{ + "_comment": "vsomeip configuration for the phase-20h conformance subscriber. Mirror of offerer.json but registers as a `clients` consumer of service 0x1234 instance 0x0001 instead of `services` provider. Used to verify simple-someip's TX-direction SD wire format: simple-someip's Server::announcement_loop emits OfferService broadcasts, vsomeip subscribes, and its availability handler fires when the offer is recognized.", + + "_unicast_comment": "Templated at container start by entrypoint.sh from VSOMEIP_UNICAST env var (same gotcha as offerer.json: Linux's lo lacks the MULTICAST flag, so SD multicast can't traverse it; pick a real interface IP).", + "unicast": "VSOMEIP_UNICAST_PLACEHOLDER", + "netmask": "255.255.255.0", + "logging": { + "level": "debug", + "console": "true" + }, + "applications": [ + { "name": "subscriber", "id": "0x1278" } + ], + "clients": [ + { + "service": "0x1234", + "instance": "0x0001", + "unreliable": [30509] + } + ], + "routing": "subscriber", + "service-discovery": { + "enable": "true", + "_multicast_comment": "Must match offerer.json and simple-someip's hardcoded MULTICAST_IP (239.255.0.255 in src/protocol/sd/mod.rs). vsomeip's spec default is 224.0.23.0 — we override here so the subscriber joins the group simple-someip actually broadcasts to. Future: make simple-someip's multicast group configurable so we can test against spec-default vsomeip.", + "multicast": "239.255.0.255", + "port": "30490", + "protocol": "udp", + "initial_delay_min": "10", + "initial_delay_max": "100", + "repetitions_base_delay": "200", + "repetitions_max": "3", + "ttl": "5", + "cyclic_offer_delay": "1000" + } +} diff --git a/tests/vsomeip_sd_compat.rs b/tests/vsomeip_sd_compat.rs index 4c47b4b..5fb748e 100644 --- a/tests/vsomeip_sd_compat.rs +++ b/tests/vsomeip_sd_compat.rs @@ -70,6 +70,72 @@ //! //! 5. Tear down: `docker stop vsomeip-offerer`. //! +//! ## Running the TX-direction tests +//! +//! There are two TX-direction tests with different tradeoffs: +//! +//! ### `tx_announcement_loop_emits_wire_format_offer` — no docker, CI-friendly +//! +//! Drives `Server::announcement_loop()` and captures the emitted bytes +//! on a second socket joined to the SD multicast group on the same +//! interface, then asserts every field of the SOME/IP + SD envelope +//! against expected values. No external reference impl involved — +//! the assertion is "the bytes match what AUTOSAR SOME/IP-SD says +//! they should be." This is the same wire format vsomeip's parser +//! consumes, so a regression here is a regression against vsomeip +//! too. Runnable in any environment whose chosen interface carries +//! the `MULTICAST` flag (loopback usually does **not** by default; +//! pass `SIMPLE_SOMEIP_TEST_INTERFACE=` to use a real NIC): +//! +//! ```text +//! SIMPLE_SOMEIP_TEST_INTERFACE=192.168.1.42 \ +//! cargo test --features client-tokio,server-tokio \ +//! --test vsomeip_sd_compat \ +//! tx_announcement_loop_emits_wire_format_offer \ +//! -- --ignored --nocapture +//! ``` +//! +//! ### `vsomeip_sees_simple_someip_offer_service` — full cross-impl +//! +//! Same image as the RX test, different role. Start a subscriber +//! container with the special name the test expects: +//! +//! ```text +//! docker run --rm -d --name vsomeip-test-subscriber --network host \ +//! -e VSOMEIP_UNICAST=192.168.1.42 \ +//! -e VSOMEIP_ROLE=subscriber \ +//! vsomeip-offerer +//! ``` +//! +//! Then run the test (subscriber container runs in parallel; the +//! test starts simple-someip's `Server::announcement_loop` and polls +//! `docker logs` for the AVAILABLE marker): +//! +//! ```text +//! SIMPLE_SOMEIP_TEST_INTERFACE=192.168.1.42 \ +//! cargo test --features client-tokio,server-tokio \ +//! --test vsomeip_sd_compat \ +//! vsomeip_sees_simple_someip_offer_service \ +//! -- --ignored --nocapture +//! ``` +//! +//! Tear down: `docker stop vsomeip-test-subscriber`. +//! +//! **Same-host caveat (observed 2026-04-29):** running the subscriber +//! container in `--network host` mode on the same machine that's +//! running the simple-someip Server can fail to deliver multicast +//! even though `tcpdump` confirms the OfferService packets are on the +//! wire and `/proc/net/igmp` confirms the subscriber joined the +//! group. The same setup also fails vsomeip-offerer → vsomeip- +//! subscriber on the same host, so this is a vsomeip routing-host +//! quirk (both endpoints bind `0.0.0.0:30490` with `SO_REUSEPORT` and +//! one of them wins the multicast delivery non-deterministically), +//! not a simple-someip wire-format bug. Run the subscriber container +//! on a **second host** sharing the same multicast-capable network +//! to get a clean cross-impl signal. The +//! `tx_announcement_loop_emits_wire_format_offer` test above +//! sidesteps this entirely. +//! //! # Why `#[ignore]`? //! //! The test depends on an external vsomeip container being up. CI @@ -95,7 +161,10 @@ use std::net::Ipv4Addr; use std::str::FromStr; use std::time::Duration; -use simple_someip::{Client, ClientUpdate, RawPayload}; +use simple_someip::protocol::sd::{self, EntryType, RebootFlag, TransportProtocol}; +use simple_someip::protocol::{MessageType, MessageView, ReturnCode}; +use simple_someip::server::ServerConfig; +use simple_someip::{Client, ClientUpdate, RawPayload, Server}; /// Service + instance ID the vsomeip-offerer config (above) must /// match. Hardcoded to keep the test minimal; if you change the @@ -229,3 +298,387 @@ async fn client_sees_vsomeip_offer_service() { } } } + +// ── Phase 20h: TX direction — simple-someip emits, vsomeip subscribes ─ + +/// Container name for the subscriber-role container. Hardcoded so the +/// test knows which `docker logs` to scrape; if you run the container +/// under a different name, change this constant. +const SUBSCRIBER_CONTAINER: &str = "vsomeip-test-subscriber"; + +/// Expected log marker emitted by `subscriber.cpp`'s availability +/// handler when vsomeip's SD subsystem decides our service is +/// available. Substring match — exact format is +/// `[subscriber] AVAILABLE service=0x1234 instance=0x1`. +const AVAILABILITY_MARKER: &str = "[subscriber] AVAILABLE service=0x1234"; + +/// Verifies simple-someip's `Server::announcement_loop` emits SD +/// `OfferService` bytes that vsomeip's reference SD-receive +/// implementation parses + recognizes. +/// +/// Test architecture: simple-someip's tokio Server runs the SD +/// announcement loop on the configured interface. A separate +/// vsomeip subscriber container (`vsomeip-test-subscriber`) is +/// already running and has registered an availability handler for +/// service 0x1234 instance 0x0001. When vsomeip's SD subsystem +/// decodes our SD broadcast and decides the service is available, +/// the C++ availability handler prints a marker to stdout. The +/// test polls `docker logs ` for that marker. +/// +/// `#[ignore]` because this depends on an external vsomeip +/// subscriber container — see module docs for the docker run +/// command. +#[tokio::test(flavor = "current_thread")] +#[ignore = "requires external vsomeip-test-subscriber container; see module docs"] +async fn vsomeip_sees_simple_someip_offer_service() { + let _ = tracing_subscriber::fmt::try_init(); + + let interface = test_interface(); + eprintln!("[test] simple-someip Server emitting SD on {interface}"); + eprintln!( + "[test] expecting vsomeip subscriber to log AVAILABLE for \ + service=0x{SERVICE_ID:04X} instance=0x{INSTANCE_ID:04X} \ + within {}s", + SD_TIMEOUT.as_secs() + ); + + // Pre-flight: confirm the subscriber container is running so a + // missing container surfaces as a clear error rather than a + // 30-second timeout. This isn't bulletproof — the container + // could die mid-test — but it catches the common "forgot to + // start it" mistake. + let pre = std::process::Command::new("docker") + .args([ + "inspect", + "--format", + "{{.State.Running}}", + SUBSCRIBER_CONTAINER, + ]) + .output() + .expect("docker CLI not available; install docker or skip this test"); + if !pre.status.success() { + panic!( + "Subscriber container '{SUBSCRIBER_CONTAINER}' not found. \ + Start it via:\n\n \ + docker run --rm -d --name {SUBSCRIBER_CONTAINER} --network host \\\n \ + -e VSOMEIP_UNICAST= -e VSOMEIP_ROLE=subscriber \\\n \ + vsomeip-offerer\n", + ); + } + let running = String::from_utf8_lossy(&pre.stdout); + if running.trim() != "true" { + panic!( + "Subscriber container '{SUBSCRIBER_CONTAINER}' exists but isn't running \ + (state: '{}'). Inspect via `docker logs {SUBSCRIBER_CONTAINER}`.", + running.trim() + ); + } + + // Build a tokio-flavor Server with multicast loopback enabled + // (matches vsomeip's default; lets a same-host subscriber see + // our broadcasts even on the actual NIC). + let config = ServerConfig::new(interface, 30500, SERVICE_ID, INSTANCE_ID); + let mut server = Server::new_with_loopback(config, true) + .await + .expect("Server::new_with_loopback failed (network setup problem?)"); + + // `announcement_loop()` returns the `+ Send + 'static` future + // that emits OfferService SD broadcasts every cyclic_offer_delay + // (default 1s in simple-someip). Spawning it on tokio works + // here because TokioSocket is Send + Sync and the std-side + // bounds are met by the convenience constructor's defaults. + let announce_fut = server + .announcement_loop() + .expect("announcement_loop failed; passive server?"); + let announce_handle = tokio::spawn(announce_fut); + + // Drive the server's run loop too — it does multicast-loopback + // SD receive, but for this test we only care that announcements + // go out. The run loop survives without subscribers. + let server_handle = tokio::spawn(async move { + let _ = server.run().await; + }); + + eprintln!("[test] announcement loop spawned; polling docker logs"); + + // Poll docker logs every 500ms for the AVAILABLE marker. Reading + // the full log each time is fine — they're tiny. Uses + // `std::process::Command` (blocking) rather than tokio's process + // module to avoid widening the crate's dev-dep tokio features + // for one test; the brief blocking call happens between half- + // second sleeps so it doesn't starve the runtime. + let saw_marker = tokio::time::timeout(SD_TIMEOUT, async { + loop { + let out = std::process::Command::new("docker") + .args(["logs", SUBSCRIBER_CONTAINER]) + .output(); + if let Ok(o) = out { + let combined = format!( + "{}{}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ); + if combined.contains(AVAILABILITY_MARKER) { + return true; + } + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + }) + .await; + + announce_handle.abort(); + server_handle.abort(); + + match saw_marker { + Ok(true) => { + eprintln!( + "[test] PASS — vsomeip subscriber recognized simple-someip's \ + OfferService SD broadcast" + ); + } + Ok(false) => unreachable!("loop only exits via timeout or marker match"), + Err(_) => { + // Final docker logs dump for the operator's debugging. + let logs = std::process::Command::new("docker") + .args(["logs", "--tail", "30", SUBSCRIBER_CONTAINER]) + .output() + .ok() + .map(|o| { + format!( + "stdout:\n{}\n\nstderr:\n{}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ) + }) + .unwrap_or_else(|| "".to_string()); + panic!( + "Timed out after {}s waiting for vsomeip subscriber to log \n\ + '{AVAILABILITY_MARKER}'. Possibilities (rough order of likelihood): \n\ + (1) simple-someip's announcement_loop isn't actually emitting on \n\ + {interface} — check tcpdump or RUST_LOG=debug; \n\ + (2) vsomeip's `unicast` doesn't match the test's interface — \n\ + set VSOMEIP_UNICAST and SIMPLE_SOMEIP_TEST_INTERFACE the same; \n\ + (3) wire-format mismatch in simple-someip's SD-emit path — \n\ + this is the genuine conformance bug case. Try the RX-direction \n\ + test (`client_sees_vsomeip_offer_service`) to triangulate; \n\ + (4) vsomeip subscriber crashed mid-test. \n\n\ + Last 30 lines of subscriber logs:\n{logs}", + SD_TIMEOUT.as_secs(), + ); + } + } +} + +// ── Phase 20h: TX direction — wire-format self-check (no docker) ────── + +/// Verifies `Server::announcement_loop` emits SOME/IP-SD bytes that +/// match the AUTOSAR SOME/IP-SD spec, by capturing the bytes on a +/// second multicast socket and asserting every field of the SOME/IP + +/// SD envelope. +/// +/// **No external reference impl is involved.** This test asserts +/// against the spec, not against vsomeip. The cross-impl validation +/// lives in `vsomeip_sees_simple_someip_offer_service` above (gated +/// on a docker container + ideally a second host); this test gives +/// CI a deterministic, dep-free signal that the emit path is healthy. +/// +/// The receive-side cross-impl path is already exercised by +/// `client_sees_vsomeip_offer_service`: vsomeip's emitter feeds +/// simple-someip's parser, and that test passes. So if our parser +/// (vsomeip-compatible by that test) decodes our emitter's bytes +/// with the expected field values here, our emitter is vsomeip- +/// shaped by transitivity. Modulo encoding subtleties not visible to +/// the parser — which is what the docker-based test is for. +/// +/// `#[ignore]` because the chosen interface needs the `MULTICAST` +/// flag. Linux's `lo` lacks it by default (`ip link show lo` does +/// not list `MULTICAST`), so this test is run on demand against a +/// real NIC via `SIMPLE_SOMEIP_TEST_INTERFACE=`. +#[tokio::test(flavor = "current_thread")] +#[ignore = "requires MULTICAST flag on the chosen interface; pass \ + SIMPLE_SOMEIP_TEST_INTERFACE=. See module docs."] +async fn tx_announcement_loop_emits_wire_format_offer() { + use std::net::{IpAddr, SocketAddr}; + + let _ = tracing_subscriber::fmt::try_init(); + + let interface = test_interface(); + eprintln!( + "[test] capturing simple-someip's SD on {interface}; expecting \ + OfferService(service=0x{SERVICE_ID:04X}, instance=0x{INSTANCE_ID:04X})" + ); + + // Receiver socket: bind to the SD multicast port on `interface`, + // SO_REUSEPORT so it coexists with the Server's own SD socket + // (also bound to that port), join the SD multicast group, and + // enable multicast loopback so a same-host sender's packets + // reach us. + let rx = { + let raw = socket2::Socket::new( + socket2::Domain::IPV4, + socket2::Type::DGRAM, + Some(socket2::Protocol::UDP), + ) + .expect("socket2 create"); + raw.set_reuse_address(true).expect("set_reuse_address"); + raw.set_reuse_port(true).expect("set_reuse_port"); + raw.set_multicast_loop_v4(true) + .expect("set_multicast_loop_v4"); + // Bind to 0.0.0.0:30490, not interface:30490: Linux only + // delivers multicast to sockets bound to INADDR_ANY (or to + // the multicast group address itself), not to ones bound to + // a specific unicast address — even after `join_multicast_v4`. + // The `join` call below specifies which interface to join on. + raw.bind(&SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), sd::MULTICAST_PORT).into()) + .expect("bind receiver to 0.0.0.0:SD_PORT"); + raw.set_nonblocking(true).expect("set_nonblocking"); + let std_sock: std::net::UdpSocket = raw.into(); + let sock = tokio::net::UdpSocket::from_std(std_sock).expect("UdpSocket::from_std"); + sock.join_multicast_v4(sd::MULTICAST_IP, interface) + .expect("join SD multicast group"); + sock + }; + + // Spawn the Server with multicast loopback so its emitted + // OfferService packets loop back to our receiver on the same + // interface. + const ADVERTISED_PORT: u16 = 30500; + let config = ServerConfig::new(interface, ADVERTISED_PORT, SERVICE_ID, INSTANCE_ID); + let mut server = Server::new_with_loopback(config, true) + .await + .expect("Server::new_with_loopback failed"); + let announce_fut = server + .announcement_loop() + .expect("announcement_loop failed; passive server?"); + let announce_handle = tokio::spawn(announce_fut); + // Drive run() too so the Server's own SD socket drains, but we + // assert against bytes we receive on our independent capture + // socket — the run-loop is just to keep the Server healthy. + let server_handle = tokio::spawn(async move { + let _ = server.run().await; + }); + + // Owned snapshot of the assertion-relevant fields. Pulled out + // inside `recv_loop` because `MessageView` / `SdHeaderView` / + // `EntryView` borrow the receive buffer. + struct CapturedOffer { + someip_service_id: u16, + someip_method_id: u16, + message_type: MessageType, + return_code: ReturnCode, + protocol_version: u8, + interface_version: u8, + sd_unicast: bool, + entry_service_id: u16, + entry_instance_id: u16, + entry_major_version: u8, + entry_minor_version: u32, + entry_ttl: u32, + endpoint_ip: Ipv4Addr, + endpoint_port: u16, + endpoint_protocol: TransportProtocol, + len: usize, + } + + // Cyclic offer delay defaults to ~1 s; 5 s is generous and + // bounded. + let recv_timeout = Duration::from_secs(5); + let recv_loop = async { + let mut buf = [0u8; 2048]; + loop { + let (len, _from) = rx.recv_from(&mut buf).await.expect("recv_from"); + let Ok(view) = MessageView::parse(&buf[..len]) else { + continue; + }; + if view.header().message_id().service_id() != 0xFFFF { + continue; + } + let Ok(sd_view) = view.sd_header() else { + continue; + }; + let Some(entry) = sd_view.entries().next() else { + continue; + }; + if !matches!(entry.entry_type(), Ok(EntryType::OfferService)) { + continue; + } + if entry.service_id() != SERVICE_ID { + continue; + } + let first_option = sd_view + .options() + .next() + .expect("OfferService should carry an endpoint option"); + let (endpoint_ip, endpoint_protocol, endpoint_port) = first_option + .as_ipv4() + .expect("endpoint option should decode as IPv4"); + return CapturedOffer { + someip_service_id: view.header().message_id().service_id(), + someip_method_id: view.header().message_id().method_id(), + message_type: view.header().message_type().message_type(), + return_code: view.header().return_code(), + protocol_version: view.header().protocol_version(), + interface_version: view.header().interface_version(), + sd_unicast: sd_view.flags().unicast(), + entry_service_id: entry.service_id(), + entry_instance_id: entry.instance_id(), + entry_major_version: entry.major_version(), + entry_minor_version: entry.minor_version(), + entry_ttl: entry.ttl(), + endpoint_ip, + endpoint_port, + endpoint_protocol, + len, + }; + } + }; + let captured = tokio::time::timeout(recv_timeout, recv_loop).await; + + announce_handle.abort(); + server_handle.abort(); + + let offer = captured.unwrap_or_else(|_| { + panic!( + "Timed out after {}s waiting to capture our own OfferService on \ + {interface}. Most likely cause: `lo` lacks the MULTICAST flag, \ + or SIMPLE_SOMEIP_TEST_INTERFACE points to an interface that \ + cannot loop multicast back to a same-host receiver. Try a \ + real NIC IP (`ip route get 239.255.0.255` to find one).", + recv_timeout.as_secs(), + ) + }); + + // SOME/IP envelope (spec-fixed for SD). + assert_eq!(offer.someip_service_id, 0xFFFF, "SD service_id"); + assert_eq!(offer.someip_method_id, 0x8100, "SD method_id"); + assert_eq!(offer.message_type, MessageType::Notification); + assert_eq!(offer.return_code, ReturnCode::Ok); + assert_eq!(offer.protocol_version, 0x01); + assert_eq!(offer.interface_version, 0x01); + // SD flags — unicast must always be set; reboot may be either + // RecentlyRebooted or Continuous depending on session counter + // wrap state, so we don't assert it here (covered by the inner + // sd_state tests). + assert!(offer.sd_unicast, "SD unicast flag must be set"); + // OfferService entry body. + assert_eq!(offer.entry_service_id, SERVICE_ID); + assert_eq!(offer.entry_instance_id, INSTANCE_ID); + assert_eq!(offer.entry_major_version, 1, "default major_version"); + assert_eq!(offer.entry_minor_version, 0, "default minor_version"); + assert!(offer.entry_ttl > 0, "TTL must be non-zero on Offer"); + // Endpoint option — must advertise the configured (interface, port) + // pair as UDP, which is what vsomeip's parser scans for. + assert_eq!(offer.endpoint_ip, interface); + assert_eq!(offer.endpoint_port, ADVERTISED_PORT); + assert_eq!(offer.endpoint_protocol, TransportProtocol::Udp); + + eprintln!( + "[test] PASS — captured wire-format OfferService for service=0x{SERVICE_ID:04X} \ + on {interface} ({len} bytes)", + len = offer.len + ); + // `RebootFlag` is referenced via the trace-friendly Display path + // implicitly by tracing; pin the import so it's not flagged. + let _ = RebootFlag::RecentlyRebooted; +}