Skip to content

Feature/shm payload transport - #36

Merged
benaliabderrahmane merged 16 commits into
mainfrom
feature/shm-payload-transport
Jul 16, 2026
Merged

Feature/shm payload transport#36
benaliabderrahmane merged 16 commits into
mainfrom
feature/shm-payload-transport

Conversation

@benaliabderrahmane

Copy link
Copy Markdown
Owner

No description provided.

benaliabderrahmane and others added 16 commits July 3, 2026 10:53
Payloads at or above 64 KiB no longer travel inside the UDS datagram.
The publisher writes the serialized bytes once into a lazily-created
per-publisher /dev/shm ring and fans out a 32-byte descriptor (the
WireHeader msg_type gains a high SHM_PAYLOAD_FLAG bit); subscribers map
the ring read-only on first use and copy the payload out under a
per-record seqlock — the same odd/even protocol the discovery registry
uses — so a publisher that laps the ring is a detected, clean drop,
never a torn message. Small messages, TRANSIENT_LOCAL topics (their
replay cache must outlive the ring) and service traffic keep the
inline datagram path, which is also the fallback when shared memory is
unavailable.

What this buys:
- The payload crosses the kernel zero times: one memcpy into the ring,
  one out per subscriber (1+N copies for N subscribers vs 2N kernel
  copies inline), no syscall proportional to the message size.
- The net.core.wmem_max per-datagram cap (EMSGSIZE at ~208 KiB stock)
  no longer applies to large topic messages; the sysctl requirement
  stays only for the inline path.

The ring holds at least 4 records of the largest payload seen (min
8 MiB) and is recreated larger under a new segment generation when
outgrown; the old segment is unlinked, and readers that already mapped
it finish safely (unlink removes the name, not the mapping). Hardening
that came out of review:
- Segment names mix in a time component (the make_socket_path idiom):
  a recycled PID must not regenerate a dead publisher's name, or a
  subscriber's cached mapping would alias the new ring.
- The ring header is validated once, overflow-safe, at map time and
  its capacity snapshotted; descriptors are bounds-checked against the
  snapshot, so neither a hostile descriptor nor a rewritten live
  header can push reads outside the mapping. Segments are 0644 —
  single writer, world readable.
- The seqlock reader re-checks seq behind an acquire fence so the
  payload loads cannot sink past the check on weakly-ordered CPUs.
- posix_fallocate reserves the ring pages up front: a full /dev/shm
  becomes a clean inline fallback instead of SIGBUS mid-memcpy.
- The reader cache sweeps mappings of vanished segments once it grows
  past a handful of publishers, so publisher churn cannot pin
  unbounded tmpfs memory in long-lived subscribers.
- Orphan segments from ungraceful exits are reclaimed at rmw_init with
  the same dead-PID sweep as the registry and socket files.

Measured (perf_test ROS2 plugin, 1:1, 100 Hz, RELIABLE, KEEP_LAST/16,
quiet host): Array64k 237-273 -> 219-228 us, Array256k 503-530 ->
348-356 us (-32%), Array1m 1447-1512 -> 955-1012 us (-33%), Array4m
3669-3957 -> 3090-3155 us, with max latency roughly halved across
sizes and 100% delivery throughout.

91 tests pass: 8 shm_transport unit tests (round-trip, ring lap,
segment growth, writer-close semantics, hostile descriptors, orphan
cleanup), an end-to-end 300 KB pub/sub round-trip, a pin that
TRANSIENT_LOCAL large messages never touch the ring, and a pin that
large payloads bypass a shrunken send buffer where they used to die
with EMSGSIZE (EMSGSIZE propagation itself is still pinned below the
threshold).
Large TRANSIENT_LOCAL (latched) payloads were always sent inline: the TL
branch in rmw_publish returns before the shm ring fork, so a >~4 MB latched
message hit the kernel's single-datagram cap (EMSGSIZE / ENOBUFS) and was
silently dropped — a late joiner received nothing. The ring could not be
reused for TL because it cycles and would lap a record still awaited by a
late joiner.

