Skip to content

feat(listener): lazily-started thread for callback-driven delivery - #67

Open
benaliabderrahmane wants to merge 6 commits into
develfrom
feat/listener-thread
Open

benaliabderrahmane wants to merge 6 commits into
develfrom
feat/listener-thread

Conversation

@benaliabderrahmane

@benaliabderrahmane benaliabderrahmane commented Sep 8, 2026 •

Copy link
Copy Markdown
Owner

Description

rclcpp's EventsExecutor — the default in performance_test and
ros2-benchmark-container — never calls rmw_wait, and only calls rmw_take
once a listener callback has told it there is something to take. With delivery
happening exclusively inside rmw_wait, that is a deadlock by construction:
nothing drains the socket, so the callback never fires; the callback never fires,
so rmw_take is never called. Registering the callback is the entire
interaction, and the executor then waits forever. This is why the crash fixed
in #61 turned into a silent hang rather than working delivery.

Each context can now run one listener thread that drains a watched socket and
fires its callback with no application thread involved.

It starts lazily, and only on a callback registration. An executor that waits
registers nothing, so a SingleThreadedExecutor or MultiThreadedExecutor
process still starts no background thread at all — async delivery is opt-in by
what the application does, not by configuration. There is a test asserting that
against /proc/self/task.

Three things make it safe to share an endpoint with rmw_wait:

  • listener_mutex is held across the drain, and listener_unwatch() takes
    it, so a thread destroying an endpoint blocks until the drain — and the callback
    it fires — has returned. rmw_destroy_subscription unwatches before it frees
    anything.

  • A per-wait-set delivery_fd closes a lost-wakeup window. Both the listener
    and a wait set may drain the same socket, which is safe in itself, but if the
    listener wins the race between a wait's queue scan and its epoll_wait then the
    socket is empty, epoll has nothing left to report, and the wait would sleep
    with a full queue. The listener signals delivery_fd strictly after
    enqueueing and rmw_wait drains it strictly before scanning its queues, so
    a message either lands in that scan or leaves the level-triggered eventfd
    readable. Same ordering pair as ring_doorbells.

    The fd belongs to the wait set rather than to the listener. rmw_create_wait_set
    creates it and registers it in context->delivery_fds; the listener writes every
    registered fd after each enqueue. One shared per-context fd is one credit,
    because an eventfd read drains the whole counter — a wait set that gained no
    work of its own still consumed that credit and threw it away, leaving the wait
    set that needed it asleep on a socket the listener had already emptied. With a
    finite timeout the message still surfaces at the caller's deadline, so the cost
    is latency; an infinite wait stalls until the next drain in the context
    enqueues something, which for the last message of a burst or a one-shot client
    response is never. Creating it with the wait set also means it exists before any
    listener can, so a callback registered while a wait is already blocked still has
    an armed fd to signal; resolving it from a listener-running flag at the top of
    rmw_wait, which is what an earlier commit on this PR did, armed nothing at all
    for exactly that case. It is therefore armed unconditionally, and
    rmw_destroy_wait_set deregisters under listener_mutex strictly before the
    close, or the listener writes 8 bytes into a recycled fd number.

    Because the listener signals every wait set for any endpoint it drains, the wake
    itself is not progress for the caller: the queue scan is factored into
    caller_queue_has_work() and the new ARMED_DELIVERY dispatch re-runs it
    instead of treating the wake as work.

  • A per-endpoint drain_mutex keeps the queue in publisher order.
    drain_endpoint recv()s outside queue_mutex and push_back()s inside it,
    so two drains of one endpoint — the listener thread and an rmw_wait or
    rmw_take on an application thread, which is the configuration this PR makes
    routine — can interleave as recv A / recv B / push B / push A and land
    same-publisher datagrams out of order. queue_mutex prevents corruption, not
    reordering, and ROS 2 guarantees per-publisher FIFO. The mutex is held for the
    whole drain, taken before queue_mutex and callback_mutex, and nothing takes
    listener_mutex while holding it, so the lock graph stays acyclic.

One more race is worth naming, because it is reachable through the public API with
no misuse. listener_stop has to release listener_mutex before it joins — the
loop takes that mutex per event, so joining under it would deadlock — and a
registration landing in that window found listener_running already false and
called listener_start, which replaces the epoll and wake fds the pending join
depends on and move-assigns over a still-joinable std::thread. Both halves are
fatal: std::terminate, or a join that never returns. is_shutdown does not
cover it on its own, since rmw_context_fini reaches listener_stop without ever
setting it, so a listener_stopping flag is now set across the join and read
together with is_shutdown under the mutex. listener_start also catches the
std::system_error std::thread throws when the process is out of threads,
rather than letting it escape an extern "C" entry point.

