Skip to content

fix(rmw_event): validate handles and set an error message on the failure paths - #64

Open
benaliabderrahmane wants to merge 2 commits into
develfrom
fix/event-api-hardening
Open

benaliabderrahmane wants to merge 2 commits into
develfrom
fix/event-api-hardening

Conversation

@benaliabderrahmane

@benaliabderrahmane benaliabderrahmane commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Description

Finishes the event API surface that #61 started. #61 made *_event_init reject
unsupported event types, which is the refusal rclcpp handles cleanly. The
remaining entry points were still bare stubs, and two of the gaps stay reachable:

  • rmw_event_set_callback returned RMW_RET_UNSUPPORTED without setting an
    error message
    . That missing message is the ": error not set" half of the
    original EventsExecutor crash report, and it is what made the failure hard to
    place. rclcpp can no longer reach this function, but a failure code with
    nothing behind it is a dead end for anyone calling it directly.
  • rmw_take_event validated neither its handle nor its implementation
    identifier, unlike the endpoint and node entry points elsewhere in this RMW.

RMW_CHECK_TYPE_IDENTIFIERS_MATCH now guards five entry points that were
missing it: both *_event_init functions, rmw_take_event,
rmw_event_set_callback and rmw_subscription_set_on_new_message_callback; the
service and client callback setters already had it.

One deliberate omission worth a look during review: rmw_event_fini is left
validating nothing at all — a bare (void)event; return RMW_RET_OK;. An earlier
revision of this branch did guard it, and the guards came back out: rcl_event_fini
skips the call entirely when event->impl is NULL, and that is exactly what
rcl_*_event_init leaves behind, since it deallocates impl and nulls it before
returning our RMW_RET_UNSUPPORTED. So no rejected init ever reaches this
function, and neither does a foreign handle — the guards were defending a state
the stack cannot produce, which is CLAUDE.md's "no error handling for impossible
scenarios". There is nothing to release either: rmw_event_t::data aliases the
endpoint's impl struct, owned by rmw_destroy_publisher /
rmw_destroy_subscription. The function carries a comment saying so.

Also adds an ## [Unreleased] / ### Fixed section to CHANGELOG.md covering
the above.

Is this user-facing behavior change?

Yes, in three ways, all narrow:

  • A failing rmw_event_set_callback now leaves an error message, so rcl logs
    something useful instead of error not set. It also now returns
    RMW_RET_INVALID_ARGUMENT for a null handle and
    RMW_RET_INCORRECT_RMW_IMPLEMENTATION for a foreign one, where devel
    returned RMW_RET_UNSUPPORTED for both.
  • rmw_take_event returns RMW_RET_INVALID_ARGUMENT /
    RMW_RET_INCORRECT_RMW_IMPLEMENTATION on misuse that previously returned
    RMW_RET_OK, and both *_event_init functions return
    RMW_RET_INCORRECT_RMW_IMPLEMENTATION for a foreign endpoint where devel
    fell through to the unsupported-type check and returned RMW_RET_UNSUPPORTED.
    Only reachable by passing a null or a foreign handle.
  • rmw_subscription_set_on_new_message_callback returns
    RMW_RET_INCORRECT_RMW_IMPLEMENTATION for a foreign subscription, where
    devel would have cast that subscription's data to
    rmw_uds::UdsSubscription * and locked its callback_mutex.

rmw_event_fini is unchanged: it still returns RMW_RET_OK for every handle.

No event type becomes supported. rmw_event_type_is_supported still returns
false for all of them.

How was this tested?

test/test_rmw_event.cpp grows from 2 tests to 9. Three of the nine fail on
the current devel behavior and pass after this change
:
EventInitRejectsForeignEndpoints, TakeEventRejectsBadArguments,
EventSetCallbackReportsUnsupportedWithAMessage. The remaining six also
pass on devel — including EventFiniIsANoOp, which pins the contract
devel already had.

The two existing tests are broadened to iterate 0 .. RMW_EVENT_INVALID rather
than naming two event types by hand. rmw_event_type_t has no explicit
initializers, so both the ordinals and RMW_EVENT_INVALID shift as upstream adds
event types — no ordinal is hardcoded anywhere.

Full suite green locally on jazzy: 163 tests, 0 failures. kilted, rolling and
lyrical are on CI.

Did you use Generative AI?

Additional Information

Independent of the other branches in this series — can merge in any order.

benaliabderrahmane added a commit that referenced this pull request Sep 9, 2026
's entry

Four corrections to the docs this PR adds.

"Edge-driven" appeared three times for the listener. The intent was
event-driven rather than timer-driven, which is what the surrounding
sentences argue, but the term means the opposite of what the code does:
listener_watch registers EPOLLIN without EPOLLET and relies on
level-triggering to notice datagrams that arrived before the watch. Now
"event-driven, not timer-driven".

The delivery_fd section described one eventfd per context and claimed
"there is no order in which it is missed". That only held for whichever
wait set woke first - a read drains the whole eventfd counter - so the
section now explains why the fd belongs to the wait set, and the
deregister-before-close rule that comes with it.

The lifetime rule understated itself. listener_mutex is one per context,
so a callback must not destroy, register on, or clear the callback of any
endpoint in the context, nor create or destroy a wait set - not just its
own endpoint - and listener_unwatch blocks on whatever drain is in
flight, not one of "that endpoint".

The CHANGELOG documented #64's event-API changes, and #64 is not an
ancestor of this stack, so merging this without it shipped a changelog
for absent code. That entry moves to #64, which now carries its own
Unreleased section. Replaced here by the fixes this stack actually makes:
the double backlog flush, the setters' error paths, and the std::thread
throw.

test/README.md gains rows for the four new tests and updates the two that
were renamed.

Docs only. Full suite green on Jazzy: 174 tests, 0 failures.
Abderahmane BENALI and others added 2 commits September 18, 2026 14:34
…ure paths

rmw_take_event, rmw_event_set_callback, both *_event_init functions and
rmw_subscription_set_on_new_message_callback now reject a NULL handle with
RMW_RET_INVALID_ARGUMENT and a foreign one with
RMW_RET_INCORRECT_RMW_IMPLEMENTATION, and every failure path leaves an
error message - the missing message was the ': error not set' half of the
original EventsExecutor crash report.

rmw_event_fini deliberately validates nothing: rcl_event_fini skips the
call when impl is NULL, which is exactly what a rejected init leaves
behind, so guards there defended a state the stack cannot produce. Nothing
to release either - rmw_event_t::data aliases the endpoint's impl struct.

Squashed from:
- fix(rmw_event): validate handles and set an error message on the failure paths
- fix(rmw_event): drop the unreachable rmw_event_fini guards, check the identifier in set_callback
…n every distro

- SetOnNewMessageCallbackRejectsForeignSubscription pins the identifier
  check this PR adds to rmw_subscription_set_on_new_message_callback; it
  was the one guarded entry point without a test.
- The *RejectsEveryEventType loops were bounded by RMW_EVENT_INVALID, which
  is the last enumerator on Jazzy but 0 from Kilted on, so they ran a single
  iteration on three of the four CI distros. Bound them by whichever of it
  and RMW_EVENT_PUBLICATION_MATCHED is larger.
- Drop the claim that rcl reuses the event_info buffer across calls; no
  upstream layer does. The rule it was propping up stands on its own.
- CHANGELOG: state the *_event_init refusal the entry is headlined with
  (#61), and stop claiming every other entry point already had the
  identifier check - only the service and client callback setters did.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNQNo26cKRPrVXcaHZnje
benaliabderrahmane pushed a commit that referenced this pull request Sep 18, 2026
"No background threads" was asserted across DESIGN.md and README.md and
is no longer unconditional: none for an executor that waits, one
per-context listener thread for an application that registers a listener
callback. Each assertion now says what it actually guarantees.

DESIGN.md gains "The listener thread: delivery without a wait": why
EventsExecutor deadlocks against wait-only delivery, the lazy start, the
reordering and lost-wakeup hazards and what closes them (drain_mutex, the
per-wait-set delivery fd), the listener_mutex lifetime rule and the
restrictions it implies, that a callback may take from its own endpoint
because callback_mutex is recursive, which context-wide locks exist, and
what the thread deliberately is not. README, test/README and CHANGELOG
follow.

Squashed from:
- docs: document the listener thread; narrow the no-threads guarantee
- docs: fix "edge-driven", describe the per-wait-set delivery fd, drop #64's entry
- docs: correct the drain-safety claim, and log the two blocker fixes
- docs: sweep the claims this stack invalidated but the prose pass missed
- docs: a callback may now take from its own endpoint
- docs: callback_mutex is recursive; listener_mutex is not the only context lock

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