feat: consolidated 0.8.0 stack (forward-ports + #130) → main#138
Merged
Conversation
JustinKovacich
changed the base branch from
feat/port_per_source_e2e_onto_0_8_0
to
main
June 26, 2026 12:27
Covers the send path end-to-end (SOME/IP envelope, SD flags, OfferService entry fields, and the IPv4 endpoint option) plus session-id advancement and wrap-through-zero exercised via send_offer_service itself, and a smoke test for Server::start_announcing. All `#[ignore]`d pending the loopback MULTICAST-flag fix on this branch; without that fix, hosts drop the multicast packet silently and the tests time out on recv. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rustfmt on stable wraps the let-else continue blocks and the assert_offer_matches signature differently than I hand-wrote. Let cargo fmt normalize the style. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per AUTOSAR SOME/IP-SD, the reboot bit on emitted SD messages must flip from RecentlyRebooted to Continuous once the session counter wraps past 0xFFFF. SdStateManager already owns the counter, so it also tracks wrap (new has_wrapped: AtomicBool latched exactly on the 0xFFFF -> 0x0001 transition) and exposes reboot_flag() as the single source of truth. The four SD emission paths — SdStateManager::send_offer_service, Server::send_unicast_offer, send_subscribe_ack_from_view, and send_subscribe_nack_from_view — all now consume the tracked flag instead of hardcoding Flags::new(true, true). Coverage: four new non-ignored unit tests cover the state machine (fresh, sub-wrap, exactly-on-wrap, monotonic-after-wrap); assert_offer_matches takes an expected RebootFlag; the existing wrap multicast test now asserts first-emit=RecentlyRebooted and second-emit=Continuous across the boundary. Responds to PR #75 feedback on src/server/sd_state.rs:90. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Round-2 Copilot review caught a real correctness bug in the prior reboot-flag refactor: the four SD emission paths read `reboot_flag()` BEFORE advancing the session counter, so the message whose session_id crosses 0xFFFF -> 0x0001 (where `has_wrapped` actually latches) still advertises `RebootFlag::RecentlyRebooted`. The flip only lands on the NEXT emission — violating AUTOSAR SOME/IP-SD semantics that say the wrap message itself should carry `Continuous`. Reordered in all four sites: call `next_session_id()` first so `has_wrapped` latches, then read `reboot_flag()` for this specific message. Sites: - `SdStateManager::send_offer_service` (sd_state.rs) - `Server::send_unicast_offer` (mod.rs) - `Server::send_subscribe_ack_from_view` (mod.rs) - `Server::send_subscribe_nack_from_view` (mod.rs) Added short comments at each site pointing at the canonical ordering note on `send_offer_service`. Also reworded the multicast-loopback `#[ignore]` comment block and per-test message to remove the stale branch-name reference (`feature/firmware_someip_conversion`) — the underlying dependency is the `lo` MULTICAST flag, not a branch-specific fix. New wording says "skipped on hosts whose `lo` lacks the MULTICAST flag" with the `ip link show lo` diagnostic pointer. Coverage: the existing ignore-gated wrap test `send_offer_service_wraps_session_id_through_zero_on_send` already asserts the pre-wrap/post-wrap flag transition on-the-wire; with the ordering fix it now passes in environments that run ignored tests (would have FAILED before this commit — which is why the bug slipped past the first round). The non-ignored state-machine tests (`reboot_flag_flips_to_continuous_exactly_on_wrap` et al.) are unaffected and still green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot round-3 flagged the `#[ignore]` reason on
start_announcing_emits_first_offer_within_timeout for still
carrying a branch-specific phrase ("re-enable after lo fix on
this branch"), which becomes stale once merged. Replaced with
a durable prerequisite description:
requires loopback multicast support (MULTICAST on lo)
Matches the companion rewording in a638a4b on the sd_state.rs
multicast-loopback harness comment block.
Addresses Copilot comment 3132878961.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eatures behind gates
- inner.rs: on request_queue overflow, reject the ControlMessage by
sending Err(Error::Capacity("request_queue")) on every oneshot it
carries (via new ControlMessage::reject_with_capacity helper) rather
than silently dropping it — the drop previously cancelled the oneshot
and panicked callers awaiting with .unwrap().
- inner.rs: on pending_responses.insert saturation, destructure the
returned (request_id, response) and send
Err(Error::Capacity("pending_responses")) on the response sender;
previously the sender was dropped, panicking PendingResponse::response.
- mod.rs: update the send_to_service saturation doc to match the new
explicit-error behavior.
- mod.rs: fix the module-header footprint doc — SESSION_CAP lives in
session.rs, not inner.rs.
- session.rs: gate the saturation warn! behind a one-shot saturation_warned
latch; previously the warning fired on every check() against every new
key once the map was full, which meant log-spam at the packet rate.
- error.rs: drop #[non_exhaustive] on client::Error; downstream crates
relying on exhaustive matches shouldn't silently break before a planned
breaking release.
New tests: reject_with_capacity_notifies_every_sender covers every
ControlMessage variant (including SendToService's dual senders);
capacity_overflow_warns_only_on_first_hit locks in the saturation latch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Commit cb1d0d1 added explicit capacity-error delivery when pending_responses.insert overflows — without the explicit Err send, the dropped Sender would cause PendingResponse::response().await to panic on RecvError instead of surfacing a clean Result. The recovery logic was inline in the SendToService run-loop arm, which made it hard to exercise without driving 64 live sockets. Lift it to a small `track_or_reject_pending_response` helper on Inner so the SendToService arm delegates, and the branch is testable against the exposed `pending_responses` map directly. Added two tests: - `track_or_reject_pending_response_inserts_when_room_available`: happy path — entry lands in the map, the sender is still live (receiver stays pending). - `track_or_reject_pending_response_rejects_on_saturation`: fill map to PENDING_RESPONSES_CAP, invoke the helper with one more request, assert the map is unchanged and the caller's receiver resolves to Err(Error::Capacity("pending_responses")) — which is the invariant cb1d0d1 guarantees. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Round-2 Copilot feedback on PR #76 caught 5 resolvable issues (6th rejected — see below): - src/client/inner.rs: the three `push_front(...).ok()` re-enqueue sites (SetInterface x2, SendSD, Subscribe) silently dropped the returned Err. Swapped each to `if let Err(rejected) = ... { ... rejected.reject_with_capacity("request_queue"); }`, matching the primary `push_back` overflow arm. These branches are defensive — by construction the slot we just popped is free — but a future refactor that changes queue usage would otherwise reintroduce the silent-drop-of-oneshot-senders regression cb1d0d1 was specifically written to prevent. - src/server/subscription_manager.rs: `list.push(...).ok()` on a freshly-allocated `SubscribersList` (first subscriber in a new event group) swapped to `.expect(...)` with a message naming the `SUBSCRIBERS_PER_GROUP >= 1` invariant. Tripwires a future cap-0 regression at test time instead of silently losing the only subscriber. - src/client/session.rs: reword the `SessionTracker` doc comment's reference to non-existent "module docs" — point directly at the `SESSION_CAP` constant instead. Rejected: src/client/error.rs `Capacity` variant as a breaking exhaustive-match break. This is consistent with the earlier decision recorded on #75/#80 to accept the breaking change rather than carry deprecated shims or wrap everything in `Error::Io` — the current release is a breaking version bump by design, and hiding the variant in `Io` would lose the lowercase-snake_case tag semantics the new error was specifically designed to carry. No production behavior changes — just defensive tripwires, docs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Address two clippy::pedantic warnings introduced by this branch's own commits: - missing_panics_doc on SubscriptionManager::subscribe — the heapless::Vec::push.expect on the first-insert path can only trip if SUBSCRIBERS_PER_GROUP is set to zero; document that. - cast_possible_truncation on 'i as u32' in the saturation test for pending_responses — use u32::try_from with an expect that documents why the 64-cap fits.
Copilot round-3 on PR #76 (comment 3138669839) caught a real gap in `track_or_reject_pending_response`: the helper only handled `Err((key, value))` (map-full + new-key), but `heapless::IndexMap:: insert` on an existing key returns `Ok(Some(old_sender))` and the old sender was silently dropped. If `request_id` is ever reused while an older pending entry is still live — the documented example is `session_counter` wrap-around — the caller awaiting the original request would see a `RecvError` on channel cancellation, which `PendingResponse::response()` turns into a panic. Reworked the `if let Err(...)` into a full `match` with all three arms: - `Ok(None)` — normal insert, nothing to do. - `Ok(Some(displaced))` — `warn!` that the slot was replaced, complete the displaced sender with `Err(Error::Capacity("pending_responses"))` so the original caller gets a clean `Result`, not a `RecvError` panic. - `Err((_req_id, r))` — existing saturation path (unchanged). Also updated the doc comment on `track_or_reject_pending_response` to describe the displacement contract. Coverage: new test `track_or_reject_pending_response_completes_displaced_sender` explicitly inserts the same key twice and asserts the first receiver resolves to `Err(Error::Capacity("pending_responses"))` before the second sender still sits pending in the map. Addresses Copilot comment 3138669839. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old docstring explained why client::Error is not #[non_exhaustive] but didn't acknowledge that adding variants to a non-#[non_exhaustive] enum is itself a breaking change for downstream exhaustive matches. Rewrite the note to call that out explicitly and flag future #[non_exhaustive] as a planned breaking-release change, and record the new Capacity variant under the Unreleased Added section. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SubscriptionManager::subscribe previously returned () and logged a warn! on capacity failure, which let handle_sd_message send SubscribeAck to a client whose subscription had silently been dropped — the client would believe it was subscribed but never receive any events. - subscribe now returns Result<(), SubscribeError> with SubscribersPerGroupFull / EventGroupsFull variants; existing capacity/refresh semantics are unchanged. - handle_sd_message inspects the result and emits SubscribeNack with a reason derived from the SubscribeError variant on rejection. - EventPublisher::register_subscriber surfaces the same Result so external SD dispatchers can take the same corrective action. - SubscribeError is re-exported from server::mod. - Tests updated to consume the Result; subscription_manager overflow tests now assert the specific error variant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ording - session::check: expand the doc comment with an explicit "Capacity behavior" section — the saturation path does not update stored state for new keys, which contradicts the old "Always updates the stored state" wording. Describe the actual behavior (normal path updates, new-key inserts under saturation are silently dropped and return Initial repeatedly) so callers don't rely on a strict guarantee the implementation doesn't hold. - session::check saturation warn!: include sender + transport in the log line so diagnosing which peer lost reboot-detection state no longer requires out-of-band correlation. - SubscriptionManager::subscribe dedup branch: rename the log from "Refreshed existing subscriber" to "already subscribed; skipping" and add a comment making it explicit that no per-subscriber state is modified. The old wording implied a refresh (TTL bump, etc) the code never performed. - inner.rs test comment: tokio::sync::oneshot doesn't drop the sender when the receiver is dropped — it flips send() to a failure mode. Rewrite the comment to describe the actual stash purpose (keep channels open for later observation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- server::run NACK arm: stop allocating a String with format!() held across the SubscribeNack await; log the SubscribeError at warn! separately and pass a static "subscription rejected" reason string to send_subscribe_nack_from_view. Cleaner and avoids the borrow- across-await style issue Copilot flagged. - EventPublisher::register_subscriber: add # Errors section describing the two SubscribeError variants, that the subscriber is NOT registered on Err, and that external dispatchers should NACK on Err just like the server's own run() loop does. - CHANGELOG: add server::SubscribeError to Added, and list the breaking signature changes on SubscriptionManager::subscribe and EventPublisher::register_subscriber under Changed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ubscribe docs - CHANGELOG.md: drop the two "Changed" bullets about `std` becoming the default feature and `thiserror`/`tracing` moving to `default-features = false`. Those landed in 0.6.0/0.6.1 (commit f161980, already on main) and are not changes made in this PR. - server/mod.rs: when a SubscribeError rejects a subscription, match on the variant and pass a specific `&'static str` reason to `send_subscribe_nack_from_view` (`"subscribers_per_group_full"` / `"event_groups_full"`) instead of the generic `"subscription rejected"`. The NACK log line now reflects the real cause, and the static-str choice avoids any String-across-await allocation. - server/subscription_manager.rs: rewrite the `subscribe` docs to say the duplicate path is idempotent / deduplicated rather than implying TTL-refresh semantics that don't exist today. Flag the future-TTL extension point explicitly so the doc and the log wording stay in sync if that behavior is added later. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…urns an error, added unit tests
Commit 343da67 added a `required_size() > UDP_BUFFER_SIZE` pre-check to `EventPublisher::publish_event` but left the new branch uncovered. Regression guard added as `publish_event_pre_encode_exceeds_udp_buffer_returns_capacity_error`: registers a subscriber (the pre-check sits after the `subscribers.is_empty()` early return, so the test needs one or else hits the false-positive Ok(0) path), constructs a 1501-byte fixture (16-byte header + 1485-byte payload, one over the cap), calls publish_event, asserts Err(Error::Capacity("udp_buffer")). Mirrors the fixture pattern from `send_raw_message_exceeding_udp_buffer_returns_capacity_error` on the client side. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- src/lib.rs: UDP_BUFFER_SIZE doc now enumerates exactly which send paths honor this cap (SocketManager::send, publish_event, publish_raw_event) and explicitly calls out the SD announcement / SubscribeAck / SubscribeNack paths that still use heap Vec buffers as a known gap planned for the bare-metal no_alloc refactor. - src/server/event_publisher.rs: reworded "stack buffer sized to MTU" comments at the two buffer-allocation sites — the buffer lives in the async future's state, not literally on the stack, and the cap is a UDP payload limit, not an Ethernet MTU. New wording points at the UDP_BUFFER_SIZE docs for the distinction. The two `use std::vec` comments (event_publisher.rs:335, socket_manager.rs:362) were verified to be false positives: removing the imports breaks the lib-test build with 4 errors about `vec!` macro not in scope. Same no_std mechanics as the prior #75-1 resolution — reply posted on the comment threads. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three doc-only Copilot round-3 nits on PR #77: - src/client/mod.rs: the per-`SocketManager` memory-footprint blurb implied the second `[u8; UDP_BUFFER_SIZE]` is always allocated. In fact `socket_loop_future` only allocates that scratch buffer when the send path actually needs E2E protection (the destination key is in the `E2ERegistry`); plain sends pay only ~1.5 KiB. Reworded the always-live vs peak budget so the ~24 KiB number is no longer presented as the steady-state cost. - src/client/socket_manager.rs: the E2E-overflow test comment said "8 over MTU", but `UDP_BUFFER_SIZE` is documented as a UDP payload cap, not an Ethernet-MTU-safe size. Reworded to "8 bytes over UDP_BUFFER_SIZE". - src/lib.rs: the `UDP_BUFFER_SIZE` doc referenced bare `Error::Capacity("udp_buffer")`, which is ambiguous at the crate root (no `crate::Error` exists). Qualified to `client::Error::Capacity(...)` / `server::Error::Capacity(...)`. Addresses Copilot comments 3138697909, 3138698042, 3138698156. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…event
Adds a unit test that registers a Profile4 E2E profile for the message
key and publishes a message whose raw encoded size fits UDP_BUFFER_SIZE
(1496 bytes) but whose protected size does not (1508 bytes, after the
12-byte Profile4 header). Asserts that publish_event returns
Error::Capacity("udp_buffer") — exercising the post-protect guard that
was previously only covered on the client send path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- publish_event: log now says "E2E-protected datagram (... header + protected payload)" so the 16+protected_len value is identified as the full SOME/IP datagram size, not the payload. - test fixture comment: "8 over MTU" → "8 bytes over UDP_BUFFER_SIZE" for terminology consistency with the rest of the PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_len
`header_len + payload.len()` used unchecked `usize` addition. On a
system with large enough `payload.len()` the sum can wrap, silently
bypassing the `> UDP_BUFFER_SIZE` guard and corrupting the slice
operations that follow. Switch to `checked_add` and treat overflow the
same as exceeding `UDP_BUFFER_SIZE` — return
`Error::Capacity("udp_buffer")`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Client/server "E2E-protected payload" logs now say "E2E-protected datagram (header + protected payload)" since the logged value is the full SOME/IP datagram size, not the payload. - publish_raw_event checked_add-fail log now describes the real condition (usize overflow) and includes the input lengths, instead of falsely pointing at UDP_BUFFER_SIZE. - Oversize fixtures in socket_manager + event_publisher tests are now sized from UDP_BUFFER_SIZE (and PROFILE4_HEADER_SIZE implicitly for the E2E case) instead of hardcoded 1480/1485. Fixtures stay valid if the cap is retuned. - client/mod.rs memory-footprint doc no longer hardcodes "(1500 bytes)" or "~1.5 KiB" as load-bearing numbers; the scaling is now expressed in terms of UDP_BUFFER_SIZE with the current-default numbers as a parenthetical reference. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`SO_REUSEPORT` / socket2's `set_reuse_port` is Unix-only and does not compile on Windows. The Windows CI job compiles the integration suite (`--no-run`); this spine-era test (added post-#130, which had gated its own `set_reuse_port` calls) was the lone unguarded call site — every `set_reuse_port` in `src/` is already `#[cfg(unix)]`-guarded. The test is `#[ignore]`'d and only runs on Linux CI, so the Windows build just needs it to compile. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4 tasks
JustinKovacich
force-pushed
the
feat/port_dual_socket_sd_onto_0_8_0
branch
from
June 27, 2026 02:38
be68baf to
019bd25
Compare
…t variables to always build with exact required memory
…criber test after #134 rebase bf21b2c made the SOME/IP server caps env-configurable but lowered the *defaults* (SUBSCRIBERS_PER_GROUP 16->1, EVENT_GROUPS_CAP 32->4, ServerConfig EVENT_GROUP_IDS_CAP 32->1, ACCEPTED_OFFERS_CAP 16->4) for every build. Those consts are shared with the std/tokio server, so plain std consumers that never inject SIMPLE_SOMEIP_MAX_* were silently capped (e.g. one subscriber per event group), and 11 lib unit tests + the multi-subscriber integration test went red. - Gate the fallback defaults by feature: bare-metal keeps the tight values (the host build injects exact sizes via the env vars — the memory win), std builds keep the historical generous defaults. Env override still applies to both via from_env_or(option_env!(...)). - Expose ServerConfig::SUBSCRIBERS_PER_GROUP_CAP so callers/tests can adapt to the build-time capacity. - from_env_or: fix clippy::pedantic (doc backticks, manual_assert, i32->usize sign-loss via is_ascii_digit) and gate on `server` (its only callers) to drop the dead_code warning on client-only / bare_metal-only builds. Test fixes (rebase artifacts from porting onto #134): - test_multiple_subscribers_receive_events / test_client_send_sd_auto_binds_discovery sent to Ipv4Addr::LOCALHOST while create_server binds SERVER_IP (127.0.0.2, the #130 dual-socket split) -> subscribes hit a dead address, 0 registered. Restore SERVER_IP. Also gate the second-subscriber assertion on SUBSCRIBERS_PER_GROUP_CAP so it passes at the bare-metal default of 1 and exercises the real fan-out under SIMPLE_SOMEIP_MAX_SUBS>=2. - inner.rs: restore the discovery_unicast_socket teardown in unbind_discovery dropped during the rebase. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ult caps The server runtime caps share one set of consts whose default is tight under `bare_metal` and generous otherwise, so a single unified build cannot satisfy both the std host tests (need the generous default) and the bare-metal tests. Split the test/coverage job accordingly: - Add `HOST_FEATURES` (= `ALLOC_FEATURES` minus `bare_metal`/`embassy_channels`). - Coverage is now two merged `cargo llvm-cov --no-report nextest` runs — the host/std surface under `$HOST_FEATURES` (generous default caps) and the bare-metal-gated test binaries under `$ALLOC_FEATURES` (tight default caps) — combined via `cargo llvm-cov report`. Merged line coverage stays ~81%. - `test-windows` switches to `$HOST_FEATURES`: it's a std/host portability check and building with `bare_metal` would cap its `--lib` server tests to one subscriber per group and fail them. The no-alloc witnesses (`harness = false`) keep running as their own minimal- feature gate and are excluded from the alloc-featured coverage run, where `std` legitimately allocates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
JustinKovacich
force-pushed
the
feat/port_dual_socket_sd_onto_0_8_0
branch
from
June 27, 2026 10:49
019bd25 to
c0ccde2
Compare
`cargo fmt --check` wants the `crate::from_env_or(...)` const initializers wrapped one-arg-per-line. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FelicianoAngulo2
approved these changes
Jun 29, 2026
FelicianoAngulo2
left a comment
Contributor
There was a problem hiding this comment.
Approved based on halo integration results OK:
https://github.com/luminartech/halo/pull/4767
This was referenced Jun 29, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Consolidated 0.8.0 stack → main
This PR now carries the entire 0.8.0 spine + forward-port stack (202 commits), rebased linearly onto
main. It supersedes and folds in the former stack:publish_raw_event_to+subscriber_addresses(closed, folded)mainalready had it; the rebase reconciled to the forward-ported spine implementation (no loss).Verification (post-rebase): workspace build clean · 556 all-features lib tests · client_server 11/11 (single-threaded; parallel run hits the known #84 fixture-collision flake) · all 5 CI clippy lanes green · no_alloc_witness · bare_metal_e2e 9/9. Pre-rebase tip backed up at
backup/port_dual_socket_sd_080-prerebase-20260626.Forward-ports #130 (per-transport SD session tracking via dual discovery sockets) onto the 0.8.0 spine. #130 shipped on the 0.7.x line and on
main, but the spine's phase-13–21 discovery-loop rewrite was branched off a pre-#130mainand never picked it up — so 0.8.0 regressed the false-reboot fix. This is also the unicast-SD discovery arm that was dropped from #137 as "not on the spine"; it wasn't out of scope, just un-ported.Why
A sensor keeps independent SD session-id domains per transport (multicast ~1468, unicast ~739). On the spine, all SD arrived on the single multicast socket tagged
Multicast, so the two counters collided on oneSessionTrackerkey and the interleaving read as perpetual reboots.What (re-applied to the generic
TransportFactory/BindDispatch/Spawnersurface — not #130's raw-socket2path)SocketManager::bind_discovery_unicast_with_transport[_local]— a receive-only unicast SD socket bound to the specific interface IP on the SD port (vs the multicast socket'sINADDR_ANY), no group join. "Most-specific bind wins" diverts the sensor's unicast SD here.BindDispatch::bind_discovery_unicast— claims a buffer lease and binds via the Send / Local dispatch. Cost: one extra socket-loop buffer per client (relevant to bare-metal static-pool sizing).Inner— newdiscovery_unicast_socket, best-effort bound inbind_discovery(a unicast-bind failure is non-fatal; multicast SD still works), shut down inunbind_discovery. The inline discovery-handling block is extracted toSelf::handle_discovery_datagramand driven from both the multicast arm (TransportKind::Multicast) and a new unicast select arm (TransportKind::Unicast).receive_discoveryalready pends gracefully on an unbound socket, so it's reused for the new arm.SessionTrackeralready keys by transport, so no engine change there.Tests
SessionTrackerregression tests (interleaved_transports_…,same_transport_mis_tag_false_reboots).dual_socket_splits_multicast_from_unicastkernel-behaviour spike (SKIPs onlo-only CI).127.0.0.2(client stays127.0.0.1) so the client's own unicast SD socket can't steal itsSubscribeAckunderSO_REUSEPORT; and arecv_unicasthelper drains interleaved discovery acks before asserting events.Verification
cargo clippypedantic: ALLOC workspace,no-default,client,bare_metal,server,bare_metal,client,server,bare_metal, and the nightlybare-metal-runtime,{client,server}lanes — all clean ✅client_server11/11 (single-threaded — the file's documented parallel-flake caveat is unchanged);no_alloc_witness(still zero-alloc);bare_metal_e2eincl. buffer-pool accounting ✅🤖 Generated with Claude Code