fix(rmw_wait): event-driven registry wakeup via a doorbell; remove the 200 ms poll - #42
Merged
Merged
Conversation
…e 200 ms poll Two defects, each with a scenario test that fails before the fix: - A latched (TRANSIENT_LOCAL) publisher in an idle process never replayed to late joiners. The top-of-wait registry check scavenged its context from the first subscription/service/client in the wait set, so a wait set holding only guard conditions -- the shape a publish-only node's executor produces -- got a null context and skipped the check entirely. The wait set now stores its context at rmw_create_wait_set. - The 200 ms poll bound that made replay work at all broke the rmw_wait timeout contract: every wait returned RMW_RET_TIMEOUT at 200 ms regardless of the caller's deadline (a 600 ms wait returned at 200 ms, and an infinite wait, which must never time out, returned TIMEOUT), and every process woke 5x/s while idle. The poll is gone. Each context binds a doorbell socket at rmw_init, registered as ENTRY_DOORBELL before the first generation snapshot so no mutation can fall into the gap between the snapshot and the wiring. Every registry mutation sends one octet to every registered doorbell strictly AFTER bumping the generation, and rmw_wait drains its doorbell strictly BEFORE reading the generation. That ordering pair makes a lost wakeup impossible: a mutation either lands in the generation the waiter is about to read, or leaves a datagram queued on a level-triggered fd. A doorbell-only wake re-checks the registry and re-blocks for the caller's remaining time, so RMW_RET_TIMEOUT surfaces only at the caller's own deadline and an infinite wait blocks until a real event. No new threads, no registry layout change. Best-effort edge cases, each reproduced before being handled: - AF_UNIX datagrams stay charged to the sender until the receiver consumes them, so one participant that never drains could exhaust the ring socket's budget and silently mute wakeups to healthy peers. The ring fd is recreated on EAGAIN and the send retried once; on a fresh fd, EAGAIN can only mean the destination's own queue is full, i.e. a wakeup is already pending there. - The slot type is re-validated inside the seqlock window, so a slot recycled to a data endpoint mid-scan cannot receive the wake octet. - The ring socket is thread-local and closed at thread exit, so ringing takes no lock and leaks no fd. Cleanup needs no new machinery: the doorbell is a PID-owned slot holding a socket path, so graceful shutdown removes it like any endpoint and the existing stale-PID reaper reclaims it after a crash (slot teardown unlinks the file). Known limit: a build that predates this change bumps the generation but never rings, so a fleet running mixed builds can miss wakeups during the upgrade window -- upgrade together, as with the shm payload flag. The dead ctx->graph_guard_condition trigger is deliberately left untouched: wiring the per-node graph guard conditions rcl actually waits on is a separate defect with its own fix. Full suite green on Jazzy (128 tests). Replay reaches a late joiner ~20 ms after it joins; a 600 ms wait now blocks the full 600 ms.
- Wait mechanism summary and wait-sequence steps 2-4: the wait set carries its context, the doorbell fd is armed with the entity fds, and step 4 blocks with no internal poll interval and honors the caller's deadline. - New subsection: the doorbell -- why an AF_UNIX datagram socket over the alternatives, the ring-after-bump / drain-before-read ordering pair that makes lost wakeups impossible, the best-effort edge cases, crash cleanup via the existing reaper, and the two accepted limits. - Registry slot state: ENTRY_DOORBELL, and the generation bump is now followed by the ring. - Operational requirements: /tmp/ros2_uds holds live sockets and must be exempted from tmp cleaners. - Limitations: notification requires a thread inside rmw_wait.
benaliabderrahmane
marked this pull request as ready for review
August 3, 2026 14:19
devel is now the integration branch (contributor PRs land there before main), so it needs the same CI coverage.
benaliabderrahmane
force-pushed
the
fix/registry-doorbell-wakeup
branch
from
August 3, 2026 14:24
c4d662b to
2f250ad
Compare
benaliabderrahmane
added a commit
that referenced
this pull request
Aug 3, 2026
Brings in "Use system clock to fill msgs timestamps (#45)". Conflict resolution in rmw_wait.cpp: #45 split now_ns() into steady_now_ns() (monotonic, for timeouts) and wall_now_ns() (system clock, for message timestamps), and patched the epoll block loop accordingly. That loop was replaced on devel by the doorbell work (#42), so #45's two hunks against the old deadline_ns/remaining_ms code no longer applied. Resolved by keeping devel's loop and applying #45's intent to it: - msg.received_timestamp_ns keeps #45's wall_now_ns() (a wall-clock timestamp is the point of #45; a steady-clock value is meaningless to a remote reader). - All three deadline computations in the doorbell loop use steady_now_ns() (caller_deadline_ns, the per-iteration remaining time, and the doorbell-wake deadline check) — timeout arithmetic must not be affected by clock steps. Full suite green on Jazzy (129 tests).
This was referenced Aug 3, 2026
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.
Fixes #41.
Problem
Two defects, one root cause: a registry change in one process cannot wake another process that is asleep in
rmw_wait.1. A latched publisher in an idle process never serves late joiners. The top-of-wait registry check scavenged its
UdsContextfrom the first subscription, service, or client in the wait set. A publish-only node's executor has none of those, so its wait set holds only guard conditions,ctxcame outnullptr, and the whole generation check — includingTRANSIENT_LOCALreplay — was skipped no matter how long the process waited.2. The 200 ms poll that made replay work broke the
rmw_waittimeout contract. The clamp was applied ahead of the infinite-wait branch, so it rewrote every caller's deadline: a 600 ms wait returnedRMW_RET_TIMEOUTafter 200 ms (measured), and an infinite wait — which must never time out — returnedTIMEOUTtoo. rclcpp'sGraphListenertreats that as fatal and aborts the process. Every process also woke 5×/s while idle. This is why the replay fix was reverted frommainin 75d75cd.That abort is not theoretical. On
devel,ros2 bag recorddies 209 ms after starting:Fix
The wait set stores its context at
rmw_create_wait_set, so the check runs for every wait, and the poll is removed entirely.Each context binds one doorbell socket at
rmw_init, registered asENTRY_DOORBELLbefore the first generation snapshot so no mutation can fall into the gap between the snapshot and the wiring. Every registry mutation sends one octet to every registered doorbell, strictly after bumping the generation counter;rmw_waitdrains its doorbell strictly before reading the generation.That ordering pair is the whole correctness argument: a mutation either lands in the generation value the waiter is about to read, or it leaves a datagram queued on a level-triggered fd that makes the next
epoll_waitreturn immediately. The datagram queues whether or not the target is currently blocked, so there is no check-then-block race to lose. A doorbell-only wake re-checks the registry and re-blocks for the caller's remaining time — soRMW_RET_TIMEOUTnow surfaces only at the caller's own deadline, and an infinite wait blocks until a real event.No new threads. No registry layout change. No daemon.
Why an
AF_UNIXdatagram socketIt reuses the one primitive this transport is already made of. The alternatives were considered and rejected: signals (process-wide hygiene), passing eventfds across processes (needs a rendezvous this design deliberately lacks), inotify (does not cover shm writes), io_uring futex (container seccomp). The doorbell is also a PID-owned registry slot holding a socket path, so cleanup needs no new machinery — graceful shutdown removes it like any endpoint, and the existing stale-PID reaper reclaims it after a crash, since slot teardown already unlinks the socket file.
Best-effort edge cases
Each was reproduced before being handled:
AF_UNIXdatagrams stay charged to the sender's buffer until the receiver consumes them, so one participant that never drains could exhaust the ring socket's budget and silently break wakeups to healthy peers. OnEAGAINthe ring fd is closed, recreated, and the send retried once; on a fresh fd,EAGAINcan only mean the destination's own queue is full — a wakeup is already pending there, so the drop is safe.Cost
ring_doorbellsis onesendtoper participant process per registry mutation, on a lock-free path (the registry uses CAS plus a seqlock, so nothing is held across the ring). The registry holds 32768 slots, so the one extra slot per process is noise. Steady-state publish and take are untouched: the only per-wait addition is onerecvdrain on an empty socket.Note the shape of that cost: the ring is O(processes) per mutation and each ring makes every process re-read the generation, so bringing up a large system is O(entities × processes) wakeups. There is no coalescing or debounce — a deliberate omission, since the alternative is a timer or a batching layer, and the registry API mutates one entity at a time so there is no natural batch point. Worth revisiting if startup cost is ever measured to matter at scale.
Scope: what this PR does not fix
This is deliberately only half the graph story. The dead
ctx->graph_guard_conditiontrigger is left untouched here — wiring the per-node graph guard conditions rcl actually waits on is a separate defect (#40) fixed in the stacked PR #43.Concretely, with only this PR merged,
ros2 bag recordstarted before its publishers no longer crashes, but still records nothing — it never subscribes. That is an improvement overdevel(which aborts) and not yet a fix for #40.Tests
Scenario-level, public rmw API only. Each fails on
develand passes here:develTransientLocalLateJoinerWhilePublisherProcessIdleWaitBlocksForFullCallerTimeoutTIMEOUTat 200 ms for a 600 ms waitLatchedTopicSurvivesAnUnresponsiveParticipantFull suite green on Jazzy — 128 tests. Compiles clean against Kilted headers locally; CI green on Jazzy, Kilted and Rolling.
Notes
Supersedes Fix rcl_wait timeout for infinite wait #38. With the clamp gone at the root, the wait-set-scoped narrowing is no longer needed.
The third commit adds
develto the CI triggers — without it a PR based ondevelgets no checks at all.Mixed old/new builds miss rings during a fleet upgrade, so upgrade together (same as the shm payload flag). A process whose threads never enter
rmw_waitstill cannot replay; that gap pre-dates this change and is unchanged.Known limit — a queue filled by another thread while this thread is blocked.
rmw_waitchecks entity queues before blocking (step 3), but a doorbell-only wake re-runs only the registry check and re-blocks; it does not re-scan the queues. So if a second thread drains the same subscription's socket into its queue while this thread sits inepoll_wait, the socket is no longer readable and this wait is not woken by it — the message is reported on the next event rather than immediately. It is delayed, never lost. Reaching it needs the same subscription to be in a blocked wait set while another thread takes from it, i.e. a reentrant callback group; rclcpp's default mutually-exclusive groups exclude a busy entity from the next wait set.devel's 200 ms poll masked this by re-checking 5×/s. The minimal fix is to re-run step 3's queue scan on a doorbell-only wake before re-blocking; it is left out here because it is a behavior change that deserves its own test rather than a rider on this PR. Raised by an adversarial review pass of this PR and confirmed against the code by hand.Removing the poll also removes an accidental self-healing property: the 200 ms retry used to paper over any missed wakeup. Wakeups are now event-driven and correct by the ordering argument above, but a doorbell socket file destroyed by an outside actor (a
/tmpcleaner) is no longer recovered from.DESIGN.mdgains an operational note requiring/tmp/ros2_udsto be exempted from tmp sweepers; data sockets have always had this same exposure.