Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions rclpy/rclpy/executors.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,10 @@ def _wait_for_ready_callbacks(
ready_tasks_count = len(self._ready_tasks)
for _ in range(ready_tasks_count):
task = self._ready_tasks.popleft()
# Skip tasks that were cancelled or set done while awaiting a
# future and got rescheduled when the future completed
if task.cancelled() or task.done():
continue
task_data = self._pending_tasks[task]
node = task_data.source_node
if node is None or node in nodes_to_use:
Expand Down
35 changes: 30 additions & 5 deletions rclpy/rclpy/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def __init__(self, *, executor=None):
# An exception raised by the handler when called
self._exception = None
self._exception_fetched = False
# callbacks to be scheduled after this task completes
# callbacks or tasks to be scheduled after this task completes
self._callbacks = []
# Lock for threadsafety
self._lock = threading.Lock()
Expand Down Expand Up @@ -157,10 +157,18 @@ def _schedule_or_invoke_done_callbacks(self):
if executor is not None:
# Have the executor take care of the callbacks
for callback in callbacks:
executor.create_task(callback, self)
if isinstance(callback, Task):
executor._call_task_in_next_spin(callback)
else:
executor.create_task(callback, self)
else:
# No executor, call right away
for callback in callbacks:
if isinstance(callback, Task):
warnings.warn(
'Dropping task awaiting future: '
'executor reference could not be resolved')
continue
try:
callback(self)
except Exception as e:
Expand Down Expand Up @@ -201,6 +209,24 @@ def add_done_callback(self, callback):
if invoke:
callback(self)

def _add_waiting_task(self, task):
"""Schedule a task to resume when this future completes."""
with self._lock:
if not self._pending():
assert self._executor is not None
executor = self._executor()
if executor is not None:
executor._call_task_in_next_spin(task)
else:
warnings.warn(
'Dropping task awaiting future: '
'executor reference could not be resolved')
else:
self._callbacks.append(task)

def remove_done_callback(self, callback):
"""
Remove a previously-added done callback.

class Task(Future):
"""
Expand Down Expand Up @@ -303,9 +329,8 @@ def _add_resume_callback(self, future: Future, executor) -> None:
elif future_executor is not executor:
raise RuntimeError('A task can only await futures associated with the same executor')

# The future is associated with the same executor, so we can resume the task directly
# in the done callback
future.add_done_callback(lambda _: self.__call__())
# Register the task to resume when the future is done or cancelled
future._add_waiting_task(self)

def _complete_task(self) -> None:
"""Cleanup after task finished."""
Expand Down
87 changes: 87 additions & 0 deletions rclpy/test/test_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,93 @@ def func():
if thr.is_alive():
executor.remove_node(self.node)

def test_coroutine_exception_after_await(self):
"""Exception in a coroutine after awaiting a future must propagate."""
self.assertIsNotNone(self.node.handle)
# EventsExecutor excluded - segfaults on exception propagation (#1641)
for cls in [SingleThreadedExecutor, MultiThreadedExecutor]:
with self.subTest(cls=cls):
executor = cls(context=self.context)
executor.add_node(self.node)

first_fut = executor.create_future()
second_fut = executor.create_future()

async def coro_that_raises():
first_fut.set_result(None)
await second_fut
raise RuntimeError('Expected error after await')

task = executor.create_task(coro_that_raises)

executor.spin_until_future_complete(first_fut, timeout_sec=5)
self.assertFalse(task.done())
# Resolve the inner future — triggers resume
second_fut.set_result(None)

with self.assertRaises(RuntimeError) as cm:
executor.spin_until_future_complete(task, timeout_sec=5)
self.assertIn('Expected error after await', str(cm.exception))

def test_cancel_task_while_awaiting_future(self):
"""Cancelling a task parked on a future must not crash the dispatch loop."""
self.assertIsNotNone(self.node.handle)
# EventsExecutor excluded - see #1641
for cls in [SingleThreadedExecutor, MultiThreadedExecutor]:
with self.subTest(cls=cls):
executor = cls(context=self.context)
executor.add_node(self.node)

first_fut = executor.create_future()
second_fut = executor.create_future()
third_fut = executor.create_future()

async def coro():
first_fut.set_result(None)
await second_fut
third_fut.set_result(None)

task = executor.create_task(coro)

executor.spin_until_future_complete(first_fut, timeout_sec=5)
self.assertFalse(task.done())

task.cancel()
self.assertTrue(task.cancelled())

second_fut.set_result(None)

executor.spin_until_future_complete(first_fut, timeout_sec=5)
self.assertFalse(third_fut.done())

def test_await_already_completed_future(self):
"""Awaiting an already-completed future must resume and return its result."""
self.assertIsNotNone(self.node.handle)
# EventsExecutor excluded - see #1641
for cls in [SingleThreadedExecutor, MultiThreadedExecutor]:
with self.subTest(cls=cls):
executor = cls(context=self.context)
executor.add_node(self.node)

fut = executor.create_future()
fut.set_result('done') # complete before the task runs

async def coro():
return await fut

task = executor.create_task(coro)

executor.spin_until_future_complete(task, timeout_sec=5)
self.assertTrue(task.done())
self.assertEqual('done', task.result())

def test_create_task_during_spin(self):
self.assertIsNotNone(self.node.handle)
for cls in [SingleThreadedExecutor, EventsExecutor]:
with self.subTest(cls=cls):
executor = cls(context=self.context)
executor.add_node(self.node)

self.assertTrue(future.done())
self.assertEqual('Sentinel Result', future.result())

Expand Down