Add a dedicated, immutable, per-cache-entry durable shm segment
(shm_stage_durable + DurableShmSegment RAII handle). A large TL payload is
staged once into its own segment; the cache entry holds the descriptor
(SHM_PAYLOAD_FLAG set) and owns the segment, which is unlinked when the entry
is evicted. Replay to late joiners fans out the small descriptor, and the
existing shm_fetch_payload reader path resolves it unchanged. Small payloads
still cache inline; the cycling ring is never used for TL.

Reader side is untouched: durable segments share the ring's on-disk layout,
each gets a unique owner id (so the reader's per-owner stale-mapping sweep
keeps them), and orphan cleanup already covers the shared name prefix.

Tests: add TransientLocalHugeMessageLateJoiner (5 MB latched -> late joiner,
fails on the old inline path, passes here) and repurpose the former
TransientLocalLargeMessageStaysInline into TransientLocalLargeMessageUsesDurableShm
to pin the new invariant (durable segment used, ring untouched, replay works).

Known gap (unchanged, pre-existing): rmw_publish_serialized_message does not
feed the TL replay cache, so large latched messages published via that entry
point are still bounded by the datagram cap.
Follow-up to the durable-shm change: the authoritative DESIGN.md and three
in-code comments still described the just-fixed behavior (TL always inline,
large messages bounded by the datagram cap) as current. Correct them:

- DESIGN.md: the per-message-cap section and "Large payloads" now state that
  large TRANSIENT_LOCAL payloads published via rmw_publish go through a durable
  per-cache-entry shm segment, and add a "Durable segments for latched replay"
  subsection describing the mechanism, lifetime, orphan reclaim, and the one
  behavioural divergence (publisher destroyed before drain -> lost). The cap
  still applies to large service payloads, large TL via
  rmw_publish_serialized_message (does not feed the replay cache), and the
  shm-failure inline fallback — spelled out rather than blanket-claimed.
- rmw_publisher.cpp: the "always sends inline" parenthetical contradicted the
  durable-staging branch it sits next to; corrected.
- shm_transport.cpp: the map_segment "same as inline exiting publisher" comment
  now notes the durable-TL divergence.
- shm_transport.hpp: DurableShmSegment is non-copyable and pinned (user dtor
  suppresses moves); the no-double-free guarantee comes from the owning
  unique_ptr, not the type being movable. Comment corrected.

No behavior change. Build clean, all 92 tests still pass.
Addresses the review findings on the durable-shm TRANSIENT_LOCAL change:

