Don't let exceptions escape destructors - #3278
Open
davetcoleman wants to merge 1 commit into
Open
davetcoleman wants to merge 1 commit into
davetcoleman wants to merge 1 commit into
Conversation
TimeSource::NodeState, EventsCBGExecutor and WaitResult each call, from their destructor, a function that throws by contract. A destructor is implicitly noexcept, so such a throw calls std::terminate instead of unwinding. Guard each call the way ~Context already does: log the exception through RCLCPP_ERROR and let destruction finish. Found with clang-tidy bugprone-exception-escape. Signed-off-by: Dave Coleman <dave@picknik.ai> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
davetcoleman
marked this pull request as ready for review
September 19, 2026 04:02
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.
Description
Space ROS runs clang-tidy over rclcpp.
ament_lint_commondoes not, and there is no.clang-tidyin this repo, sobugprone-exception-escapehas not been pointed at this code before. It flags three destructors that can reachstd::terminate.These three destructors call something that throws:
TimeSource::NodeState::~NodeStatecallsdetachNode(), which callsNodeParameters::remove_on_set_parameters_callback(). That constructs aParameterMutationRecursionGuard, which throwsParameterModifiedInCallbackExceptionwhile a set-parameters callback is running.EventsCBGExecutor::~EventsCBGExecutorcallsshutdown(), thenremove_all_nodes_and_callback_groups(), thenremove_node(), which throwsstd::runtime_errorwhen a node is no longer associated with the executor.WaitResult::~WaitResultcallswait_result_release(), which throwsstd::runtime_errorwhen the wait set is not holding the result (wait_set_template.hpp:750).The three are not equally reachable, and it is worth saying which is which. The first happens today and the added test hits it. For the second I have no reproducer: it needs the node disassociated from the executor by some other path first. The third is likely unreachable without prior misuse, since
wait_result_holding_is cleared only in that function and the move constructor nulls the moved-from pointer, so guarding it is defensive. Same defect and same fix in all three, so they go together.On the
noexceptpoint, since it came up on #2948: a destructor's implicit exception specification isnoexcept(true)unless a base or member destructor is itself potentially throwing, which none of these three have. The gdb trace below is the confirmation rather than the argument, since it lands in__cxa_call_terminate.~Contextalready guards this case, and #2953 did the same for~ServerGoalHandleafter #2948 reported it as a throwing destructor. Those two picked different severities:~ServerGoalHandlelogs atRCLCPP_DEBUG,~ContextatRCLCPP_ERROR. I followed~Contexton the grounds that failed teardown is worth seeing by default, but I will happily drop these toDEBUGif you would rather they stay quiet.Evidence
The test added to
test_time_source.cppdestroys aTimeSourcefrom inside an on-set-parameters callback. Before the change it takes down the whole test binary instead of failing an assertion:Under gdb:
After:
Full rclcpp suite with this patch applied, Ubuntu 24.04 on aarch64: 3096 tests, 0 failures.
What the guard changes
The guarded call is abandoned partway, which is the part worth checking. In
~NodeStatethat skips resets of members that are about to be destroyed anyway. In~EventsCBGExecutorit skipscontext_->remove_on_shutdown_callback(), so the registration outlives the executor; that callback holds aweak_ptrto the guard condition, so it becomes a no-op rather than a dangling call, but the entry does stay on the context until shutdown. Both are better than terminating.No API or ABI change: destructor bodies only, no members, bases, signatures or vtables. One of the three is an inline destructor in a public header (
wait_result.hpp) and adds#include "rclcpp/logging.hpp"there, which that header already pulled in transitively viaclient.hppandservice.hpp.No linked issue. Related: #2948 and #2953 (same defect class in
rclcpp_action).Is this user-facing behavior change?
Yes. These three paths previously aborted the process; they now log and finish destroying the object.
~Contextand~ServerGoalHandlealready made the same tradeoff. A destructor cannot report failure to its caller, and terminating denies the application any chance to shut down in an orderly way.Did you use Generative AI?
Yes. The patch, the regression test and this description were written with Claude Opus 5 via Claude Code. The clang-tidy run, the gdb backtrace and the full-suite result quoted above were produced and checked locally rather than asserted by the model, and the three throw paths were each read in the source.
Additional Information
To see the warnings, inside
docker run --rm -it ros:rolling-ros-base bash:Three warnings on that tree, zero with this branch checked out instead. The
ros-rolling-rclcpppackage is only there to pull the dependency closure;rosdep install --from-paths src -ydoes the same.Scope. The same clang-tidy run reports roughly a thousand other findings in this package and I left all of them out. The large ones are style rules whose fix would break the public API (
google-explicit-constructor,google-default-arguments) or would change exported signatures and break ABI (misc-const-correctness,performance-unnecessary-value-param).cppcoreguidelines-owning-memorywantsgsl::owner, which this project does not use.Two more are worth fixing but not here:
bugprone-use-after-moveatnode_logging.cpp:84. ASetLoggerLevelsResultdeclared outside the loop is moved into the response on every iteration, so later iterations write into a moved-from object and the success branch never clearsreason. libstdc++ leaves the string empty, so you will not see it today, but the state is unspecified.bugprone-exception-escapeonmain()inrclcpp_components/src/component_container.cpp. That one has history (bugprone-exception-escape in node_main.cpp.in #1890, and Adding nolint to node_main.cpp.in to skip lint check #3184 which tried to silence it withNOLINTand was closed), so it does not belong in a patch about destructors.Backport. All three sites exist on
jazzy, so this wantsbackport-jazzy. Space ROS pins rclcpp on the jazzy branch, so without the backport the fix does not reach it.