Skip to content

Commit 400d061

Browse files
authored
[foxy] Fix race with launch context changes when loading composable nodes (#166) (#206)
* Fix race with launch context changes when loading composable nodes (#166) * Fix race with launch context changes when loading composable nodes This bug was discovered when trying load composable nodes from a GroupAction. The ROS namespace (and presumably other remaps) pushed onto the context stack was popped after the LoadComposableNodes execute() function finished. But because the loading happens asynchronously, we need to make sure we get the necessary information from the context before execute() finishes. Signed-off-by: Jacob Perron <[email protected]> * Add regression tests for LoadComposableNode Signed-off-by: Jacob Perron <[email protected]> * Properly shutdown mock conatiner node Also added some debug logs to the load node action for posterity. Signed-off-by: Jacob Perron <[email protected]> * Fix docblock in LoadComposableNodes (#207) Signed-off-by: Jacob Perron <[email protected]>
1 parent 68bd522 commit 400d061

File tree

2 files changed

+304
-11
lines changed

2 files changed

+304
-11
lines changed

launch_ros/launch_ros/actions/load_composable_nodes.py

+23-11
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,13 @@ def __init__(
8282

8383
def _load_node(
8484
self,
85-
composable_node_description: ComposableNode,
85+
request: composition_interfaces.srv.LoadNode.Request,
8686
context: LaunchContext
8787
) -> None:
8888
"""
8989
Load node synchronously.
9090
91-
:param composable_node_description: description of composable node to be loaded
91+
:param request: service request to load a node
9292
:param context: current launch context
9393
"""
9494
while not self.__rclpy_load_node_client.wait_for_service(timeout_sec=1.0):
@@ -99,8 +99,13 @@ def _load_node(
9999
)
100100
)
101101
return
102-
request = get_composable_node_load_request(composable_node_description, context)
102+
self.__logger.debug(
103+
"Calling the '{}' service with request '{}'".format(
104+
self.__rclpy_load_node_client.srv_name, request
105+
)
106+
)
103107
response = self.__rclpy_load_node_client.call(request)
108+
self.__logger.debug("Received response '{}'".format(response))
104109
node_name = response.full_node_name if response.full_node_name else request.node_name
105110
if response.success:
106111
if node_name is not None:
@@ -125,22 +130,22 @@ def _load_node(
125130

126131
def _load_in_sequence(
127132
self,
128-
composable_node_descriptions: List[ComposableNode],
133+
load_node_requests: List[composition_interfaces.srv.LoadNode.Request],
129134
context: LaunchContext
130135
) -> None:
131136
"""
132137
Load composable nodes sequentially.
133138
134-
:param composable_node_descriptions: descriptions of composable nodes to be loaded
139+
:param load_node_requests: a list of LoadNode service requests to execute
135140
:param context: current launch context
136141
"""
137-
next_composable_node_description = composable_node_descriptions[0]
138-
composable_node_descriptions = composable_node_descriptions[1:]
139-
self._load_node(next_composable_node_description, context)
140-
if len(composable_node_descriptions) > 0:
142+
next_load_node_request = load_node_requests[0]
143+
load_node_requests = load_node_requests[1:]
144+
self._load_node(next_load_node_request, context)
145+
if len(load_node_requests) > 0:
141146
context.add_completion_future(
142147
context.asyncio_loop.run_in_executor(
143-
None, self._load_in_sequence, composable_node_descriptions, context
148+
None, self._load_in_sequence, load_node_requests, context
144149
)
145150
)
146151

@@ -169,9 +174,16 @@ def execute(
169174
)
170175
)
171176

177+
# Generate load requests before execute() exits to avoid race with context changing
178+
# due to scope change (e.g. if loading nodes from within a GroupAction).
179+
load_node_requests = [
180+
get_composable_node_load_request(node_description, context)
181+
for node_description in self.__composable_node_descriptions
182+
]
183+
172184
context.add_completion_future(
173185
context.asyncio_loop.run_in_executor(
174-
None, self._load_in_sequence, self.__composable_node_descriptions, context
186+
None, self._load_in_sequence, load_node_requests, context
175187
)
176188
)
177189

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
# Copyright 2020 Open Source Robotics Foundation, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for the LoadComposableNodes Action."""
16+
17+
import threading
18+
19+
from composition_interfaces.srv import LoadNode
20+
21+
from launch import LaunchDescription
22+
from launch import LaunchService
23+
from launch.actions import GroupAction
24+
from launch_ros.actions import LoadComposableNodes
25+
from launch_ros.actions import PushRosNamespace
26+
from launch_ros.actions import SetRemap
27+
from launch_ros.descriptions import ComposableNode
28+
from launch_ros.utilities import get_node_name_count
29+
30+
import pytest
31+
32+
from rcl_interfaces.msg import ParameterType
33+
34+
import rclpy
35+
import rclpy.context
36+
import rclpy.executors
37+
import rclpy.node
38+
39+
TEST_CONTAINER_NAME = 'mock_component_container'
40+
TEST_NODE_NAME = 'test_load_composable_nodes_node'
41+
42+
43+
class MockComponentContainer(rclpy.node.Node):
44+
45+
def __init__(self):
46+
# List of LoadNode requests received
47+
self.requests = []
48+
49+
self._context = rclpy.context.Context()
50+
rclpy.init(context=self._context)
51+
52+
super().__init__(TEST_CONTAINER_NAME, context=self._context)
53+
54+
self.load_node_service = self.create_service(
55+
LoadNode,
56+
'~/_container/load_node',
57+
self.load_node_callback
58+
)
59+
60+
self._executor = rclpy.executors.SingleThreadedExecutor(context=self._context)
61+
62+
# Start spinning in a thread
63+
self._thread = threading.Thread(
64+
target=rclpy.spin,
65+
args=(self, self._executor),
66+
daemon=True
67+
)
68+
self._thread.start()
69+
70+
def load_node_callback(self, request, response):
71+
self.requests.append(request)
72+
response.success = True
73+
response.full_node_name = f'{request.node_namespace}/{request.node_name}'
74+
response.unique_id = len(self.requests)
75+
return response
76+
77+
def shutdown(self):
78+
self._executor.shutdown()
79+
self.destroy_node()
80+
rclpy.shutdown(context=self._context)
81+
self._thread.join()
82+
83+
84+
def _assert_launch_no_errors(actions):
85+
ld = LaunchDescription(actions)
86+
ls = LaunchService(debug=True)
87+
ls.include_launch_description(ld)
88+
assert 0 == ls.run()
89+
return ls.context
90+
91+
92+
def _load_composable_node(
93+
*,
94+
package,
95+
plugin,
96+
name,
97+
namespace='',
98+
parameters=None,
99+
remappings=None,
100+
target_container=f'/{TEST_CONTAINER_NAME}'
101+
):
102+
return LoadComposableNodes(
103+
target_container=target_container,
104+
composable_node_descriptions=[
105+
ComposableNode(
106+
package=package,
107+
plugin=plugin,
108+
name=name,
109+
namespace=namespace,
110+
parameters=parameters,
111+
remappings=remappings,
112+
)
113+
])
114+
115+
116+
@pytest.fixture
117+
def mock_component_container():
118+
container = MockComponentContainer()
119+
yield container
120+
container.shutdown()
121+
122+
123+
def test_load_node(mock_component_container):
124+
"""Test loading a node."""
125+
context = _assert_launch_no_errors([
126+
_load_composable_node(
127+
package='foo_package',
128+
plugin='bar_plugin',
129+
name='test_node_name',
130+
namespace='test_node_namespace'
131+
)
132+
])
133+
134+
# Check that launch is aware of loaded component
135+
assert get_node_name_count(context, '/test_node_namespace/test_node_name') == 1
136+
137+
# Check that container recieved correct request
138+
assert len(mock_component_container.requests) == 1
139+
request = mock_component_container.requests[0]
140+
assert request.package_name == 'foo_package'
141+
assert request.plugin_name == 'bar_plugin'
142+
assert request.node_name == 'test_node_name'
143+
assert request.node_namespace == '/test_node_namespace'
144+
assert len(request.remap_rules) == 0
145+
assert len(request.parameters) == 0
146+
assert len(request.extra_arguments) == 0
147+
148+
149+
def test_load_node_with_remaps(mock_component_container):
150+
"""Test loading a node with remappings."""
151+
context = _assert_launch_no_errors([
152+
_load_composable_node(
153+
package='foo_package',
154+
plugin='bar_plugin',
155+
name='test_node_name',
156+
namespace='test_node_namespace',
157+
remappings=[
158+
('test_topic1', 'test_remap_topic1'),
159+
('test/topic/two', 'test/remap_topic2')
160+
]
161+
)
162+
])
163+
164+
# Check that launch is aware of loaded component
165+
assert get_node_name_count(context, '/test_node_namespace/test_node_name') == 1
166+
167+
# Check that container recieved correct request
168+
assert len(mock_component_container.requests) == 1
169+
request = mock_component_container.requests[0]
170+
assert request.package_name == 'foo_package'
171+
assert request.plugin_name == 'bar_plugin'
172+
assert request.node_name == 'test_node_name'
173+
assert request.node_namespace == '/test_node_namespace'
174+
assert len(request.remap_rules) == 2
175+
assert request.remap_rules[0] == 'test_topic1:=test_remap_topic1'
176+
assert request.remap_rules[1] == 'test/topic/two:=test/remap_topic2'
177+
assert len(request.parameters) == 0
178+
assert len(request.extra_arguments) == 0
179+
180+
181+
def test_load_node_with_params(mock_component_container):
182+
"""Test loading a node with parameters."""
183+
context = _assert_launch_no_errors([
184+
_load_composable_node(
185+
package='foo_package',
186+
plugin='bar_plugin',
187+
name='test_node_name',
188+
namespace='test_node_namespace',
189+
parameters=[{
190+
'test_param1': 'test_value_param1',
191+
'test.param2': '42.0',
192+
}]
193+
)
194+
])
195+
196+
# Check that launch is aware of loaded component
197+
assert get_node_name_count(context, '/test_node_namespace/test_node_name') == 1
198+
199+
# Check that container recieved correct request
200+
assert len(mock_component_container.requests) == 1
201+
request = mock_component_container.requests[0]
202+
assert request.package_name == 'foo_package'
203+
assert request.plugin_name == 'bar_plugin'
204+
assert request.node_name == 'test_node_name'
205+
assert request.node_namespace == '/test_node_namespace'
206+
assert len(request.remap_rules) == 0
207+
assert len(request.parameters) == 2
208+
assert request.parameters[0].name == 'test_param1'
209+
assert request.parameters[0].value.type == ParameterType.PARAMETER_STRING
210+
assert request.parameters[0].value.string_value == 'test_value_param1'
211+
assert request.parameters[1].name == 'test.param2'
212+
# TODO(jacobperron): I would expect this to be a double value, but we can only pass strings
213+
# assert request.parameters[1].value.type == ParameterType.PARAMETER_DOUBLE
214+
# assert request.parameters[1].value.double_value == 42.0
215+
assert request.parameters[1].value.string_value == '42.0'
216+
assert len(request.extra_arguments) == 0
217+
218+
219+
def test_load_node_with_global_remaps_in_group(mock_component_container):
220+
"""Test loading a node with global remaps scoped to a group."""
221+
context = _assert_launch_no_errors([
222+
GroupAction(
223+
[
224+
SetRemap('chatter', 'new_topic_name'),
225+
_load_composable_node(
226+
package='foo_package',
227+
plugin='bar_plugin',
228+
name='test_node_name',
229+
namespace='test_node_namespace'
230+
),
231+
],
232+
scoped=True,
233+
),
234+
])
235+
236+
# Check that launch is aware of loaded component
237+
assert get_node_name_count(context, '/test_node_namespace/test_node_name') == 1
238+
239+
# Check that container recieved correct request
240+
assert len(mock_component_container.requests) == 1
241+
request = mock_component_container.requests[0]
242+
assert request.package_name == 'foo_package'
243+
assert request.plugin_name == 'bar_plugin'
244+
assert request.node_name == 'test_node_name'
245+
assert request.node_namespace == '/test_node_namespace'
246+
assert len(request.remap_rules) == 1
247+
assert request.remap_rules[0] == 'chatter:=new_topic_name'
248+
assert len(request.parameters) == 0
249+
assert len(request.extra_arguments) == 0
250+
251+
252+
def test_load_node_with_namespace_in_group(mock_component_container):
253+
"""Test loading a node with namespace scoped to a group."""
254+
context = _assert_launch_no_errors([
255+
GroupAction(
256+
[
257+
PushRosNamespace('foo'),
258+
_load_composable_node(
259+
package='foo_package',
260+
plugin='bar_plugin',
261+
name='test_node_name',
262+
namespace='test_node_namespace'
263+
),
264+
],
265+
scoped=True,
266+
),
267+
])
268+
269+
# Check that launch is aware of loaded component
270+
assert get_node_name_count(context, '/foo/test_node_namespace/test_node_name') == 1
271+
272+
# Check that container recieved correct request
273+
assert len(mock_component_container.requests) == 1
274+
request = mock_component_container.requests[0]
275+
assert request.package_name == 'foo_package'
276+
assert request.plugin_name == 'bar_plugin'
277+
assert request.node_name == 'test_node_name'
278+
assert request.node_namespace == '/foo/test_node_namespace'
279+
assert len(request.remap_rules) == 0
280+
assert len(request.parameters) == 0
281+
assert len(request.extra_arguments) == 0

0 commit comments

Comments
 (0)