- Return code (finding #1): the TL publish path ignored send_to's result and
  always returned RMW_RET_OK, even when the live send of the current message
  was rejected by the kernel size cap (EMSGSIZE) — a lying success, and
  inconsistent with the non-TL path. Extract the TL cache+replay+send logic
  into transient_local_publish(), which now surfaces ConfigError as
  RMW_RET_ERROR. (ENOBUFS/oversize on a raised-buffer machine stays a soft
  drop in BOTH paths, unchanged — a shared, pre-existing limitation.)

- Serialized TL gap (completeness): rmw_publish_serialized_message did not feed
  the replay cache at all, so a large latched payload published through it was
  sent inline once and dropped for late joiners. It now routes TL through the
  same transient_local_publish() helper (durable staging + replay) and gained
  the same known-subscriber prune-on-refresh as rmw_publish.

- Fallback coverage (finding #6): add a cold-path test seam (env var
  RMW_UDS_TEST_FORCE_SHM_FAILURE, checked only on large latched staging) so a
  test can force the shm-unavailable path and assert the payload is cached
  inline (no durable segment, no flag) and still delivered.

Tests: TransientLocalPublishReturnsErrorOnEMSGSIZE and
TransientLocalSerializedLargeMessageLateJoiner both confirmed to FAIL on the
pre-change publisher and pass here; TransientLocalLargeMessageInlineFallbackWhenShmUnavailable
covers the fallback. Full suite: 95 tests, clean build.
The previous doc commit described large TRANSIENT_LOCAL via
rmw_publish_serialized_message as a known gap (still inline, still capped).
The follow-up commit closed that gap by routing both publish entry points
through the shared transient_local_publish path, so update the four DESIGN.md
statements accordingly: large latched payloads go through a durable shm
segment whether published via rmw_publish or rmw_publish_serialized_message.
The only latched payloads still bound by the datagram cap are those that fall
back to inline because shm staging failed.
From the final adversarial PR review (verdict: ready-with-nits, no blocker):

- Test the serialized-path known-subscriber prune. rmw_publish_serialized_message
  carries its own copy of the prune-on-refresh logic, previously unexercised
  (KnownSubscriberPathsPrunedOnChurn drives rmw_publish only). Add
  TransientLocalSerializedKnownSubscriberPathsPrunedOnChurn as a divergence guard.

- Harden the test seam: match RMW_UDS_TEST_FORCE_SHM_FAILURE only when it is
  exactly "1" (strcmp) rather than any-value presence, so a stray "0"/"" export
  cannot silently force the inline fallback.

- Pin the inline-path assumption of TransientLocalPublishReturnsErrorOnEMSGSIZE
  with static_assert(32 KiB < SHM_PAYLOAD_THRESHOLD), matching its volatile
  sibling PublishReturnsErrorOnEMSGSIZE instead of a prose-only comment.

Full suite: 96 tests, clean build.
Services and clients sent requests/responses inline, so a >~4 MB request or
response hit the AF_UNIX datagram cap and was dropped. Extend the ephemeral
shm ring to the service path (non-latched, so the cycling ring — not a durable
segment — is the right fit):

- UdsService/UdsClient each gain a shm_ring (outbound) + shm_cache (inbound) +
  shm_mutex, mirroring the publisher/subscription pair.
- rmw_send_request / rmw_send_response stage payloads >= SHM_PAYLOAD_THRESHOLD
  into the ring and send a descriptor with SHM_PAYLOAD_FLAG; inline fallback on
  shm failure. rmw_take_request / rmw_take_response mask the flag in the
  msg_type filter and resolve the descriptor via shm_fetch_payload.
- Destroy paths close the ring and reader cache.

The wire format already reserved SHM_PAYLOAD_FLAG as the msg_type high bit and
the receive checks already mask it, so no wire/layout change.

Test: LargeRequestAndResponseViaShm round-trips a 5 MB request and 5 MB
response (both above the datagram cap, so success proves the ring carried
them); confirmed to fail on the pre-change service/client. Full suite: 97 tests.
… helpers

The "stage into ring + set flag, else send inline" and "resolve descriptor or
drop" logic was inline-duplicated across 8 sites (topic publish x2, service
response, client request; subscription drain, wait drain, service take, client
take). Extract two helpers in the transport layer:

  OutboundPayload shm_prepare_send(ring, mtx, domain, payload, size, hdr, desc)
  bool           shm_resolve_incoming(cache, domain, hdr, payload)

Every sender and receiver now calls one of these, so each site is ~4 lines and
the inline-vs-shm decision lives in exactly one place.

Also fixes a latent bug this unification exposed: rmw_wait's drain_socket
resolved shm only for subscriptions (services/clients passed a null cache), so
a large service request/response drained during wait — the path real executors
always take — had its descriptor dropped and the message lost. drain_socket now
takes the reader cache by reference and every caller passes its own, so
services and clients resolve large payloads on the wait path too.

test_transport gains shm_transport.cpp (transport.cpp now calls into it).
New test LargeRequestDeliveredThroughWaitDrain covers the wait-path fix.
Full suite: 98 tests, clean build, no behavior change beyond the bug fix.
… indent

From the correctness review of the service-shm change:
- DESIGN.md: the service-shm commit made three statements false — "all service
  traffic" is inline (L165), "the cap still bites large service payloads" (L187),
  and "Service traffic never uses shared memory" (L200). Correct all three: large
  (>= SHM_PAYLOAD_THRESHOLD) service requests/responses now ride the ephemeral
  ring; only sub-threshold traffic and the shm-failure fallback send inline. Note
  the best-effort ring-lapping drop this introduces even under RELIABLE service QoS.
- rmw_wait.cpp: the two post-epoll service/client drain_socket continuation lines
  were indented call+0 instead of call+2 like every sibling site; align to match.

No behavior change. Build clean, 98 tests pass.
…ader cache

Three measured wins from the performance/scaling review (all correctness-verified;
98 tests still pass):

