Skip to content

fix: one node per gateway, container bind docs, and two test races - #704

Closed
bburda wants to merge 37 commits into
mainfrom
fix/gateway-in-container
Closed

bburda wants to merge 37 commits into
mainfrom
fix/gateway-in-container

Conversation

@bburda

@bburda bburda commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Pull Request

Summary

Fixes found while running the gateway in a container.

  • One gateway now shows up as one node. launch_ros adds -r __node:=<name> to the whole process, so the three helper nodes (fault-service clients, lifecycle state reader, subscription executor) took the gateway's name. They are now created by make_helper_node(): a hidden name _<gateway name><suffix>, a node-local __node remap, the gateway's namespace and use_sim_time, no parameter services and no /rosout publisher. The other process arguments (namespace, remaps, parameter files) still reach them. A helper with its own name got its own /rosout publisher, and context shutdown finalises every /rosout publisher on the thread that shuts down. That thread then wrote the helper's type cache while the subscription worker destroyed entities on the helper, and TSan reported it. The lifecycle state reader now logs through the gateway's logger.
  • The docs now say when server_host:=0.0.0.0 is needed. bringup.launch.py and gateway.launch.py bind 127.0.0.1, and in a bridge-network container a published port then answers with Connection reset by peer. README, troubleshooting and the bringup, Docker and web UI tutorials show the startup line to check and the fix. They also say that a CORS origin must match exactly and that cors_allowed_origins:= replaces the defaults. The web UI compose example used an image without the gateway and served the UI on an origin the defaults reject.
  • Two test races are fixed. A test that runs spin() on its own thread now waits for is_spinning() before it can cancel the executor: a cancel() that comes before spin() starts is lost, and join() then blocks until the ctest timeout. The node_death integration cases wait for fault reports with a service round trip on the same client.
  • The test files touched here also get their clang-tidy findings fixed.
  • test_grouping_entity_aggregation waits for the peer operation it runs before it sends it. Two gateway tests read files through rdbuf(), which removes the 16 -Wnull-dereference warnings they caused in the sanitizer builds.

Changes users can see: the helper loggers are named _ros2_medkit_gateway_* and publish nothing on /rosout, the helpers no longer serve parameters, and with ros2 run (the Docker image's default command) /apps no longer lists the helpers as Apps.


Issue


Type

  • Bug fix
  • New feature or tests
  • Breaking change
  • Documentation only

Testing

  • New integration tests test_gateway_helper_nodes and test_gateway_helper_nodes_namespaced launch the gateway through launch_ros, once plainly and once in a namespace with use_sim_time and remap rules. On main they fail with /ros2_medkit_gateway appears 4 times.
  • Container bind, with the Jazzy apt package behind -p 8080:8080 on Docker Engine: the default bringup gives curl: (56) Recv failure: Connection reset by peer, server_host:=0.0.0.0 gives HTTP 200.
  • Test races: in 20 runs each of the gateway unit suite with ctest -j16, main timed out in 6 runs and this branch in none.
    A 300 ms delay before spin() makes test_discovery_manager hang every time without the fix and pass with it. A 20 ms delay in the node_death fake fault manager fails 9 of 49 cases without the round trip and none with it.
  • colcon test on gateway, fault_manager, graph_watchdog, opcua and integration_tests: 0 failures. clang-tidy on the changed files: no findings. Run on Jazzy only.
  • test_helper_node checks that the helper has no /rosout publisher, and shuts a context down while another thread creates and destroys subscriptions on the helper. With the /rosout publisher in place, one round of that test reported the race under TSan in one of two runs, so it now runs ten rounds. Without the publisher, three runs of ten rounds reported nothing. test_gateway_helper_nodes checks that no helper of a launched gateway publishes on /rosout. Both checks fail without the change.
  • After the /rosout change: colcon test on gateway and integration_tests, 0 failures. clang-tidy on the changed files: no findings.

Checklist

  • Breaking changes are clearly described (and announced in docs / changelog if needed)
  • Tests were added or updated if needed
  • Docs were updated if behavior or public API changed

launch_ros Node(name=...) passes -r __node:=<name> to the whole process.
The fault-service clients, the lifecycle state reader and the subscription
executor each create an rclcpp::Node that honoured that rule, so one
gateway showed up as four nodes named /ros2_medkit_gateway: ros2 node list
warned about duplicate names, rosout logged "Publisher already registered"
three times, and ros2 param list printed every parameter four times.

The helpers are now built by make_helper_node():

- named _<gateway name><suffix>, so they are hidden from ros2 node list and
  from discovery (no new Apps; count_peer_nodes no longer needs the helper
  exception list);
- a node-local __node remap overrides the process-wide one. The other
  process arguments (namespace, remaps, parameter files) still apply;
- use_sim_time is copied from the gateway, because a parameters file keyed
  by the gateway's name does not match the helper;
- no parameter services or parameter event publisher: the fault-client
  node is spun only during its own requests, and a get_parameters call to it
  timed out; the lifecycle reader has the same shape.

New integration tests launch the gateway through launch_ros, once plainly
and once in a namespace with use_sim_time and with remap rules that are the
only route to the fault manager. They check a single gateway node in the
graph, ros2 param list without duplicates, unchanged /apps, hidden helpers
in the gateway namespace, /clock subscriptions on the helpers under sim
time, and that faults, topic samples and lifecycle states still work.

The graph watchdog compiles the lifecycle state reader in, so it now
compiles helper_node.cpp too. test_gateway_node.cpp drops redundant c_str()
calls flagged by clang-tidy.
bringup.launch.py and gateway.launch.py bind 127.0.0.1. Inside a container
on a bridge network that is the container's loopback, so a published port
answers with "Connection reset by peer" or an empty reply. The image's
default command binds 0.0.0.0. The README, the bringup and Docker tutorials
and the troubleshooting page now say so, show the startup line that names
the bind address, and give the fix with -p 127.0.0.1:8080:8080 to keep the
unauthenticated API off the LAN.

The README no longer tells native users to set cors.allowed_origins: the
launch files already allow http://localhost:3000 and http://localhost:5173.
The pages now state that the origin must match exactly
(http://127.0.0.1:3000 and http://localhost:3000 are different origins)
and that cors_allowed_origins:= replaces the defaults. The bringup
tutorial and the gateway README list that launch argument.

The web UI compose example used ros:jazzy, which has no gateway, and served
the UI on port 80, an origin the defaults reject; it now uses the gateway
image on the host network and the UI on port 3000. The getting started page
quotes the gateway's real startup lines.
Executor::cancel() only clears the spinning flag, and spin() sets it
again when it starts. A test that starts spin() on its own thread and
cancels it later loses the cancel when the thread has not reached spin()
yet. spin() then never returns and join() blocks until the ctest timeout.
Short cases under parallel load hit this in test_discovery_manager,
test_ros2_topic_data_provider and test_ros2_subscription_executor.

Wait for is_spinning() after each spin thread starts, in every test that
cancels an executor spun on its own thread.

Gateway unit suite with ctest -j16: 10 of 40 runs timed out before,
0 of 20 after.
The node_death integration cases slept 5-100 ms after a detector tick
and then read what the fake fault manager had received. Under parallel
load a report can take longer, so a case sees it one tick late (X2
expected the raise at tick 16 and saw 17), or a late report from the
previous case lands in the next one.

flush_reports() sends a marker request on the same client and waits for
the response. The sink serves one request at a time in arrival order,
so every earlier report is recorded by then. SetUp flushes before it
clears the record.

With a 20 ms delay injected into the sink, 9 of 49 cases failed before
and 49 of 49 pass after.
container-size-empty, unnecessary-value-param, named-parameter,
inefficient-vector-operation and special-member-functions in
test_fault_manager (fault manager), test_ros2_subscription_slot,
test_graph_watchdog_plugin and test_opcua_plugin. No behaviour change.
@bburda bburda self-assigned this Sep 26, 2026
A helper node now has its own name, so rcl gives it its own /rosout
publisher. When the context shuts down, rcl_logging_rosout_fini()
finalises every /rosout publisher on the thread that shuts down. That
thread then writes the helper's type cache while the helper's owner
creates or destroys entities on it, for example the subscription worker
destroying a slot. TSan reported this race in two integration tests.

make_helper_node() now sets enable_rosout(false). The lifecycle state
reader logs through the gateway's logger, so its warning still reaches
/rosout.

Tests:
- a unit test that the helper does not publish on /rosout
- a unit test that shuts a context down while another thread creates
  and destroys subscriptions on the helper; under TSan it reports the
  race when the helper has a /rosout publisher
- an integration check that no helper node of a launched gateway
  publishes on /rosout
test_a_compound_operation_id_runs_on_the_members_gateway sent
peer_calibration:calibrate as soon as the class set-up returned. The
set-up waits for a different peer operation only, so under TSan the
request could reach the aggregate before this operation was merged and
get 404. The test now waits for the operation it runs, like the other
peer operation tests in this file.
In the RelWithDebInfo build the sanitizer jobs use, GCC 13 reports
-Wnull-dereference inside libstdc++ for a std::string built from
istreambuf_iterator. Two gateway tests read files that way. They now
copy rdbuf() into an ostringstream, which reads the same content
without the warning.
# Conflicts:
#	README.md
#	src/ros2_medkit_gateway/src/gateway_node.cpp
#	src/ros2_medkit_gateway/test/test_gateway_node.cpp
#	src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py
#	src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/test/test_node_death_integration.cpp
#	src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_plugin.cpp
…ves them

The gateway's helper nodes are now _<gateway name><suffix> in the gateway's
namespace. is_own_gateway_helper_node matched <gateway><suffix>, plus a
root-namespace spelling for two helpers that used to take the process default
namespace. It now matches the new names in the gateway's namespace, exactly.

The orphan report, the app filter's warning for a declared app bound to a
helper node and param_beacon's skip list follow from the predicate. Tests,
the internal node filtering docs and the /health example follow the new names.
Runtime discovery qualifies an app id with the namespace when the bare node
name exists in more than one namespace, so the id of a hidden node such as
/subsystem_a/_ros2_medkit_gateway_sub loses its leading underscore. Gateways
that share a node name then served each other's helper nodes as apps.

filter_internal_node_apps now also reads the node name from the FQN of a
local runtime app. Declared apps are still judged by their id, and peer apps
by their id after the peer prefix.

The new integration test runs three gateways with one node name, in the root
namespace, moved by a node-specific namespace remap and moved by a
process-wide namespace, and checks that each lists the three gateways and
none of the nine helper nodes. The predicate tests use neighbours that share
the helpers' /_<name> prefix, and collision-form ids where a runtime app is
meant.
…death check

- helper_node.hpp: the leading underscore hides a helper from discovery only
  while discovery.runtime.filter_internal_nodes is on.
- graph_node_list.hpp: the self_fqn parameter lists every input that matches
  nothing.
- param_beacon: every name the helper predicate accepts starts with '_' and
  is skipped one line earlier, so the predicate call is gone.
- test_own_node_apps_namespaced: the docstring says what the file checks.
- test_node_death_integration: the reclaim check waits on the flush barrier
  instead of a sleep.
A host can build a GatewayNode on a private rclcpp::Context and never
initialise the default one. Construction then threw: the parameter
transport's _param_client_node took the default context, and the
subscription executor, the fault-service transport and the lifecycle-state
reader each built their SingleThreadedExecutor on it.

_param_client_node now takes the gateway's context, and the three helper
executors take their helper node's context (executor_options_for). The
parameter transport's free spin_until_future_complete already builds its
executor on the node's context.

test_gateway_private_context initialises only a private context and checks
construction, a fault-manager call, a parameter read through the parameter
transport, a lifecycle-state read and a subscription through the
subscription executor.
_cleanup_package waited up to 10 s for status completed, failed or a 404.
A registered package that was never prepared reports pending with phase
none, so every such cleanup spent the full 10 s, and the file ran close
to its 120 s ctest timeout.

The cleanup now waits while the status is inProgress or x-medkit.phase is
preparing, executing or deleting: the states in which an operation runs or
a DELETE is in flight. Every other state is idle, and DELETE answers 204
for it.
The node-name rule of the internal-node filter applied to every local app
whose source the merge pipeline does not protect. A plugin app that keeps a
protocol source such as "opcua" was therefore hidden when its node name
started with '_', and a runtime app was still judged by its id after the
node-name check, so a visible node /_ops/camera whose bare name also runs
elsewhere got the id _ops_camera and was hidden.

Runtime introspection is the only place that gives an App the source
"heuristic", now the shared constant discovery::kRuntimeAppSource. An app
with that source is hidden if and only if its node name starts with '_'.
Every other app, remote ones included, is judged by its id. Peer apps always
arrive as "peer:<name>", so the separate remote guard is gone. A declared
app dropped on one of the gateway's own helper nodes is reported for any
source other than runtime introspection.

The filter tests name every fixture's source, add a plugin app with source
"opcua" on /plant/_driver that is kept, give the peer fixture its real
"peer:sub_b" source, and use a foreign gateway's helper node as a control.
The same-name gateways integration test adds a visible node in /_ops whose
name also runs in the root namespace and expects every gateway to list it.

The discovery options page states the rule per source, scopes the claim that
gateways list the same Apps, and places the warning for declared apps next to
the gateway's own helper nodes.
…ion hosts

Functions are grouped from the discovered apps before the internal-node
filter runs, so a namespace Function kept hosts the gateway does not serve,
such as subsystem_a__ros2_medkit_gateway_sub, and a namespace that held only
hidden nodes could leave a Function behind.

After the filter, every id it removed is dropped from every Function's
hosts, unless the id still names a served App or a Component. A Function
left with no hosts is not served. A Function declared without hosts stays,
as before.

The same-name gateways integration test reads Function subsystem_a from
every gateway and expects its hosts to be exactly the served apps in that
namespace.
…erves

GET /functions/{id}/hosts fell back to the discovery manager when the
Function was not in the entity cache. Discovery still returns Functions the
internal-node filter dropped, so for a namespace that holds only hidden
nodes GET /functions/{id} answered 404 while its hosts route answered 200
with an empty collection.

Both handlers now read the Function from the cache only, the set the gateway
serves and lists. The detail route already required the cache through route
validation, so its fallback could only fire in a refresh race. The cache is
filled before the REST server starts, so no startup path needs the fallback.

The same-name gateways integration test adds a hidden node alone in
/hidden_ns and expects 404 from both Function routes on every gateway.
The Function host pruning dropped any Function that had hosts before the
internal-node filter and none after. Aggregation merges a peer's hosts into
a local Function of the same id before the filter runs, so a declared
Function without hosts of its own was removed when a peer gave it only
hidden hosts.

Runtime introspection is the only place that gives a Function the source
"runtime", now the shared constant discovery::kRuntimeFunctionSource. Pruning
still removes filtered ids from every Function's hosts, but drops a Function
only when it has that source and lost its last host. A Function from a
manifest, a plugin or a peer stays.

drop_filtered_function_hosts is declared in gateway_node.hpp so its two
cases are pinned without a node: a declared Function that loses its only
host stays, and a runtime one is dropped.
The gateway's own node stays an App. When its name starts with an
underscore, runtime discovery hides it like any other such node, and a
declared App bound to it is judged by its id.
… both Function routes

The snapshot wait polled for the root witness id. While only the /_ops
witness existed its bare name was unique, so it took that same id, and a
refresh between the two Node() calls gave a snapshot without
_ops_same_name_gateways_witness. The wait is now for that id, which exists
only while both witnesses are on the graph.

The Function checks now compare against written-out host sets on both
GET /functions/{id} and GET /functions/{id}/hosts, cover Function _ops,
whose only host is a visible App with an id that starts with '_', and check
the 404 body names the Function as entity-not-found.
…er reported

The peer client drops a peer's Apps whose id starts with '_' before the
merge, but a Function's hosts arrive from its detail route and were kept
whole. merge_functions then unioned them into the aggregator's Function, so
a peer that runs with filter_internal_nodes false had its hidden Apps listed
as hosts the aggregator does not serve.

A peer Function now keeps only the hosts that name an App or a Component the
same fetch read from that peer. Two existing in-process tests change with
it: the peer client test expects a host that names nothing the peer reported
to be dropped, and the host remap test's peer now reports the App its
Function hosts.

The new test_aggregated_function_hosts integration test runs an aggregator
and a peer with the filter off in separate DDS domains, and expects
Function line_a on the aggregator to list exactly the peer's visible App on
both Function routes.
…s sole origin

Host pruning dropped a Function whose source read "runtime" once the filter
emptied it. The source does not say who contributed: in hybrid mode a merged
Function keeps the first layer's source, so a plugin Function with the same
id as a namespace Function read "runtime", and aggregation keeps the local
source when a peer merges into a local Function. Both were dropped.

Function gains introspection_only. Runtime introspection sets it. The merge
pipeline clears it when any other layer contributes the same id, and the
entity merger clears it when a peer does. Pruning drops a Function only while
it is set and no host is left. The served-Component exception moves into the
pruning function, which now takes the served Components.

The kRuntimeAppSource and kRuntimeFunctionSource docs now say what the
strings do and do not prove.

Tests:
- In-process: a manifest, a plugin and a peer-merged Function that lose
  every host stay, a Function only introspection created is dropped, and a
  host that also names a served Component stays.
- test_aggregated_function_hosts adds namespace cell_b, hidden on both
  sides: the merged Function stays on the aggregator with no hosts.
- test_hybrid_plugin_function_hosts runs a hybrid gateway whose test plugin
  declares a Function with the id runtime discovery derives for a namespace
  of one hidden node. It stays with no hosts, while a namespace nothing else
  declares has no Function. The test plugin can now declare a Function with
  an id and a host from its configuration.
…ere it does not

The namespace Function section now says how Function ids derive from
namespaces, including root, that a Function this gateway answers itself
hosts only Apps and Components it serves, that a peer's Function keeps only
hosts the peer reported, and that only a Function runtime discovery alone
created disappears when it loses every host. It names the two cases that
still list hosts the gateway does not serve: hybrid mode with
allow_heuristic_functions, where runtime App ids can name Apps the merge
pipeline does not serve, and a Function only a peer contributes, whose
detail route is forwarded and answers with the peer's view.

The bold sentence about the gateway's own node now says what holds
unconditionally: the helper rule does not remove it.
…bers

The plugin defines a destructor and no other special member, which
cppcoreguidelines-special-member-functions reports. The plugin owns a thread
and a publisher, so copy and move are deleted.
…he end of the refresh

Function hosts were pruned per mechanism: by the internal-node filter in the
refresh, and by the peer client for a peer's hosts. Every other way a host can
name something the gateway does not serve escaped: hybrid runtime Functions
grouping a node linked to a manifest App, unmanifested_nodes ignore,
allow_heuristic_apps false, a namespace blacklist, a manifest host that names
nothing.

One pass now runs after every filter and merge in refresh_cache.
keep_served_function_hosts keeps only the hosts that name an App or a
Component in the final served sets, and drops a Function left without hosts
only when runtime introspection is its only origin. The per-mechanism prunes
are gone: drop_filtered_function_hosts and the peer client's host filter.

The provenance moves off the plugin-facing Function type:
- Function::introspection_only is removed.
- MergeResult::introspection_only_function_ids holds the Function ids only
  the runtime layer returned. Any other layer's Function with the same id,
  a plugin's unchanged copy included, takes the id out.
- DiscoveryManager::introspection_only_function_ids returns that set in
  hybrid mode, every discovered id in runtime mode, none from a manifest.
- The refresh removes ids a peer contributes to and ids a plugin injects
  outside hybrid mode.

test_unmanifested_policy U11 and U12 told the allow_manifest_override
policies apart by the runtime id temp_sensor in Function powertrain. The node
is bound to a manifest App, so that id is not served and the pass removes it.
They now use rpm_sensor, an undeclared late node served under its runtime id,
after a barrier that the gateway serves it.

Tests:
- Unit: the pass keeps hosts that name a served App or Component, drops a
  Function only introspection created once no host is left, and keeps one
  with any other origin.
- test_hybrid_plugin_function_hosts runs three gateways. A (warn, blacklist,
  test plugin) covers linked, blacklisted, hidden-only and root namespaces,
  a manifest Function hosting nothing, a plugin Function and a plugin's copy
  of a runtime Function. B covers unmanifested_nodes ignore. C covers
  runtime_only with an injected plugin Function.
- test_aggregated_function_hosts adds a Function only the peer has: its
  detail is forwarded to the peer, its hosts route is answered locally.
- The test plugin can return an unchanged copy of an input Function.
The entity models cross the plugin boundary in IntrospectionInput and
NewEntities, and several gained fields while the version stayed 7: the asset
identity on Component, the external classification on App and Component, and
declared_source and available on the entity models. A plugin built before
those changes reads them with the old layout, and the loader accepted it.

PLUGIN_API_VERSION is now 8. The rule is written down: a change to the layout
of a type that crosses the plugin boundary bumps the version, next to the
existing vtable and interface rules. The plugin tutorial's stale "current
version is 5" now reads 8, and the v7 design note records the current version.
In-tree plugins return PLUGIN_API_VERSION and follow the bump.
discovery-options.rst states the rule: after every filter and merge, a
Function keeps only the hosts that name an App or Component the gateway
serves, and a Function only runtime discovery created goes once it has no
host. The hybrid exception paragraph is gone. The scope sentence keeps
forwarded Functions and the fan-out of a Function's resource collections to
contributing peers, which answer with their own view.

The filter paragraph now says what happens to peer Apps: an id that starts
with '_' is never aggregated whatever the setting, and with the setting on
the others are judged by id. The paragraph on the gateway's own node keeps
its claims true when the node is hidden.

manifest-schema.rst says that allow_manifest_override false widens a
Function's hosts only by runtime hosts the gateway serves. The aggregation
design doc says where peer Function hosts are reduced.
…mes before spinning

A demo node blocks SIGINT and SIGTERM and takes them on a sigwait thread,
which called executor.cancel() to end executor.spin(). Under load a signal
can arrive while the node is still being built. The signal thread then
consumes it and calls cancel() before spin() starts, and rclcpp drops a
cancel() made before spinning: spin() later sets the spinning flag again and
never returns. SIGTERM is not waited for any more, so launch escalated to
SIGKILL and the test's exit-code check failed. Seen on
test_graph_provider_plugin: SIGINT was sent 1.9 s before the brake pressure
sensor logged its start, and the node kept publishing until SIGKILL.

The signal thread now also sets a stop flag, and the main thread spins in
100 ms steps until that flag is set, so a stop request made before spinning
starts is kept.
The entity model layout changes behind the bump to 8 already shipped in a
release with API 7, and this branch changes no plugin-facing header, so the
version goes back to 7. The rule stays: a layout change of a type that
crosses the plugin boundary bumps the version. The tutorial now names the
current version.
… App

The peer client drops peer Apps whose id starts with '_', but kept the peer
Function hosts that name them. Such a host then bound to another entity
with the same id on the aggregator, for example a local '_dup' node that an
aggregator with filter_internal_nodes false serves. The peer client now
removes those hosts with the Apps.

The aggregated hosts test gets a second aggregator with the filter off. It
pins that the peer client drops '_' peer Apps whatever the aggregator's
setting, and that a dropped peer App id does not bind to a local App of the
same id.
…tion host cases

- An aggregated Function keeps a host that names a served Component.
- Under allow_manifest_override false the merged hosts are exactly the
  manifest hosts plus the served runtime host.
- Positive controls: the runtime_only gateway lists blocked_node and
  linked_node as hosts, and the warn gateway lists ignored_node.
- A manifest Function with the id runtime discovery derives from a namespace
  that holds only a hidden node stays served with no hosts.
- A hybrid gateway with allow_heuristic_apps false serves no visible_ns
  Function, because it serves no undeclared node.
…s before spinning

The ghost node injector spun its leftover node with executor.spin() on a
thread and stopped it with cancel() then join(). The node can be stopped
right after it is started, before spin() runs, and rclcpp drops a cancel()
made before spinning, so join() would wait forever. The thread now spins in
100 ms steps until a stop flag is set, as the demo nodes do.

The unresponsive parameter node's comment now describes the demo node
helper's spin_once loop.
…Apps

- manifest-schema: hosted_by is optional and takes App and Component ids. An
  id that names no manifest entity is rule R009, a warning that fails the
  load under the default strict validation. The logged message format is
  the real one.
- manifest-schema: a node bound to a manifest App joins a Function's hosts
  under its runtime id only when the App's id equals the node's name.
- discovery-options: a peer Function host that names a dropped '_' peer App
  is removed with it, and a peer with the filter off can send hidden nodes
  under namespace-qualified ids, which are aggregated.
- discovery-options: a declared App whose id does not start with '_' bound
  to a gateway node named '_...' is served and carries its parameters.
- aggregation design: /functions/{id}/data reads the served hosts and fans
  out to contributing peers, and the peer client drops hosts that name the
  peer Apps it drops.
- migration guide: the R009 message is the one the validator logs.
The end-of-file-fixer hook requires a final newline in every text file.
@bburda
bburda marked this pull request as ready for review October 15, 2026 20:35
…uments by reference

The six parameter service callbacks took their request and response
shared_ptrs by value, which clang-tidy reports as
performance-unnecessary-value-param. They now take const references, a
callback shape rclcpp accepts.
… gateway read the departure

test_06 published the late sample one second after the node left and started
asserting as soon as the test process graph listed every node as a
leftover. That graph can show the leftovers before the late sample goes
out: the announce message, sent earlier for the same participant, can be
applied there after the participant's removal. The check then ran while the
gateway had not yet refreshed after the departure and still listed the 50
nodes as running. When the check failed at once, tearDown stopped the
injector before its delay ran out, so no late sample was ever sent and the
gateway logged no leftover warning.

The case now does what the scale case does: the late sample waits for a
command, GET /apps must stop listing the nodes first, and the case waits for
the late sample to be published and acknowledged before it checks. The
leftover fixture gets wait_published() for that.
…truction

The forwarding, peer client and aggregation manager tests pointed their
unreachable peer at a fixed 127.0.0.1:59999 (and 59999 + i), and the OPC UA
connect test at localhost:49999. Both are inside the kernel's ephemeral
range, so any process, a test server bound to port 0 included, can hold
them. When something accepted there, a forward waited out
forward_timeout_ms (15 s), and the client in the handler-context wrapper
tests gave up first. With nothing there, the connect is refused and the
forward answers 502 at once.

test_support::RefusedPort binds a loopback socket to a kernel-chosen port
and never listens. A connect to it is refused at once, and no other socket
can bind the port while the object lives. The forwarding fixture holds one
per test, the peer client and aggregation manager tests hold theirs for the
life of the binary, and the OPC UA test holds one for its case.
@bburda

bburda commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator Author

Split into a stack of six PRs with one commit each, starting at #722. The stack carries the same changes plus three test fixes.

@bburda bburda closed this Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Gateway in a bridge-network container is unreachable from the host, and one gateway shows up as four nodes

1 participant