Don't let exceptions escape destructors - #1
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>
4a0736e to
d870d7c
Compare
|
@dv-picknik @JWhitleyWork tagging you both for review before this goes anywhere near ros2/rclcpp. Short version: Space ROS runs clang-tidy over rclcpp, and GitHub will not let me put you in the Reviewers field because this is my personal fork and you are not collaborators on it, so this mention is the tag. Happy to add you as collaborators if you would rather have the formal review request. |
|
Actually I think i will just open it on rclcpp |
|
This is now open upstream as ros2#3278, so review there rather than here: ros2#3278 Same commit ( |
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.The bug
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 ros2#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 ros2#2953 did the same for~ServerGoalHandleafter ros2#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.Reproducing
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.
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.The fix
Catch and log, the way
~Contextdoes.Behavior change: these paths aborted the process and now log and continue.
~Contextmade the same tradeoff.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.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. Different call site, and aborting on a fatal startup error may be what is wanted there.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.