From 16daf84e89893e8dd97d7f5147a7d41f2d48aac8 Mon Sep 17 00:00:00 2001 From: Michael Carroll Date: Fri, 29 May 2026 15:08:36 -0500 Subject: [PATCH 1/2] Add test case for _WorkTracker waiter membership leak on timeout Signed-off-by: Michael Carroll --- rclpy/test/test_executor.py | 62 +++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/rclpy/test/test_executor.py b/rclpy/test/test_executor.py index a7a3ce1c6..635c007a9 100644 --- a/rclpy/test/test_executor.py +++ b/rclpy/test/test_executor.py @@ -972,6 +972,68 @@ def shutdown_from_callback() -> None: self.node.destroy_timer(tmr_a) self.node.destroy_timer(tmr_b) + def test_work_tracker_waiter_leak_on_timeout(self) -> None: + wt = _WorkTracker() + + worker_a_running = threading.Event() + worker_a_should_exit = threading.Event() + worker_b_running = threading.Event() + + errors_a = [] + wait_returned_a = [] + + def worker_a_thread(): + try: + with wt.track_callback(): + worker_a_running.set() + # Call wait with a short timeout. This times out because + # Worker B is executing a callback and not waiting. + res = wt.wait(timeout_sec=0.1) + wait_returned_a.append(res) + # Stay alive inside the callback context + worker_a_should_exit.wait() + except Exception as e: + errors_a.append(e) + + def worker_b_thread(): + with wt.track_callback(): + worker_b_running.set() + # Run until Worker A's wait times out + time.sleep(0.5) + + tb = threading.Thread(target=worker_b_thread, name='WorkerB') + tb.start() + + ta = threading.Thread(target=worker_a_thread, name='WorkerA') + ta.start() + + self.assertTrue(worker_a_running.wait(timeout=2.0)) + self.assertTrue(worker_b_running.wait(timeout=2.0)) + + # Wait for Worker B to finish + tb.join(timeout=2.0) + + self.assertFalse(errors_a) + self.assertEqual(wait_returned_a, [False]) + + # MainThread calls wait(). Since Worker B finished, only Worker A is active. + # However, Worker A leaked into _waiting_threads from the timeout. + # MainThread's wait() will prematurely evaluate to True and exit instantly. + start_time = time.monotonic() + res_main = wt.wait(timeout_sec=0.2) + elapsed = time.monotonic() - start_time + + try: + # Under the bug, res_main is True and elapsed is ~0.0s. + # In the corrected code, it correctly blocks/returns False after 0.2s. + self.assertFalse( + res_main, + 'MainThread wait should have timed out because WorkerA is still running') + self.assertGreaterEqual(elapsed, 0.15) + finally: + worker_a_should_exit.set() + ta.join(timeout=2.0) + def test_context_manager(self) -> None: self.assertIsNotNone(self.node.handle) From b6768926e48162ce671b5e89bc51c80fb4a2e273 Mon Sep 17 00:00:00 2001 From: Michael Carroll Date: Fri, 29 May 2026 15:18:02 -0500 Subject: [PATCH 2/2] Fix waiter membership leak in _WorkTracker.wait on timeout Signed-off-by: Michael Carroll --- rclpy/rclpy/executors.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/rclpy/rclpy/executors.py b/rclpy/rclpy/executors.py index bc5f7ed8d..9eb93732f 100644 --- a/rclpy/rclpy/executors.py +++ b/rclpy/rclpy/executors.py @@ -171,9 +171,10 @@ def other_work_drained() -> bool: # A new waiter may have just satisfied an existing # waiter's condition (its in-flight work is now excluded). self._work_condition.notify_all() + drained = False try: - if not self._work_condition.wait_for(other_work_drained, timeout_sec): - return False + drained = self._work_condition.wait_for(other_work_drained, timeout_sec) + return drained finally: # Keep the waiter membership while a callback is still # in flight on this thread -- removing it now would let @@ -184,10 +185,12 @@ def other_work_drained() -> bool: # __exit__ will drop the membership when the callback # ends. For external callers with no in-flight # callback, no __exit__ will run, so discard here. - if added_self and current not in self._executing_thread_counts: - self._waiting_threads.discard(current) - self._work_condition.notify_all() - return True + # However, if we timed out (not drained), we are NOT committed + # to finishing successfully, so we must discard the membership. + if not drained or (added_self and current not in self._executing_thread_counts): + if added_self: + self._waiting_threads.discard(current) + self._work_condition.notify_all() @overload