Is this user-facing behavior change?

Yes — this is the significant one in the series.

  • A process that registers a listener callback now has one extra thread. The
    zero-background-thread property still holds for every executor that waits, but
    it is no longer unconditional. Documented in the docs PR in this series.
  • Every wait set now costs one eventfd, and every rmw_wait one extra
    read().
    The delivery fd is created with the wait set and armed
    unconditionally, so a process that never registers a callback pays one fd per
    wait set and one EAGAIN read per wait where it previously paid nothing.
    Gating it on a listener-running flag is what produced the lost wakeup above,
    so the cost is the price of the fix rather than an oversight.
  • EventsExecutor works. Subscriptions deliver with no thread in rmw_wait.
  • A listener callback must not block, and must not touch any endpoint in its
    context
    — listener_mutex is one per context and is held across the drain
    that fires the callback, so destroying, registering on, or clearing the callback
    of an endpoint, and creating or destroying a wait set, deadlock against the
    drain the callback was called from. listener.hpp states that rule for all
    three endpoint kinds; the paths that actually take the mutex at this point in
    the stack are subscription registration and destroy, plus wait set
    create/destroy, and services and clients join them in the next PR. It must not
    rmw_take its own endpoint either: the callback fires inside drain_endpoint,
    which holds that endpoint's drain_mutex. rclcpp's callbacks only push to a
    queue, which is what rmw/event_callback_type.h expects of them, so this holds
    in practice.
  • Registration failure is returned rather than swallowed, and the callback is
    rolled back with it.
    An endpoint whose socket cannot be watched delivers only
    from rmw_wait, which is exactly the silent hang above, so rclcpp should
    raise it at construction instead — and un-installing the callback is what makes
    the error true, rather than leaving the caller with a failure and a callback
    that still fires. Worth a second opinion during review — the alternative is
    logging and returning OK. The one deliberate exception is a context already past
    rmw_shutdown or inside listener_stop: listener_watch returns OK without
    watching, because restarting the thread for an endpoint on its way out would
    leak it.

How was this tested?

test_rmw_listener_callbacks.cpp grows to 17 tests. Two fail on the parent
commit
, and one of them fails by reproducing the hang:

  • CallbackFiresWithNoThreadInWaitOrTake — times out after the full 2 s budget on
    the parent, passes in 5 ms here. This is the bug itself.
  • NoListenerThreadUntilACallbackIsRegistered — no thread is started on the
    parent.

Also added: destroy-while-messages-arrive churn (20 rounds) for the lifetime
rule, and a 50-round publish-into-a-blocked-rmw_wait stress for the
delivery_fd window. That last one drives the race rather than interleaving it
exactly — a deterministic test of that window would need fault injection.
ASecondWaitSetDoesNotStealTheDeliveryWakeup runs those same 50 rounds with a
second wait set spinning rmw_wait against them, and
DestroyingWaitSetsWhileTheListenerDeliversIsSafe churns 200 wait-set
create/destroy rounds against a listener that is delivering the whole time, for
the recycled-fd path. Neither lost wakeup is directly unit-testable — both need
the victim to be between its queue scan and its epoll_wait, a window a test
cannot open from outside — so these drive the paths repeatedly instead, and the
recycled-fd one pays off under the sanitizer jobs.

Two more regression tests came out of reviewing this PR, both failing on its own
earlier commits:

  • RegisteringACallbackWhileTheContextShutsDownDoesNotAbort — 250 rounds, four
    concurrent registrar threads per round. It hung forever before (main in
    futex_wait on the join, the listener in ep_poll, zero CPU); its 250 rounds
    now finish in 81 ms.
  • ConcurrentDrainsPreservePublisherOrder — 1500 samples at depth 5000, asserting
    zero inversions. Before the drain_mutex it reported "first inversion: 531 then
    530" with all 1500 samples collected, so the gap is reordering rather than
    overflow.

Three of the parent's tests change with the listener, since registering a callback
now also hands the socket to a second drainer.
SubscriptionCallbackFiresFromWait becomes
SubscriptionCallbackFiresExactlyOnceOnDelivery and
SubscriptionCallbackReportsTheBatchCount drops its calls == 1 assertion:
which path delivers, and how many batches a burst is split into, is no longer the
middleware's to promise — the event total is.
BatchCountExcludesDatagramsDroppedByOverflow becomes
BatchCountMatchesWhatCanBeTaken and compares credits against takes rather than
against qos.depth, because how much of a 15-message burst survives the socket
buffer is timing and asserting it flaked 7 runs in 15 here. It fails 6 of 6 on the
old count and passes 20 of 20 now.

Full suite on jazzy: 174 tests, 0 failures, and 12 repeat runs of
test_rmw_listener_callbacks with no flakes. All 16 test binaries are clean under
-fsanitize=thread — deadlock detection confirmed live against a
lock-order-inversion control, so the new mutex is covered. The only TSan reports
left in the suite are the pre-existing registry.cpp seqlock ones, which this
stack does not touch. -fsanitize=address,undefined with leak detection was
clean on this PR's first commit and has not been re-run since the per-wait-set
delivery fd and the drain_mutex landed; it would not be the evidence for the
join in any case, since LeakSanitizer tracks heap allocations only, and a process
that leaks an epoll fd and an eventfd and detaches an unjoined thread still exits
0 under it. What covers the join is the shutdown-race test above, which reproduces
by hanging or aborting.

End-to-end, with a real rclcpp::experimental::executors::EventsExecutor node
(measured with the whole series applied): devel receives 0 of 5 messages,
this receives 5 of 5.

kilted, rolling and lyrical are on CI.

Did you use Generative AI?

Additional Information

Scope is subscriptions. The listener itself is endpoint-agnostic and already
dispatches all three kinds, but only
rmw_subscription_set_on_new_message_callback registers; services and clients
are the next PR in the series. The drain_mutex is added to all three endpoint
types anyway, since rmw_wait and rmw_take already race each other there.

drain_endpoint returns a count again: the number of datagrams it pushed into the
queue, before the QoS trim, which is what the listener tests before signalling the
delivery fds. That is deliberately not the batch count the callback is handed —
since the parent PR that number is the queue's net growth, enqueued - dropped —
because the signal is about whether the drain moved anything into a queue, not
about take credits.

The thread is joined in rmw_shutdown, not rmw_context_fini, so an endpoint
destroyed between those two calls is not drained by a live thread;
rmw_context_fini repeats the call for a context finalized without a shutdown.

@benaliabderrahmane

Copy link
Copy Markdown
Owner Author

Correction to the sanitizer evidence in this PR description.

This PR says every test binary is clean under -fsanitize=thread. That was one run per binary, and it is weaker evidence than the wording implies.

The new TSan CI job (#71) found a pre-existing write-write data race in the seqlock registry — write_slot_payload (registry.cpp:235) against teardown_slot (registry.cpp:319), because registry_remove publishes ENTRY_EMPTY before zeroing the payload, letting a concurrent try_add_once claim the slot and write it at the same time. Reproduced locally at 4 of 40 runs of test_registry_concurrent, so a single clean run has roughly a 90% chance of missing it.

Two things worth being clear about:

  • It is not caused by this PR. It reproduces on plain devel, it is in the registry rather than the listener, and it is already diagnosed and fixed on the unmerged fix/registry-teardown-before-slot-release (a05ba3f).
  • The listener-specific claims still hold, but on the same caveat: the lifetime and lock-order checks here were single runs. DestroySubscriptionWhileMessagesArrive does 20 rounds and WaitAndListenerOnTheSameSubscriptionDoNotHang does 50, so those exercise their own interleavings repeatedly, but the sanitizer sweep behind them was not repeated.

No change requested here — the four-distro CI on this PR is green and this is a separate, older bug. Flagging it so the sanitizer line in the description is not read as stronger than it is.

Abderahmane BENALI and others added 6 commits September 18, 2026 14:36
The receive loop existed in four near-identical copies (drain_subscription,
drain_socket in rmw_wait.cpp, and inline in rmw_take_request and
rmw_take_response) that had drifted: each decided for itself whether to
cap the queue, warn about an overflow, or notify a listener.

drain_endpoint() is now the only receive loop. DrainTarget carries the
per-endpoint policy (queue and its mutex, depth cap, accepted msg_type,
shm reader cache, ignore_local, the TRANSIENT_LOCAL watermark map, the
listener callback, the name to log) and the drain_target() overloads build
it from each impl struct, so a call site can no longer pick its own policy
by accident. Where the copies disagreed the stricter one wins: the
rmw_wait drain now fires on_new_message and reports overflow, and the
request/response queues are capped at SERVICE_QUEUE_DEPTH on the take path
too - recorded in drain.hpp as a deliberate tradeoff.

Squashed from:
- refactor(drain): collapse the four socket drains into one
- docs(drain): record the take-path queue cap as a deliberate tradeoff
- fix(drain): restore the <cstring> include, retarget the names this PR renamed
drain_endpoint() resolved the shm descriptor and then asked whether the
datagram should be ignored. For a subscription with
ignore_local_publications set, a large same-context publication was
therefore mapped out of the sender's ring and copied into the payload
buffer before being thrown away - up to SHM ring-record size of pointless
copying per message, on a path whose whole purpose is to avoid the copy.

is_same_context() reads only WireHeader::gid, which is on the wire before
anything is resolved, so the check moves ahead of the resolve. That is
the same reasoning the TRANSIENT_LOCAL dedup above it already gives for
its own position ("Checked before the descriptor resolve so a duplicate
never maps a segment").

Behaviour is identical either way: a resolve failure and an ignore both
end in `continue`. So the new test does not fail beforehand - it covers a
branch nothing reached. The two existing ignore_local tests publish small
inline payloads, which carry no descriptor, so the shm path combined with
ignore_local had no coverage at all.

Full suite green on Jazzy: 157 tests, 0 failures.
The headline behaviour change of the unified drain - rmw_wait now fires
on_new_message, where only the rmw_take path did - had no test on this
branch. WaitDrainFiresOnNewMessageCallback publishes three messages, waits
without ever taking, and expects the callback to have been credited three
events. It fails on devel (0 events) and passes here. The total is summed
and awaited so the test stays valid once a listener thread delivers it.

Also corrects the SERVICE_QUEUE_DEPTH comment: services and clients do
carry a QoS depth, it is just not enforced on these queues.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNQNo26cKRPrVXcaHZnje
on_new_request_cb and on_new_response_cb were stored by their setters and
flushed once against an already-queued backlog, but no drain ever fired
them again - a service or client was callback-dead the moment its
registration returned. With one drain filling all three queues, the two
drain_target() overloads now carry the callback.

The notification also moves out of the receive loop: one call per drain
that grew the queue, carrying enqueued - dropped. number_of_events is a
take credit, so an overflow that pushed 100 and popped 90 reports 10, and
a drain whose net gain is zero stays silent. The setters flush a queued
backlog only when a callback takes over from none, because rclcpp
registers twice in a row on purpose and paid the same backlog out twice.

Squashed from:
- fix(drain): notify services and clients, and report the batch count
- fix(drain): report the queue's growth, and flush a backlog only once
No user code runs under queue_mutex any more: the setters copy the backlog
size, release the queue lock and notify holding callback_mutex alone, and
drain_endpoint() notifies the same way.

That was only half of it. rmw_take drains before it pops, so a take from
inside the callback re-enters drain_endpoint() on the thread already
inside the callback, and a nested drain that gains a datagram notifies
again - locking callback_mutex a second time on the same thread. With a
std::mutex that is a self-deadlock, seen only when a message arrives
during the callback. callback_mutex is now a std::recursive_mutex; it
still keeps the callback pointer and user_data stable for the duration of
a call, which is all it was ever for.

ACallbackMayTakeWhileItsOwnEndpointGainsMessages has the callback publish
before it takes, so the nested drain gains one every time. It hangs on the
previous code and passes in ~130 ms after.

Squashed from:
- fix(callbacks): never run a listener callback under queue_mutex
- fix(callbacks): make callback_mutex recursive so a callback can really take
- test(callbacks): name the re-entrant fixture PublishThenTakeCallback

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNQNo26cKRPrVXcaHZnje
rclcpp's EventsExecutor never calls rmw_wait and only calls rmw_take once
a listener callback has told it there is something to take. With delivery
happening only inside rmw_wait that is a hang by construction: nothing
drains the socket, so the callback never fires.

Each context can now run one listener thread that drains a watched socket
and fires its callback with no application thread involved. It starts
lazily, on the first callback registration, so an executor that waits
still starts no background thread (pinned against /proc/self/task).

Three things make it safe to share an endpoint with rmw_wait:
- listener_mutex is held across the drain and listener_unwatch() takes it,
  so destroying an endpoint blocks until an in-flight drain and the
  callback it fires have returned; the destroy paths unwatch first.
- A per-endpoint drain_mutex serialises the listener against an
  rmw_wait/rmw_take draining the same socket, so one publisher's samples
  cannot be reordered by interleaved recv/push pairs. It covers the
  receive loop only and is released before the notification, so a
  callback that takes from its own endpoint re-enters drain_endpoint()
  without wedging on it; a callback runs holding callback_mutex alone.
- Each wait set owns a delivery eventfd. The listener signals every
  registered one strictly after enqueueing and rmw_wait drains its own
  strictly before scanning its queues, which closes the lost-wakeup window
  without one wait set consuming another's credit.

The thread wakes on socket readiness, never on a clock; it does not make
deadline or liveliness enforceable.

Squashed from:
- feat(listener): lazily-started thread for callback-driven delivery
- fix(listener): give each wait set its own delivery eventfd
- fix(listener): close the shutdown/registration race and serialise drains
- test(listener): wait for the event total instead of racing the listener
- fix(drain): release drain_mutex before notifying, and allow a re-entrant take
- docs(drain): drain_mutex covers the receive loop, not the whole drain

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNQNo26cKRPrVXcaHZnje
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