Skip to content

Commit defb36a

Browse files
committed
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]>
1 parent e0bd3f5 commit defb36a

File tree

2 files changed

+318
-11
lines changed

2 files changed

+318
-11
lines changed

launch_ros/launch_ros/actions/load_composable_nodes.py

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,13 @@ def __init__(
6969

7070
def _load_node(
7171
self,
72-
composable_node_description: ComposableNode,
72+
request: composition_interfaces.srv.LoadNode.Request,
7373
context: LaunchContext
7474
) -> None:
7575
"""
7676
Load node synchronously.
7777
78-
:param composable_node_description: description of composable node to be loaded
78+
:param request: service request to load a node
7979
:param context: current launch context
8080
"""
8181
while not self.__rclpy_load_node_client.wait_for_service(timeout_sec=1.0):
@@ -86,9 +86,28 @@ def _load_node(
8686
)
8787
)
8888
return
89-
request = get_composable_node_load_request(composable_node_description, context)
89+
self.__logger.debug(
90+
"Calling the '{}' service with request '{}'".format(
91+
self.__rclpy_load_node_client.srv_name, request
92+
)
93+
)
9094
response = self.__rclpy_load_node_client.call(request)
91-
if not response.success:
95+
self.__logger.debug("Received response '{}'".format(response))
96+
node_name = response.full_node_name if response.full_node_name else request.node_name
97+
if response.success:
98+
if node_name is not None:
99+
add_node_name(context, node_name)
100+
node_name_count = get_node_name_count(context, node_name)
101+
if node_name_count > 1:
102+
container_logger = launch.logging.get_logger(self.__target_container.name)
103+
container_logger.warning(
104+
'there are now at least {} nodes with the name {} created within this '
105+
'launch context'.format(node_name_count, node_name)
106+
)
107+
self.__logger.info("Loaded node '{}' in container '{}'".format(
108+
response.full_node_name, self.__final_target_container_name
109+
))
110+
else:
92111
self.__logger.error(
93112
"Failed to load node '{}' of type '{}' in container '{}': {}".format(
94113
response.full_node_name if response.full_node_name else request.node_name,
@@ -101,7 +120,7 @@ def _load_node(
101120

102121
def _load_in_sequence(
103122
self,
104-
composable_node_descriptions: List[ComposableNode],
123+
load_node_requests: List[composition_interfaces.srv.LoadNode.Request],
105124
context: LaunchContext
106125
) -> None:
107126
"""
@@ -110,13 +129,13 @@ def _load_in_sequence(
110129
:param composable_node_descriptions: descriptions of composable nodes to be loaded
111130
:param context: current launch context
112131
"""
113-
next_composable_node_description = composable_node_descriptions[0]
114-
composable_node_descriptions = composable_node_descriptions[1:]
115-
self._load_node(next_composable_node_description, context)
116-
if len(composable_node_descriptions) > 0:
132+
next_load_node_request = load_node_requests[0]
133+
load_node_requests = load_node_requests[1:]
134+
self._load_node(next_load_node_request, context)
135+
if len(load_node_requests) > 0:
117136
context.add_completion_future(
118137
context.asyncio_loop.run_in_executor(
119-
None, self._load_in_sequence, composable_node_descriptions, context
138+
None, self._load_in_sequence, load_node_requests, context
120139
)
121140
)
122141

@@ -132,9 +151,16 @@ def execute(
132151
)
133152
)
134153

154+
# Generate load requests before execute() exits to avoid race with context changing
155+
# due to scope change (e.g. if loading nodes from within a GroupAction).
156+
load_node_requests = [
157+
get_composable_node_load_request(node_description, context)
158+
for node_description in self.__composable_node_descriptions
159+
]
160+
135161
context.add_completion_future(
136162
context.asyncio_loop.run_in_executor(
137-
None, self._load_in_sequence, self.__composable_node_descriptions, context
163+
None, self._load_in_sequence, load_node_requests, context
138164
)
139165
)
140166

Lines changed: 281 additions & 0 deletions
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)