Skip to content

Commit 6d87e27

Browse files
committed
Add load_node_timeout parameter to ComposableNodeContainer and LoadComposableNodes actions
Signed-off-by: Błażej Sowa <bsowa123@gmail.com>
1 parent 746ffee commit 6d87e27

4 files changed

Lines changed: 117 additions & 7 deletions

File tree

launch_ros/launch_ros/actions/composable_node_container.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@
1515
"""Module for the ComposableNodeContainer action."""
1616

1717
from typing import List
18-
from typing import Optional
18+
from typing import Optional, Union
1919

2020
from launch.action import Action
2121
from launch.frontend import Entity
2222
from launch.frontend import expose_action
2323
from launch.frontend import Parser
2424
from launch.launch_context import LaunchContext
2525
from launch.some_substitutions_type import SomeSubstitutionsType
26+
from launch.utilities import type_utils
2627

2728
from .node import Node
2829

@@ -40,6 +41,7 @@ def __init__(
4041
name: SomeSubstitutionsType,
4142
namespace: SomeSubstitutionsType,
4243
composable_node_descriptions: Optional[List[ComposableNode]] = None,
44+
load_node_timeout: Optional[Union[float, SomeSubstitutionsType]] = None,
4345
**kwargs
4446
) -> None:
4547
"""
@@ -52,15 +54,21 @@ def __init__(
5254
:param: namespace the ROS namespace for this Node, mandatory for full container node
5355
name resolution
5456
:param composable_node_descriptions: optional descriptions of composable nodes to be loaded
57+
:param load_node_timeout: optional timeout for loading each composable node, in seconds
5558
"""
5659
super().__init__(name=name, namespace=namespace, **kwargs)
5760
self.__composable_node_descriptions = composable_node_descriptions
61+
self.__load_node_timeout = type_utils.normalize_typed_substitution(
62+
load_node_timeout, float) if load_node_timeout is not None else None
5863

5964
@classmethod
6065
def parse(cls, entity: Entity, parser: Parser):
6166
"""Parse node_container."""
6267
_, kwargs = super().parse(entity, parser)
6368

69+
kwargs['load_node_timeout'] = parser.parse_if_substitutions(
70+
entity.get_attr('load_node_timeout', data_type=float, optional=True, can_be_str=True))
71+
6472
composable_nodes = entity.get_attr(
6573
'composable_node', data_type=List[Entity], optional=True)
6674
composable_lifecycle_nodes = entity.get_attr(
@@ -106,7 +114,8 @@ def execute(self, context: LaunchContext) -> Optional[List[Action]]:
106114
load_actions = [
107115
LoadComposableNodes(
108116
composable_node_descriptions=valid_composable_nodes,
109-
target_container=self
117+
target_container=self,
118+
load_node_timeout=self.__load_node_timeout
110119
)
111120
]
112121
container_actions = super().execute(context) # type: Optional[List[Action]]

launch_ros/launch_ros/actions/load_composable_nodes.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def __init__(
6565
*,
6666
composable_node_descriptions: List[ComposableNode],
6767
target_container: Union[SomeSubstitutionsType, ComposableNodeContainer],
68+
load_node_timeout: Optional[Union[float, SomeSubstitutionsType]] = None,
6869
**kwargs,
6970
) -> None:
7071
"""
@@ -79,6 +80,7 @@ def __init__(
7980
8081
:param composable_node_descriptions: descriptions of composable nodes to be loaded
8182
:param target_container: the container to load the nodes into
83+
:param load_node_timeout: optional timeout for loading each composable node, in seconds
8284
"""
8385
ensure_argument_type(
8486
target_container,
@@ -90,6 +92,8 @@ def __init__(
9092
super().__init__(**kwargs)
9193
self.__composable_node_descriptions = composable_node_descriptions
9294
self.__target_container = target_container
95+
self.__load_node_timeout = type_utils.normalize_typed_substitution(
96+
load_node_timeout, float) if load_node_timeout is not None else None
9397
self.__final_target_container_name: Optional[Text] = None
9498
self.__logger = launch.logging.get_logger(__name__)
9599

@@ -101,6 +105,9 @@ def parse(cls, entity: Entity, parser: Parser):
101105
kwargs['target_container'] = parser.parse_substitution(
102106
entity.get_attr('target', data_type=str))
103107

108+
kwargs['load_node_timeout'] = parser.parse_if_substitutions(
109+
entity.get_attr('load_node_timeout', data_type=float, optional=True, can_be_str=True))
110+
104111
kwargs['composable_node_descriptions'] = []
105112
composable_nodes = entity.get_attr(
106113
'composable_node', data_type=List[Entity], optional=True) or []
@@ -149,7 +156,7 @@ def _load_node(
149156
# Asynchronously wait on service call so that we can periodically check for shutdown
150157
event = threading.Event()
151158

152-
def unblock(future):
159+
def unblock(future=None):
153160
nonlocal event
154161
event.set()
155162

@@ -162,13 +169,36 @@ def unblock(future):
162169
response_future = self.__rclpy_load_node_client.call_async(request)
163170
response_future.add_done_callback(unblock)
164171

172+
timeout_value = None
173+
if self.__load_node_timeout is not None:
174+
value = type_utils.perform_typed_substitution(context, self.__load_node_timeout, float)
175+
if value > 0.0:
176+
timeout_value = value
177+
timeout_timer = get_ros_node(context).create_timer(timeout_value, unblock)
178+
165179
while not event.wait(1.0):
166180
if context.is_shutdown:
167181
self.__logger.warning(
168182
"Abandoning wait for the '{}' service response, due to shutdown.".format(
169183
self.__rclpy_load_node_client.srv_name),
170184
)
171185
response_future.cancel()
186+
if timeout_value is not None:
187+
timeout_timer.cancel()
188+
timeout_timer.destroy()
189+
return
190+
191+
if timeout_value is not None:
192+
timeout_timer.cancel()
193+
timeout_timer.destroy()
194+
195+
if not response_future.done():
196+
self.__logger.error(
197+
"Load node request for node '{}' timed out after {} seconds".format(
198+
request.node_name, timeout_value
199+
)
200+
)
201+
response_future.cancel()
172202
return
173203

174204
# Get response

test_launch_ros/test/test_launch_ros/actions/test_load_composable_nodes.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import pathlib
1818
import threading
19+
import time
1920

2021
from composition_interfaces.srv import LoadNode
2122

@@ -44,9 +45,10 @@
4445

4546
class MockComponentContainer(rclpy.node.Node):
4647

47-
def __init__(self, context):
48+
def __init__(self, context, delay=0.0):
4849
# List of LoadNode requests received
4950
self.requests = []
51+
self.delay = delay
5052

5153
super().__init__(TEST_CONTAINER_NAME, context=context)
5254

@@ -57,6 +59,8 @@ def __init__(self, context):
5759
)
5860

5961
def load_node_callback(self, request, response):
62+
if self.delay > 0.0:
63+
time.sleep(self.delay)
6064
self.requests.append(request)
6165
response.success = True
6266
if request.node_namespace == '/':
@@ -84,10 +88,12 @@ def _load_composable_node(
8488
condition=None,
8589
parameters=None,
8690
remappings=None,
87-
target_container=f'/{TEST_CONTAINER_NAME}'
91+
target_container=f'/{TEST_CONTAINER_NAME}',
92+
load_node_timeout=None
8893
):
8994
return LoadComposableNodes(
9095
target_container=target_container,
96+
load_node_timeout=load_node_timeout,
9197
composable_node_descriptions=[
9298
ComposableNode(
9399
condition=condition,
@@ -119,6 +125,25 @@ def mock_component_container():
119125
thread.join()
120126

121127

128+
@pytest.fixture
129+
def slow_mock_component_container():
130+
"""Mock container with 3-second delay for timeout testing."""
131+
context = rclpy.context.Context()
132+
with rclpy.init(context=context):
133+
executor = rclpy.executors.SingleThreadedExecutor(context=context)
134+
135+
container = MockComponentContainer(context, delay=3.0)
136+
executor.add_node(container)
137+
138+
# Start spinning in a thread
139+
thread = threading.Thread(target=lambda executor: executor.spin(), args=(executor,))
140+
thread.start()
141+
yield container
142+
executor.remove_node(container)
143+
executor.shutdown()
144+
thread.join()
145+
146+
122147
def test_load_node(mock_component_container):
123148
"""Test loading a node."""
124149
context = _assert_launch_no_errors([
@@ -654,3 +679,45 @@ def test_load_node_with_condition_in_group(mock_component_container):
654679
assert len(request.remap_rules) == 0
655680
assert len(request.parameters) == 0
656681
assert len(request.extra_arguments) == 0
682+
683+
684+
def test_load_node_with_timeout(slow_mock_component_container):
685+
"""Test that load_node_timeout actually times out slow services."""
686+
# Test 1: Timeout is shorter than service delay - should timeout and not load node
687+
start_time = time.time()
688+
launch_context = _assert_launch_no_errors([
689+
_load_composable_node(
690+
package='foo_package',
691+
plugin='bar_plugin',
692+
name='test_node_timeout',
693+
namespace='test_namespace',
694+
load_node_timeout=0.5 # Shorter than the 3 second delay
695+
)
696+
])
697+
elapsed_time = time.time() - start_time
698+
699+
# Should timeout before the 3 second service response
700+
assert elapsed_time < 3.0, f'Expected quick timeout, but took {elapsed_time:.2f} seconds'
701+
# Node should NOT be registered since we skipped waiting for service response
702+
assert get_node_name_count(launch_context, '/test_namespace/test_node_timeout') == 0
703+
704+
# Test 2: Timeout is longer than service delay - should succeed
705+
start_time = time.time()
706+
launch_context = _assert_launch_no_errors([
707+
_load_composable_node(
708+
package='foo_package',
709+
plugin='bar_plugin',
710+
name='test_node_success',
711+
namespace='test_namespace',
712+
load_node_timeout=5.0 # Longer than the 3 second delay
713+
)
714+
])
715+
elapsed_time = time.time() - start_time
716+
717+
# Should wait for the full service response (around 3-4 seconds including overhead)
718+
assert elapsed_time >= 2.5, f'Should have waited for service, took {elapsed_time:.2f} seconds'
719+
assert elapsed_time < 7.0, f'Should not have timed out, took {elapsed_time:.2f} seconds'
720+
# Node SHOULD be registered since it completed successfully
721+
assert get_node_name_count(launch_context, '/test_namespace/test_node_success') == 1
722+
# Verify the container received both requests
723+
assert len(slow_mock_component_container.requests) == 2

test_launch_ros/test/test_launch_ros/frontend/test_component_container.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def test_launch_component_container_yaml():
3434
name: my_container
3535
namespace: ''
3636
args: 'test_args'
37+
load_node_timeout: 5.0
3738
composable_node:
3839
- pkg: composition
3940
plugin: composition::Talker
@@ -51,6 +52,7 @@ def test_launch_component_container_yaml():
5152
5253
- load_composable_node:
5354
target: my_container
55+
load_node_timeout: 5.0
5456
composable_node:
5557
- pkg: composition
5658
plugin: composition::Listener
@@ -75,15 +77,15 @@ def test_launch_component_container_xml():
7577
xml_file = textwrap.dedent(
7678
r"""
7779
<launch>
78-
<node_container pkg="rclcpp_components" exec="component_container" name="my_container" namespace="" args="test_args">
80+
<node_container pkg="rclcpp_components" exec="component_container" name="my_container" namespace="" args="test_args" load_node_timeout="5.0">
7981
<composable_node pkg="composition" plugin="composition::Talker" name="talker" namespace="test_namespace">
8082
<remap from="chatter" to="/remap/chatter" />
8183
<param name="use_sim_time" value="true"/>
8284
<extra_arg name="use_intra_process_comms" value="true"/>
8385
</composable_node>
8486
</node_container>
8587
86-
<load_composable_node target="my_container">
88+
<load_composable_node target="my_container" load_node_timeout="5.0">
8789
<composable_node pkg="composition" plugin="composition::Listener" name="listener" namespace="test_namespace">
8890
<remap from="chatter" to="/remap/chatter" />
8991
<param name="use_sim_time" value="true"/>
@@ -119,8 +121,10 @@ def perform(substitution):
119121
assert perform(node_container._Node__node_name) == 'my_container'
120122
assert perform(node_container._Node__node_namespace) == ''
121123
assert perform(node_container._Node__arguments[0]) == 'test_args'
124+
assert node_container._ComposableNodeContainer__load_node_timeout == 5.0
122125

123126
assert perform(load_composable_node._LoadComposableNodes__target_container) == 'my_container'
127+
assert load_composable_node._LoadComposableNodes__load_node_timeout == 5.0
124128

125129
# Check node parameters
126130
talker_remappings = list(talker._ComposableNode__remappings)

0 commit comments

Comments
 (0)