Repository navigation
feat(tl): pull-based TRANSIENT_LOCAL replay; interest-scoped doorbell - #60
Conversation
Replace push-based latched replay (publisher process woken to resend) with a pull: the publisher writes each latched sample into a per-publisher shm cache at publish time, and a late-joining subscription reads that cache itself inside rmw_create_subscription. The idle publisher is never woken, polled, or rung — the doorbell broadcast that melted 200-node launches (every registry mutation ringing every process, each wake rescanning the registry) is gone. Mechanism: - tl_ring (shm_transport): fixed ring of qos.depth per-record-seqlocked slots, created strictly BEFORE registry_add and named in the publisher slot's previously-unused socket_path field; unique per-incarnation name (counter + time salt) so a recycled pid can never alias a dead publisher's cache, plus the full 16-byte GID embedded and verified by the puller. Sparse ftruncate + per-record posix_fallocate: an idle latched publisher costs one inode and a page; ENOSPC degrades to latch-less with a logged error, live delivery unaffected. Payloads > TL_EMBED_CAP (1 KiB) ride the existing durable segments as 32-byte descriptors; ring bytes capped at TL_RING_MAX_BYTES. - Losslessness is a store-buffering fence pair: publisher does ring-write -> seq_cst fence -> FRESH generation load -> refresh -> fan-out (sequence numbers assigned inside the latch critical section, so ring order == seq order); subscriber does registry_add -> seq_cst fence -> pull. Overlap is deduped by a per-publisher watermark keyed on the full GID (context_id bytes make it pid-reuse-safe), checked in both drains before descriptor resolve. - Pulled records resolve descriptors through a pull-local reader cache, are filtered by durability (VOLATILE late joiners no longer receive history — DDS-correct; the old push replayed to every subscriber on the topic) and by ignore_local_publications, trimmed to queue depth, and enqueued before the subscription handle returns, so history always precedes live samples. - Doorbell: socket still bound at rmw_init, but the ENTRY_DOORBELL slot is registered lazily, on the first rmw_wait holding a graph guard condition — only graph-event consumers (wait_for_service, GraphListener, rosbag2) need registry wakeups now. Registration precedes that wait's generation check (register-before-snapshot, as rmw_init did); failure retries next wait. Doorbell-slot mutations no longer ring (no consumers; K lazily-registering processes would otherwise mini-storm K^2/2 datagrams). - registry: teardown_slot shm_unlinks tl_-prefixed slot paths, so the stale reaper reclaims a dead publisher's cache promptly; the orphan sweep gains a ros2_uds_tl_ prefix pass. - Deleted: transient_local_pubs (+mutex), known_subscriber_paths, CachedMessage / heap message_cache, transient_local_publish's replay loop, and the whole wait-side TL replay block. Launch arithmetic at N=200 (verified against the doorbell regression analysis): doorbell datagrams drop from ~100,300 (19,900 of them from doorbell self-registration alone) to ~the number of graph-event consumers; wake-side registry copies from O(mutations x processes x slots) to zero for plain pub/sub processes. Replay latency improves from ~20 ms (doorbell wake) to synchronous at subscription creation. Tests: 14/14 suites green on jazzy. New: pull with no rmw_wait anywhere in the process, exactly-once under pull/live overlap, ignore_local filtering of pulled history, subscriber-churn redelivery. Rewritten to the new contract: shm-unavailable large latched payloads are not latched (was: inline heap fallback replayed by push); known_subscriber_paths pruning tests deleted with the machinery. Container note: the full suite needs --shm-size >= 1g (the 64 MB Docker default cannot hold the 38 MB registry plus payload rings). Mixed-build window: a new publisher no longer push-replays and an old subscriber never pulls, so latched replay across that pair is lost during a rolling upgrade — upgrade together (same precedent as the shm payload flag). Old publisher + new subscriber keeps working (empty socket_path skips the pull; the old push path still delivers).
There was a problem hiding this comment.
Pull request overview
Replaces publisher-driven TRANSIENT_LOCAL replay with synchronous subscriber-side shared-memory pulls and scopes doorbell registration to graph consumers.
Changes:
- Adds per-publisher shared-memory history rings and replay deduplication.
- Lazily registers graph doorbells to reduce launch-time broadcasts.
- Expands replay, failure, churn, and concurrency tests.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
DESIGN.md |
Documents pull replay and scoped doorbells. |
src/types.hpp |
Adds replay-ring and watermark state. |
src/shm_transport.hpp |
Defines the latched-ring API and layout. |
src/shm_transport.cpp |
Implements ring creation, latching, pulling, and cleanup. |
src/rmw_publisher.cpp |
Latches samples before live fan-out. |
src/rmw_subscription.cpp |
Pulls history during subscription creation. |
src/rmw_wait.cpp |
Removes wait-side replay and lazily registers doorbells. |
src/rmw_init.cpp |
Defers doorbell registry registration. |
src/registry.cpp |
Scopes ringing and cleans stale replay caches. |
test/test_shm_transport.cpp |
Tests ring behavior and poisoned slots. |
test/test_rmw_qos.cpp |
Tests pull replay, deduplication, failure, and churn. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| sub->ignore_local_publications, sub->context->context_id, | ||
| &sub->replayed_watermarks); |
There was a problem hiding this comment.
Confirmed and fixed in 0898d48 — the post-epoll drain now passes &sub->replayed_watermarks like the pre-block drain. Good catch: this was a real duplicate-delivery window for a datagram landing while the wait was blocked.
| if (live_desc_out) { | ||
| *live_desc_out = desc; | ||
| } | ||
| if (staged_out) { | ||
| *staged_out = true; | ||
| } |
There was a problem hiding this comment.
Confirmed and fixed in 0898d48 — live_desc_out/staged_out are now filled only after the record commits and durable_segs[slot] owns the segment, so an ENOSPC on the slot fallocate can no longer hand the caller a descriptor to an unlinked segment.
| const int32_t idx = rmw_uds::registry_add(reg_header, dentry); | ||
| if (idx >= 0) { | ||
| ctx->doorbell_registry_index = idx; | ||
| ctx->doorbell_registered.store(true, std::memory_order_release); | ||
| } |
There was a problem hiding this comment.
Confirmed; fixed in 0898d48 with a different remedy than an error return: RMW_RET_ERROR from rmw_wait is executor-fatal in rclcpp, so an unwired graph wait now bounds its block at 200 ms (spurious-OK wake) and retries registration on each re-entry until a registry slot frees, with a throttled warning. Liveness is preserved without killing the executor over a transient registry-full.
| auto * sub = rmw_create_subscription(node, ts, "/pull_stress", &qos, &sub_opts); | ||
| ASSERT_NE(nullptr, sub); |
There was a problem hiding this comment.
Confirmed and fixed in 0898d48 — added a stop/join RAII guard so a fatal assertion reports the failure instead of std::terminate-ing the test process.
…wired graph waits, test join guard Four findings from PR review, all confirmed against the code: - rmw_wait: the post-epoll subscription drain did not receive the replay watermark map, so a pull/live-overlap datagram arriving while the wait was blocked bypassed dedup and was delivered twice. Both subscription drain sites now pass the watermarks. - tl_ring_latch: live_desc_out/staged_out were set before the slot's posix_fallocate; on ENOSPC the durable segment was destroyed on return while the caller had already seen staged==true and would fan out a descriptor to an unlinked segment — silently losing the live large message. Outputs are now filled only after the record commits and the ring owns the segment. - rmw_wait: a graph-GC wait whose lazy doorbell registration failed (registry full) could block unbounded with no wakeup wiring, losing graph events forever. Such waits now bound their block at 200 ms (spurious-OK wake), degrading to a coarse retry loop until a slot frees and registration succeeds; a throttled warning surfaces the state. - test: the churn stress test's fatal assertions could return with the publisher thread still joinable, turning a test failure into std::terminate. Added a stop/join guard on every exit path. 14/14 suites green on jazzy.
…aging off the lock, ordered fan-out 21 confirmed findings from the independent adversarial diff review (29 agents, every finding verified against the code before counting). The blocker and the significant ones: - BLOCKER, watermark soundness: tl_ring_pull's slot scan is not a point-in-time snapshot — a publisher latching DURING the scan could produce a max-seq watermark covering a sample the scan missed, whose in-flight datagram the drains would then drop: silent loss. The pull now detects scan overlap (re-reads every pulled slot's seq after the pass); on overlap it keeps only the contiguous sequence prefix and lets everything above the first gap arrive as datagrams. Without overlap, a gap can only be a dead writer's poisoned slot (datagrams will never come), where keeping the partial history remains correct. - CRASH (introduced by the first review round's restructure, caught by the second): an over-cap latched payload whose durable staging failed fell through to the embed path and memcpy'd past the 1 KiB slot — and the mapping. The latch now rejects over-cap payloads without a descriptor. - Staging back off the lock: mid-size (>1 KiB) latched payloads were staged (shm_open/fallocate/mmap, ms-scale) INSIDE cache_mutex, and evicted segments were destroyed under it too — the deleted push code deliberately kept both outside. Restored: stage before the lock, destroy evicted after. - Fan-out ordering: the latch released cache_mutex before sending, so two threads publishing on one TL publisher could hit the wire in inverted sequence order and the watermark would then drop the older sample as a "duplicate". Latch + fence + refresh + fan-out now run under cache_mutex, matching the deleted push path's send serialization. - cached_generation could move backward (!= guard); now monotonic (>), making the stated "only ever advances" invariant actually true. - TL_RING_MAX_BYTES 1 MiB clamped the stock rosout profile (depth 1000 > 963 slots) and warned on every node; raised to 2 MiB (sparse — free for idle publishers). tl name now carries the full 32-bit counter plus the time salt (the truncated 16-bit form could alias a live same-process ring after 65k creations and unlink it). - rmw_init failure paths: dead registry_remove(-1) calls replaced with the unlink of the never-registered doorbell socket file. Tests: +5 (forged-duplicate watermark drop via raw sendto; two publishers one topic, both histories exactly once; mid-band 4 KiB latched + live byte-equality; depth-clamp + newest-suffix at 10000; TL_EMBED_CAP boundary at 1024/1025 with descriptor resolve). Stress test gained a round-0 publish gate (flake) and the post-close pull assertion now exercises the real ENOENT path (was vacuous through the cleared name). Suite: 14/14, run twice. DESIGN.md: /dev/shm budgeting for latched rings, the exact dedup bound (pulled-or-lapped, contiguous-prefix rule under overlap), and the mixed-build reaper leak note.
|
Independent adversarial review completed (29 agents over the diff, four lenses, every finding verified against the code before counting): 21 confirmed findings, all addressed in ba57e60. The two that mattered most:
Also in ba57e60: staging moved back off cache_mutex and evicted-segment destruction moved after it (the deleted push code's discipline, lost in the rewrite); fan-out now serialized under cache_mutex so concurrent publishes cannot invert sequence order into the watermark; monotonic cached_generation; TL_RING_MAX_BYTES→2 MiB so the stock rosout depth-1000 profile no longer clamps+warns on every node; full-width tl name counter; init failure-path unlink. +5 targeted tests (forged-duplicate watermark drop, two-publisher histories, mid-band 4 KiB, depth clamp, embed-cap boundary), suite 14/14 twice. Remaining before merge, in my view: the 200-node launch benchmark against this branch — the storm-elimination claim is arithmetic until measured. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (4)
rmw_unix_socket_cpp/src/shm_transport.cpp:904
- Overlap detection rechecks only slots that produced an accepted record. A writer can finish a slot after it was skipped as empty/odd and then commit a later slot before the scan reaches it; no accepted slot changes, so
overlappedstays false despite a gap. The subscription then installsmax_seqand drops the missing sample's live datagram. Use a scan-wide writer epoch or snapshot/recheck every slot, including skipped slots, and ensure the watermark cannot span leading or internal gaps.
if (overlapped_out) {
for (const auto & [slot_i, s1] : accepted_at) {
const auto * rec_hdr =
reinterpret_cast<const ShmRecordHeader *>(area + slot_i * stride);
if (rec_hdr->seq.load(std::memory_order_acquire) != s1) {
rmw_unix_socket_cpp/src/rmw_subscription.cpp:318
max_seqis installed even when descriptor resolution skips a record here. If an over-cap slot is copied and then overwritten/unlinked before this mmap, the sample is not enqueued, but its sequence is still covered by the watermark; for a 1–64 KiB payload, the concurrently sent live datagram is inline and otherwise deliverable, so it is incorrectly dropped. Track only successfully delivered sequences without spanning resolution failures (likely exact sequences/ranges rather than one maximum).
if (!rmw_uds::shm_resolve_incoming(
pull_cache, ctx->domain_id, msg.header, msg.payload))
{
continue; // large payload's segment already evicted: lapped
rmw_unix_socket_cpp/DESIGN.md:606
- The documented cap is 1 MiB here and in the PR description, but
TL_RING_MAX_BYTESis implemented as 2 MiB insrc/shm_transport.hpp:287. This understates the per-publisher/dev/shmbudget; update the documentation and release note to the implemented 2 MiB value (or change the constant if 1 MiB was intended).
Losslessness rests on a store-buffering fence pair: the publisher does *ring-write → `seq_cst` fence → fresh generation load → refresh subscriber cache → fan out*, the subscriber does *registry_add → `seq_cst` fence → pull*. A sample therefore either lands in a cache the pull observes, or its publisher observes the subscriber's registration and delivers it as a datagram; the overlap is deduplicated by a per-publisher sequence watermark recorded at pull time (sequence numbers are assigned inside the latch critical section, so ring order equals sequence order and "at or below the watermark" exactly means "pulled or lapped"). Payloads above a small embed cap (`TL_EMBED_CAP`, 1 KiB) are staged once into the existing durable segments and the slot carries the 32-byte descriptor; the ring's byte footprint is capped (`TL_RING_MAX_BYTES`, 1 MiB), so an extreme `depth` replays the newest samples that fit — a stated, accepted limit. If the cache cannot be created or a slot cannot be committed (`/dev/shm` unavailable or full), the publisher runs latch-less with a logged error: live delivery is unaffected, late joiners get nothing — there is no heap fallback to replay from anymore. `depth` still controls this one publisher-side structure. Two consequences are deliberate: a VOLATILE late joiner no longer receives latched history (the pull is durability-gated — the DDS-correct behaviour, where the old push replayed to every subscriber on the topic), and replay from an already-dead publisher's still-mapped cache remains possible until its slot is reaped, a mild extension of DDS writer-lifetime semantics.
rmw_unix_socket_cpp/DESIGN.md:608
- The
/dev/shmestimate for over-cap samples is incorrect:shm_stage_durablerounds andposix_fallocates the full payload size (src/shm_transport.cpp:267-300), so a retained 5 MiB sample consumes about 5 MiB, not “an inode and a couple of pages.” Budgeting guidance should include the sum of retained over-cap payload sizes (up to each publisher's effective depth), or deployments can substantially undersize tmpfs.
Three operational notes. **/dev/shm budget:** latched history that used to live on the heap now lives in tmpfs — budget roughly one latched ring per TRANSIENT_LOCAL publisher (sparse file up to `TL_RING_MAX_BYTES`; committed pages only for slots actually latched, so a chatty depth-1000 topic like `/rosout` converges toward ~1 MiB) plus one small durable segment (an inode and a couple of pages) per over-cap latched sample currently live in a ring. Docker's default 64 MB `--shm-size` is not enough at fleet scale: the registry alone is 38 MB — size the mount explicitly. **Dedup bound:** the per-publisher watermark claims only "pulled or already lapped out of the publisher's ring at pull time"; a subscriber that matches mid-burst may therefore not receive samples older than the newest ring-depth — exactly the history DDS would not owe it either — and when a publisher latches concurrently with the pull's scan, the pull keeps only the contiguous prefix of what it saw, letting the rest arrive as datagrams rather than risk the watermark swallowing a missed sample. **Mixed-build reaping:** an old binary's stale-slot reaper does not know the tl_ prefix, so it zeroes a dead new-binary publisher's slot without unlinking the cache segment; the leak is bounded and reclaimed by any new-binary process's `rmw_init` sweep.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
rmw_unix_socket_cpp/DESIGN.md:606
- The documented 1 MiB cap contradicts
TL_RING_MAX_BYTES = 2 * 1024 * 1024insrc/shm_transport.hpp:287(and the source comment explicitly says 2 MiB is required for depth-1000 rosout). Update this budget and the PR release note to 2 MiB, or restore the implementation to the advertised 1 MiB limit.
Losslessness rests on a store-buffering fence pair: the publisher does *ring-write → `seq_cst` fence → fresh generation load → refresh subscriber cache → fan out*, the subscriber does *registry_add → `seq_cst` fence → pull*. A sample therefore either lands in a cache the pull observes, or its publisher observes the subscriber's registration and delivers it as a datagram; the overlap is deduplicated by a per-publisher sequence watermark recorded at pull time (sequence numbers are assigned inside the latch critical section, so ring order equals sequence order and "at or below the watermark" exactly means "pulled or lapped"). Payloads above a small embed cap (`TL_EMBED_CAP`, 1 KiB) are staged once into the existing durable segments and the slot carries the 32-byte descriptor; the ring's byte footprint is capped (`TL_RING_MAX_BYTES`, 1 MiB), so an extreme `depth` replays the newest samples that fit — a stated, accepted limit. If the cache cannot be created or a slot cannot be committed (`/dev/shm` unavailable or full), the publisher runs latch-less with a logged error: live delivery is unaffected, late joiners get nothing — there is no heap fallback to replay from anymore. `depth` still controls this one publisher-side structure. Two consequences are deliberate: a VOLATILE late joiner no longer receives latched history (the pull is durability-gated — the DDS-correct behaviour, where the old push replayed to every subscriber on the topic), and replay from an already-dead publisher's still-mapped cache remains possible until its slot is reaped, a mild extension of DDS writer-lifetime semantics.
| for (const auto & [slot_i, s1] : accepted_at) { | ||
| const auto * rec_hdr = | ||
| reinterpret_cast<const ShmRecordHeader *>(area + slot_i * stride); | ||
| if (rec_hdr->seq.load(std::memory_order_acquire) != s1) { |
There was a problem hiding this comment.
Confirmed and fixed in 9fa4bdc — this was a real losslessness hole, and the reproduction is worse than "an empty slot": any slot the scan produces no record for is invisible to the re-check, which includes both the never-written slots of a ring that hasn't wrapped and the ones abandoned after the bounded odd-seqlock retries.
Concrete failure, depth-8 ring: samples 1–2 sit in slots 0–1, the scan passes slots 2–5 as never-written, the writer then fills slots 2–7 with samples 3–8, and the scan accepts slots 6–7. The pull returns {1,2,7,8} — no accepted slot moved, so overlapped stays false, the subscriber's contiguous-prefix trim never runs, the watermark installs at 8, and the in-flight datagrams for samples 3–6 are all deduped away. Four samples lost.
Fix is your second suggestion: the scan now records the last seq it observed in every slot, skipped ones included, and re-checks all of them. False positives are free — with no gap the trim keeps everything, so over-reporting only ever costs replay depth, never a sample. The header contract was also stating the weaker invariant ("any pulled slot's seq moved"), which is exactly what let this survive review; it now states the one the watermark actually needs.
TlRingPullFlagsOverlapWhenASkippedSlotIsFilled pins it and fails on ba57e60. It forces the interleaving rather than racing for it: both threads share one CPU, so the latching thread can only run when the scan yields — which it does at a poisoned slot, after it has already passed the empty ones.
| if (!rmw_uds::shm_resolve_incoming( | ||
| pull_cache, ctx->domain_id, msg.header, msg.payload)) | ||
| { | ||
| continue; // large payload's segment already evicted: lapped |
There was a problem hiding this comment.
Confirmed and fixed in 5a730f3. The comment on that continue asserted the sample was "lapped", and that premise is what made the skip look free — it isn't. A resolution failure means the slot was overwritten and its segment unlinked between the scan and the mmap, which says nothing about the wire: at 1–64 KiB the live datagram carries the payload inline and is fully deliverable, so covering that sequence with the watermark loses the sample outright rather than deduplicating it.
I did not go to exact sequence sets. A scalar watermark cannot express a hole, so it now stops below the unresolved sequence and the rest of the pulled history arrives as datagrams instead — the same tradeoff the overlap trim already makes, and it keeps the dedup structure a single integer per publisher. The cost is bounded: with the sibling fix in 9fa4bdc, a resolution failure implies a concurrent overwrite, so the truncated history is history the publisher is actively lapping anyway.
ReplayWatermarkStopsAtAnUnresolvableRecord pins it — three over-cap latched samples, their durable segments unlinked before the subscription's pull, asserting the watermark lands at 0 and not 3. Fails on ba57e60.
The overlap re-check only revisited slots that produced a record, so a slot skipped as never-written (any ring that has not wrapped) or given up on mid-write was invisible to it. A writer committing into such a slot after the scan passed it, then into a slot still ahead of the scan, opens a sequence gap with overlapped left false: the subscriber's contiguous- prefix trim never runs, the watermark spans the gap, and the in-flight datagrams for the missing samples are deduped away and lost. Snapshot the seq every slot showed the scan and re-check all of them. False positives are free — with no gap the trim keeps everything. Reported by Copilot on #60.
max_seq was fixed before the enqueue loop, so a record whose descriptor could no longer be resolved — its slot overwritten and its durable segment unlinked between the scan and the mmap — was skipped while its sequence stayed under the watermark. At 1-64 KiB the same sample's live datagram is inline and deliverable, and dedup dropped it: the sample was lost, not lapped, despite the comment claiming otherwise. A watermark cannot express a hole, so it stops below the unresolved sequence and the rest of the history arrives as datagrams. Reported by Copilot on #60.
TL_RING_MAX_BYTES is 2 MiB, documented as 1 MiB. And a durable segment is posix_fallocate'd to its whole payload, so a retained 5 MiB sample costs 5 MiB, not "an inode and a couple of pages" — the budgeting note understated tmpfs sizing for over-cap latched samples. Reported by Copilot on #60.
The overlap re-check only revisited slots that produced a record, so a slot skipped as never-written (any ring that has not wrapped) or given up on mid-write was invisible to it. A writer committing into such a slot after the scan passed it, then into a slot still ahead of the scan, opens a sequence gap with overlapped left false: the subscriber's contiguous- prefix trim never runs, the watermark spans the gap, and the in-flight datagrams for the missing samples are deduped away and lost. Snapshot the seq every slot showed the scan and re-check all of them. False positives are free — with no gap the trim keeps everything. Reported by Copilot on #60.
max_seq was fixed before the enqueue loop, so a record whose descriptor could no longer be resolved — its slot overwritten and its durable segment unlinked between the scan and the mmap — was skipped while its sequence stayed under the watermark. At 1-64 KiB the same sample's live datagram is inline and deliverable, and dedup dropped it: the sample was lost, not lapped, despite the comment claiming otherwise. A watermark cannot express a hole, so it stops below the unresolved sequence and the rest of the history arrives as datagrams. Reported by Copilot on #60.
TL_RING_MAX_BYTES is 2 MiB, documented as 1 MiB. And a durable segment is posix_fallocate'd to its whole payload, so a retained 5 MiB sample costs 5 MiB, not "an inode and a couple of pages" — the budgeting note understated tmpfs sizing for over-cap latched samples. Reported by Copilot on #60.
Third review round — Copilot's findings on
|
rmw_wait.cpp conflicted (#57's armed-fd cache vs this branch's pull-based replay); resolved with the reconciliation reviewed and tested on integration/tl-pull-launch-fixes: watermarks passed to both subscription drains, the 200 ms bounded block covering unwired graph waits and unarmed fallback entities, and the armed_this_call dispatch gate. rmw_graph.cpp's sweep comment picks up the clause about the registry_add retry path this branch introduces. The resulting tree is byte-identical to integration/tl-pull-launch-fixes @ 85022eb.
… and clarify the byte count
The drop warning printed only the peer's socket path and worded the
message size as if it were the receive-buffer fill level
("recv buffer full (N bytes)"). Both made a slow-subscriber drop hard
to diagnose: the path only encodes a PID, and the byte count looked
like it contradicted the configured buffer size.
Now the ENOBUFS/EAGAIN branch names the offending subscriber by its
fully-qualified node name and topic, states plainly that the number is
the dropped message's size, and shows the configured recv-buffer size
for reference.
The peer label is built once per graph change (in refresh_subscribers,
off the hot path) and stored in the copy-on-write subscriber cache
alongside the socket path, so the publish hot path still copies just a
refcount and never formats a string. send_to takes an optional
peer_label; the three publisher fan-out paths supply it,
request/response callers pass none and are unaffected.
Rewritten against current devel: the wake-side TRANSIENT_LOCAL replay
this originally also touched in rmw_wait.cpp no longer exists (#60's
pull-based replay), and the three cache-refresh sites it edited are now
the single refresh_subscribers helper.
What
Replaces push-based TRANSIENT_LOCAL replay with a pull: the publisher writes each latched sample into a per-publisher shm cache at publish time, and a late-joining subscription reads that cache itself inside
rmw_create_subscription. The idle publisher is never woken, polled, or rung.This removes the doorbell broadcast that regressed 200-node launches (#42's every-mutation-rings-every-process, ~100k wake datagrams and the wake-side registry rescans), while keeping — and improving — the late-joiner guarantee #37/#42 fought for: replay is now synchronous at subscription creation, and works even for a publisher process that never enters
rmw_wait(previously impossible).How
tl_ring(shm_transport): fixed ring ofqos.depthper-record-seqlocked slots, created strictly beforeregistry_addand named in the publisher slot's previously-unusedsocket_pathfield. Unique per-incarnation name (counter + time salt) so a recycled PID can never alias a dead publisher's cache; the full 16-byte GID is embedded in the header and verified by the puller. Sparseftruncate+ per-recordposix_fallocate: an idle latched publisher costs an inode and a page; ENOSPC degrades to latch-less with a logged error (live delivery unaffected). Payloads >TL_EMBED_CAP(1 KiB) ride the existing durable segments as 32-byte descriptors; ring bytes capped atTL_RING_MAX_BYTES(2 MiB).seq_cstfence → fresh generation load → refresh → fan-out (sequence numbers assigned inside the latch critical section, so ring order == seq order); subscriber doesregistry_add→seq_cstfence → pull. A sample is either in a cache the pull observes, or its publisher observed the registration and datagram-delivers it; the overlap is deduped by a per-publisher watermark keyed on the full GID (context_id bytes make it pid-reuse-safe).teardown_slotshm-unlinkstl_-prefixed slot paths so the stale reaper reclaims a dead publisher's cache promptly; the orphan sweep gains aros2_uds_tl_prefix pass.rmw_init, but theENTRY_DOORBELLslot registers lazily, on the firstrmw_waitwhose set holds a graph guard condition — only graph-event consumers (wait_for_service, GraphListener, rosbag2) need registry wakeups now. Registration precedes that wait's generation check (register-before-snapshot, asrmw_initdid). Doorbell-slot mutations no longer ring (no consumers; K lazily-registering processes would otherwise send K²/2 datagrams).transient_local_pubs(+mutex),known_subscriber_paths,CachedMessage/heapmessage_cache, and the entire wait-side replay block — including thetransient_local_pubs_mutexthat was held across queries and replay sends, serializing publisher creation during storms.Launch arithmetic (N=200, from the doorbell regression analysis)
Doorbell datagrams: ~100,300 → ~number of graph-event consumers (19,900 of the old count came from doorbell self-registration alone). Wake-side registry copies for plain pub/sub processes: → zero. Replay latency: ~20 ms after wake → synchronous at create.
Deliberate behaviour changes (release-note these)
/dev/shmis unavailable is not replayable (was: inline heap fallback that the push path could still replay). Live delivery is unaffected; the degradation is logged.socket_pathskips the pull; the old push still delivers, and new binaries still ring old binaries' doorbells).Testing
14/14 suites green on jazzy. New tests: pull with no
rmw_waitanywhere in the process; exactly-once under pull/live overlap;ignore_local_publicationsfiltering of pulled history; subscriber-churn redelivery; concurrent publish + churn stress (20 rounds, exactly-once + in-order + newest-depth suffix); poisoned-slot (odd seqlock) bounded pull. Rewritten to the new contract: shm-unavailable large latched payloads are not latched;known_subscriber_pathspruning tests deleted with the machinery.CI/container note: the full suite needs
--shm-size≥ 1 GB (the 64 MB Docker default cannot hold the 38 MB registry plus the payload rings the large-message tests create).Not yet done — recommended before deploying to the vessel: run the 200-node launch perf test against this branch to validate the storm elimination end-to-end.
Related