Skip to content

Commit 87d3f0b

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 87d3f0b

File tree

2 files changed

+265
-11
lines changed

2 files changed

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

0 commit comments

Comments
 (0)