1. transient_local_publish: stage the durable segment BEFORE taking cache_mutex,
   and destruct trimmed entries after releasing it. shm_stage_durable does
   shm_open+posix_fallocate+mmap (~120us at 256 KiB to ~2.3 ms at 4 MiB) and touches
   no cache_mutex-guarded state, so holding the lock across it was a regression this
   feature introduced: cache_mutex nests under the process-wide
   transient_local_pubs_mutex on the rmw_wait replay path, so one large latched
   publish could stall whole-process graph replay/publisher lifecycle during launch
   bursts. Now the lock only covers push_back + trim + the send loops. Cache order is
   unchanged (push_back still under the lock, in lock-acquisition order).

2. SHM_RING_MIN_BYTES 8 MiB -> 1 MiB. posix_fallocate commits the whole ring's RAM
   up front, and this PR grew the payers from P publishers to P+S+C senders (services
   and clients now have rings). The 4-records-of-largest invariant is enforced by the
   independent max(floor, 4*record) term, so the floor only sets the minimum for
   sub-256 KiB payloads (1 MiB still holds 16 records at the 64 KiB threshold). ~8x
   less resident /dev/shm per large-sender and ~8x faster first-publish create.

3. ShmReaderCache keyed by a 16-byte POD {owner_pid, owner_id, segment_id} instead of
   the segment-path string. domain_id is constant per cache, so those three wire
   fields uniquely identify a segment. Removes a per-received-large-message string
   rebuild (~117 ns) + hash + heap alloc on the receive hot path, and simplifies the
   generation-supersede sweep to integer compares. The name is built only on a miss
   and kept in Mapping.shm_name for the liveness probe. No wire/ABI change.

Also documents the durable create/destroy-per-publish cost as a known characteristic
(latched data is low-rate by nature; high-rate large streams should use VOLATILE).
Six accuracy fixes, no behavior change:

- DESIGN.md ring paragraph still said "(minimum 8 MiB)" after e451e29 lowered
  SHM_RING_MIN_BYTES to 1 MiB; now matches the code and explains why the floor
  is modest.
- DESIGN.md msg_type bullet said SHM_PAYLOAD_FLAG is "only ever combined with
  0" — false since e6de9a6: services and clients set it on msg_type 1 and 2.
  Same falsehood in the shm_transport.hpp flag comment; both now state the flag
  combines with all three types and receivers mask it off.
- shm_transport.hpp file-header block framed the shm path as "large topic
  messages" written by "the publisher"; generalized to all three senders.
- "1 MiB still holds 16 records at the 64 KiB threshold" was off by one:
  align_up(8 + 65536, 64) = 65600, and floor(1 MiB / 65600) = 15. Corrected
  with the arithmetic shown.
- DESIGN.md mixed-build limitation extended to service peers: old-build
  services/clients also silently drop flagged large requests/responses from
  new-build senders.
Closes the top follow-up from the merge-readiness audit: every other binary
runs sender and receiver in one process, so the shared-memory path was never
exercised across a real PID boundary.

New binary test_rmw_cross_process with two tests, both pushing 5 MB payloads
(above the ~4 MB AF_UNIX datagram cap, so byte-identical arrival proves the
bytes crossed through /dev/shm, not the socket):

- LargeTransientLocalLateJoiner: parent latches 5 MB (durable segment), a
  forked child inits on the same domain, subscribes late, and receives the
  replay byte-equal — the child maps the parent's durable segment cross-PID.
