feat(listener): lazily-started thread for callback-driven delivery - #67
benaliabderrahmane wants to merge 6 commits into
Conversation
|
Correction to the sanitizer evidence in this PR description. This PR says every test binary is clean under The new TSan CI job (#71) found a pre-existing write-write data race in the seqlock registry — Two things worth being clear about:
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. |
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
db73697 to
899b1f4
Compare
Description
rclcpp'sEventsExecutor— the default inperformance_testandros2-benchmark-container— never callsrmw_wait, and only callsrmw_takeonce 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_takeis never called. Registering the callback is the entireinteraction, 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
SingleThreadedExecutororMultiThreadedExecutorprocess 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_mutexis held across the drain, andlistener_unwatch()takesit, so a thread destroying an endpoint blocks until the drain — and the callback
it fires — has returned.
rmw_destroy_subscriptionunwatches before it freesanything.
A per-wait-set
delivery_fdcloses a lost-wakeup window. Both the listenerand 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_waitthen thesocket is empty,
epollhas nothing left to report, and the wait would sleepwith a full queue. The listener signals
delivery_fdstrictly afterenqueueing and
rmw_waitdrains it strictly before scanning its queues, soa 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_setcreates it and registers it in
context->delivery_fds; the listener writes everyregistered 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 allfor exactly that case. It is therefore armed unconditionally, and
rmw_destroy_wait_setderegisters underlistener_mutexstrictly before theclose, 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 newARMED_DELIVERYdispatch re-runs itinstead of treating the wake as work.
A per-endpoint
drain_mutexkeeps the queue in publisher order.drain_endpointrecv()s outsidequeue_mutexandpush_back()s inside it,so two drains of one endpoint — the listener thread and an
rmw_waitorrmw_takeon an application thread, which is the configuration this PR makesroutine — can interleave as recv A / recv B / push B / push A and land
same-publisher datagrams out of order.
queue_mutexprevents corruption, notreordering, and ROS 2 guarantees per-publisher FIFO. The mutex is held for the
whole drain, taken before
queue_mutexandcallback_mutex, and nothing takeslistener_mutexwhile 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_stophas to releaselistener_mutexbefore it joins — theloop takes that mutex per event, so joining under it would deadlock — and a
registration landing in that window found
listener_runningalready false andcalled
listener_start, which replaces the epoll and wake fds the pending joindepends on and move-assigns over a still-joinable
std::thread. Both halves arefatal:
std::terminate, or a join that never returns.is_shutdowndoes notcover it on its own, since
rmw_context_finireacheslistener_stopwithout eversetting it, so a
listener_stoppingflag is now set across the join and readtogether with
is_shutdownunder the mutex.listener_startalso catches thestd::system_errorstd::threadthrows 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.
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.
rmw_waitone extraread(). The delivery fd is created with the wait set and armedunconditionally, so a process that never registers a callback pays one fd per
wait set and one
EAGAINread 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.
EventsExecutorworks. Subscriptions deliver with no thread inrmw_wait.context —
listener_mutexis one per context and is held across the drainthat 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.hppstates that rule for allthree 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_takeits own endpoint either: the callback fires insidedrain_endpoint,which holds that endpoint's
drain_mutex.rclcpp's callbacks only push to aqueue, which is what
rmw/event_callback_type.hexpects of them, so this holdsin practice.
rolled back with it. An endpoint whose socket cannot be watched delivers only
from
rmw_wait, which is exactly the silent hang above, sorclcppshouldraise 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_shutdownor insidelistener_stop:listener_watchreturns OK withoutwatching, because restarting the thread for an endpoint on its way out would
leak it.
How was this tested?
test_rmw_listener_callbacks.cppgrows to 17 tests. Two fail on the parentcommit, and one of them fails by reproducing the hang:
CallbackFiresWithNoThreadInWaitOrTake— times out after the full 2 s budget onthe parent, passes in 5 ms here. This is the bug itself.
NoListenerThreadUntilACallbackIsRegistered— no thread is started on theparent.
Also added: destroy-while-messages-arrive churn (20 rounds) for the lifetime
rule, and a 50-round publish-into-a-blocked-
rmw_waitstress for thedelivery_fdwindow. That last one drives the race rather than interleaving itexactly — a deterministic test of that window would need fault injection.
ASecondWaitSetDoesNotStealTheDeliveryWakeupruns those same 50 rounds with asecond wait set spinning
rmw_waitagainst them, andDestroyingWaitSetsWhileTheListenerDeliversIsSafechurns 200 wait-setcreate/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 testcannot 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, fourconcurrent registrar threads per round. It hung forever before (main in
futex_waiton the join, the listener inep_poll, zero CPU); its 250 roundsnow finish in 81 ms.
ConcurrentDrainsPreservePublisherOrder— 1500 samples at depth 5000, assertingzero inversions. Before the
drain_mutexit reported "first inversion: 531 then530" 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.
SubscriptionCallbackFiresFromWaitbecomesSubscriptionCallbackFiresExactlyOnceOnDeliveryandSubscriptionCallbackReportsTheBatchCountdrops itscalls == 1assertion:which path delivers, and how many batches a burst is split into, is no longer the
middleware's to promise — the event total is.
BatchCountExcludesDatagramsDroppedByOverflowbecomesBatchCountMatchesWhatCanBeTakenand compares credits against takes rather thanagainst
qos.depth, because how much of a 15-message burst survives the socketbuffer 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_callbackswith no flakes. All 16 test binaries are clean under-fsanitize=thread— deadlock detection confirmed live against alock-order-inversion control, so the new mutex is covered. The only TSan reports
left in the suite are the pre-existing
registry.cppseqlock ones, which thisstack does not touch.
-fsanitize=address,undefinedwith leak detection wasclean on this PR's first commit and has not been re-run since the per-wait-set
delivery fd and the
drain_mutexlanded; it would not be the evidence for thejoin 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::EventsExecutornode(measured with the whole series applied):
develreceives 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_callbackregisters; services and clientsare the next PR in the series. The
drain_mutexis added to all three endpointtypes anyway, since
rmw_waitandrmw_takealready race each other there.drain_endpointreturns a count again: the number of datagrams it pushed into thequeue, 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, notrmw_context_fini, so an endpointdestroyed between those two calls is not drained by a live thread;
rmw_context_finirepeats the call for a context finalized without a shutdown.