Skip to content

Commit 1698882

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 1698882

File tree

2 files changed

+295
-11
lines changed

2 files changed

+295
-11
lines changed

launch_ros/launch_ros/actions/load_composable_nodes.py

Lines changed: 30 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,21 @@ 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+
self.__logger.info("Loaded node '{}' in container '{}'".format(
101+
response.full_node_name, self.__final_target_container_name
102+
))
103+
else:
92104
self.__logger.error(
93105
"Failed to load node '{}' of type '{}' in container '{}': {}".format(
94106
response.full_node_name if response.full_node_name else request.node_name,
@@ -101,7 +113,7 @@ def _load_node(
101113

102114
def _load_in_sequence(
103115
self,
104-
composable_node_descriptions: List[ComposableNode],
116+
load_node_requests: List[composition_interfaces.srv.LoadNode.Request],
105117
context: LaunchContext
106118
) -> None:
107119
"""
@@ -110,13 +122,13 @@ def _load_in_sequence(
110122
:param composable_node_descriptions: descriptions of composable nodes to be loaded
111123
:param context: current launch context
112124
"""
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:
125+
next_load_node_request = load_node_requests[0]
126+
load_node_requests = load_node_requests[1:]
127+
self._load_node(next_load_node_request, context)
128+
if len(load_node_requests) > 0:
117129
context.add_completion_future(
118130
context.asyncio_loop.run_in_executor(
119-
None, self._load_in_sequence, composable_node_descriptions, context
131+
None, self._load_in_sequence, load_node_requests, context
120132
)
121133
)
122134

@@ -132,9 +144,16 @@ def execute(
132144
)
133145
)
134146

147+
# Generate load requests before execute() exits to avoid race with context changing
148+
# due to scope change (e.g. if loading nodes from within a GroupAction).
149+
load_node_requests = [
150+
get_composable_node_load_request(node_description, context)
151+
for node_description in self.__composable_node_descriptions
152+
]
153+
135154
context.add_completion_future(
136155
context.asyncio_loop.run_in_executor(
137-
None, self._load_in_sequence, self.__composable_node_descriptions, context
156+
None, self._load_in_sequence, load_node_requests, context
138157
)
139158
)
140159

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
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+
29+
import pytest
30+
31+
from rcl_interfaces.msg import ParameterType
32+
33+
import rclpy
34+
import rclpy.context
35+
import rclpy.executors
36+
import rclpy.node
37+
38+
TEST_CONTAINER_NAME = 'mock_component_container'
39+
TEST_NODE_NAME = 'test_load_composable_nodes_node'
40+
41+
42+
class MockComponentContainer(rclpy.node.Node):
43+
44+
def __init__(self):
45+
# List of LoadNode requests received
46+
self.requests = []
47+
48+
self._context = rclpy.context.Context()
49+
rclpy.init(context=self._context)
50+
51+
super().__init__(TEST_CONTAINER_NAME, context=self._context)
52+
53+
self.load_node_service = self.create_service(
54+
LoadNode,
55+
'~/_container/load_node',
56+
self.load_node_callback
57+
)
58+
59+
self._executor = rclpy.executors.SingleThreadedExecutor(context=self._context)
60+
61+
# Start spinning in a thread
62+
self._thread = threading.Thread(
63+
target=rclpy.spin,
64+
args=(self, self._executor),
65+
daemon=True
66+
)
67+
self._thread.start()
68+
69+
def load_node_callback(self, request, response):
70+
self.requests.append(request)
71+
response.success = True
72+
response.full_node_name = f'{request.node_namespace}/{request.node_name}'
73+
response.unique_id = len(self.requests)
74+
return response
75+
76+
def shutdown(self):
77+
self._executor.shutdown()
78+
self.destroy_node()
79+
rclpy.shutdown(context=self._context)
80+
self._thread.join()
81+
82+
83+
def _assert_launch_no_errors(actions):
84+
ld = LaunchDescription(actions)
85+
ls = LaunchService(debug=True)
86+
ls.include_launch_description(ld)
87+
assert 0 == ls.run()
88+
return ls.context
89+
90+
91+
def _load_composable_node(
92+
*,
93+
package,
94+
plugin,
95+
name,
96+
namespace='',
97+
parameters=None,
98+
remappings=None,
99+
target_container=f'/{TEST_CONTAINER_NAME}'
100+
):
101+
return LoadComposableNodes(
102+
target_container=target_container,
103+
composable_node_descriptions=[
104+
ComposableNode(
105+
package=package,
106+
plugin=plugin,
107+
name=name,
108+
namespace=namespace,
109+
parameters=parameters,
110+
remappings=remappings,
111+
)
112+
])
113+
114+
115+
@pytest.fixture
116+
def mock_component_container():
117+
container = MockComponentContainer()
118+
yield container
119+
container.shutdown()
120+
121+
122+
def test_load_node(mock_component_container):
123+
"""Test loading a node."""
124+
context = _assert_launch_no_errors([
125+
_load_composable_node(
126+
package='foo_package',
127+
plugin='bar_plugin',
128+
name='test_node_name',
129+
namespace='test_node_namespace'
130+
)
131+
])
132+
133+
# Check that container recieved correct request
134+
assert len(mock_component_container.requests) == 1
135+
request = mock_component_container.requests[0]
136+
assert request.package_name == 'foo_package'
137+
assert request.plugin_name == 'bar_plugin'
138+
assert request.node_name == 'test_node_name'
139+
assert request.node_namespace == '/test_node_namespace'
140+
assert len(request.remap_rules) == 0
141+
assert len(request.parameters) == 0
142+
assert len(request.extra_arguments) == 0
143+
144+
145+
def test_load_node_with_remaps(mock_component_container):
146+
"""Test loading a node with remappings."""
147+
context = _assert_launch_no_errors([
148+
_load_composable_node(
149+
package='foo_package',
150+
plugin='bar_plugin',
151+
name='test_node_name',
152+
namespace='test_node_namespace',
153+
remappings=[
154+
('test_topic1', 'test_remap_topic1'),
155+
('test/topic/two', 'test/remap_topic2')
156+
]
157+
)
158+
])
159+
160+
# Check that container recieved correct request
161+
assert len(mock_component_container.requests) == 1
162+
request = mock_component_container.requests[0]
163+
assert request.package_name == 'foo_package'
164+
assert request.plugin_name == 'bar_plugin'
165+
assert request.node_name == 'test_node_name'
166+
assert request.node_namespace == '/test_node_namespace'
167+
assert len(request.remap_rules) == 2
168+
assert request.remap_rules[0] == 'test_topic1:=test_remap_topic1'
169+
assert request.remap_rules[1] == 'test/topic/two:=test/remap_topic2'
170+
assert len(request.parameters) == 0
171+
assert len(request.extra_arguments) == 0
172+
173+
174+
def test_load_node_with_params(mock_component_container):
175+
"""Test loading a node with parameters."""
176+
context = _assert_launch_no_errors([
177+
_load_composable_node(
178+
package='foo_package',
179+
plugin='bar_plugin',
180+
name='test_node_name',
181+
namespace='test_node_namespace',
182+
parameters=[{
183+
'test_param1': 'test_value_param1',
184+
'test.param2': '42.0',
185+
}]
186+
)
187+
])
188+
189+
# Check that container recieved correct request
190+
assert len(mock_component_container.requests) == 1
191+
request = mock_component_container.requests[0]
192+
assert request.package_name == 'foo_package'
193+
assert request.plugin_name == 'bar_plugin'
194+
assert request.node_name == 'test_node_name'
195+
assert request.node_namespace == '/test_node_namespace'
196+
assert len(request.remap_rules) == 0
197+
assert len(request.parameters) == 2
198+
assert request.parameters[0].name == 'test_param1'
199+
assert request.parameters[0].value.type == ParameterType.PARAMETER_STRING
200+
assert request.parameters[0].value.string_value == 'test_value_param1'
201+
assert request.parameters[1].name == 'test.param2'
202+
# TODO(jacobperron): I would expect this to be a double value, but we can only pass strings
203+
# assert request.parameters[1].value.type == ParameterType.PARAMETER_DOUBLE
204+
# assert request.parameters[1].value.double_value == 42.0
205+
assert request.parameters[1].value.string_value == '42.0'
206+
assert len(request.extra_arguments) == 0
207+
208+
209+
def test_load_node_with_global_remaps_in_group(mock_component_container):
210+
"""Test loading a node with global remaps scoped to a group."""
211+
context = _assert_launch_no_errors([
212+
GroupAction(
213+
[
214+
SetRemap('chatter', 'new_topic_name'),
215+
_load_composable_node(
216+
package='foo_package',
217+
plugin='bar_plugin',
218+
name='test_node_name',
219+
namespace='test_node_namespace'
220+
),
221+
],
222+
scoped=True,
223+
),
224+
])
225+
226+
# Check that container recieved correct request
227+
assert len(mock_component_container.requests) == 1
228+
request = mock_component_container.requests[0]
229+
assert request.package_name == 'foo_package'
230+
assert request.plugin_name == 'bar_plugin'
231+
assert request.node_name == 'test_node_name'
232+
assert request.node_namespace == '/test_node_namespace'
233+
assert len(request.remap_rules) == 1
234+
assert request.remap_rules[0] == 'chatter:=new_topic_name'
235+
assert len(request.parameters) == 0
236+
assert len(request.extra_arguments) == 0
237+
238+
239+
def test_load_node_with_namespace_in_group(mock_component_container):
240+
"""Test loading a node with namespace scoped to a group."""
241+
context = _assert_launch_no_errors([
242+
GroupAction(
243+
[
244+
PushRosNamespace('foo'),
245+
_load_composable_node(
246+
package='foo_package',
247+
plugin='bar_plugin',
248+
name='test_node_name',
249+
namespace='test_node_namespace'
250+
),
251+
],
252+
scoped=True,
253+
),
254+
])
255+
256+
# Check that container recieved correct request
257+
assert len(mock_component_container.requests) == 1
258+
request = mock_component_container.requests[0]
259+
assert request.package_name == 'foo_package'
260+
assert request.plugin_name == 'bar_plugin'
261+
assert request.node_name == 'test_node_name'
262+
assert request.node_namespace == '/foo/test_node_namespace'
263+
assert len(request.remap_rules) == 0
264+
assert len(request.parameters) == 0
265+
assert len(request.extra_arguments) == 0

0 commit comments

Comments
 (0)