Skip to content

Commit cc88675

Browse files
authored
Check error flags when canceling an action task (#19)
Signed-off-by: Joni Pöllänen <[email protected]>
1 parent 5a60585 commit cc88675

File tree

6 files changed

+219
-33
lines changed

6 files changed

+219
-33
lines changed

task_manager/mypy.ini

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
[mypy]
2+
ignore_missing_imports = True
3+
4+
# Python version to be compatible with
5+
python_version = 3.8
6+
7+
#
8+
# Define type checking settings. These settings are pretty strict.
9+
#
10+
11+
# Warns about per-module sections in the config file that do not match any files processed when invoking mypy.
12+
warn_unused_configs = True
13+
14+
# Disallows usage of generic types that do not specify explicit type parameters.
15+
disallow_any_generics = True
16+
17+
# Disallows subclassing a value of type Any.
18+
disallow_subclassing_any = True
19+
20+
# Disallows calling functions without type annotations from functions with type annotations.
21+
# Hopefully this makes adding types more attractive.
22+
disallow_untyped_calls = True
23+
24+
# Disallows defining functions without type annotations or with incomplete type annotations.
25+
disallow_untyped_defs = False
26+
27+
# Disallows defining functions with incomplete type annotations.
28+
disallow_incomplete_defs = True
29+
30+
# Type-checks the interior of functions without type annotations.
31+
check_untyped_defs = True
32+
33+
# Reports an error whenever a function with type annotations is decorated with a decorator without annotations.
34+
disallow_untyped_decorators = True
35+
36+
# Warns about casting an expression to its inferred type.
37+
warn_redundant_casts = True
38+
39+
# Warns about unneeded # type: ignore comments.
40+
warn_unused_ignores = True
41+
42+
# Shows errors for missing return statements on some execution paths.
43+
warn_return_any = True
44+
45+
# Shows a warning when encountering any code inferred to be unreachable or redundant after performing type analysis.
46+
warn_unreachable = True
47+
48+
# By default, imported values to a module are treated as exported and mypy allows other modules to import them. When
49+
# false, mypy will not re-export unless the item is imported using from-as or is included in __all__.
50+
implicit_reexport = false
51+
52+
# Prohibit equality checks, identity checks, and container checks between non-overlapping types.
53+
strict_equality = True
54+
55+
# Changes the treatment of arguments with a default value of None by not implicitly making their type Optional.
56+
no_implicit_optional = True

task_manager/task_manager/task_client.py

Lines changed: 76 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
from rclpy.node import Node
2727

2828
# ROS messages
29-
from action_msgs.msg import GoalInfo, GoalStatus
29+
from action_msgs.msg import GoalStatus
30+
from action_msgs.srv import CancelGoal
3031

3132
# Task Manager messages
3233
from task_manager_msgs.msg import TaskStatus
@@ -65,6 +66,8 @@ def cancel_task(self) -> None:
6566
class ActionTaskClient(TaskClient):
6667
"""Task client that keeps track of a single Action task."""
6768

69+
DONE_STATES = [GoalStatus.STATUS_SUCCEEDED, GoalStatus.STATUS_ABORTED, GoalStatus.STATUS_CANCELED]
70+
6871
def __init__(
6972
self,
7073
node: Node,
@@ -94,6 +97,7 @@ def __init__(
9497
self._client: ActionClient = action_clients[task_specs.task_name]
9598

9699
self._goal_handle: Optional[ClientGoalHandle] = None
100+
self._result_future: Optional[Future] = None
97101
self.server_wait_timeout = 10.0
98102
self.cancel_task_timeout = 5.0 # Timeout to wait for task to cancel
99103

@@ -147,27 +151,21 @@ def start_task_async(self, goal_message: Any) -> None:
147151
raise TaskStartError(f"Goal was not accepted by the action server for the task {self.task_specs.task_name}")
148152

149153
self.task_details.status = TaskStatus.IN_PROGRESS
150-
future: Future = self._goal_handle.get_result_async()
151-
future.add_done_callback(self._goal_done_cb)
154+
self._result_future = self._goal_handle.get_result_async()
155+
self._result_future.add_done_callback(self._goal_done_cb)
152156

153157
def cancel_task(self) -> None:
154-
"""
158+
"""Cancel the task.
159+
155160
:raises CancelTaskFailedError: If cancel request fails, due to timeout or other
156161
"""
162+
if not self._goal_handle:
163+
raise CancelTaskFailedError("Couldn't cancel the task, goal handle does not exist!")
164+
157165
# In some rare cases the goal might already be done at this point. If not, cancel it.
158-
done_states = [GoalStatus.STATUS_SUCCEEDED, GoalStatus.STATUS_ABORTED, GoalStatus.STATUS_CANCELED]
159-
if self._goal_handle.status not in done_states:
160-
# There seems to be a bug in rclpy, making the return code to be 0 (ERROR_NONE),
161-
# no matter if the cancel was rejected or accepted. So checking instead if the
162-
# goal is within the cancelling goals.
163-
goals_canceling = self._request_canceling(self.cancel_task_timeout)
164-
goal_ids_cancelling = [goal_info.goal_id for goal_info in goals_canceling]
165-
if self._goal_handle.goal_id not in goal_ids_cancelling:
166-
self._node.get_logger().error(
167-
f"Couldn't cancel the task. Action server {self.task_specs.topic} did not "
168-
f"accept to cancel the goal."
169-
)
170-
raise CancelTaskFailedError("Couldn't cancel the task!")
166+
if self._goal_handle.status not in self.DONE_STATES:
167+
response = self._request_canceling(self.cancel_task_timeout)
168+
self._handle_cancel_response(response)
171169

172170
# Wait until _goal_done_cb is called and callbacks have been notified
173171
if not self.goal_done.wait(timeout=self.cancel_task_timeout):
@@ -176,7 +174,15 @@ def cancel_task(self) -> None:
176174
f"Is the task cancel implemented correctly?"
177175
)
178176

179-
def _request_canceling(self, timeout: float) -> List[GoalInfo]:
177+
def _request_canceling(self, timeout: float) -> CancelGoal.Response:
178+
"""Requests canceling for the goal and returns the cancel response.
179+
180+
:raises CancelTaskFailedError: If the cancel request timed out
181+
:param timeout: Time to wait for cancel request to pass
182+
:return: CancelGoal.Response
183+
"""
184+
if not self._goal_handle:
185+
raise CancelTaskFailedError("Couldn't cancel the task, goal handle does not exist!")
180186
future = self._goal_handle.cancel_goal_async()
181187
try:
182188
self._wait_for_future_to_complete(future, timeout=timeout)
@@ -185,17 +191,65 @@ def _request_canceling(self, timeout: float) -> List[GoalInfo]:
185191
f"Timeout while waiting response to cancel request from server {self.task_specs.task_name}: {str(e)}."
186192
)
187193
raise CancelTaskFailedError("Cancel request timed out.") from e
188-
return future.result().goals_canceling
194+
return future.result()
195+
196+
def _handle_cancel_response(self, response: CancelGoal.Response) -> None:
197+
"""Handles the response from the cancel request.
198+
199+
:raises CancelTaskFailedError: If the cancel request fails
200+
"""
201+
# There seems to be a bug in rclpy, making the return code to be 0 (ERROR_NONE),
202+
# no matter if the cancel was rejected or accepted. So checking instead if the
203+
# goal is within the cancelling goals.
204+
if response.return_code == CancelGoal.Response.ERROR_UNKNOWN_GOAL_ID:
205+
self._node.get_logger().info(
206+
f"Action server {self.task_specs.topic} did not recognize the goal id. "
207+
f"Maybe server has restarted during the task execution and the goal no longer exists. "
208+
f"Considering the task canceled."
209+
)
210+
if not self._result_future:
211+
raise CancelTaskFailedError("Couldn't cancel the task result future since it does not exist!")
212+
self._result_future.cancel()
213+
214+
elif response.return_code == CancelGoal.Response.ERROR_GOAL_TERMINATED:
215+
self._node.get_logger().info(
216+
f"Action server {self.task_specs.topic} did not accept to cancel the goal. "
217+
f"Goal seems to have already finished. Considering the task canceled."
218+
)
219+
if not self._result_future:
220+
raise CancelTaskFailedError("Couldn't cancel the task result future since it does not exist!")
221+
self._result_future.cancel()
222+
223+
else:
224+
if not self._goal_handle:
225+
raise CancelTaskFailedError("Couldn't cancel the task, goal handle does not exist!")
226+
goal_ids_cancelling = [goal_info.goal_id for goal_info in response.goals_canceling]
227+
if self._goal_handle.goal_id not in goal_ids_cancelling:
228+
self._node.get_logger().error(
229+
f"Couldn't cancel the task. Action server {self.task_specs.topic} did not "
230+
f"accept to cancel the goal."
231+
)
232+
raise CancelTaskFailedError("Couldn't cancel the task!")
189233

190234
def _goal_done_cb(self, future: Future) -> None:
191-
"""Called when the Action Client's goal finishes. Updates the task status and notifies callbacks further the
192-
other callbacks.
235+
"""Called when the Action Client's goal finishes. Updates the task status and invokes task done callbacks.
193236
194237
:param future: Future object giving the result of the action call.
195238
"""
239+
if future.cancelled():
240+
self.task_details.status = TaskStatus.CANCELED
241+
self.task_details.result = self.task_specs.msg_interface.Result()
242+
else:
243+
self._fill_in_task_details(future)
244+
245+
for callback in self._task_done_callbacks:
246+
callback(self.task_specs, self.task_details)
247+
self.goal_done.set()
248+
249+
def _fill_in_task_details(self, future: Future) -> None:
250+
"""Fills in the task details based on the future result."""
196251
result = future.result()
197252
goal_status = result.status
198-
199253
try:
200254
end_goal_status = ros_goal_status_to_task_status(goal_status)
201255
except RuntimeError as e:
@@ -208,13 +262,8 @@ def _goal_done_cb(self, future: Future) -> None:
208262
self.task_details.status = TaskStatus.DONE
209263
else:
210264
self.task_details.status = end_goal_status
211-
212265
self.task_details.result = result.result
213266

214-
for callback in self._task_done_callbacks:
215-
callback(self.task_specs, self.task_details)
216-
self.goal_done.set()
217-
218267
@staticmethod
219268
def _wait_for_future_to_complete(future: Future, timeout: Optional[float]) -> None:
220269
event = Event()

task_manager/task_manager/task_registrator.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ def _cancel_task_of_same_type(self, task_name: str) -> None:
146146
active_task_clients = self._active_tasks.get_active_tasks_by_name(task_name)
147147
if not active_task_clients:
148148
return
149-
149+
self._node.get_logger().info(f"Detected running task of the same type '{task_name}', canceling it.")
150150
if len(active_task_clients) > 1:
151151
self._node.get_logger().error("Found multiple task clients with the same name!")
152152

@@ -167,6 +167,7 @@ def _cancel_active_blocking_task(self) -> None:
167167
return
168168

169169
try:
170+
self._node.get_logger().info("Detected running blocking task, canceling it.")
170171
task_client.cancel_task()
171172
except CancelTaskFailedError as e:
172173
raise TaskStartError(

task_manager/test/integration_tests/mock_servers.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@
2121
from rclpy.action.server import ActionServer, CancelResponse, ServerGoalHandle
2222
from rclpy.callback_groups import MutuallyExclusiveCallbackGroup, ReentrantCallbackGroup
2323
from rclpy.node import Node
24+
from rclpy.service import Service
2425

2526
# ROS messages
2627
from example_interfaces.action import Fibonacci
2728
from example_interfaces.srv import AddTwoInts
2829

2930

30-
def create_fib_action_server(node, action_name):
31+
def create_fib_action_server(node: Node, action_name: str) -> ActionServer:
3132
"""Action server that execution time depends on the given Fibonacci goal."""
3233
return ActionServer(
3334
node=node,
@@ -97,7 +98,7 @@ def _execute_cb(goal_handle: ServerGoalHandle) -> Fibonacci.Result:
9798
return result
9899

99100

100-
def create_add_two_ints_service(node: Node, service_name):
101+
def create_add_two_ints_service(node: Node, service_name: str) -> Service:
101102
"""Creates a AddTwoInts service."""
102103

103104
def service_callback(req: AddTwoInts.Request, result: AddTwoInts.Response) -> AddTwoInts.Response:

task_manager/test/integration_tests/test_action_task_client.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
# ROS
2222
import rclpy
2323
from rclpy.action import CancelResponse, GoalResponse
24+
from rclpy.action.client import ClientGoalHandle
2425
from rclpy.executors import MultiThreadedExecutor
2526

2627
# Thirdparty
@@ -37,7 +38,7 @@
3738
from task_manager.task_details import TaskDetails
3839
from task_manager.task_specs import TaskServerType, TaskSpecs
3940

40-
# pylint: disable=duplicate-code
41+
# pylint: disable=duplicate-code, protected-access
4142
# The init is very similar to test_service_task_client, but it is fine in this case
4243

4344

@@ -191,6 +192,58 @@ def execute_cb(_goal_handle):
191192
# Finally, wait for the goal to finish to avoid error logs
192193
client.goal_done.wait(timeout=5)
193194

195+
def test_cancel_task_goal_not_known_by_server(self) -> None:
196+
"""Action server doesn't know the goal when trying to cancel it."""
197+
client = ActionTaskClient(self._node, self._task_details, self._task_specs, action_clients={})
198+
client.start_task_async(Fibonacci.Goal(order=1))
199+
self.fibonacci_server.destroy()
200+
self.fibonacci_server = create_fib_action_server(self._node, "fibonacci")
201+
client.cancel_task()
202+
self.assertEqual(client.task_details.status, TaskStatus.CANCELED)
203+
204+
def test_goal_done_cb_called_only_once(self) -> None:
205+
"""Checks that goal_done_cb is ran only once per goal and we do not throw error for the cancel."""
206+
207+
def execute_cb(_goal_handle):
208+
_goal_handle.succeed()
209+
return Fibonacci.Result()
210+
211+
client = ActionTaskClient(self._node, self._task_details, self._task_specs, action_clients={})
212+
self.fibonacci_server.register_execute_callback(execute_cb)
213+
client.start_task_async(Fibonacci.Goal(order=1))
214+
client.goal_done.wait(timeout=1)
215+
handle = ClientGoalHandle(
216+
goal_id=client._goal_handle.goal_id, # type: ignore[union-attr]
217+
action_client=client._client,
218+
goal_response=Fibonacci.Result,
219+
)
220+
client._goal_handle = handle
221+
client.cancel_task()
222+
self.assertEqual(client.task_details.status, TaskStatus.DONE)
223+
224+
def test_cancel_task_goal_terminated_before_cancel(self) -> None:
225+
"""Case where the goal has already been finished when trying to cancel it.
226+
227+
Checks that goal_done_cb is ran only once per goal and we do not throw error for the cancel.
228+
"""
229+
client = ActionTaskClient(self._node, self._task_details, self._task_specs, action_clients={})
230+
client.start_task_async(Fibonacci.Goal(order=0))
231+
client.goal_done.wait(timeout=1)
232+
233+
# Reset state. Simulates a state where goal has finished but the response did not arrive to task manager
234+
handle = ClientGoalHandle(
235+
goal_id=client._goal_handle.goal_id, # type: ignore[union-attr]
236+
action_client=client._client,
237+
goal_response=Fibonacci.Result,
238+
)
239+
client._goal_handle = handle
240+
client._result_future._done = False # type: ignore[union-attr]
241+
client._result_future.add_done_callback(client._goal_done_cb) # type: ignore[union-attr]
242+
client.goal_done.clear()
243+
244+
client.cancel_task()
245+
self.assertEqual(client.task_details.status, TaskStatus.CANCELED)
246+
194247

195248
if __name__ == "__main__":
196249
unittest.main()

task_manager/test/test_task_clients.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# ------------------------------------------------------------------
1616

1717
import unittest
18-
from unittest.mock import Mock
18+
from unittest.mock import Mock, patch
1919

2020
# ROS
2121
from rclpy.node import Node
@@ -26,13 +26,14 @@
2626

2727
# ROS messages
2828
from action_msgs.msg import GoalStatus
29+
from action_msgs.srv import CancelGoal
2930
from example_interfaces.action import Fibonacci
3031

3132
# Task Manager messages
3233
from task_manager_msgs.msg import TaskStatus
3334

3435
# Task Manager
35-
from task_manager.task_client import ActionTaskClient, ServiceTaskClient
36+
from task_manager.task_client import ActionTaskClient, CancelTaskFailedError, ServiceTaskClient
3637
from task_manager.task_details import TaskDetails
3738

3839
# pylint: disable=protected-access
@@ -88,6 +89,31 @@ def test_done_cb_bad_status(self) -> None:
8889
result = extract_values(task_client.task_details.result)
8990
self.assertEqual(result, {"sequence": []}, msg=str(task_client.task_details.result))
9091

92+
def test_cancel_task_no_goal_handle(self):
93+
"""Tests that we do not crash if goal handle does not exist."""
94+
task_client = get_action_task_client("task_1")
95+
self.assertRaises(CancelTaskFailedError, task_client.cancel_task)
96+
97+
def test_request_canceling_no_goal_handle(self):
98+
"""Tests that we do not crash if goal handle does not exist."""
99+
task_client = get_action_task_client("task_1")
100+
self.assertRaises(CancelTaskFailedError, task_client._request_canceling, 1)
101+
102+
@patch("task_manager.task_client.ActionTaskClient._request_canceling")
103+
def test_cancel_task_no_result_future(self, mock_request_canceling: Mock):
104+
"""Tests that we do not crash if result future does not exist."""
105+
cases = [
106+
{"case": "Unknown goal id", "return_code": CancelGoal.Response.ERROR_UNKNOWN_GOAL_ID},
107+
{"case": "Goal terminated", "return_code": CancelGoal.Response.ERROR_GOAL_TERMINATED},
108+
]
109+
110+
task_client = get_action_task_client("task_1")
111+
task_client._goal_handle = Mock()
112+
for case in cases:
113+
mock_request_canceling.return_value = CancelGoal.Response(return_code=case["return_code"])
114+
with self.subTest(case["case"]):
115+
self.assertRaises(CancelTaskFailedError, task_client.cancel_task)
116+
91117

92118
class ServiceTaskClientUnittests(unittest.TestCase):
93119
"""Unittests for ServiceTaskClient.

0 commit comments

Comments
 (0)