Skip to content

Commit ca3ce21

Browse files
committed
Defensively look up result future in _execute_get_result_request
`_execute_get_result_request` checks `_goal_handles` for the requested goal ID before accessing `_result_futures`, but the two dicts are managed separately and `_execute_expire_goals` removes entries from both in another coroutine. When expiration runs between the existing check and the access, the executor crashes with `KeyError`. Look up the result future defensively and log a warning when it has already been removed, matching the existing handling for unknown goal IDs above. Signed-off-by: Saad Sharif Ahmed <saad.sharifahmed@gmail.com>
1 parent c2b0cb2 commit ca3ce21

2 files changed

Lines changed: 61 additions & 3 deletions

File tree

rclpy/rclpy/action/server.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -548,9 +548,15 @@ async def _execute_get_result_request(
548548
return
549549

550550
# There is an accepted goal matching the goal ID, register a callback to send the
551-
# response as soon as it's ready
552-
self._result_futures[bytes(goal_uuid)].add_done_callback(
553-
functools.partial(self._send_result_response, request_header))
551+
# response as soon as it's ready. The result future may have been removed by
552+
# `_execute_expire_goals` between the membership check above and this access,
553+
# so look it up defensively to avoid raising KeyError out of the executor.
554+
if bytes(goal_uuid) in self._result_futures:
555+
self._result_futures[bytes(goal_uuid)].add_done_callback(
556+
functools.partial(self._send_result_response, request_header))
557+
else:
558+
self._logger.warning(
559+
'Requested result has been expired already (goal ID: {0})'.format(goal_uuid))
554560

555561
async def _execute_expire_goals(self, expired_goals: Tuple[GoalInfo, ...]) -> None:
556562
for goal in expired_goals:

rclpy/test/test_action_server.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,58 @@ def test_expire_goals_multi(self) -> None:
706706
self.assertEqual(0, len(action_server._goal_handles))
707707
action_server.destroy()
708708

709+
def test_get_result_request_handles_expired_future(self) -> None:
710+
"""Regression test for ros2/rclpy#1236.
711+
712+
If ``_execute_expire_goals`` removes the result future from
713+
``_result_futures`` between the membership check on
714+
``_goal_handles`` and the access on ``_result_futures`` inside
715+
``_execute_get_result_request``, the action server must log a
716+
warning and return rather than raising ``KeyError`` out of the
717+
executor. The race is simulated deterministically here by
718+
manipulating ``_result_futures`` directly.
719+
"""
720+
action_server = ActionServer(
721+
self.node,
722+
Fibonacci,
723+
'fibonacci',
724+
execute_callback=self.execute_goal_callback,
725+
)
726+
727+
goal_uuid = UUID(uuid=list(uuid.uuid4().bytes))
728+
goal_msg = Fibonacci.Impl.SendGoalService.Request()
729+
goal_msg.goal_id = goal_uuid
730+
goal_future = self.mock_action_client.send_goal(goal_msg)
731+
rclpy.spin_until_future_complete(self.node, goal_future, self.executor)
732+
goal_handle = goal_future.result()
733+
assert goal_handle
734+
self.assertTrue(goal_handle.accepted)
735+
736+
# Let the executor run ``_execute_goal`` so the goal's result
737+
# future is created and resolved before we tamper with state.
738+
self.timed_spin(0.5)
739+
740+
uuid_bytes = bytes(goal_uuid.uuid)
741+
self.assertIn(uuid_bytes, action_server._goal_handles)
742+
self.assertIn(uuid_bytes, action_server._result_futures)
743+
744+
# Simulate the race: ``_execute_expire_goals`` removed the result
745+
# future between the ``_goal_handles`` check and the access at
746+
# the bottom of ``_execute_get_result_request``.
747+
del action_server._result_futures[uuid_bytes]
748+
749+
# Without the defensive lookup this raises ``KeyError`` out of
750+
# ``spin_once`` and crashes the executor.
751+
get_result_future = self.mock_action_client.get_result(goal_uuid)
752+
rclpy.spin_until_future_complete(
753+
self.node, get_result_future, self.executor, timeout_sec=2)
754+
755+
# The defensive path logs a warning instead of sending a result
756+
# response, so the client-side future never completes — the
757+
# important assertion is that no exception was raised.
758+
self.assertFalse(get_result_future.done())
759+
action_server.destroy()
760+
709761
def test_feedback(self) -> None:
710762

711763
def execute_with_feedback(goal_handle: ServerGoalHandle[Fibonacci.Goal, Fibonacci.Result,

0 commit comments

Comments
 (0)