diff --git a/README.md b/README.md index 532571891..25ac811ed 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,13 @@ sudo apt install ros-jazzy-ros2-medkit-gateway # or ros-humble- / ros-lyrical- ros2 launch ros2_medkit_gateway bringup.launch.py ``` +> [!IMPORTANT] +> `bringup.launch.py` binds the REST API to `127.0.0.1`. Inside a container without +> `--network host` that is the container's own loopback, and the host gets +> `Connection reset by peer` or an empty reply on the published port. Pass `server_host:=0.0.0.0` +> and publish the port. Publishing it as `-p 127.0.0.1:8080:8080` keeps the API, which has no +> authentication by default, off the LAN. + > [!TIP] > That is the whole setup. It auto-discovers every node, topic, service and action and starts > emitting structured faults over REST - no instrumentation, no changes to your stack. Prefer @@ -157,8 +164,11 @@ docker run -p 3000:80 ghcr.io/selfpatch/ros2_medkit_web_ui:latest ``` The browser calls the gateway from a different origin, so the gateway must allow that origin via -CORS (the prebuilt gateway Docker image enables it; for a native bringup set -`cors.allowed_origins`). See the [web UI tutorial](https://selfpatch.github.io/ros2_medkit/tutorials/web-ui.html). +CORS. The Docker image and `bringup.launch.py` allow `http://localhost:3000` and +`http://localhost:5173`. The origin must match exactly: `http://127.0.0.1:3000` is not +`http://localhost:3000`. To allow other origins, pass the full list, for example +`cors_allowed_origins:=http://localhost:3000,http://127.0.0.1:3000` (it replaces the defaults). +See the [web UI tutorial](https://selfpatch.github.io/ros2_medkit/tutorials/web-ui.html). diff --git a/docs/api/rest.rst b/docs/api/rest.rst index 5f55fb862..fb4e9c678 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -229,9 +229,9 @@ Server Capabilities "/powertrain/engine/rpm_sensor", "/ros2_medkit_gateway", "/_param_client_node", - "/ros2_medkit_gateway_fault_clients", - "/ros2_medkit_gateway_lifecycle_state_reader", - "/ros2_medkit_gateway_sub" + "/_ros2_medkit_gateway_fault_clients", + "/_ros2_medkit_gateway_lifecycle_state_reader", + "/_ros2_medkit_gateway_sub" ], "peer_names": [] } diff --git a/docs/config/discovery-options.rst b/docs/config/discovery-options.rst index c47613dc0..22cf29310 100644 --- a/docs/config/discovery-options.rst +++ b/docs/config/discovery-options.rst @@ -81,7 +81,9 @@ Internal Node Filtering When ``filter_internal_nodes`` is true (the default), ROS 2 nodes whose names start with an underscore (``_``) are excluded from the entity tree. This filters out ROS 2 internal infrastructure nodes such as ``_ros2cli_*``, -``_param_client_node``, and similar system nodes that should not appear as +``_param_client_node``, the gateway's own helper nodes +(``__fault_clients``, ``__lifecycle_state_reader``, +``__sub``), and similar system nodes that should not appear as SOVD entities. The filter applies to both locally discovered Apps and peer-discovered Apps (after stripping the peer prefix). diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 7ee1ccd57..3863e565b 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -46,8 +46,8 @@ You should see: .. code-block:: text - [gateway_node]: REST server starting on http://127.0.0.1:8080 - [gateway_node]: REST server started successfully + [ros2_medkit_gateway]: Configuration: REST API at 127.0.0.1:8080, backstop refresh interval: 30000ms + [ros2_medkit_gateway]: ROS 2 Medkit Gateway ready on HTTP://127.0.0.1:8080 **Terminal 2 - Start demo nodes:** diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index c0fedb8b6..7e1e3ca9f 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -73,6 +73,34 @@ For network access, set host to 0.0.0.0: ros2 launch ros2_medkit_gateway gateway.launch.py server_host:=0.0.0.0 +**"Connection reset by peer" or "Empty reply from server" from a container** + +.. code-block:: text + + curl: (56) Recv failure: Connection reset by peer + curl: (52) Empty reply from server + +The gateway runs in a container on a bridge network with a published port +(``-p 8080:8080``), ``curl`` inside the container works, and ``curl`` on the host +fails. The gateway listens on the container's loopback: ``bringup.launch.py`` and +``gateway.launch.py`` default to ``server_host:=127.0.0.1``. The startup log shows +the bind address: + +.. code-block:: text + + Configuration: REST API at 127.0.0.1:8080, backstop refresh interval: 30000ms + +Bind all interfaces inside the container: + +.. code-block:: bash + + ros2 launch ros2_medkit_gateway bringup.launch.py server_host:=0.0.0.0 + +Publish the port as ``-p 127.0.0.1:8080:8080`` to keep the API off the LAN; +authentication is off by default. With ``--network host`` the default bind is +enough, because the container shares the host's loopback. The Docker image's +default command already binds ``0.0.0.0``. + **Topic data returns empty or timeout** Possible causes: @@ -163,9 +191,19 @@ For development, try ``network_mode: host``. **Web UI can't connect to gateway in container** -1. Gateway must listen on ``0.0.0.0``, not ``127.0.0.1`` -2. CORS must allow the UI origin -3. Port must be exposed in docker-compose +1. Gateway must listen on ``0.0.0.0``, not ``127.0.0.1``. The image's default + command does; a launch file needs the argument: + + .. code-block:: bash + + ros2 launch ros2_medkit_gateway bringup.launch.py server_host:=0.0.0.0 + +2. CORS must allow the UI origin exactly as the browser sends it (scheme, host + and port): ``http://127.0.0.1:3000`` is not ``http://localhost:3000``. The + defaults are ``http://localhost:3000`` and ``http://localhost:5173``. Pass + ``cors_allowed_origins:=,`` to the launch file for others; it + replaces the defaults, so list every origin you need. +3. Port must be published (``-p 8080:8080`` or ``ports`` in docker-compose) Fault Manager Issues -------------------- diff --git a/docs/tutorials/bringup.rst b/docs/tutorials/bringup.rst index 60ec7f083..0e545da24 100644 --- a/docs/tutorials/bringup.rst +++ b/docs/tutorials/bringup.rst @@ -48,6 +48,11 @@ launch arguments: * - ``server_port`` - ``8080`` - Gateway REST API port. + * - ``cors_allowed_origins`` + - ``http://localhost:3000,http://localhost:5173`` + - Comma-separated browser origins allowed to call the gateway. Each must + match exactly (``http://127.0.0.1:3000`` is not ``http://localhost:3000``). + A value replaces the defaults; empty disables CORS. * - ``enable_fault_manager`` - ``true`` - Start the fault_manager node. @@ -62,6 +67,14 @@ launch arguments: - Start the diagnostic_bridge (``/diagnostics`` -> faults). Opt-in, for legacy ``diagnostic_updater`` publishers. +.. note:: + + In a container without ``--network host``, ``127.0.0.1`` is the container's + own loopback: the published port answers with ``Connection reset by peer`` + or an empty reply. Run ``bringup.launch.py server_host:=0.0.0.0`` and publish + the port as ``-p 127.0.0.1:8080:8080`` to keep the API (no authentication by + default) off the LAN. + Verify ------ diff --git a/docs/tutorials/docker.rst b/docs/tutorials/docker.rst index 84c042e67..abd2ece16 100644 --- a/docs/tutorials/docker.rst +++ b/docs/tutorials/docker.rst @@ -67,10 +67,28 @@ Test the gateway: Custom Configuration -------------------- -The default configuration listens on ``0.0.0.0:8080``. CORS is enabled for the -default web UI origins (``http://localhost:3000`` and ``http://localhost:5173``) -so the web UI works out of the box; add your own UI origin(s) as needed (see -`CORS for Web UI`_ below). To use a custom configuration, mount a params file: +The image's default command runs the gateway alone with +``/etc/ros2_medkit/params.yaml``, which listens on ``0.0.0.0:8080``. CORS is +enabled for the default web UI origins (``http://localhost:3000`` and +``http://localhost:5173``) so the web UI works out of the box; add your own UI +origin(s) as needed (see `CORS for Web UI`_ below). + +.. note:: + + A launch file run in the container binds ``127.0.0.1`` unless told + otherwise. Without ``--network host`` that is the container's own loopback, + and the published port answers with ``Connection reset by peer`` or an empty + reply. Pass ``server_host:=0.0.0.0``: + + .. code-block:: bash + + docker run -p 127.0.0.1:8080:8080 ghcr.io/selfpatch/ros2_medkit-jazzy:latest \ + ros2 launch ros2_medkit_gateway bringup.launch.py server_host:=0.0.0.0 + + ``127.0.0.1:8080:8080`` publishes the port on the host's loopback only, which + keeps the API (no authentication by default) off the LAN. + +To use a custom configuration, mount a params file: .. code-block:: bash @@ -227,6 +245,11 @@ writes. Add your own UI origin(s): - "http://localhost:3000" - "https://my-dashboard.example.com" +The origin must match what the browser sends exactly: ``http://127.0.0.1:3000`` +is not ``http://localhost:3000``. When the container runs a launch file, pass +the origins as ``cors_allowed_origins:=http://localhost:3000,http://127.0.0.1:3000``; +the argument replaces the defaults. + Health Checks ------------- diff --git a/docs/tutorials/web-ui.rst b/docs/tutorials/web-ui.rst index af230fb3f..af2bd7b0c 100644 --- a/docs/tutorials/web-ui.rst +++ b/docs/tutorials/web-ui.rst @@ -105,8 +105,11 @@ Connecting to ros2_medkit .. tip:: - If the gateway runs on a different host, ensure CORS is configured. - See :doc:`/config/server` for CORS settings. + The web UI is a different origin than the gateway, so the gateway must allow + it via CORS. The launch files and the Docker image allow + ``http://localhost:3000`` and ``http://localhost:5173``, matched exactly: + ``http://127.0.0.1:3000`` is rejected. See :doc:`/config/server` for CORS + settings, or pass ``cors_allowed_origins:=`` to the launch file. Using the Interface ------------------- @@ -182,21 +185,24 @@ Run both gateway and web UI together: .. code-block:: yaml # docker-compose.yml - version: '3.8' services: gateway: - image: ros:jazzy - command: > - bash -c "source /opt/ros/jazzy/setup.bash && - ros2 launch ros2_medkit_gateway gateway.launch.py server_host:=0.0.0.0" - ports: - - "8080:8080" + image: ghcr.io/selfpatch/ros2_medkit-jazzy:latest + command: ros2 launch ros2_medkit_gateway bringup.launch.py network_mode: host + ipc: host web_ui: image: ghcr.io/selfpatch/ros2_medkit_web_ui:latest ports: - - "80:80" + - "3000:80" + +The gateway shares the host network, so it joins the robot's DDS graph and its +default bind ``127.0.0.1:8080`` is the host's loopback. Open +``http://localhost:3000``: that origin is allowed by default, while +``http://127.0.0.1:3000`` is not. On a bridge network instead, run +``bringup.launch.py server_host:=0.0.0.0`` and publish ``8080`` (see +:doc:`/troubleshooting`). Docker Image Tags ----------------- diff --git a/src/ros2_medkit_fault_manager/test/test_fault_manager.cpp b/src/ros2_medkit_fault_manager/test/test_fault_manager.cpp index 92b0abaaf..8654a9d7c 100644 --- a/src/ros2_medkit_fault_manager/test/test_fault_manager.cpp +++ b/src/ros2_medkit_fault_manager/test/test_fault_manager.cpp @@ -1060,7 +1060,7 @@ class FaultEventPublishingTest : public ::testing::Test { std::string events_topic = ns + "/fault_manager/events"; auto qos = rclcpp::QoS(100).reliable().durability_volatile(); event_subscription_ = - test_node_->create_subscription(events_topic, qos, [this](const FaultEvent::SharedPtr msg) { + test_node_->create_subscription(events_topic, qos, [this](const FaultEvent::ConstSharedPtr & msg) { received_events_.push_back(*msg); }); @@ -1206,7 +1206,7 @@ TEST_F(FaultEventPublishingTest, NewFaultPublishesConfirmedEvent) { // Wait for event to arrive (polling, robust under CPU contention) ASSERT_TRUE(spin_until([this]() { - return received_events_.size() >= 1; + return !received_events_.empty(); })); // Verify EVENT_CONFIRMED was published @@ -1221,7 +1221,7 @@ TEST_F(FaultEventPublishingTest, UpdateExistingFaultPublishesUpdatedEvent) { // Report a new fault first ASSERT_TRUE(call_report_fault("TEST_FAULT_2", Fault::SEVERITY_WARN, "/test_node1")); ASSERT_TRUE(spin_until([this]() { - return received_events_.size() >= 1; + return !received_events_.empty(); })); // Clear received events @@ -1230,7 +1230,7 @@ TEST_F(FaultEventPublishingTest, UpdateExistingFaultPublishesUpdatedEvent) { // Report same fault again - should trigger EVENT_UPDATED ASSERT_TRUE(call_report_fault("TEST_FAULT_2", Fault::SEVERITY_ERROR, "/test_node2")); ASSERT_TRUE(spin_until([this]() { - return received_events_.size() >= 1; + return !received_events_.empty(); })); // Verify EVENT_UPDATED was published (severity/sources changed; still one occurrence) @@ -1244,7 +1244,7 @@ TEST_F(FaultEventPublishingTest, ClearFaultPublishesClearedEvent) { // Report a fault first ASSERT_TRUE(call_report_fault("TEST_FAULT_3", Fault::SEVERITY_ERROR, "/test_node")); ASSERT_TRUE(spin_until([this]() { - return received_events_.size() >= 1; + return !received_events_.empty(); })); // Clear received events @@ -1253,7 +1253,7 @@ TEST_F(FaultEventPublishingTest, ClearFaultPublishesClearedEvent) { // Clear the fault ASSERT_TRUE(call_clear_fault("TEST_FAULT_3")); ASSERT_TRUE(spin_until([this]() { - return received_events_.size() >= 1; + return !received_events_.empty(); })); // Verify EVENT_CLEARED was published @@ -1280,7 +1280,7 @@ class HealingFaultEventPublishingTest : public FaultEventPublishingTest { TEST_F(HealingFaultEventPublishingTest, HealPublishesClearedEventSoStreamConsumersSeeTheEnd) { ASSERT_TRUE(call_report_fault("HEAL_ME", Fault::SEVERITY_ERROR, "/test_node")); ASSERT_TRUE(spin_until([this]() { - return received_events_.size() >= 1; + return !received_events_.empty(); })); received_events_.clear(); @@ -1323,7 +1323,7 @@ TEST_F(FaultEventPublishingTest, EventContainsCorrectTimestamp) { ASSERT_TRUE(call_report_fault("TEST_FAULT_4", Fault::SEVERITY_WARN, "/test_node")); ASSERT_TRUE(spin_until([this]() { - return received_events_.size() >= 1; + return !received_events_.empty(); })); auto after = fault_manager_->now(); @@ -1339,7 +1339,7 @@ TEST_F(FaultEventPublishingTest, EventContainsCorrectTimestamp) { TEST_F(FaultEventPublishingTest, EventContainsFullFaultData) { ASSERT_TRUE(call_report_fault("FULL_DATA_TEST", Fault::SEVERITY_CRITICAL, "/sensor/temperature")); ASSERT_TRUE(spin_until([this]() { - return received_events_.size() >= 1; + return !received_events_.empty(); })); ASSERT_EQ(received_events_.size(), 1u); @@ -1362,7 +1362,7 @@ TEST_F(FaultEventPublishingTest, TimestampUsesWallClockNotSimTime) { ASSERT_TRUE(call_report_fault("WALL_CLOCK_TEST", Fault::SEVERITY_WARN, "/test_node")); ASSERT_TRUE(spin_until([this]() { - return received_events_.size() >= 1; + return !received_events_.empty(); })); auto wall_after = std::chrono::system_clock::now(); @@ -1390,7 +1390,7 @@ TEST_F(FaultEventPublishingTest, GetFaultReturnsExpectedFault) { // Report a fault first ASSERT_TRUE(call_report_fault("GET_FAULT_TEST", Fault::SEVERITY_ERROR, "/test_node")); ASSERT_TRUE(spin_until([this]() { - return received_events_.size() >= 1; + return !received_events_.empty(); })); // Get fault via service @@ -1414,7 +1414,7 @@ TEST_F(FaultEventPublishingTest, GetFaultReturnsEnvironmentData) { // Report a fault ASSERT_TRUE(call_report_fault("ENV_DATA_TEST", Fault::SEVERITY_WARN, "/sensor/temp")); ASSERT_TRUE(spin_until([this]() { - return received_events_.size() >= 1; + return !received_events_.empty(); })); auto response = call_get_fault("ENV_DATA_TEST"); @@ -1434,7 +1434,7 @@ TEST_F(FaultEventPublishingTest, GetFaultReturnsExtendedDataRecords) { // Report fault twice to have first and last occurrence timestamps differ ASSERT_TRUE(call_report_fault("EDR_TEST", Fault::SEVERITY_ERROR, "/node1")); ASSERT_TRUE(spin_until([this]() { - return received_events_.size() >= 1; + return !received_events_.empty(); })); ASSERT_TRUE(call_report_fault("EDR_TEST", Fault::SEVERITY_ERROR, "/node2")); ASSERT_TRUE(spin_until([this]() { @@ -1482,7 +1482,7 @@ TEST_F(FaultEventPublishingTest, ListFaultsForEntityEmptyResult) { // Report faults from a different entity ASSERT_TRUE(call_report_fault("SOME_FAULT", Fault::SEVERITY_ERROR, "/some/other_entity")); ASSERT_TRUE(spin_until([this]() { - return received_events_.size() >= 1; + return !received_events_.empty(); })); // Query faults for non-existent entity @@ -1883,6 +1883,10 @@ class SnapshotCooldownTest : public ::testing::Test { spin_thread_ = std::thread([this]() { executor_.spin(); }); + // A cancel() that lands before spin() starts is lost, and join() would block. + while (!executor_.is_spinning()) { + std::this_thread::yield(); + } ASSERT_TRUE(report_client_->wait_for_service(std::chrono::seconds(5))); ASSERT_TRUE(clear_client_->wait_for_service(std::chrono::seconds(5))); diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index ca40feb9a..d9d7d5d11 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -210,6 +210,7 @@ add_library(gateway_ros2 STATIC src/plugins/plugin_loader.cpp src/plugins/plugin_manager.cpp src/ros2_common/callback_groups.cpp + src/ros2_common/helper_node.cpp src/ros2_common/ros2_subscription_executor.cpp src/ros2_common/ros2_subscription_slot.cpp src/script_manager.cpp @@ -882,6 +883,11 @@ if(BUILD_TESTING) target_link_libraries(test_ros2_lifecycle_state_reader gateway_ros2) medkit_target_dependencies(test_ros2_lifecycle_state_reader rclcpp lifecycle_msgs) + # Helper nodes keep their own name under a process-wide __node remap + medkit_add_gtest(test_helper_node test/test_helper_node.cpp) + target_link_libraries(test_helper_node gateway_ros2) + medkit_target_dependencies(test_helper_node rclcpp rcl_interfaces) + # Private client nodes torn down after a first graph wait that follows rclcpp::shutdown() medkit_add_gtest(test_graph_listener_join test/test_graph_listener_join.cpp) target_link_libraries(test_graph_listener_join gateway_ros2) diff --git a/src/ros2_medkit_gateway/README.md b/src/ros2_medkit_gateway/README.md index 5c0f55990..8c701130b 100644 --- a/src/ros2_medkit_gateway/README.md +++ b/src/ros2_medkit_gateway/README.md @@ -1570,6 +1570,7 @@ ros2 launch ros2_medkit_gateway gateway.launch.py \ | `server_host` | `127.0.0.1` | Host to bind the REST server (`127.0.0.1` or `0.0.0.0`). Launch-arg only. | | `server_port` | `8080` | Port for the REST API. Launch-arg only. | | `refresh_interval_ms` | `30000` | Safety-backstop refresh interval in ms (graph events drive the primary refresh). Launch-arg only. | +| `cors_allowed_origins` | `http://localhost:3000,http://localhost:5173` | Comma-separated browser origins, matched exactly (`http://127.0.0.1:3000` is not `http://localhost:3000`). A value replaces the defaults; empty disables CORS. With a custom `config_file` and this argument unset, CORS comes from the config files only. | ### Authentication Configuration Examples diff --git a/src/ros2_medkit_gateway/design/ros2_subscription_architecture.rst b/src/ros2_medkit_gateway/design/ros2_subscription_architecture.rst index e6b2eb007..28a2b4ee4 100644 --- a/src/ros2_medkit_gateway/design/ros2_subscription_architecture.rst +++ b/src/ros2_medkit_gateway/design/ros2_subscription_architecture.rst @@ -68,11 +68,18 @@ Ros2SubscriptionExecutor Owns: - One dedicated ``std::thread`` (the worker). -- One ``rclcpp::Node`` (the subscription node, suffixed ``_sub``) exclusively - owned by this executor's internal ``SingleThreadedExecutor``. The gateway's +- One ``rclcpp::Node`` (the subscription node, ``__sub``) + exclusively owned by this executor's internal ``SingleThreadedExecutor``. + It is created by ``make_helper_node`` (``ros2_common/helper_node.hpp``): + hidden by the leading underscore, in the gateway's namespace, with the + gateway's ``use_sim_time`` and a node-local name remap, so a launch_ros + ``Node(name=...)`` does not rename it to the gateway's own name. The gateway's main ``MultiThreadedExecutor`` never sees this node; creation, destruction and callback dispatch of every subscription on it run on the single worker thread, preserving the single-writer invariant against rcl's hash-map. + The node has no ``/rosout`` publisher: context shutdown finalises every + ``/rosout`` publisher on the thread that shuts down, which would be a second + writer to the node's type cache. - A bounded task queue guarded by ``queue_mtx_`` + ``queue_cv_``. - One ``aux`` thread driving the watchdog and graph-event polling ticks on their own cadence. The aux thread only touches atomics and the diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp index a46a11bf5..6c54b11d3 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp @@ -73,8 +73,8 @@ class GatewayNode : public rclcpp::Node { ~GatewayNode() override; /// Count application (peer) nodes from (name, namespace) pairs: excludes hidden - /// nodes and the gateway's own nodes (whose FQN starts with @p self_fqn). Static - /// and public so the startup-summary counting logic is unit-testable. + /// nodes (the gateway's helper nodes are hidden) and the node at @p self_fqn. + /// Static and public so the startup-summary counting logic is unit-testable. static size_t count_peer_nodes(const std::vector> & nodes_and_namespaces, const std::string & self_fqn); diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2/status/ros2_lifecycle_state_reader.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2/status/ros2_lifecycle_state_reader.hpp index 128277a86..2d114510f 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2/status/ros2_lifecycle_state_reader.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2/status/ros2_lifecycle_state_reader.hpp @@ -56,6 +56,8 @@ class Ros2LifecycleStateReader : public LifecycleStateReader { std::shared_ptr client_node_; std::shared_ptr executor_; std::chrono::duration timeout_; + /// The host's logger. The helper node has no /rosout publisher. + rclcpp::Logger logger_; std::mutex mutex_; }; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2_common/helper_node.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2_common/helper_node.hpp new file mode 100644 index 000000000..c00bb9cb3 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2_common/helper_node.hpp @@ -0,0 +1,43 @@ +// Copyright 2026 bburda +// +// 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. + +#pragma once + +#include +#include + +#include + +namespace ros2_medkit_gateway::ros2_common { + +/** + * @brief Create an in-process helper node of @p host, in the host's namespace and context. + * + * The helper is named `_`. The leading underscore hides it from + * `ros2 node list` and from discovery. A node-local `__node` remap keeps a + * process-wide `-r __node:=` (added by launch_ros `Node(name=...)`) from + * giving the helper the host's name. All other process arguments still apply. + * `use_sim_time` is copied from the host, because a parameters file keyed by the + * host's name does not match the helper. + * + * The helper has no parameter services and no parameter event publisher. A helper + * spun only during its own requests would leave those services unanswered. + * + * The helper has no /rosout publisher. Context shutdown finalises every /rosout + * publisher on the shutting-down thread, which would race the helper's owner + * creating or destroying entities on the helper. Log through the host's logger. + */ +std::shared_ptr make_helper_node(rclcpp::Node & host, const std::string & suffix); + +} // namespace ros2_medkit_gateway::ros2_common diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2_common/ros2_subscription_executor.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2_common/ros2_subscription_executor.hpp index 6cc7a240a..19541d259 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2_common/ros2_subscription_executor.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2_common/ros2_subscription_executor.hpp @@ -83,6 +83,7 @@ class Ros2SubscriptionExecutor final { std::chrono::milliseconds watchdog_threshold; std::chrono::milliseconds watchdog_tick; std::chrono::milliseconds graph_poll_tick; + /// The subscription node is named `_`. std::string subscription_node_name_suffix; // Explicit ctor needed because GCC does not allow default member initializers @@ -121,8 +122,8 @@ class Ros2SubscriptionExecutor final { * deliberately not wired up - sharing the subscription node with a multi-threaded * executor reintroduces the rcutils_hash_map race this class exists to eliminate. * - * @param gateway_node Owning gateway node. Used only to derive the subscription - * node name and namespace; no references retained after + * @param gateway_node Owning gateway node. Used only to set up the subscription + * node (see make_helper_node); no references retained after * construction. * @param cfg Bounded resource configuration. */ diff --git a/src/ros2_medkit_gateway/src/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index b17256644..1f52448e4 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -1601,17 +1601,15 @@ size_t GatewayNode::count_peer_nodes(const std::vector