Repository navigation
fix(python): no deadlock when a running Task / Timer / TrajectoryPlanner is deleted or left alive at exit - #853
Merged
Conversation
…ner is deleted or left alive at exit Deleting a python-owned espp object whose destructor joins a thread ran that join with the GIL held; when the thread was waiting for the GIL to run its python callback, neither side could proceed and `del task` hung forever. The call-guard pass could not help (tp_dealloc is not a .def), and at interpreter shutdown the same join happened during finalisation, when a thread can no longer re-acquire the GIL at all. - espp::py_detail::StopOnExitHolder (lib/python_bindings/stop_on_exit_holder.h) is now the pybind11 holder for Task, Timer, TrajectoryPlanner, ThreadPool, FtpServer and RtspServer (_STOP_ON_EXIT_CLASSES, applied by a new postprocess pass of autogenerate_bindings.py). It deletes with the GIL released and registers every live object in a StopRegistry (stop_registry.h); the module's atexit hook stops all of them -- GIL released -- before finalisation, so the later destructors have nothing to join. Classes with a python-visible base (UdpSocket) keep the default holder, which pybind11 requires to match the base's. - TrajectoryPlanner::start_task() / stop_task() become public (they are the documented way to pause and resume the planner, and the hook needs stop_task()); both are bound, stop_task with the GIL released. - python/blocking_destructors_test.py runs each case in a child interpreter under a timeout: delete a running Task / Timer / TrajectoryPlanner whose callback blocks in python, exit with all three alive, and an unhandled exception with a live planner. Before the fix the three delete cases hang; after it all five pass. Added to the wheel test command. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
|
✅Static analysis result - no issues found! ✅ |
…ile holding the planner mutex The blocking-destructor test hung on CI (Linux cp311, macOS x86_64) and in roughly one run in four locally, and a stack sample showed why: the main thread held the GIL and waited for the planner's recursive_mutex inside set_target(), while the callback task held that mutex and waited for the GIL -- it copied the output_callback std::function under the lock, and copying (or destroying) a std::function that wraps a python callable takes the GIL in pybind11. A GIL / mutex lock-order inversion that predates this branch and hits any python user of the planner, not only the destructor paths. The callback is now held in a shared_ptr: the callback task snapshots the pointer under the mutex (no GIL) and calls it with the mutex released, and set_config() copies the new configuration before taking the mutex and lets the previous one die after releasing it. Observable behaviour is unchanged. The test's callback-count assertions only require that the callbacks ran at all (an emulated x86_64 CI runner manages a handful in the sampling window). Result: 0 hangs in 50 runs across Python 3.12 and 3.15, versus 7 of 23 before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
…estructors # Conflicts: # components/trajectory_planner/include/trajectory_planner.hpp
…nt start_task / stop_task Self-review of the holder: stop_all() copied raw stop functions and ran them with the GIL released, so a daemon python thread deleting one of those objects at the same moment would have been a use-after-free. Each registry entry now carries its own mutex and an alive flag: the holder takes it (after releasing the GIL, so no GIL / mutex inversion) around the delete, stop_all() takes it around each stop and skips dead entries. A stop function that throws is ignored at exit. The planner README lists the now-public start_task() / stop_task(). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
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.
Summary
Fast follow to #851. Deleting a python-owned espp object whose destructor joins a thread ran that join with the GIL held; when the thread was waiting for the GIL to invoke its python callback, neither side could proceed and
del taskhung forever. The existing call-guard pass cannot cover this (tp_deallocis not a.def), and at interpreter shutdown the same join happened during finalisation, when a C++ thread can no longer re-acquire the GIL at all.espp::py_detail::StopOnExitHolder(lib/python_bindings/stop_on_exit_holder.h) is now the pybind11 holder forTask,Timer,TrajectoryPlanner,ThreadPool,FtpServerandRtspServer(_STOP_ON_EXIT_CLASSES, applied by a new postprocess pass inautogenerate_bindings.py, idempotent, reapplied to the generated file). It owns the object likeunique_ptr, deletes it with the GIL released, and registers every live object in aStopRegistry(stop_registry.h); the module'satexithook stops all of them with the GIL released before finalisation, so the destructors that run later have nothing left to join.UdpSocketkeeps the default holder: pybind11 requires a derived class to share its base's holder kind.TrajectoryPlanner::start_task()/stop_task()become public. They are the documented way to pause and resume the planner, the atexit hook needsstop_task(), and a python script can now stop a planner explicitly. Both are bound;stop_taskcarries the GIL-release guard likeTask.stop.python/blocking_destructors_test.pyruns each case in a child interpreter under a 15 s timeout, so a regression is a failed case rather than a hung test: delete a runningTask/Timer/TrajectoryPlannerwhose python callback blocks, exit with all three alive, and an unhandled exception with a live planner. Added to the cibuildwheel test command. The lib README documents the holder next to the call-guard note.Evidence
Against the unmodified
mainbuild (host lib + python package, macOS, Python 3.15):With this change:
python/dispatcher_test.pyandpython/rtps_bindings_smoke.pystill pass against the rebuilt module;components/trajectory_planner/examplebuilds for esp32 (IDF 6.1).Second finding: a GIL / mutex lock-order inversion in the planner (pre-existing)
The first CI run of the new test hung on Linux cp311 and on macOS x86_64, and locally in about one run in four. A stack sample of a hung process showed the main thread holding the GIL and blocked on the planner's
recursive_mutexinsideset_target(), while the planner's callback task held that mutex and was blocked intake_gil: it copied theoutput_callbackstd::functionunder the mutex, and copying or destroying astd::functionthat wraps a Python callable takes the GIL in pybind11. That inversion exists onmainand can deadlock any Python user of the planner, independent of the destructor paths.Fix (third commit): the callback is held in a
shared_ptr; the callback task snapshots the pointer under the mutex (no GIL involved) and calls it with the mutex released, andset_config()copies the new configuration before taking the mutex and lets the old one die after releasing it. No behaviour change. The test's callback-count assertions now only require that callbacks ran at all, since an emulated x86_64 CI runner manages only a handful in the sampling window.Result: 0 hangs in 50 runs (25 on Python 3.15 from the lib build, 25 on a Python 3.12 wheel of this branch), versus 7 hangs in 23 runs before the fix. The esp32 example still builds.
Merge with
main+ self-review (at d236ba1)main(with #851 squash-merged) is merged in; the one conflict was the planner header, where this branch movesstart_task()/stop_task()into the public section and #851 changed the protected section right below it (newStatedocs,reverse_velocity_limit_locked()). Resolved by keeping both: the public pair first, thenmain's protected section.Review findings acted on (fourth commit):
stop_all()ran copied stop functions with the GIL released, so a daemon python thread deleting one of those objects at the same moment would have been a use-after-free. Each registry entry now has its own mutex and alive flag; the holder takes it around the delete, after releasing the GIL (so there is no GIL / mutex inversion), andstop_all()takes it around each stop and skips dead entries.unique_ptr/shared_ptrof them crosses the boundary), so the custom holder cannot clash with a default-holder cast elsewhere.TimerandTaskinvoke their callbacks outside their mutexes, so the inversion was specific to the planner's callback task.Evidence on the merged tree (macOS arm64):
blocking_destructors_test.py0 hangs in 40 runs (20 on a Python 3.15 lib build, 20 on a Python 3.12 wheel);dispatcher_test.pyandrtps_bindings_smoke.pypass; the #850 and #852 scenarios from #851 pass through the Python API with a normal interpreter exit and live planners (previously they neededos._exit()); the planner host test passes and the esp32 example builds.Generator validation
The committed
pybind_espp.cpp/__init__.pyiwere updated by applying the new postprocess pass to them directly (it is idempotent), not by regenerating. To validate the pass inside the real pipeline, litgen was installed (Python 3.12; itssrcml-callerhas no wheel for 3.15) andautogenerate_bindings.pyrun twice on this branch's headers, once withmain's script and once with this one. The two fresh litgen outputs differ in exactly the intended places: the sixpy::class_<espp::X>→StopOnExitHolderrewrites, the#include "stop_on_exit_holder.h", and thecall_guardonTrajectoryPlanner.start_task/stop_task; the stubs are identical.Worth knowing, pre-existing and not touched here: a fresh regeneration no longer matches the committed file (about 500 lines after clang-format) and does not compile as is (
UdpSocket::set_optionoverload cast,ThreadPoolJob, an explicit constructor in copy-initialisation), and theQosBand/Dscpenums have been hand-bound since #735 with capitalised value names where litgen emits lowercase ones. So the committed file is the source of truth and regeneration needs a reconciliation pass before it can be used again; that is a separate job.Test plan
main, 5/5 pass with the fix🤖 Generated with Claude Code
https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU