Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/server/event_publisher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ where
socket: H,
e2e_registry: R,
/// `T` appears only in the bound `H: SharedHandle<T>`; 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<T>,
}
Expand Down Expand Up @@ -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<Mutex<E2ERegistry>>,
Arc<RwLock<SubscriptionManager>>,
Expand Down Expand Up @@ -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<Mutex<E2ERegistry>>,
Arc<RwLock<SubscriptionManager>>,
Expand Down
2 changes: 1 addition & 1 deletion src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions src/server/sd_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`].
Expand Down
248 changes: 248 additions & 0 deletions tests/vsomeip_sd_compat.rs
Original file line number Diff line number Diff line change
@@ -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)
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module docs and sample docker run instructions reference SD multicast 224.0.23.0, but this crate’s SD implementation joins crate::protocol::sd::MULTICAST_IP which is 239.255.0.255 (see src/protocol/sd/mod.rs). As written, following these instructions will configure vsomeip to announce on a different multicast group than the client listens to, so the test will never observe OfferService. Update the docs/config snippets to use 239.255.0.255 (or make the client’s SD multicast group configurable and document that instead).

Suggested change
//! the container in host-network mode so SD multicast (224.0.23.0)
//! the container in host-network mode so SD multicast (239.255.0.255)

Copilot uses AI. Check for mistakes.
//! 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 \
//! <your vsomeip image>
//! ```
//!
//! 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",
Comment on lines +64 to +68
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sample vsomeip-offerer.json sets "multicast": "224.0.23.0", but the client listens on crate::protocol::sd::MULTICAST_IP (239.255.0.255). Unless vsomeip is configured to match the same group, bind_discovery() won’t receive any SD traffic. Align this JSON snippet with the crate’s multicast constant (or document how to override the client’s multicast group if supported).

Copilot uses AI. Check for mistakes.
//! "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;
Comment on lines +108 to +110
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike other feature-gated integration tests in this repo (e.g. client_server, bare_metal_*), this test isn’t registered in Cargo.toml with [[test]] required-features = ["client-tokio", "server-tokio"]. With only the in-file #![cfg(...)], cargo test --test vsomeip_sd_compat can succeed with “0 tests” when the features aren’t enabled, which is confusing for users following the module docs. Consider adding the [[test]] required-features entry (and optionally keeping this #![cfg] as a belt-and-suspenders guard).

Copilot uses AI. Check for mistakes.
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::<RawPayload, _, _, _>::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()
);
}
}
}