Skip to content
Closed
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
9 changes: 7 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@

## [Unreleased]

### Added

- **`client::Error::Capacity(&'static str)`** — new variant returned when a fixed-capacity internal structure is full (e.g. `"unicast_sockets"`, `"udp_buffer"`). Because `client::Error` is not `#[non_exhaustive]`, this is a breaking change for downstream crates that match the enum exhaustively.
- **`server::SubscribeError`** — new public enum (`SubscribersPerGroupFull`, `EventGroupsFull`) returned by `SubscriptionManager::subscribe` and `EventPublisher::register_subscriber` when a bounded capacity rejects a subscription. Re-exported from `server::mod`.

Comment thread
JustinKovacich marked this conversation as resolved.
### Changed

- **`std` is now the default feature** — the crate enables `std` (with `thiserror` and `tracing`) by default. Users targeting `no_std` environments must set `default-features = false` in their `Cargo.toml`.
- **`thiserror` and `tracing` use `default-features = false`** — both dependencies are always included but their `std` features are only enabled when the crate's `std` feature is active. This removes the need for `#[cfg(feature = "std")]` gating on error types and logging macros.
- **Breaking: `server::SubscriptionManager::subscribe` signature change** — now returns `Result<(), server::SubscribeError>` instead of `()`. Previously, capacity rejections were silently dropped with only a `warn!` log, which let the server emit a `SubscribeAck` for a subscription that had not been recorded. Callers must now handle the `Err` path (the server's own SD loop emits `SubscribeNack` on `Err`).
- **Breaking: `server::EventPublisher::register_subscriber` signature change** — now returns `Result<(), server::SubscribeError>` instead of `()`, surfacing the same capacity-rejection signal to externally managed subscription dispatchers.


## [0.6.0](https://github.com/luminartech/simple_someip/compare/v0.5.3...v0.6.0) - 2026-04-20
Expand Down
20 changes: 20 additions & 0 deletions src/client/error.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
use thiserror::Error;

/// Errors that can occur during SOME/IP client operations.
///
/// # Stability
///
/// This enum is **not** marked `#[non_exhaustive]`, so downstream crates
/// may currently match it exhaustively. That convenience comes with a
/// real cost: **any new variant added here is a breaking change** and
/// must be flagged in the changelog and reflected in the next SemVer
/// bump (pre-1.0, a minor bump is sufficient, but it still requires a
/// release-notes entry). The same is true of renaming or restructuring
/// existing variants.
///
/// Marking this `#[non_exhaustive]` — so future additions become
/// non-breaking — is planned as part of an explicit breaking release;
/// until then, treat variant additions as breaking and plan the release
/// accordingly.
#[derive(Error, Debug)]
pub enum Error {
/// A SOME/IP protocol-level error.
Expand All @@ -24,4 +39,9 @@ pub enum Error {
/// An E2E protection or checking error occurred.
#[error(transparent)]
E2e(#[from] crate::e2e::Error),
/// A fixed-capacity internal structure is full. The argument names the
/// structure so bare-metal users can size the corresponding compile-time
/// constant up (e.g. `"unicast_sockets"`).
#[error("internal capacity exceeded: {0}")]
Capacity(&'static str),
}
Comment thread
JustinKovacich marked this conversation as resolved.
460 changes: 432 additions & 28 deletions src/client/inner.rs

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions src/client/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
//! SOME/IP client.
//!
//! # Memory footprint
//!
//! The client's internal `Inner` state is allocated inline rather than on
//! the heap. With the default capacity constants used by the client
//! internals — `REQUEST_QUEUE_CAP=32`, `PENDING_RESPONSES_CAP=64`, and
//! `UNICAST_SOCKETS_CAP=8` in `inner.rs`, plus `SESSION_CAP=64` in
//! `session.rs` — `Inner<P>` occupies on the order of **8–12 KiB**,
//! depending on `sizeof::<P>()` and `sizeof::<SocketManager<P>>()`. On
//! `std + tokio`, this is allocated on the heap when the run-loop is
//! spawned, so the overhead is invisible to callers. On the bare-metal
//! port (future), whoever drives the future must arrange storage for it
//! (either a `static` or a heap allocator); these capacity constants are
//! the primary knobs for trimming this footprint.
mod error;
mod inner;
mod service_registry;
Expand Down Expand Up @@ -550,6 +565,18 @@ where
///
/// Call `.response()` on the returned handle to await the reply payload.
///
/// # Saturation behavior
///
/// Response tracking uses a fixed-capacity internal map. If it is
/// saturated at the moment the reply-tracking slot would be installed,
/// this method still returns `Ok(PendingResponse)` — the UDP send has
/// already happened — but the returned `PendingResponse` will resolve to
/// `Err(Error::Capacity("pending_responses"))`. Any reply that later
/// arrives for that `request_id` is delivered as
/// [`ClientUpdate::Unicast`] on the update stream instead of through the
/// `PendingResponse`. Treat this error as "reply lost to saturation",
/// not "send failed". A `warn!`-level log accompanies the drop.
Comment thread
JustinKovacich marked this conversation as resolved.
///
/// # Errors
///
/// Returns an error if the service is not found, unicast binding fails,
Expand Down
143 changes: 132 additions & 11 deletions src/client/session.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
use crate::protocol::sd::RebootFlag;
use std::{collections::HashMap, net::SocketAddr};
use heapless::index_map::FnvIndexMap;
use std::net::SocketAddr;

/// Max number of distinct `(sender, transport, service, instance)` tuples tracked
/// for reboot detection. Must be a power of two (heapless `FnvIndexMap`
/// requirement). Sized for a small fleet of peers each offering several
/// services; bare-metal builds with more peers may need to edit this constant.
const SESSION_CAP: usize = 64;

/// Distinguishes multicast vs unicast transport for per-sender session tracking.
/// The AUTOSAR spec requires separate session ID tracking per transport.
Expand Down Expand Up @@ -45,14 +52,57 @@ pub enum SessionVerdict {
/// Tracking per service instance (rather than per sender) avoids false
/// positives when a sensor interleaves SD offers for multiple services
/// with independent session counters on the same source address.
#[derive(Debug, Default)]
///
/// Capacity is bounded at compile time by [`SESSION_CAP`].
/// When the map is full, new sender entries are dropped with a `warn!` log
/// and reboot detection for those senders is disabled.
///
/// # Security posture
///
/// The backing map uses FNV hashing rather than the DoS-resistant hasher used
/// by `std::collections::HashMap`. For SOME/IP on isolated automotive or
/// sensor networks this is not a concern. Deployments where `SessionKey`
/// inputs (notably `SocketAddr`) are adversary-controlled should be aware
/// that an attacker can craft keys to force collisions and degrade lookup
/// cost; the blast radius is bounded by [`SESSION_CAP`].
#[derive(Debug)]
pub struct SessionTracker {
state: HashMap<SessionKey, SessionState>,
state: FnvIndexMap<SessionKey, SessionState, SESSION_CAP>,
/// Set after the first saturation warning. Prevents the saturated-map
/// log from firing on every `check()` for every new key once capacity
/// is reached — which would spam the log at the packet rate.
saturation_warned: bool,
}

impl Default for SessionTracker {
fn default() -> Self {
Self {
state: FnvIndexMap::new(),
saturation_warned: false,
}
}
}

impl SessionTracker {
/// Check the session ID and reboot flag for a specific service instance
/// and return a verdict. Always updates the stored state after the check.
/// and return a verdict.
///
/// On the normal (non-saturated) path, the stored state is updated
/// after the check so subsequent calls see the latest session id and
/// reboot flag for the key.
///
/// # Capacity behavior
///
/// The tracker is backed by a `heapless::FnvIndexMap` bounded by
/// [`SESSION_CAP`]. If the map is already full and the incoming key
/// is new, the insert fails and stored state is **not** updated for
/// that key — subsequent `check()` calls with the same new key will
/// continue to return [`SessionVerdict::Initial`] until an existing
/// key is evicted or capacity is raised. A single `warn!` fires the
/// first time saturation is hit (further saturation drops are
/// suppressed to avoid log spam at the packet rate). For existing
/// keys under saturation the update still succeeds, because
Comment on lines +100 to +104

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

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

The doc comment says new sender keys will keep returning Initial until an existing key is "evicted", but SessionTracker never evicts/removes entries (and there is no eviction policy documented/implemented). Please reword this to reflect actual behavior (e.g., until capacity is increased or the tracker is reset/cleared), so callers don't assume entries will eventually be evicted automatically.

Suggested change
/// continue to return [`SessionVerdict::Initial`] until an existing
/// key is evicted or capacity is raised. A single `warn!` fires the
/// first time saturation is hit (further saturation drops are
/// suppressed to avoid log spam at the packet rate). For existing
/// keys under saturation the update still succeeds, because
/// continue to return [`SessionVerdict::Initial`] unless capacity is
/// increased or the tracker state is explicitly reset/cleared. A single
/// `warn!` fires the first time saturation is hit (further saturation
/// drops are suppressed to avoid log spam at the packet rate). For
/// existing keys under saturation the update still succeeds, because

Copilot uses AI. Check for mistakes.
/// `FnvIndexMap::insert` replaces in place.
///
/// Call this once per service entry in an SD message (not once per message),
/// so each service instance gets its own session counter.
Expand Down Expand Up @@ -89,13 +139,31 @@ impl SessionTracker {
}
}
};
self.state.insert(
key,
SessionState {
last_session_id: session_id,
last_reboot_flag: reboot_flag,
},
);
let new_state = SessionState {
last_session_id: session_id,
last_reboot_flag: reboot_flag,
};
if self.state.insert(key, new_state).is_err() {
// Map at capacity and key is new — silently dropping the update
// would lose reboot-detection state. Log the first time we hit
// the wall so bare-metal users can size `SESSION_CAP` up, then
// suppress further warnings so a saturated tracker does not
// spam the log at the incoming-packet rate.
Comment thread
JustinKovacich marked this conversation as resolved.
if !self.saturation_warned {
tracing::warn!(
"SessionTracker at capacity ({}); dropping new sender state for \
sender={} transport={:?} svc=0x{:04X} inst=0x{:04X}. Reboot \
detection disabled for this entry and any further new entries \
(subsequent drops not logged).",
SESSION_CAP,
sender,
transport,
service_id,
instance_id
);
self.saturation_warned = true;
}
}
verdict
}
}
Expand Down Expand Up @@ -310,4 +378,57 @@ mod tests {
let verdict = tracker.check(addr(1000), TransportKind::Multicast, SVC, INST, 2, CONT);
assert_eq!(verdict, SessionVerdict::Ok);
}

#[test]
fn capacity_overflow_drops_new_entries_but_keeps_existing_tracking() {
// Fill the tracker to capacity with unique (sender, service) tuples.
let mut tracker = SessionTracker::default();
for i in 0..super::SESSION_CAP {
let port = 1000 + u16::try_from(i).unwrap();
let v = tracker.check(addr(port), TransportKind::Multicast, SVC, INST, 1, RB);
assert_eq!(v, SessionVerdict::Initial);
}

// One more insert — map is full, new entry dropped. The verdict is
// still Initial (no prior state for this key), but the state is
// never stored so a follow-up is also Initial.
let overflow_addr = addr(9999);
let v = tracker.check(overflow_addr, TransportKind::Multicast, SVC, INST, 1, RB);
assert_eq!(v, SessionVerdict::Initial);
// Because the insert failed, a second call with the same key still
// sees no stored state.
let v = tracker.check(overflow_addr, TransportKind::Multicast, SVC, INST, 2, RB);
assert_eq!(v, SessionVerdict::Initial);

// Previously-tracked senders continue to work normally.
let v = tracker.check(addr(1000), TransportKind::Multicast, SVC, INST, 2, RB);
assert_eq!(v, SessionVerdict::Ok);
}

#[test]
fn capacity_overflow_warns_only_on_first_hit() {
// `saturation_warned` is the latch that guards the tracing::warn!
// call in `check()`. It must flip false → true on the first
// rejected insert and stay true for subsequent hits — otherwise
// a saturated tracker spams the log at the packet rate.
let mut tracker = SessionTracker::default();
for i in 0..super::SESSION_CAP {
let port = 1000 + u16::try_from(i).unwrap();
tracker.check(addr(port), TransportKind::Multicast, SVC, INST, 1, RB);
}
assert!(
!tracker.saturation_warned,
"filling to exactly capacity must not trip the warn flag",
);

// First overflowing key: flag flips to true.
tracker.check(addr(9001), TransportKind::Multicast, SVC, INST, 1, RB);
assert!(tracker.saturation_warned);

// Subsequent overflows leave the flag true; the flag is what the
// implementation checks before emitting a fresh warn!.
tracker.check(addr(9002), TransportKind::Multicast, SVC, INST, 1, RB);
tracker.check(addr(9003), TransportKind::Multicast, SVC, INST, 1, RB);
assert!(tracker.saturation_warned);
}
}
Loading