diff --git a/Dockerfile b/Dockerfile index 79ce6d37..c2135632 100644 --- a/Dockerfile +++ b/Dockerfile @@ -71,7 +71,7 @@ COPY src/ros2_medkit_plugins/ ${COLCON_WS}/src/ros2_medkit_plugins/ RUN bash -c "source /opt/ros/${ROS_DISTRO}/setup.bash && \ rosdep update && \ rosdep install --from-paths src --ignore-src -r -y \ - --skip-keys='ament_cmake_clang_format ament_cmake_clang_tidy test_msgs sqlite3 rosbag2_storage_mcap' && \ + --skip-keys='ament_cmake_clang_format ament_cmake_clang_tidy test_msgs sqlite3 libcpp-httplib-dev rosbag2_storage_mcap' && \ colcon build --cmake-args -DBUILD_TESTING=OFF" # ============================================================================ diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index 3094221e..5a84a8ac 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -589,6 +589,11 @@ if(BUILD_TESTING) target_link_libraries(test_plugin_loader gateway_lib) medkit_target_dependencies(test_plugin_loader ament_index_cpp) + # Plugin config from YAML tests + ament_add_gtest(test_plugin_config test/test_plugin_config.cpp) + target_link_libraries(test_plugin_config gateway_lib) + medkit_set_test_domain(test_plugin_config) + # Plugin manager tests ament_add_gtest(test_plugin_manager test/test_plugin_manager.cpp) target_link_libraries(test_plugin_manager gateway_lib) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/native_topic_sampler.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/native_topic_sampler.hpp index d9a106e9..3d162832 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/native_topic_sampler.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/native_topic_sampler.hpp @@ -292,6 +292,17 @@ class NativeTopicSampler { /// Native JSON serializer for topic deserialization std::shared_ptr serializer_; + /// Dedicated callback group for sampling subscriptions. Isolates short-lived + /// sampling subscriptions from the main callback group so subscription + /// creation/destruction from httplib threads does not race with the executor + /// iterating the default group's entity list. + rclcpp::CallbackGroup::SharedPtr sampling_cb_group_; + + /// Serializes subscription creation/destruction in sample_topic(). Only one + /// sampling subscription is active at a time, preventing concurrent + /// create_generic_subscription calls from multiple httplib threads. + std::mutex sampling_mutex_; + /// Cached component topic map, updated by build_component_topic_map() std::map topic_map_cache_; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/param_utils.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/param_utils.hpp new file mode 100644 index 00000000..1a908588 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/param_utils.hpp @@ -0,0 +1,78 @@ +// 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 +#include +#include + +namespace ros2_medkit_gateway { + +/// Declare plugin config parameters from the global --params-file YAML. +/// +/// Parameters from --params-file go into the ROS 2 global rcl context, +/// NOT into NodeOptions::parameter_overrides(). We must discover and +/// declare them explicitly so list_parameters()/get_parameter() can find them. +inline void declare_plugin_params_from_yaml(rclcpp::Node * node, const std::string & prefix, + const std::string & path_key = "") { + auto rcl_ctx = node->get_node_base_interface()->get_context()->get_rcl_context(); + rcl_params_t * global_params = nullptr; + auto ret = rcl_arguments_get_param_overrides(&rcl_ctx->global_arguments, &global_params); + if (ret != RCL_RET_OK || global_params == nullptr) { + return; + } + auto cleanup = [](rcl_params_t * p) { + if (p) { + rcl_yaml_node_struct_fini(p); + } + }; + std::unique_ptr guard(global_params, cleanup); + + std::string node_name = node->get_name(); + std::string node_fqn = node->get_fully_qualified_name(); + for (size_t n = 0; n < global_params->num_nodes; ++n) { + std::string yaml_node = global_params->node_names[n]; + if (yaml_node != node_name && yaml_node != node_fqn && yaml_node != "/**") { + continue; + } + auto * node_p = &global_params->params[n]; + for (size_t p = 0; p < node_p->num_params; ++p) { + std::string pname = node_p->parameter_names[p]; + if (pname.rfind(prefix, 0) == 0 && pname != path_key && !node->has_parameter(pname)) { + auto & val = node_p->parameter_values[p]; + try { + if (val.string_value != nullptr) { + node->declare_parameter(pname, std::string(val.string_value)); + } else if (val.bool_value != nullptr) { + node->declare_parameter(pname, *val.bool_value); + } else if (val.integer_value != nullptr) { + node->declare_parameter(pname, static_cast(*val.integer_value)); + } else if (val.double_value != nullptr) { + node->declare_parameter(pname, *val.double_value); + } else { + RCLCPP_WARN(node->get_logger(), "Skipping param '%s': unsupported type (array?)", pname.c_str()); + } + } catch (const std::exception & e) { + RCLCPP_DEBUG(node->get_logger(), "Could not declare param '%s': %s", pname.c_str(), e.what()); + } + } + } + } +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/trigger_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/trigger_manager.hpp index a2aef16a..f9799e9c 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/trigger_manager.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/trigger_manager.hpp @@ -149,6 +149,17 @@ class TriggerManager { /// Called by GatewayNode after both TriggerManager and LogManager are available. void set_log_manager(LogManager * log_manager); + /// Resolves a resource_path to a full ROS 2 topic name for a given entity. + /// Returns the topic name, or empty string if unresolvable. + using ResolveTopicFn = std::function; + + /// Set the topic name resolver. Called by GatewayNode after cache is available. + void set_resolve_topic_fn(ResolveTopicFn fn); + + /// Retry resolving data triggers whose topic names were unknown at creation. + /// Called periodically by TriggerTopicSubscriber's retry timer. + void retry_unresolved_triggers(); + // --- Entity existence check (for orphan sweep) ---------------------------- /// Function that checks whether an entity still exists in the discovery cache. @@ -255,6 +266,18 @@ class TriggerManager { // that would re-enter on_resource_change(). Only accessed from the notifier // worker thread (single-threaded dispatch), so no synchronization needed. bool evaluating_trigger_{false}; + + // Deferred resolution for data triggers created before topic was discoverable. + // Periodically retried via retry_unresolved_triggers(). + struct UnresolvedTrigger { + std::string trigger_id; + std::string entity_id; + std::string resource_path; + std::chrono::steady_clock::time_point created_at; + }; + std::vector unresolved_data_triggers_; // guarded by triggers_mutex_ + ResolveTopicFn resolve_topic_fn_; // guarded by triggers_mutex_ + static constexpr int kUnresolvedTimeoutSec = 60; }; } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/trigger_topic_subscriber.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/trigger_topic_subscriber.hpp index 1f9ebc3a..b45d5f29 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/trigger_topic_subscriber.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/trigger_topic_subscriber.hpp @@ -98,6 +98,12 @@ class TriggerTopicSubscriber { */ void shutdown(); + /// Callback invoked by the retry timer alongside topic type resolution. + /// Used to let TriggerManager re-resolve triggers whose topic names + /// were unknown at creation time. + using RetryCallback = std::function; + void set_retry_callback(RetryCallback cb); + private: /// Per-topic subscription state with multi-entity support. struct SubscriptionEntry { @@ -144,6 +150,7 @@ class TriggerTopicSubscriber { std::unordered_map subscriptions_; std::unordered_map pending_; ///< Topics awaiting type resolution rclcpp::TimerBase::SharedPtr retry_timer_; + RetryCallback retry_callback_; std::atomic shutdown_flag_{false}; }; diff --git a/src/ros2_medkit_gateway/src/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index f61b12f0..82376418 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -23,6 +23,7 @@ #include #include "ros2_medkit_gateway/aggregation/network_utils.hpp" +#include "ros2_medkit_gateway/param_utils.hpp" #include "ros2_medkit_gateway/http/handlers/sse_transport_provider.hpp" #include "ros2_medkit_gateway/sqlite_trigger_store.hpp" @@ -59,20 +60,36 @@ nlohmann::json parameter_to_json(const rclcpp::Parameter & param) { } } -/// Extract per-plugin config from YAML parameter overrides. +/// Extract per-plugin config from ROS 2 parameters. /// Scans for keys matching "plugins.." (excluding ".path") /// and builds a flat JSON object: {"": value, ...} -nlohmann::json extract_plugin_config(const std::vector & overrides, - const std::string & plugin_name) { +/// +/// Checks two sources: +/// 1. NodeOptions::parameter_overrides (set programmatically, e.g. in unit tests) +/// 2. Global YAML overrides from --params-file (declared on-demand, production path) +nlohmann::json extract_plugin_config(rclcpp::Node * node, const std::string & plugin_name) { auto config = nlohmann::json::object(); std::string prefix = "plugins." + plugin_name + "."; std::string path_key = prefix + "path"; - for (const auto & param : overrides) { + // Source 1: NodeOptions parameter_overrides (programmatic, used in tests) + for (const auto & param : node->get_node_options().parameter_overrides()) { const auto & name = param.get_name(); if (name.rfind(prefix, 0) == 0 && name != path_key) { - auto key = name.substr(prefix.size()); - config[key] = parameter_to_json(param); + config[name.substr(prefix.size())] = parameter_to_json(param); + } + } + if (!config.empty()) { + return config; + } + + // Source 2: global YAML overrides from --params-file + declare_plugin_params_from_yaml(node, prefix, path_key); + + auto result = node->list_parameters({prefix}, 10); + for (const auto & name : result.names) { + if (name != path_key) { + config[name.substr(prefix.size())] = parameter_to_json(node->get_parameter(name)); } } return config; @@ -601,7 +618,7 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki RCLCPP_ERROR(get_logger(), "Plugin '%s' has no path configured", pname.c_str()); continue; } - auto plugin_config = extract_plugin_config(get_node_options().parameter_overrides(), pname); + auto plugin_config = extract_plugin_config(this, pname); if (!plugin_config.empty()) { RCLCPP_INFO(get_logger(), "Plugin '%s' config: %zu key(s)", pname.c_str(), plugin_config.size()); } @@ -849,6 +866,26 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki trigger_topic_subscriber_ = std::make_unique(this, *resource_change_notifier_); trigger_mgr_->set_topic_subscriber(trigger_topic_subscriber_.get()); + // Wire deferred topic resolution: when a trigger's resource_path couldn't + // be resolved to a ROS 2 topic at creation time, retry periodically using + // the entity cache to find matching topics. + trigger_mgr_->set_resolve_topic_fn([this](const std::string & entity_id, + const std::string & resource_path) -> std::string { + const auto & cache = get_thread_safe_cache(); + auto aggregated = cache.get_entity_data(entity_id); + for (const auto & topic : aggregated.topics) { + if (topic.name == resource_path || + (topic.name.size() > resource_path.size() && + topic.name.compare(topic.name.size() - resource_path.size(), resource_path.size(), resource_path) == 0)) { + return topic.name; + } + } + return ""; + }); + trigger_topic_subscriber_->set_retry_callback([this]() { + trigger_mgr_->retry_unresolved_triggers(); + }); + // Wire node-to-entity resolver for trigger notifications (#305). // The resolver looks up node FQNs in the ThreadSafeEntityCache's node_to_app // mapping (populated from linking result). Captures 'this' - the lambda is diff --git a/src/ros2_medkit_gateway/src/main.cpp b/src/ros2_medkit_gateway/src/main.cpp index 532085a1..8a3421df 100644 --- a/src/ros2_medkit_gateway/src/main.cpp +++ b/src/ros2_medkit_gateway/src/main.cpp @@ -21,7 +21,14 @@ int main(int argc, char ** argv) { auto node = std::make_shared(); - rclcpp::spin(node); + // MultiThreadedExecutor is required because cpp-httplib handler threads call + // Node::create_generic_subscription() for topic sampling. SingleThreadedExecutor + // does not synchronize external subscription creation with its internal iteration, + // causing non-deterministic SIGSEGV on rolling. All gateway callbacks are already + // protected by mutexes (EntityCache, LogManager, TriggerManager, etc.). + rclcpp::executors::MultiThreadedExecutor executor; + executor.add_node(node); + executor.spin(); rclcpp::shutdown(); return 0; diff --git a/src/ros2_medkit_gateway/src/native_topic_sampler.cpp b/src/ros2_medkit_gateway/src/native_topic_sampler.cpp index 3e3e14a3..6941e1e2 100644 --- a/src/ros2_medkit_gateway/src/native_topic_sampler.cpp +++ b/src/ros2_medkit_gateway/src/native_topic_sampler.cpp @@ -125,6 +125,10 @@ NativeTopicSampler::NativeTopicSampler(rclcpp::Node * node) if (!node_) { throw std::invalid_argument("NativeTopicSampler requires a valid node pointer"); } + // Dedicated callback group isolates sampling subscriptions from the default + // group. This prevents subscription create/destroy from httplib threads + // racing with the executor iterating the default group's entity list. + sampling_cb_group_ = node_->create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive); RCLCPP_INFO(node_->get_logger(), "NativeTopicSampler initialized with native serialization"); } @@ -238,6 +242,11 @@ TopicSampleResult NativeTopicSampler::sample_topic(const std::string & topic_nam return result; } + // Serialize sampling: only one subscription active at a time. + // This prevents concurrent create_generic_subscription calls from multiple + // httplib threads racing with each other and with the executor. + std::lock_guard sampling_lock(sampling_mutex_); + // Use native GenericSubscription for sampling try { constexpr double kDefaultTimeoutSec = 1.0; @@ -248,10 +257,16 @@ TopicSampleResult NativeTopicSampler::sample_topic(const std::string & topic_nam timeout_sec = kDefaultTimeoutSec; } - // Set up one-shot subscription with promise/future - std::promise message_promise; - auto message_future = message_promise.get_future(); - std::atomic received{false}; + // Heap-allocated state shared between the callback and this function. + // The callback captures a shared_ptr (not stack references) so it remains + // valid even if the subscription outlives sample_topic()'s stack frame - + // e.g. when the executor dispatches a callback after wait_for() times out. + struct SampleState { + std::promise message_promise; + std::atomic received{false}; + }; + auto state = std::make_shared(); + auto message_future = state->message_promise.get_future(); // Choose QoS based on publisher QoS (best effort for sensor data, reliable for others) rclcpp::QoS qos = rclcpp::SensorDataQoS(); // Best effort, keep last 1 @@ -266,17 +281,19 @@ TopicSampleResult NativeTopicSampler::sample_topic(const std::string & topic_nam // Create generic subscription // NOLINTNEXTLINE(performance-unnecessary-value-param) - GenericSubscription requires value type in callback - auto callback = [&message_promise, &received](std::shared_ptr msg) { + auto callback = [state](std::shared_ptr msg) { // Only process first message (one-shot) bool expected = false; - if (received.compare_exchange_strong(expected, true)) { - message_promise.set_value(*msg); + if (state->received.compare_exchange_strong(expected, true)) { + state->message_promise.set_value(*msg); } }; rclcpp::GenericSubscription::SharedPtr subscription; try { - subscription = node_->create_generic_subscription(topic_name, info->type, qos, callback); + rclcpp::SubscriptionOptions sub_opts; + sub_opts.callback_group = sampling_cb_group_; + subscription = node_->create_generic_subscription(topic_name, info->type, qos, callback, sub_opts); } catch (const std::exception & e) { RCLCPP_WARN(node_->get_logger(), "Failed to create subscription for '%s': %s", topic_name.c_str(), e.what()); result.has_data = false; @@ -285,10 +302,11 @@ TopicSampleResult NativeTopicSampler::sample_topic(const std::string & topic_nam RCLCPP_DEBUG(node_->get_logger(), "sample_topic: Created GenericSubscription for '%s'", topic_name.c_str()); - // Wait for message using future with timeout - // The main executor will deliver callbacks - we just wait for the result - // Note: This works because HTTP requests are handled in a separate thread, - // while the main executor processes ROS callbacks + // Wait for message using future with timeout. + // The main executor delivers callbacks on the executor thread; we block here. + // Thread safety: sampling_mutex_ serializes subscription lifecycle, + // sampling_cb_group_ isolates from the default callback group, and + // SampleState (heap-allocated, shared_ptr) keeps callback state alive. const auto timeout = std::chrono::duration(timeout_sec); auto future_status = message_future.wait_for(timeout); diff --git a/src/ros2_medkit_gateway/src/trigger_manager.cpp b/src/ros2_medkit_gateway/src/trigger_manager.cpp index 637c81e4..b56f0ace 100644 --- a/src/ros2_medkit_gateway/src/trigger_manager.cpp +++ b/src/ros2_medkit_gateway/src/trigger_manager.cpp @@ -114,6 +114,57 @@ void TriggerManager::set_log_manager(LogManager * log_manager) { log_manager_ = log_manager; } +void TriggerManager::set_resolve_topic_fn(ResolveTopicFn fn) { + std::lock_guard lock(triggers_mutex_); + resolve_topic_fn_ = std::move(fn); +} + +void TriggerManager::retry_unresolved_triggers() { + if (shutdown_flag_.load()) { + return; + } + + std::lock_guard lock(triggers_mutex_); + + if (unresolved_data_triggers_.empty() || !resolve_topic_fn_ || !topic_subscriber_) { + return; + } + + auto now = std::chrono::steady_clock::now(); + std::vector resolved_indices; + std::vector expired_indices; + + for (size_t i = 0; i < unresolved_data_triggers_.size(); ++i) { + auto & entry = unresolved_data_triggers_[i]; + + if (now - entry.created_at > std::chrono::seconds(kUnresolvedTimeoutSec)) { + expired_indices.push_back(i); + continue; + } + + std::string topic_name = resolve_topic_fn_(entry.entity_id, entry.resource_path); + if (!topic_name.empty()) { + // Update the trigger's resolved_topic_name + auto it = triggers_.find(entry.trigger_id); + if (it != triggers_.end()) { + std::lock_guard state_lock(it->second->mtx); + it->second->info.resolved_topic_name = topic_name; + } + topic_subscriber_->subscribe(topic_name, entry.resource_path, entry.entity_id); + resolved_indices.push_back(i); + } + } + + // Remove resolved and expired entries (reverse order to maintain indices) + std::vector to_remove; + to_remove.insert(to_remove.end(), resolved_indices.begin(), resolved_indices.end()); + to_remove.insert(to_remove.end(), expired_indices.begin(), expired_indices.end()); + std::sort(to_remove.rbegin(), to_remove.rend()); + for (size_t idx : to_remove) { + unresolved_data_triggers_.erase(unresolved_data_triggers_.begin() + static_cast(idx)); + } +} + void TriggerManager::set_entity_exists_fn(EntityExistsFn fn) { std::lock_guard lock(entity_exists_mutex_); entity_exists_fn_ = std::move(fn); @@ -258,9 +309,17 @@ tl::expected TriggerManager::create(const Trigg triggers_[state->info.id] = std::move(state); } - // Subscribe to topic for data triggers - if (topic_subscriber_ && req.collection == "data" && !req.resolved_topic_name.empty()) { - topic_subscriber_->subscribe(req.resolved_topic_name, req.resource_path, req.entity_id); + // Subscribe to topic for data triggers. + // When resolved_topic_name is empty (topic not yet discoverable at creation time), + // queue for deferred resolution - retry_unresolved_triggers() will pick it up. + if (topic_subscriber_ && req.collection == "data") { + if (!req.resolved_topic_name.empty()) { + topic_subscriber_->subscribe(req.resolved_topic_name, req.resource_path, req.entity_id); + } else if (!req.resource_path.empty()) { + std::lock_guard lock(triggers_mutex_); + unresolved_data_triggers_.push_back( + {info_copy.id, req.entity_id, req.resource_path, std::chrono::steady_clock::now()}); + } } return info_copy; diff --git a/src/ros2_medkit_gateway/src/trigger_topic_subscriber.cpp b/src/ros2_medkit_gateway/src/trigger_topic_subscriber.cpp index 49defb1e..e870052e 100644 --- a/src/ros2_medkit_gateway/src/trigger_topic_subscriber.cpp +++ b/src/ros2_medkit_gateway/src/trigger_topic_subscriber.cpp @@ -203,7 +203,16 @@ void TriggerTopicSubscriber::shutdown() { RCLCPP_INFO(node_->get_logger(), "TriggerTopicSubscriber: shutdown complete"); } +void TriggerTopicSubscriber::set_retry_callback(RetryCallback cb) { + retry_callback_ = std::move(cb); +} + void TriggerTopicSubscriber::retry_pending_subscriptions() { + // Also retry unresolved triggers in TriggerManager (resource_path -> topic_name) + if (retry_callback_) { + retry_callback_(); + } + if (shutdown_flag_.load()) { return; } diff --git a/src/ros2_medkit_gateway/test/test_native_topic_sampler.cpp b/src/ros2_medkit_gateway/test/test_native_topic_sampler.cpp index 574f5cf7..7eaaecee 100644 --- a/src/ros2_medkit_gateway/test/test_native_topic_sampler.cpp +++ b/src/ros2_medkit_gateway/test/test_native_topic_sampler.cpp @@ -270,3 +270,108 @@ TEST_F(NativeTopicSamplerWithPublishersTest, GetTopicsForNamespace_ReturnsCorrec EXPECT_TRUE(found_status) << "Should find /test_robot/status topic"; EXPECT_TRUE(found_sensor) << "Should find /test_robot/sensor/data topic"; } + +// ============================================================================= +// Thread-safety tests for sample_topic() +// ============================================================================= + +class NativeTopicSamplerThreadSafetyTest : public ::testing::Test { + protected: + static void SetUpTestSuite() { + rclcpp::init(0, nullptr); + } + static void TearDownTestSuite() { + rclcpp::shutdown(); + } + + void SetUp() override { + node_ = std::make_shared("test_thread_safety_node"); + sampler_ = std::make_unique(node_.get()); + + // Spin the node in a background thread so subscription callbacks fire + executor_ = std::make_shared(); + executor_->add_node(node_); + spin_thread_ = std::thread([this]() { + executor_->spin(); + }); + + // Publisher on a known topic with high frequency + pub_ = node_->create_publisher("/thread_safety_test/data", 10); + timer_ = node_->create_wall_timer(std::chrono::milliseconds(50), [this]() { + std_msgs::msg::String msg; + msg.data = "test"; + pub_->publish(msg); + }); + + // Let DDS discover the topic + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + } + + void TearDown() override { + executor_->cancel(); + spin_thread_.join(); + executor_.reset(); + timer_.reset(); + pub_.reset(); + sampler_.reset(); + node_.reset(); + } + + std::shared_ptr node_; + std::unique_ptr sampler_; + std::shared_ptr executor_; + std::thread spin_thread_; + rclcpp::Publisher::SharedPtr pub_; + rclcpp::TimerBase::SharedPtr timer_; +}; + +/// Verify that sample_topic on timeout path does not crash. +/// Exercises the shared_ptr SampleState fix: the callback may fire after +/// wait_for returns timeout, and must not access destroyed stack locals. +TEST_F(NativeTopicSamplerThreadSafetyTest, SampleTopicTimeoutDoesNotCrash) { + for (int i = 0; i < 20; ++i) { + auto result = sampler_->sample_topic("/no_such_topic_xyz", 0.05); + EXPECT_FALSE(result.has_data); + } +} + +/// Verify that concurrent sample_topic calls from multiple threads do not crash. +/// Exercises the sampling_mutex_ + dedicated callback group fix: concurrent +/// create_generic_subscription calls must be serialized. +TEST_F(NativeTopicSamplerThreadSafetyTest, ConcurrentSampleTopicDoesNotCrash) { + constexpr int kThreads = 4; + constexpr int kIterations = 5; + std::vector threads; + threads.reserve(kThreads); + std::atomic successes{0}; + std::atomic completions{0}; + + for (int t = 0; t < kThreads; ++t) { + threads.emplace_back([this, &successes, &completions]() { + for (int i = 0; i < kIterations; ++i) { + auto result = sampler_->sample_topic("/thread_safety_test/data", 0.5); + if (result.has_data) { + successes.fetch_add(1); + } + completions.fetch_add(1); + } + }); + } + + for (auto & t : threads) { + t.join(); + } + + EXPECT_EQ(completions.load(), kThreads * kIterations); + EXPECT_GT(successes.load(), 0) << "At least one concurrent sample should receive data"; +} + +/// Verify that sample_topic with a very short timeout and active publisher +/// does not crash when the message arrives right at the timeout boundary. +/// Stress-tests the SampleState fix: callback fires while stack is unwinding. +TEST_F(NativeTopicSamplerThreadSafetyTest, RapidTimeoutWithActivePublisher) { + for (int i = 0; i < 30; ++i) { + auto result = sampler_->sample_topic("/thread_safety_test/data", 0.02); + // Don't check has_data - timing is non-deterministic. Just don't crash. + } +} diff --git a/src/ros2_medkit_gateway/test/test_plugin_config.cpp b/src/ros2_medkit_gateway/test/test_plugin_config.cpp new file mode 100644 index 00000000..861ca4d1 --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_plugin_config.cpp @@ -0,0 +1,95 @@ +// 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. + +// @verifies REQ_INTEROP_098 +/// Tests that plugin config from --params-file YAML reaches the gateway plugin framework. +/// +/// The bug: extract_plugin_config() read from get_node_options().parameter_overrides(), +/// which only contains programmatically-set overrides. Parameters from --params-file +/// go into the ROS 2 global rcl context and are NOT copied to NodeOptions::parameter_overrides_. +/// +/// The fix: declare_plugin_params_from_yaml() accesses rcl_arguments_get_param_overrides() +/// to discover plugin params and declares them on the node. + +#include + +#include +#include +#include + +#include + +#include "ros2_medkit_gateway/param_utils.hpp" + +/// Proves the bug and validates the fix using a lightweight rclcpp::Node. +/// +/// 1. Writes a YAML params file with plugin config +/// 2. Inits rclcpp with --params-file (production path) +/// 3. Creates a plain Node (NOT GatewayNode) with the matching name +/// 4. Verifies NodeOptions::parameter_overrides() is empty (the bug) +/// 5. Verifies declare_plugin_params_from_yaml() resolves the YAML values (the fix) +TEST(PluginConfig, YamlPluginParamsReachGateway) { + // Write YAML with plugin config using the gateway node name + std::string yaml_path = "/tmp/test_plugin_config_" + std::to_string(getpid()) + ".yaml"; + { + std::ofstream yaml(yaml_path); + yaml << "test_plugin_config_node:\n" + << " ros__parameters:\n" + << " plugins.my_plugin.custom_key: \"custom_value\"\n" + << " plugins.my_plugin.mode: \"testing\"\n" + << " plugins.my_plugin.timeout: 42\n" + << " plugins.my_plugin.verbose: true\n" + << " plugins.my_plugin.threshold: 3.14\n"; + } + + // Init rclcpp with --params-file (simulates: ros2 run ... --ros-args --params-file ...) + const char * args[] = {"test", "--ros-args", "--params-file", yaml_path.c_str()}; + rclcpp::init(4, const_cast(args)); + + // Create a lightweight node (no HTTP server, no DDS subscriptions) + auto node = std::make_shared("test_plugin_config_node"); + + // BUG: NodeOptions::parameter_overrides() is empty for --params-file params + const auto & overrides = node->get_node_options().parameter_overrides(); + EXPECT_EQ(overrides.size(), 0u) << "parameter_overrides() should be empty for --params-file YAML params " + << "(this confirms the root cause of the bug)"; + + // FIX: declare_plugin_params_from_yaml discovers and declares them + ros2_medkit_gateway::declare_plugin_params_from_yaml(node.get(), "plugins.my_plugin."); + + // Verify all param types are correctly declared + ASSERT_TRUE(node->has_parameter("plugins.my_plugin.custom_key")); + EXPECT_EQ(node->get_parameter("plugins.my_plugin.custom_key").as_string(), "custom_value"); + + ASSERT_TRUE(node->has_parameter("plugins.my_plugin.mode")); + EXPECT_EQ(node->get_parameter("plugins.my_plugin.mode").as_string(), "testing"); + + ASSERT_TRUE(node->has_parameter("plugins.my_plugin.timeout")); + EXPECT_EQ(node->get_parameter("plugins.my_plugin.timeout").as_int(), 42); + + ASSERT_TRUE(node->has_parameter("plugins.my_plugin.verbose")); + EXPECT_EQ(node->get_parameter("plugins.my_plugin.verbose").as_bool(), true); + + ASSERT_TRUE(node->has_parameter("plugins.my_plugin.threshold")); + EXPECT_DOUBLE_EQ(node->get_parameter("plugins.my_plugin.threshold").as_double(), 3.14); + + node.reset(); + rclcpp::shutdown(); + std::remove(yaml_path.c_str()); +} + +int main(int argc, char ** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/src/ros2_medkit_gateway/test/test_trigger_manager.cpp b/src/ros2_medkit_gateway/test/test_trigger_manager.cpp index 3cbc2cb7..3c36e28a 100644 --- a/src/ros2_medkit_gateway/test/test_trigger_manager.cpp +++ b/src/ros2_medkit_gateway/test/test_trigger_manager.cpp @@ -1068,3 +1068,67 @@ TEST_F(TriggerManagerTest, Sweep_FreesCapacitySlots) { auto after_sweep = manager_->create(make_request("new_entity")); EXPECT_TRUE(after_sweep.has_value()) << "Should have capacity after sweep: " << after_sweep.error().message; } + +// =========================================================================== +// Deferred topic resolution tests +// =========================================================================== + +/// Creating a trigger with empty resolved_topic_name (late publisher scenario) +/// should succeed and queue it for deferred resolution. +TEST_F(TriggerManagerTest, DeferredResolution_CreateWithEmptyTopicSucceeds) { + auto req = make_request("sensor"); + req.resolved_topic_name = ""; // Simulate unresolvable at creation time + + auto result = manager_->create(req); + ASSERT_TRUE(result.has_value()) << result.error().message; + EXPECT_EQ(result->status, TriggerStatus::ACTIVE); + + auto info = manager_->get(result->id); + ASSERT_TRUE(info.has_value()); + EXPECT_TRUE(info->resolved_topic_name.empty()) << "Should be unresolved at creation"; +} + +/// retry_unresolved_triggers should not crash when no subscriber or resolver is set. +TEST_F(TriggerManagerTest, DeferredResolution_RetryWithoutSubscriberSafe) { + auto req = make_request("sensor"); + req.resolved_topic_name = ""; + + auto result = manager_->create(req); + ASSERT_TRUE(result.has_value()); + + // No subscriber, no resolve_fn -> should safely no-op + EXPECT_NO_THROW(manager_->retry_unresolved_triggers()); + + // Set resolve_fn but no subscriber -> still no-op (skips resolve) + manager_->set_resolve_topic_fn( + [](const std::string & /*entity_id*/, const std::string & /*resource_path*/) -> std::string { + return "/sensor/temperature"; + }); + EXPECT_NO_THROW(manager_->retry_unresolved_triggers()); + + // Trigger should still exist + auto info = manager_->get(result->id); + EXPECT_TRUE(info.has_value()); +} + +/// Resolved trigger name can be consumed by SSE after deferred resolution. +/// Tests the data flow: empty at creation -> resolved after retry -> info updated. +TEST_F(TriggerManagerTest, DeferredResolution_ResolvedTopicUpdatedInInfo) { + auto req = make_request("sensor"); + req.resolved_topic_name = ""; + + auto result = manager_->create(req); + ASSERT_TRUE(result.has_value()); + + // Verify unresolved + auto info_before = manager_->get(result->id); + ASSERT_TRUE(info_before.has_value()); + EXPECT_TRUE(info_before->resolved_topic_name.empty()); + + // After creating a trigger with empty resolved_topic_name, + // the trigger should be queued for deferred resolution. + // The actual subscription happens in retry_unresolved_triggers() + // when both resolve_fn AND topic_subscriber are available. + // We test the resolve_fn path indirectly via the integration test + // (test_triggers_late_publisher) which exercises the full flow. +} diff --git a/src/ros2_medkit_integration_tests/demo_nodes/beacon_publisher.cpp b/src/ros2_medkit_integration_tests/demo_nodes/beacon_publisher.cpp index 2df70d81..03eda516 100644 --- a/src/ros2_medkit_integration_tests/demo_nodes/beacon_publisher.cpp +++ b/src/ros2_medkit_integration_tests/demo_nodes/beacon_publisher.cpp @@ -13,6 +13,7 @@ // limitations under the License. #include +#include #include #include @@ -47,12 +48,17 @@ class BeaconPublisher : public rclcpp::Node { ~BeaconPublisher() { timer_->cancel(); + std::lock_guard lock(callback_mutex_); timer_.reset(); publisher_.reset(); } private: void publish_beacon() { + std::lock_guard lock(callback_mutex_); + if (!publisher_) { + return; + } if (get_parameter("beacon_pause").as_bool()) { return; } @@ -72,6 +78,7 @@ class BeaconPublisher : public rclcpp::Node { publisher_->publish(msg); } + std::mutex callback_mutex_; rclcpp::Publisher::SharedPtr publisher_; rclcpp::TimerBase::SharedPtr timer_; }; diff --git a/src/ros2_medkit_integration_tests/demo_nodes/brake_actuator.cpp b/src/ros2_medkit_integration_tests/demo_nodes/brake_actuator.cpp index 419829ec..879d5c55 100644 --- a/src/ros2_medkit_integration_tests/demo_nodes/brake_actuator.cpp +++ b/src/ros2_medkit_integration_tests/demo_nodes/brake_actuator.cpp @@ -23,6 +23,7 @@ */ #include +#include #include #include @@ -47,6 +48,7 @@ class BrakeActuator : public rclcpp::Node { ~BrakeActuator() { cmd_sub_.reset(); timer_->cancel(); + std::lock_guard lock(callback_mutex_); timer_.reset(); pressure_pub_.reset(); } @@ -67,6 +69,10 @@ class BrakeActuator : public rclcpp::Node { } void timer_callback() { + std::lock_guard lock(callback_mutex_); + if (!pressure_pub_) { + return; + } // Simulate gradual pressure change (5 bar/sec = 0.5 bar per 100ms) const float step = 0.5f; @@ -84,6 +90,7 @@ class BrakeActuator : public rclcpp::Node { pressure_pub_->publish(msg); } + std::mutex callback_mutex_; rclcpp::Subscription::SharedPtr cmd_sub_; rclcpp::Publisher::SharedPtr pressure_pub_; rclcpp::TimerBase::SharedPtr timer_; diff --git a/src/ros2_medkit_integration_tests/demo_nodes/brake_pressure_sensor.cpp b/src/ros2_medkit_integration_tests/demo_nodes/brake_pressure_sensor.cpp index 1d0cbadf..5117efe6 100644 --- a/src/ros2_medkit_integration_tests/demo_nodes/brake_pressure_sensor.cpp +++ b/src/ros2_medkit_integration_tests/demo_nodes/brake_pressure_sensor.cpp @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +#include #include #include @@ -28,12 +29,17 @@ class BrakePressureSensor : public rclcpp::Node { ~BrakePressureSensor() { timer_->cancel(); + std::lock_guard lock(callback_mutex_); timer_.reset(); pressure_pub_.reset(); } private: void publish_data() { + std::lock_guard lock(callback_mutex_); + if (!pressure_pub_) { + return; + } current_pressure_ += 5.0; if (current_pressure_ > 100.0) { current_pressure_ = 0.0; @@ -47,6 +53,7 @@ class BrakePressureSensor : public rclcpp::Node { RCLCPP_INFO(this->get_logger(), "Brake Pressure: %.1f bar", current_pressure_); } + std::mutex callback_mutex_; rclcpp::Publisher::SharedPtr pressure_pub_; rclcpp::TimerBase::SharedPtr timer_; double current_pressure_ = 0.0; diff --git a/src/ros2_medkit_integration_tests/demo_nodes/door_status_sensor.cpp b/src/ros2_medkit_integration_tests/demo_nodes/door_status_sensor.cpp index 5dfe88eb..438fcdd0 100644 --- a/src/ros2_medkit_integration_tests/demo_nodes/door_status_sensor.cpp +++ b/src/ros2_medkit_integration_tests/demo_nodes/door_status_sensor.cpp @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +#include #include #include #include @@ -29,6 +30,7 @@ class DoorStatusSensor : public rclcpp::Node { ~DoorStatusSensor() { timer_->cancel(); + std::lock_guard lock(callback_mutex_); timer_.reset(); is_open_pub_.reset(); state_pub_.reset(); @@ -36,6 +38,10 @@ class DoorStatusSensor : public rclcpp::Node { private: void publish_data() { + std::lock_guard lock(callback_mutex_); + if (!is_open_pub_) { + return; + } // Toggle door state is_open_ = !is_open_; @@ -50,6 +56,7 @@ class DoorStatusSensor : public rclcpp::Node { RCLCPP_INFO(this->get_logger(), "Door: %s", state_msg.data.c_str()); } + std::mutex callback_mutex_; rclcpp::Publisher::SharedPtr is_open_pub_; rclcpp::Publisher::SharedPtr state_pub_; rclcpp::TimerBase::SharedPtr timer_; diff --git a/src/ros2_medkit_integration_tests/demo_nodes/engine_temp_sensor.cpp b/src/ros2_medkit_integration_tests/demo_nodes/engine_temp_sensor.cpp index acd0c2dc..19fb6ae1 100644 --- a/src/ros2_medkit_integration_tests/demo_nodes/engine_temp_sensor.cpp +++ b/src/ros2_medkit_integration_tests/demo_nodes/engine_temp_sensor.cpp @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +#include #include #include #include @@ -64,12 +65,17 @@ class EngineTempSensor : public rclcpp::Node { ~EngineTempSensor() { param_callback_handle_.reset(); timer_->cancel(); + std::lock_guard lock(callback_mutex_); timer_.reset(); temp_pub_.reset(); } private: rcl_interfaces::msg::SetParametersResult on_parameter_change(const std::vector & parameters) { + std::lock_guard lock(callback_mutex_); + if (!temp_pub_) { + return rcl_interfaces::msg::SetParametersResult(); + } rcl_interfaces::msg::SetParametersResult result; result.successful = true; @@ -103,6 +109,10 @@ class EngineTempSensor : public rclcpp::Node { } void publish_data() { + std::lock_guard lock(callback_mutex_); + if (!temp_pub_) { + return; + } current_temp_ += temp_step_; if (current_temp_ > max_temp_) { current_temp_ = min_temp_; @@ -119,6 +129,7 @@ class EngineTempSensor : public rclcpp::Node { RCLCPP_INFO(this->get_logger(), "Temperature: %.1f°C", current_temp_); } + std::mutex callback_mutex_; rclcpp::Publisher::SharedPtr temp_pub_; rclcpp::TimerBase::SharedPtr timer_; rclcpp::node_interfaces::OnSetParametersCallbackHandle::SharedPtr param_callback_handle_; diff --git a/src/ros2_medkit_integration_tests/demo_nodes/lidar_sensor.cpp b/src/ros2_medkit_integration_tests/demo_nodes/lidar_sensor.cpp index d54df3f5..5d806e47 100644 --- a/src/ros2_medkit_integration_tests/demo_nodes/lidar_sensor.cpp +++ b/src/ros2_medkit_integration_tests/demo_nodes/lidar_sensor.cpp @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +#include #include #include #include @@ -87,15 +88,16 @@ class LidarSensor : public rclcpp::Node { } ~LidarSensor() { + param_callback_handle_.reset(); scan_timer_->cancel(); - scan_timer_.reset(); fault_check_timer_->cancel(); - fault_check_timer_.reset(); if (initial_check_timer_) { initial_check_timer_->cancel(); - initial_check_timer_.reset(); } - param_callback_handle_.reset(); + std::lock_guard lock(callback_mutex_); + scan_timer_.reset(); + fault_check_timer_.reset(); + initial_check_timer_.reset(); calibrate_srv_.reset(); scan_pub_.reset(); report_fault_client_.reset(); @@ -103,6 +105,10 @@ class LidarSensor : public rclcpp::Node { private: rcl_interfaces::msg::SetParametersResult on_parameter_change(const std::vector & parameters) { + std::lock_guard lock(callback_mutex_); + if (!scan_pub_) { + return rcl_interfaces::msg::SetParametersResult(); + } rcl_interfaces::msg::SetParametersResult result; result.successful = true; @@ -133,7 +139,7 @@ class LidarSensor : public rclcpp::Node { } // Trigger fault check after parameter change - check_and_report_faults(); + check_and_report_faults_unlocked(); return result; } @@ -155,6 +161,17 @@ class LidarSensor : public rclcpp::Node { } void check_and_report_faults() { + std::lock_guard lock(callback_mutex_); + if (!report_fault_client_) { + return; + } + check_and_report_faults_unlocked(); + } + + void check_and_report_faults_unlocked() { + if (!report_fault_client_) { + return; + } if (!report_fault_client_->service_is_ready()) { RCLCPP_DEBUG(this->get_logger(), "Fault manager service not available, skipping fault check"); return; @@ -194,6 +211,10 @@ class LidarSensor : public rclcpp::Node { } void publish_scan() { + std::lock_guard lock(callback_mutex_); + if (!scan_pub_) { + return; + } auto scan_msg = sensor_msgs::msg::LaserScan(); scan_msg.header.stamp = this->now(); scan_msg.header.frame_id = "lidar_link"; @@ -221,6 +242,8 @@ class LidarSensor : public rclcpp::Node { scan_pub_->publish(scan_msg); } + std::mutex callback_mutex_; + // Publishers and services rclcpp::Publisher::SharedPtr scan_pub_; rclcpp::Service::SharedPtr calibrate_srv_; diff --git a/src/ros2_medkit_integration_tests/demo_nodes/light_controller.cpp b/src/ros2_medkit_integration_tests/demo_nodes/light_controller.cpp index ab05e2d8..be06e3a5 100644 --- a/src/ros2_medkit_integration_tests/demo_nodes/light_controller.cpp +++ b/src/ros2_medkit_integration_tests/demo_nodes/light_controller.cpp @@ -23,6 +23,7 @@ */ #include +#include #include #include @@ -47,6 +48,7 @@ class LightController : public rclcpp::Node { ~LightController() { cmd_sub_.reset(); timer_->cancel(); + std::lock_guard lock(callback_mutex_); timer_.reset(); status_pub_.reset(); } @@ -58,12 +60,17 @@ class LightController : public rclcpp::Node { } void timer_callback() { + std::lock_guard lock(callback_mutex_); + if (!status_pub_) { + return; + } // Publish current status auto msg = std_msgs::msg::Bool(); msg.data = light_on_; status_pub_->publish(msg); } + std::mutex callback_mutex_; rclcpp::Subscription::SharedPtr cmd_sub_; rclcpp::Publisher::SharedPtr status_pub_; rclcpp::TimerBase::SharedPtr timer_; diff --git a/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/CMakeLists.txt b/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/CMakeLists.txt index 4c2f755a..6690f38a 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/CMakeLists.txt +++ b/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/CMakeLists.txt @@ -33,8 +33,11 @@ find_package(rclcpp REQUIRED) find_package(nlohmann_json REQUIRED) find_package(OpenSSL REQUIRED) -# cpp-httplib via multi-distro compatibility macro -medkit_find_cpp_httplib() +# cpp-httplib via multi-distro compatibility macro. +# On Humble the system package is too old (0.10.x); fall back to gateway's +# vendored copy which is installed alongside the gateway package. +set(_gw_vendored "${ros2_medkit_gateway_DIR}/../vendored/cpp_httplib") +medkit_find_cpp_httplib(VENDORED_DIR "${_gw_vendored}") # Enable OpenSSL support for cpp-httplib add_compile_definitions(CPPHTTPLIB_OPENSSL_SUPPORT) diff --git a/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/test/test_sovd_service_interface.cpp b/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/test/test_sovd_service_interface.cpp index a4433dad..2d46ef9a 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/test/test_sovd_service_interface.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/test/test_sovd_service_interface.cpp @@ -13,7 +13,6 @@ // limitations under the License. #include -#include #include #include @@ -29,20 +28,35 @@ #include "ros2_medkit_gateway/discovery/models/component.hpp" #include "ros2_medkit_gateway/discovery/models/function.hpp" #include "ros2_medkit_gateway/plugins/plugin_context.hpp" +#include "ros2_medkit_gateway/plugins/plugin_http_types.hpp" #include "sovd_service_interface.hpp" using namespace ros2_medkit_gateway; -// Stubs for PluginContext static methods (defined in gateway_lib, not linked into tests) +// Stubs for PluginRequest/PluginResponse (defined in gateway_lib, not linked into plugin tests) namespace ros2_medkit_gateway { -void PluginContext::send_json(httplib::Response & res, const nlohmann::json & data) { - res.set_content(data.dump(), "application/json"); +PluginRequest::PluginRequest(const void * impl) : impl_(impl) { } -void PluginContext::send_error(httplib::Response & res, int status, const std::string & /*error_code*/, - const std::string & message, const nlohmann::json & /*parameters*/) { - res.status = status; - nlohmann::json err = {{"error", message}}; - res.set_content(err.dump(), "application/json"); +std::string PluginRequest::path_param(size_t /*index*/) const { + return {}; +} +std::string PluginRequest::header(const std::string & /*name*/) const { + return {}; +} +const std::string & PluginRequest::path() const { + static const std::string empty; + return empty; +} +const std::string & PluginRequest::body() const { + static const std::string empty; + return empty; +} +PluginResponse::PluginResponse(void * impl) : impl_(impl) { +} +void PluginResponse::send_json(const nlohmann::json & /*data*/) { +} +void PluginResponse::send_error(int /*status*/, const std::string & /*error_code*/, const std::string & /*message*/, + const nlohmann::json & /*parameters*/) { } } // namespace ros2_medkit_gateway @@ -73,11 +87,11 @@ class FakePluginContext : public PluginContext { return it->second; } - std::optional validate_entity_for_route(const httplib::Request & /*req*/, httplib::Response & res, + std::optional validate_entity_for_route(const PluginRequest & /*req*/, PluginResponse & res, const std::string & entity_id) const override { auto entity = get_entity(entity_id); if (!entity) { - send_error(res, 404, "entity-not-found", "Entity not found"); + res.send_error(404, "entity-not-found", "Entity not found"); return std::nullopt; } return entity;