- LargeServiceRoundTrip: client child sends a 5 MB request, service parent
  takes it (maps the client's ring), responds with 5 MB, child verifies (maps
  the service's ring). Both ring directions crossed.

CI-safety choreography: fork() before either process touches rmw (no inherited
registry mapping/fds/mutex state); pipe handshakes with bounded poll(); child
alarm() as last-resort kill and _exit() with distinct diagnostic codes (no
gtest in the child); parent reaps via bounded WNOHANG loop with SIGKILL
fallback; a ChildGuard kills+reaps the child if an early ASSERT bails out of
the test (prevents ctest stalling on the inherited stdio pipe and re-run
poisoning); SIGPIPE ignored so a dead child fails the test, not the binary;
children tear down cleanly on success so nothing outlives a green run.
Private domains 91/92 keep it isolated from the single-process suite (99).

Sensitivity-verified: with RMW_UDS_TEST_FORCE_SHM_FAILURE=1 the latched test
fails (child recv timeout) because the 5 MB replay goes inline and the kernel
rejects it — the test genuinely detects a broken cross-process shm path.
Full suite: 100 tests across 14 binaries; cross-process binary green 10/10
consecutive runs, no leftover /dev/shm segments.
Large messages were serialized into a fresh heap vector (whose resize()
zero-initializes every byte), then memcpy'd into the ring — three full passes
over the payload plus an allocation, per publish. Now the size walk picks the
destination up front and the CDR serializer writes straight into the reserved
ring record; the intermediate payload never exists.

- serialization: serialized_size() (size walk without serializing) and
  serialize_into() (serialize into caller memory; wire-equivalent to
  serialize() — CDR padding bytes are unspecified since fastCDR skips them,
  and decoders skip the same bytes).
- shm_transport: two-phase staging — shm_stage_reserve() marks the record's
  seqlock odd and returns its payload area; shm_stage_commit() publishes the
  ACTUAL serialized length (cursor advances by actual, so a size-walk
  overestimate wastes nothing); shm_stage_abort() leaves the seqlock odd and
  the cursor unchanged, so a failed serialize is never observable and the slot
  is reused. shm_stage_payload() is now reserve + memcpy + commit — one
  protocol implementation.
- transport: shm_serialize_prepare_send() composes size walk -> reserve ->
  serialize-into -> commit, falling back to the inline vector path on any shm
  failure (and to it for sub-threshold payloads; exactly one size walk in all
  paths). Used by rmw_publish (non-latched), rmw_send_request,
  rmw_send_response. TRANSIENT_LOCAL keeps serialize-then-stage (its replay
  cache owns the bytes); rmw_publish_serialized_message has no serialize step.

Measured with the committed benchmark (bench_serialize_into_ring, real
fastCDR, UnboundedSequences): staging 2.0-3.2x faster — 15 us saved per 64 KiB
message, 264 us per 1 MiB, 1.29 ms per 4 MiB.

Tests: serialize_into wire-equivalence (incl. field-heavy MultiNested) and
capacity-overflow rejection; reserve/commit round-trip; commit-smaller-than-
reserved packs tight; abort leaves the slot reusable and unobservable; a
descriptor into a reserved-but-uncommitted slot drops cleanly; over-
reservation commit rejected. Full suite: 109 tests across 14 binaries.
From the R7 adversarial review (verdict: needs-minor-changes):

- The inline path's payload.resize(est) in shm_serialize_prepare_send had no
  exception containment, so bad_alloc/length_error escaped across the
  extern "C" boundary (rmw_publish, rmw_send_request, rmw_send_response) and
  killed the node — the exact failure serialize()'s try/catch existed to
  contain, deterministically reachable when est > UINT32_MAX forces the
  inline fallback. Contained; callers map false to RMW_RET_ERROR as before.
- Extend the RMW_UDS_TEST_FORCE_SHM_FAILURE seam into shm_stage_reserve and
  add VolatileLargeMessageInlineFallbackWhenShmUnavailable: a large VOLATILE
  publish under forced shm failure must create no ring, carry no flag, and
  deliver byte-equal inline — pins the reserve-failure branch and the
  contained resize.
- CommitOverReservationIsRejected now also pins cursor/index non-advance
  (the rejection behaves like an abort).
- DESIGN.md: the Staging-and-fanout parenthetical wrongly said TRANSIENT_LOCAL
  stages via shm_stage_payload; it uses shm_stage_durable's immutable segment
  (matching the Durable-segments paragraph). Corrected.
- test/perf/README.md: run command for bench_serialize_into_ring.

Full suite: 110 tests across 14 binaries.
perf(shm): serialize CDR directly into the ring record (R7)
…-payload

feat(shm): shm-backed large payloads for latched topics and services
@benaliabderrahmane
benaliabderrahmane merged commit e312f84 into main Jul 16, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant