From 18194682e8503ea2631dec065514ce747844c5e1 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 2 Sep 2026 16:09:40 -0400 Subject: [PATCH] composition --- docs/configuration.md | 2 +- docs/design/architecture.md | 16 ++ docs/limitations.md | 12 ++ docs/running.md | 47 +++++ src/ros_portal/CMakeLists.txt | 19 ++ .../include/ros_portal/ros_portal.hpp | 18 ++ .../launch/ros_portal_composable.launch.py | 90 ++++++++++ src/ros_portal/package.xml | 3 + src/ros_portal/src/ros_portal.cpp | 13 +- src/ros_portal/src/ros_portal_component.cpp | 120 +++++++++++++ src/ros_portal/test/CMakeLists.txt | 10 ++ .../test/launch/test_ros_portal_component.py | 97 ++++++++++ .../test_ros_portal_composable_launch.py | 167 ++++++++++++++++++ 13 files changed, 611 insertions(+), 3 deletions(-) create mode 100644 src/ros_portal/launch/ros_portal_composable.launch.py create mode 100644 src/ros_portal/src/ros_portal_component.cpp create mode 100644 src/ros_portal/test/launch/test_ros_portal_component.py create mode 100644 src/ros_portal/test/launch/test_ros_portal_composable_launch.py diff --git a/docs/configuration.md b/docs/configuration.md index 8b60b770..76bc6628 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -181,7 +181,7 @@ All config lives under `ros_portal`. |---|---:|---:|---:|---| | `$schema` | string | no | - | Link to JSON schema. Use `https://raw.githubusercontent.com/livekit/ros-portal/main/src/ros_portal_config/schema/ros_portal_config.schema.json` and an IDE YAML plugin to validate config files and add autocomplete. | | `version` | string | yes | - | Configuration schema version, currently `"0.0.1"`. | -| `ros_threads` | integer | no | `0` | ROS executor thread count. `0` uses the available CPU-core count, matching `rclcpp` default. | +| `ros_threads` | integer | no | `0` | Standalone `ros_portal_node` executor thread count. `0` uses the available CPU-core count. In composition, configure the container's `thread_num` parameter instead. | | `services` | list | no | `[]` | Service route declarations. | | `topics` | list | no | `[]` | Topic route declarations. | diff --git a/docs/design/architecture.md b/docs/design/architecture.md index c133fde8..00105f04 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -29,6 +29,22 @@ ROS Portal is implemented as a single ROS2 node (`RosPortal`) that: against the local ROS installation, and republishes accepted CDR payloads into ROS using the track name as the local topic name. +The node supports two deployment layouts. `ros_portal_node` runs it in a +dedicated process with its own multithreaded executor. The registered +`ros_portal::RosPortalComponent` adapter loads the same implementation into a +generic ROS2 component container. Component mode defers the initial blocking +LiveKit connection attempt until after construction, when the container's +executor is running. + +The bundled launch file uses a multithreaded component container. The container +owns its executor, so `thread_num` replaces the standalone `ros_threads` +configuration in this layout. A single worker is supported. More workers let +other callbacks run during synchronous LiveKit RPC operations. + +Component instances in one process share a reference-counted LiveKit SDK +runtime. They release the runtime after the last ROS Portal component destroys +its LiveKit resources. + ## Topic Discovery For outbound topic patterns, ROS Portal reconciles subscriptions after each ROS diff --git a/docs/limitations.md b/docs/limitations.md index cf604256..b098aff0 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -1,5 +1,17 @@ # Current Limitations +## ROS2 Composition + +- A component container can use one worker thread. A synchronous LiveKit RPC + operation occupies that worker until the operation completes. Other ROS + callbacks in the container wait during this time. +- `LIVEKIT_URL`, `LIVEKIT_TOKEN`, LiveKit logging, memory, and fault isolation + are process-wide in composition. A failure that terminates the container + also terminates every component it hosts. +- ROS Portal reference-counts the LiveKit runtime across its component + instances. Other LiveKit components must coordinate process-global SDK + ownership with ROS Portal. + ## Video Tracks - `VideoSource` and `LocalVideoTrack` are created from the first received image dimensions. If camera resolution changes mid-stream, the track is not diff --git a/docs/running.md b/docs/running.md index 60d233e6..45d95354 100644 --- a/docs/running.md +++ b/docs/running.md @@ -65,6 +65,53 @@ source /opt/livekit/ros/$ROS_DISTRO/setup.bash ros-portal-$ROS_DISTRO config_path:=/path/to/config.yaml ``` +## Running ROS Portal in Composition + +The package registers `ros_portal::RosPortalComponent`. The bundled composable +launch file starts this component in a multithreaded container: + +```bash +# LIVEKIT_URL and LIVEKIT_TOKEN are set in the environment +source install/setup.bash +ros2 launch ros_portal ros_portal_composable.launch.py \ + config_path:=/path/to/config.yaml \ + container_threads:=4 +``` + +Use `container_threads:=0` to let `rclcpp` select the worker count. The +`ros_threads` configuration field only controls the standalone +`ros_portal_node` executable. + +To compose ROS Portal with other components, add its descriptor to the same +multithreaded container in the parent launch file: + +```python +from launch_ros.actions import ComposableNodeContainer +from launch_ros.descriptions import ComposableNode + +container = ComposableNodeContainer( + name='robot_container', + namespace='', + package='rclcpp_components', + executable='component_container_mt', + parameters=[{'thread_num': 4}], + composable_node_descriptions=[ + ComposableNode( + package='ros_portal', + plugin='ros_portal::RosPortalComponent', + name='ros_portal', + parameters=[{'config_path': '/path/to/config.yaml'}], + ), + # Add other component descriptions here. + ], + output='screen', +) +``` + +The container process must inherit `LIVEKIT_URL` and `LIVEKIT_TOKEN`. These +environment variables, LiveKit logging, and process failure boundaries are +shared by every component in the container. + ## Forwarding Custom Topics Forwarding custom message types work like any other ROS type: diff --git a/src/ros_portal/CMakeLists.txt b/src/ros_portal/CMakeLists.txt index 6d1e8174..a452ab94 100644 --- a/src/ros_portal/CMakeLists.txt +++ b/src/ros_portal/CMakeLists.txt @@ -295,6 +295,25 @@ target_link_libraries(ros_portal_lib ) livekit_set_runtime_rpath(ros_portal_lib) +# --------------------------------------------------------------------------- +# Composable ROS Portal node +# --------------------------------------------------------------------------- +ament_auto_add_library(ros_portal_component SHARED + src/ros_portal_component.cpp +) +target_link_libraries(ros_portal_component + ros_portal_lib + "${LIVEKIT_CORE_TARGET}" + ${LIVEKIT_EXTRA_LINK_LIBS} +) +medkit_target_dependencies(ros_portal_component + rclcpp_components +) +livekit_set_runtime_rpath(ros_portal_component) +rclcpp_components_register_nodes(ros_portal_component + "ros_portal::RosPortalComponent" +) + # --------------------------------------------------------------------------- # Main ROS Portal node # --------------------------------------------------------------------------- diff --git a/src/ros_portal/include/ros_portal/ros_portal.hpp b/src/ros_portal/include/ros_portal/ros_portal.hpp index b0dd8eaa..6c0d22ca 100644 --- a/src/ros_portal/include/ros_portal/ros_portal.hpp +++ b/src/ros_portal/include/ros_portal/ros_portal.hpp @@ -93,7 +93,25 @@ class RosPortal : public rclcpp::Node, public livekit::RoomDelegate { /// should poll this until it returns true rather than assuming immediacy. bool hasParticipant(const std::string& participant_id) const; +protected: + /// @brief Initialize ROS Portal without making the initial blocking LiveKit + /// connection attempt. + /// + /// Component containers own the executor and must not be blocked while a + /// component is being constructed. The regular connection timer makes the + /// first attempt after construction instead. + /// @return True if local initialization completed, false for expected + /// startup failures that have already been logged. + bool initializeForComposition(); + private: + /// @brief Initialize ROS Portal and optionally connect immediately. + /// @param connect_immediately Whether to make the first LiveKit connection + /// attempt before returning. + /// @return True if initialization completed, false for expected startup + /// failures that have already been logged. + bool initializeImpl(const bool connect_immediately); + /// @brief Poll the room connection state and make a scheduled connection /// attempt when needed. void pollConnection(); diff --git a/src/ros_portal/launch/ros_portal_composable.launch.py b/src/ros_portal/launch/ros_portal_composable.launch.py new file mode 100644 index 00000000..246b0d42 --- /dev/null +++ b/src/ros_portal/launch/ros_portal_composable.launch.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 + +# Copyright 2026 LiveKit +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path + +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument +from launch.actions import OpaqueFunction +from launch.actions import SetEnvironmentVariable +from launch.substitutions import LaunchConfiguration +from launch.substitutions import PathJoinSubstitution +from launch_ros.actions import ComposableNodeContainer +from launch_ros.descriptions import ComposableNode +from launch_ros.substitutions import FindPackageShare + + +def _launch_setup(context, *args, **kwargs): + config_path_value = LaunchConfiguration('config_path').perform(context).strip() + if config_path_value: + config_path = Path(config_path_value).expanduser() + if not config_path.is_file(): + raise RuntimeError(f'Config file does not exist: {config_path}') + config_path_value = str(config_path) + + container_name = LaunchConfiguration('container_name').perform(context).strip() + if not container_name: + raise RuntimeError('container_name launch argument must be non-empty') + + container_threads_value = LaunchConfiguration('container_threads').perform(context).strip() + try: + container_threads = int(container_threads_value) + except ValueError as exc: + raise RuntimeError('container_threads must be an integer') from exc + if container_threads < 0: + raise RuntimeError('container_threads must be non-negative') + + return [ + ComposableNodeContainer( + name=container_name, + namespace='', + package='rclcpp_components', + executable='component_container_mt', + output='screen', + emulate_tty=True, + parameters=[{'thread_num': container_threads}], + composable_node_descriptions=[ + ComposableNode( + package='ros_portal', + plugin='ros_portal::RosPortalComponent', + name='ros_portal', + namespace=LaunchConfiguration('ns').perform(context), + parameters=[{'config_path': config_path_value}], + ), + ], + ), + ] + + +def generate_launch_description(): + default_config = PathJoinSubstitution([ + FindPackageShare('ros_portal'), + 'config', + 'all_topics.yaml', + ]) + + return LaunchDescription([ + SetEnvironmentVariable('RCUTILS_COLORIZED_OUTPUT', '1'), + DeclareLaunchArgument('config_path', default_value=default_config), + DeclareLaunchArgument('container_name', default_value='ros_portal_container'), + DeclareLaunchArgument( + 'container_threads', + default_value='0', + description='Component container worker threads. Use 0 for the rclcpp default.', + ), + DeclareLaunchArgument('ns', default_value='/'), + OpaqueFunction(function=_launch_setup), + ]) diff --git a/src/ros_portal/package.xml b/src/ros_portal/package.xml index b7ac4e4c..3a8bb6e2 100644 --- a/src/ros_portal/package.xml +++ b/src/ros_portal/package.xml @@ -31,6 +31,7 @@ geometry_msgs nlohmann-json-dev rclcpp + rclcpp_components ros2_medkit_serialization rosbag2_cpp rosbag2_storage @@ -52,7 +53,9 @@ ament_cmake_pytest ament_lint_auto ament_lint_common + launch_testing_ament_cmake nav_msgs + rclpy std_srvs test_msgs diff --git a/src/ros_portal/src/ros_portal.cpp b/src/ros_portal/src/ros_portal.cpp index 68de7096..30cae90e 100644 --- a/src/ros_portal/src/ros_portal.cpp +++ b/src/ros_portal/src/ros_portal.cpp @@ -77,7 +77,11 @@ RosPortal::RosPortal(const rclcpp::NodeOptions& options) initializeDiagnostics(); } -bool RosPortal::initialize() { +bool RosPortal::initialize() { return initializeImpl(true); } + +bool RosPortal::initializeForComposition() { return initializeImpl(false); } + +bool RosPortal::initializeImpl(const bool connect_immediately) { if (initialized_.load(std::memory_order_relaxed)) { RCLCPP_WARN(this->get_logger(), "ROS Portal is already initialized"); return true; @@ -224,8 +228,13 @@ bool RosPortal::initialize() { // Mark initialized before the first poll so pollConnection() is not skipped. // Connect immediately to avoid a 1s timer delay, then log initialized. initialized_.store(true, std::memory_order_relaxed); - pollConnection(); RCLCPP_INFO(this->get_logger(), "ROS Portal initialized"); + + if (connect_immediately) { + // Standalone startup retains its immediate first connection attempt. A + // component lets the timer perform this blocking work after construction. + pollConnection(); + } return true; } diff --git a/src/ros_portal/src/ros_portal_component.cpp b/src/ros_portal/src/ros_portal_component.cpp new file mode 100644 index 00000000..3c5c97d8 --- /dev/null +++ b/src/ros_portal/src/ros_portal_component.cpp @@ -0,0 +1,120 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include +#include +#include +#include +#include +#include + +#include "ros_portal/ros_portal.hpp" + +namespace ros_portal { +namespace { + +/// Owns the process-global LiveKit runtime while ROS Portal components exist. +/// +/// This base is constructed before and destroyed after the RosPortal base, so +/// every LiveKit object is released before the component-owned runtime stops. +class LiveKitRuntimeGuard { +public: + LiveKitRuntimeGuard() { + const std::lock_guard lock(runtimeMutex()); + if (instanceCount() == 0U) { + runtimeOwnedByComponents() = livekit::initialize(livekit::LogLevel::Info); + } + ++instanceCount(); + } + + virtual ~LiveKitRuntimeGuard() { + const std::lock_guard lock(runtimeMutex()); + if (instanceCount() == 0U) { + return; + } + + --instanceCount(); + if (instanceCount() == 0U && runtimeOwnedByComponents()) { + livekit::shutdown(); + runtimeOwnedByComponents() = false; + } + } + + LiveKitRuntimeGuard(const LiveKitRuntimeGuard&) = delete; + LiveKitRuntimeGuard& operator=(const LiveKitRuntimeGuard&) = delete; + LiveKitRuntimeGuard(LiveKitRuntimeGuard&&) = delete; + LiveKitRuntimeGuard& operator=(LiveKitRuntimeGuard&&) = delete; + +private: + static std::mutex& runtimeMutex() { + static std::mutex mutex; + return mutex; + } + + static std::size_t& instanceCount() { + static std::size_t count = 0U; + return count; + } + + static bool& runtimeOwnedByComponents() { + static bool owned = false; + return owned; + } +}; + +} // namespace + +/// ROS Portal entry point for rclcpp component containers. +class RosPortalComponent final : private LiveKitRuntimeGuard, public RosPortal { +public: + /// @brief Construct a composable ROS Portal node. + /// @param options Options supplied by the component container. + explicit RosPortalComponent(const rclcpp::NodeOptions& options) + : LiveKitRuntimeGuard(), + RosPortal(options), + initialization_timer_(create_wall_timer(std::chrono::milliseconds(1), [this]() { initializeNode(); })) {} + +private: + /// @brief Initialize after the component manager owns this node. + void initializeNode() { + initialization_timer_->cancel(); + + try { + if (!initializeForComposition()) { + RCLCPP_FATAL(get_logger(), "Failed to initialize composable ROS Portal"); + return; + } + + if (rosThreads() != 0) { + RCLCPP_WARN(get_logger(), + "ros_threads is ignored in composition; configure thread_num on the component container"); + } + } catch (const std::exception& error) { + RCLCPP_FATAL(get_logger(), "Unhandled exception while initializing composable ROS Portal: %s", error.what()); + } catch (...) { + RCLCPP_FATAL(get_logger(), "Unknown exception while initializing composable ROS Portal"); + } + } + + /// One-shot timer that defers initialization until shared ownership exists. + rclcpp::TimerBase::SharedPtr initialization_timer_; +}; + +} // namespace ros_portal + +RCLCPP_COMPONENTS_REGISTER_NODE(ros_portal::RosPortalComponent) diff --git a/src/ros_portal/test/CMakeLists.txt b/src/ros_portal/test/CMakeLists.txt index 8f4f80fe..1ea7c922 100644 --- a/src/ros_portal/test/CMakeLists.txt +++ b/src/ros_portal/test/CMakeLists.txt @@ -20,6 +20,7 @@ endif() find_package(ament_cmake_gtest REQUIRED) find_package(ament_cmake_pytest REQUIRED) find_package(ament_lint_auto REQUIRED) +find_package(launch_testing_ament_cmake REQUIRED) find_package(nav_msgs REQUIRED) find_package(std_srvs REQUIRED) find_package(test_msgs REQUIRED) @@ -32,6 +33,15 @@ ament_add_pytest_test( ros_portal_local_launch_test launch/test_ros_portal_local_launch.py ) +ament_add_pytest_test( + ros_portal_composable_launch_test + launch/test_ros_portal_composable_launch.py +) +add_launch_test( + launch/test_ros_portal_component.py + TARGET ros_portal_component_runtime_test + TIMEOUT 30 +) # --------------------------------------------------------------------------- # Unit tests diff --git a/src/ros_portal/test/launch/test_ros_portal_component.py b/src/ros_portal/test/launch/test_ros_portal_component.py new file mode 100644 index 00000000..62a8c99b --- /dev/null +++ b/src/ros_portal/test/launch/test_ros_portal_component.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 + +# Copyright 2026 LiveKit +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path +import time +import unittest + +from launch import LaunchDescription +from launch.actions import SetEnvironmentVariable +import launch_testing +import launch_testing.actions +import launch_testing.asserts +from launch_ros.actions import ComposableNodeContainer +from launch_ros.descriptions import ComposableNode +import rclpy + + +def generate_test_description(): + config_path = Path(__file__).resolve().parents[2] / 'config' / 'all_topics.yaml' + container = ComposableNodeContainer( + name='ros_portal_component_test_container', + namespace='', + package='rclcpp_components', + executable='component_container_mt', + output='screen', + parameters=[{'thread_num': 1}], + composable_node_descriptions=[ + ComposableNode( + package='ros_portal', + plugin='ros_portal::RosPortalComponent', + name='ros_portal_component_a', + parameters=[{'config_path': str(config_path)}], + ), + ComposableNode( + package='ros_portal', + plugin='ros_portal::RosPortalComponent', + name='ros_portal_component_b', + parameters=[{'config_path': str(config_path)}], + ), + ], + ) + + return ( + LaunchDescription([ + SetEnvironmentVariable('LIVEKIT_URL', 'ws://127.0.0.1:1'), + SetEnvironmentVariable('LIVEKIT_TOKEN', 'unused-component-test-token'), + container, + launch_testing.actions.ReadyToTest(), + ]), + {'container': container}, + ) + + +class TestRosPortalComponent(unittest.TestCase): + def test_both_components_join_the_ros_graph(self): + rclpy.init() + observer = rclpy.create_node('ros_portal_component_test_observer') + expected_names = { + 'ros_portal_component_a', + 'ros_portal_component_b', + } + + try: + deadline = time.monotonic() + 15.0 + discovered_names = set() + while time.monotonic() < deadline: + rclpy.spin_once(observer, timeout_sec=0.1) + discovered_names = {name for name, _ in observer.get_node_names_and_namespaces()} + if expected_names.issubset(discovered_names): + break + + self.assertTrue( + expected_names.issubset(discovered_names), + f'Expected component nodes {expected_names}, discovered {discovered_names}', + ) + finally: + observer.destroy_node() + rclpy.shutdown() + + +@launch_testing.post_shutdown_test() +class TestRosPortalComponentShutdown(unittest.TestCase): + def test_container_exits_cleanly(self, proc_info, container): + launch_testing.asserts.assertExitCodes(proc_info, process=container) diff --git a/src/ros_portal/test/launch/test_ros_portal_composable_launch.py b/src/ros_portal/test/launch/test_ros_portal_composable_launch.py new file mode 100644 index 00000000..f1ec1ba0 --- /dev/null +++ b/src/ros_portal/test/launch/test_ros_portal_composable_launch.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 + +# Copyright 2026 LiveKit +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from importlib.util import module_from_spec +from importlib.util import spec_from_file_location +from pathlib import Path + +from launch import LaunchContext +import pytest + + +LAUNCH_FILE = ( + Path(__file__).resolve().parents[2] + / 'launch' + / 'ros_portal_composable.launch.py' +) + + +def _load_launch_module(): + spec = spec_from_file_location('ros_portal_composable_launch', LAUNCH_FILE) + module = module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _write_config(path): + path.write_text( + """ros_portal: + version: "0.0.1" + ros_threads: 0 + topics: [] +""", + encoding='utf-8', + ) + + +def _context(config_path, container_threads='0', container_name='portal_container', namespace='/robot'): + context = LaunchContext() + context.launch_configurations['config_path'] = str(config_path) + context.launch_configurations['container_name'] = container_name + context.launch_configurations['container_threads'] = container_threads + context.launch_configurations['ns'] = namespace + return context + + +def test_launch_setup_creates_multithreaded_component_container(tmp_path, monkeypatch): + module = _load_launch_module() + config_path = tmp_path / 'ros_portal.yaml' + _write_config(config_path) + + class FakeComposableNode: + def __init__(self, **kwargs): + self.kwargs = kwargs + + class FakeComposableNodeContainer: + def __init__(self, **kwargs): + self.kwargs = kwargs + + monkeypatch.setattr(module, 'ComposableNode', FakeComposableNode) + monkeypatch.setattr(module, 'ComposableNodeContainer', FakeComposableNodeContainer) + + actions = module._launch_setup( + _context(config_path, container_threads='4') + ) + + assert len(actions) == 1 + container = actions[0].kwargs + assert container['package'] == 'rclcpp_components' + assert container['executable'] == 'component_container_mt' + assert container['name'] == 'portal_container' + assert container['parameters'] == [{'thread_num': 4}] + + assert len(container['composable_node_descriptions']) == 1 + component = container['composable_node_descriptions'][0].kwargs + assert component['package'] == 'ros_portal' + assert component['plugin'] == 'ros_portal::RosPortalComponent' + assert component['name'] == 'ros_portal' + assert component['namespace'] == '/robot' + assert component['parameters'] == [{'config_path': str(config_path)}] + + +def test_launch_setup_uses_builtin_config_when_path_is_empty(monkeypatch): + module = _load_launch_module() + + class FakeComposableNode: + def __init__(self, **kwargs): + self.kwargs = kwargs + + class FakeComposableNodeContainer: + def __init__(self, **kwargs): + self.kwargs = kwargs + + monkeypatch.setattr(module, 'ComposableNode', FakeComposableNode) + monkeypatch.setattr(module, 'ComposableNodeContainer', FakeComposableNodeContainer) + + actions = module._launch_setup(_context('')) + component = actions[0].kwargs['composable_node_descriptions'][0].kwargs + assert component['parameters'] == [{'config_path': ''}] + + +def test_launch_setup_accepts_single_thread(tmp_path, monkeypatch): + module = _load_launch_module() + config_path = tmp_path / 'ros_portal.yaml' + _write_config(config_path) + + class FakeComposableNode: + def __init__(self, **kwargs): + self.kwargs = kwargs + + class FakeComposableNodeContainer: + def __init__(self, **kwargs): + self.kwargs = kwargs + + monkeypatch.setattr(module, 'ComposableNode', FakeComposableNode) + monkeypatch.setattr(module, 'ComposableNodeContainer', FakeComposableNodeContainer) + + actions = module._launch_setup(_context(config_path, container_threads='1')) + + assert actions[0].kwargs['parameters'] == [{'thread_num': 1}] + + +def test_launch_setup_rejects_negative_thread_count(tmp_path): + module = _load_launch_module() + config_path = tmp_path / 'ros_portal.yaml' + _write_config(config_path) + + with pytest.raises(RuntimeError, match='container_threads must be non-negative'): + module._launch_setup(_context(config_path, container_threads='-1')) + + +def test_launch_setup_rejects_non_integer_thread_count(tmp_path): + module = _load_launch_module() + config_path = tmp_path / 'ros_portal.yaml' + _write_config(config_path) + + with pytest.raises(RuntimeError, match='container_threads must be an integer'): + module._launch_setup(_context(config_path, container_threads='many')) + + +def test_launch_setup_rejects_empty_container_name(tmp_path): + module = _load_launch_module() + config_path = tmp_path / 'ros_portal.yaml' + _write_config(config_path) + + with pytest.raises(RuntimeError, match='container_name launch argument must be non-empty'): + module._launch_setup(_context(config_path, container_name='')) + + +def test_launch_setup_rejects_missing_config_file(tmp_path): + module = _load_launch_module() + missing_config = tmp_path / 'missing.yaml' + + with pytest.raises(RuntimeError, match=f'Config file does not exist: {missing_config}'): + module._launch_setup(_context(missing_config))