From d961fc163b703bf53eac88efef40577ad59a5833 Mon Sep 17 00:00:00 2001 From: Justin Kovacich Date: Wed, 29 Apr 2026 10:36:06 -0400 Subject: [PATCH] phase 20f: vsomeip-based SD-conformance test (POC, #[ignore]'d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First test in the simple-someip crate that catches **protocol non-compliance** bugs against an external SOME/IP-SD reference (the COVESA vsomeip implementation), rather than running our own impl on both sides of the wire and only catching internal- consistency issues. Scope: single SD `OfferService` reception. simple-someip's `Client::bind_discovery()` listens for vsomeip's announcement of a known service+instance pair, asserts the SD entry surfaces on the update stream within a 30 s timeout. That single signal is the load-bearing wire-conformance check we have zero of today. Subsequent phases will layer Subscribe/Ack roundtrips, request/response, E2E protect/check, etc. against the same reference. `#[ignore]`'d by default. The test depends on an external vsomeip Docker container being up — see the test file's module-level docs for the docker setup, the JSON config to mount, and the env-var (`SIMPLE_SOMEIP_TEST_INTERFACE`) to point at the test's listening interface. Phase 20g will wire this into CI via TestContainers-rs (or similar) once the manual setup is proven. Why ignored not a CI step yet: Per FW team confirmation, vsomeip-in-docker on CI is approved-in-principle but not yet stood up. Shipping the test infrastructure first lets the firmware team pick up the test locally for debugging during the codec-MVP integration; CI automation lands as 20g. Cleanup folded in: clippy warnings surfaced under broader feature combos (`--features client-tokio,server-tokio`) by prior phases: - `event_publisher.rs:57` doc-markdown `PhantomData` now backticked. - `event_publisher.rs:670/732` `clippy::type_complexity` `#[allow(...)]`'d on the test type aliases (with reason string explaining why). - `server/mod.rs:929` doc-markdown `TokioSocket` now backticked. - `sd_state.rs` `Default` impl added on `SdStateManager` (clippy::pedantic; bare-metal callers should still prefer the explicit `const` `new()` for `static` initializers). Default-features `cargo clippy --tests` had only 2 pre-existing warnings before this commit and still has only 2 after; no new warnings on the canonical CI gate. The broader-feature warnings were a 20e-introduced side-effect. Gates green: - cargo fmt --check - cargo clippy --tests (default features; 2 pre-existing warnings unrelated to this work) - cargo build --workspace --all-targets - cargo build --no-default-features --features client,server,bare_metal - cargo build -p simple-someip-embassy-net --target thumbv7em-none-eabihf - cargo test --features client-tokio,server-tokio --test client_server -- --test-threads=1 (11/11) - cargo test --features client,server,bare_metal --test bare_metal_e2e (2/2) - cargo test -p simple-someip-embassy-net --test loopback (3/3) - cargo test --features client-tokio,server-tokio --test vsomeip_sd_compat (1 ignored as expected) What this leaves for 20g: - `tests/data/vsomeip-offerer/Dockerfile` building vsomeip from source. - TestContainers-rs (or equivalent) integration so `cargo test --features client-tokio,server-tokio --test vsomeip_sd_compat -- --ignored` works in a CI runner with Docker available. - vsomeip version pin matching whatever the FW team selects for production validation. - Subsequent conformance tests: Subscribe/Ack, request/response, E2E roundtrips. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/event_publisher.rs | 12 +- src/server/mod.rs | 2 +- src/server/sd_state.rs | 12 ++ tests/vsomeip_sd_compat.rs | 248 ++++++++++++++++++++++++++++++++++ 4 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 tests/vsomeip_sd_compat.rs diff --git a/src/server/event_publisher.rs b/src/server/event_publisher.rs index c4c45da..9c45607 100644 --- a/src/server/event_publisher.rs +++ b/src/server/event_publisher.rs @@ -54,8 +54,8 @@ where socket: H, e2e_registry: R, /// `T` appears only in the bound `H: SharedHandle`; the - /// struct doesn't directly hold a `T`. PhantomData carries the - /// type so the parameter is well-formed without affecting + /// struct doesn't directly hold a `T`. `PhantomData` carries + /// the type so the parameter is well-formed without affecting /// drop-check or auto-trait propagation negatively. _phantom: PhantomData, } @@ -667,6 +667,10 @@ mod tests { let mut mgr = subscriptions.write().await; mgr.subscribe(0x5B, 1, 0x01, addr).unwrap(); } + #[allow( + clippy::type_complexity, + reason = "tests reasonably spell out the full type for clarity" + )] let publisher: EventPublisher< Arc>, Arc>, @@ -729,6 +733,10 @@ mod tests { let mut mgr = subscriptions.write().await; mgr.subscribe(0x5B, 1, 0x01, addr).unwrap(); } + #[allow( + clippy::type_complexity, + reason = "tests reasonably spell out the full type for clarity" + )] let publisher: EventPublisher< Arc>, Arc>, diff --git a/src/server/mod.rs b/src/server/mod.rs index c7a868c..ecd4175 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -926,7 +926,7 @@ where /// unicast port. Backends that surface truncation /// (`ReceivedDatagram::truncated`) emit a `tracing::warn!` when /// the caller's buffer was too small; backends that don't - /// (TokioSocket today) silently truncate at the OS level. + /// (`TokioSocket` today) silently truncate at the OS level. /// /// On bare-metal, callers typically place the buffers in /// `static` storage: diff --git a/src/server/sd_state.rs b/src/server/sd_state.rs index 211b7cc..e118d38 100644 --- a/src/server/sd_state.rs +++ b/src/server/sd_state.rs @@ -65,7 +65,19 @@ impl SdStateManager { pub const fn new() -> Self { Self::with_initial(1) } +} +impl Default for SdStateManager { + /// Equivalent to [`Self::new`]. Provided for clippy-pedantic + /// completeness; bare-metal callers should prefer the explicit + /// `SdStateManager::new()` because it is `const` and works in a + /// `static` initializer. + fn default() -> Self { + Self::new() + } +} + +impl SdStateManager { /// Construct with a specific starting session counter. Primarily used by /// tests to validate wrap behavior; callers in production should use /// [`Self::new`]. diff --git a/tests/vsomeip_sd_compat.rs b/tests/vsomeip_sd_compat.rs new file mode 100644 index 0000000..3c9de94 --- /dev/null +++ b/tests/vsomeip_sd_compat.rs @@ -0,0 +1,248 @@ +//! Phase 20f — Conformance test against the COVESA vsomeip reference +//! SOME/IP-SD implementation. +//! +//! `#[ignore]`'d by default. Run on demand once you have vsomeip +//! running on the host network (see "Running locally" below). This is +//! the first test in the simple-someip crate that catches **protocol +//! non-compliance** bugs against an external reference, vs. our +//! existing tests which all run simple-someip on both sides of the +//! wire and only catch internal-consistency issues. +//! +//! Goal of THIS test (deliberately tight scope for a first POC): +//! prove that simple-someip's `Client` can `bind_discovery()` and see +//! a vsomeip-emitted `OfferService` for a known service+instance ID +//! within a timeout. That single signal is the load-bearing wire- +//! conformance check we have zero of today. +//! +//! Subsequent phases will layer Subscribe/Ack roundtrips, +//! request/response, E2E protect/check, etc. against the same +//! vsomeip peer. +//! +//! # Running locally +//! +//! 1. Pull or build a vsomeip container. The COVESA project doesn't +//! publish a "ready-to-go" image; the simplest path is a small +//! Dockerfile around vsomeip's cmake build. The image needs +//! `routingmanagerd` (the SD daemon) plus a JSON config that +//! declares an "offerer" application with the service we want +//! advertised. Phase 20g will add a reference Dockerfile under +//! `tests/data/vsomeip-offerer/` once the manual setup is +//! proven; until then, hand-rolled is fine. +//! +//! 2. Save the config below as `vsomeip-offerer.json` and start +//! the container in host-network mode so SD multicast (224.0.23.0) +//! flows between the host and the container: +//! +//! ```text +//! docker run --rm -d \ +//! --name vsomeip-offerer \ +//! --network host \ +//! -v $(pwd)/vsomeip-offerer.json:/etc/vsomeip.json:ro \ +//! -e VSOMEIP_CONFIGURATION=/etc/vsomeip.json \ +//! -e VSOMEIP_APPLICATION_NAME=offerer \ +//! +//! ``` +//! +//! Sample vsomeip-offerer.json that offers service 0x1234 +//! instance 0x0001 over UDP port 30509: +//! +//! ```json +//! { +//! "unicast": "127.0.0.1", +//! "logging": { "level": "info", "console": "true" }, +//! "applications": [ +//! { "name": "offerer", "id": "0x1277" } +//! ], +//! "services": [ +//! { +//! "service": "0x1234", +//! "instance": "0x0001", +//! "unreliable": "30509" +//! } +//! ], +//! "routing": "offerer", +//! "service-discovery": { +//! "enable": "true", +//! "multicast": "224.0.23.0", +//! "port": "30490", +//! "protocol": "udp", +//! "initial_delay_min": "10", +//! "initial_delay_max": "100", +//! "repetitions_base_delay": "200", +//! "repetitions_max": "3", +//! "ttl": "5" +//! } +//! } +//! ``` +//! +//! 3. Set the test's listening interface via env var to whatever IP +//! vsomeip is announcing on. For host-network Docker, that's +//! typically `127.0.0.1` (matches `unicast` in the config above): +//! +//! ```text +//! SIMPLE_SOMEIP_TEST_INTERFACE=127.0.0.1 \ +//! cargo test --features client-tokio,server-tokio \ +//! --test vsomeip_sd_compat -- --ignored --nocapture +//! ``` +//! +//! 4. Tear down: `docker stop vsomeip-offerer`. +//! +//! # Why `#[ignore]`? +//! +//! The test depends on an external vsomeip container being up. CI +//! runners don't have that today; flipping it on `cargo test` would +//! fail 100% of CI builds. Until we have a CI step that brings up +//! vsomeip via TestContainers-rs (or equivalent), this test runs on +//! demand only. +//! +//! # Why `127.0.0.1` defaults? +//! +//! Loopback is the easiest network model for an initial POC — it +//! avoids needing a real NIC, multicast-capable bridge, or specific +//! interface IP detection. SOME/IP-SD multicast over loopback works +//! on Linux when both sides set `IP_MULTICAST_LOOP` (which our +//! `Server::new_with_loopback` does, and vsomeip's default does). +//! For real-NIC testing, set `SIMPLE_SOMEIP_TEST_INTERFACE` to the +//! interface's IP and configure vsomeip's `unicast` field to match. + +#![cfg(all(feature = "client-tokio", feature = "server-tokio"))] + +use std::env; +use std::net::Ipv4Addr; +use std::str::FromStr; +use std::time::Duration; + +use simple_someip::{Client, ClientUpdate, RawPayload}; + +/// Service + instance ID the vsomeip-offerer config (above) must +/// match. Hardcoded to keep the test minimal; if you change the +/// config, change these. +const SERVICE_ID: u16 = 0x1234; +const INSTANCE_ID: u16 = 0x0001; + +/// Default timeout for the SD `OfferService` to land on the +/// Client's update stream. vsomeip's default +/// `initial_delay_max = 100` ms + a few `repetitions_base_delay +/// = 200` ms ticks, so 30 s is generous. +const SD_TIMEOUT: Duration = Duration::from_secs(30); + +/// Default interface if `SIMPLE_SOMEIP_TEST_INTERFACE` is unset. +/// `127.0.0.1` matches the `vsomeip-offerer.json` `"unicast"` +/// field above. +const DEFAULT_INTERFACE: Ipv4Addr = Ipv4Addr::LOCALHOST; + +fn test_interface() -> Ipv4Addr { + match env::var("SIMPLE_SOMEIP_TEST_INTERFACE") { + Ok(s) => Ipv4Addr::from_str(s.trim()) + .unwrap_or_else(|_| panic!("SIMPLE_SOMEIP_TEST_INTERFACE not a valid IPv4: {s}")), + Err(_) => DEFAULT_INTERFACE, + } +} + +/// Verifies simple-someip's `Client` sees vsomeip's `OfferService` +/// SD broadcast for the configured service + instance ID. +/// +/// `#[ignore]` because the test depends on an external vsomeip +/// container being up — see this file's module-level docs for the +/// docker setup. +#[tokio::test(flavor = "current_thread")] +#[ignore = "requires external vsomeip-offerer container; see module docs"] +async fn client_sees_vsomeip_offer_service() { + // Initialize tracing if RUST_LOG is set so the test prints + // simple-someip's SD-receive logs alongside `[client] received` + // events. Helpful when the test fails and you want to know whether + // simple-someip got bytes at all. + let _ = tracing_subscriber::fmt::try_init(); + + let interface = test_interface(); + eprintln!("[test] listening on interface {interface}"); + eprintln!( + "[test] expecting vsomeip OfferService(service=0x{:04X}, \ + instance=0x{:04X}) within {}s", + SERVICE_ID, + INSTANCE_ID, + SD_TIMEOUT.as_secs() + ); + + // Build a tokio-flavor Client with multicast loopback enabled so + // a vsomeip container running on the same host (host-network + // mode) gets to send + we get to receive on the same loopback + // interface. + let (client, mut updates, run_fut) = + Client::::new_with_loopback(interface, true); + + // Spawn the run-loop. `tokio::spawn` works because the tokio + // backend's run future is `Send + 'static`. + let run_handle = tokio::spawn(run_fut); + + // Bind the SD multicast socket. Without this no SD traffic + // surfaces. + client + .bind_discovery() + .await + .expect("bind_discovery failed (network setup problem?)"); + eprintln!("[test] bind_discovery OK; waiting for OfferService"); + + // Drain the update stream until either (a) we see an + // `OfferService` matching the expected service+instance, or + // (b) the timeout fires. + let saw_offer = tokio::time::timeout(SD_TIMEOUT, async { + while let Some(update) = updates.recv().await { + let ClientUpdate::DiscoveryUpdated(msg) = update else { + eprintln!("[test] ignoring non-Discovery update: {update:?}"); + continue; + }; + // The SD message may carry multiple entries; scan for an + // `OfferService` matching our (service, instance). + for entry in &msg.sd_header.entries { + use simple_someip::protocol::sd::Entry; + if let Entry::OfferService(svc) = entry + && svc.service_id == SERVICE_ID + && svc.instance_id == INSTANCE_ID + { + eprintln!( + "[test] matched OfferService from {} (ttl={}, mv={}.{})", + msg.source, svc.ttl, svc.major_version, svc.minor_version + ); + return true; + } + } + eprintln!( + "[test] saw DiscoveryUpdated from {} but no matching OfferService entry", + msg.source + ); + } + false + }) + .await; + + run_handle.abort(); + + match saw_offer { + Ok(true) => { + eprintln!("[test] PASS — simple-someip Client matched vsomeip's OfferService SD entry"); + } + Ok(false) => { + panic!( + "Update stream closed before OfferService(service=0x{SERVICE_ID:04X}, \ + instance=0x{INSTANCE_ID:04X}) arrived. \ + Most likely cause: vsomeip's run loop crashed or never started. \ + Check `docker logs vsomeip-offerer`." + ) + } + Err(_) => { + panic!( + "Timed out after {}s waiting for OfferService(service=0x{SERVICE_ID:04X}, \ + instance=0x{INSTANCE_ID:04X}). Possibilities (rough order of likelihood): \ + (1) vsomeip container not running on host network — try `docker ps`; \ + (2) vsomeip's `unicast` config doesn't match the listening interface — \ + set SIMPLE_SOMEIP_TEST_INTERFACE accordingly; \ + (3) firewall dropping multicast 224.0.23.0:30490 — try `sudo iptables -L`; \ + (4) vsomeip configured with a different service ID — recheck the JSON; \ + (5) genuine bug in simple-someip's SD-receive path (least likely \ + given existing loopback tests pass).", + SD_TIMEOUT.as_secs() + ); + } + } +}