Skip to content

opcua: rescan while disconnected, heal PLC_COMMS_LOST on connect, and name a plugin freeze-frame's source - #660

Open
bburda wants to merge 25 commits into
mainfrom
fix/opcua-rescan-comms-lost-freeze-source
Open

bburda wants to merge 25 commits into
mainfrom
fix/opcua-rescan-comms-lost-freeze-source

Conversation

@bburda

@bburda bburda commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

In config-less discovery mode (OPCUA_DISCOVERY_ENABLED=1, subnets set, no
OPCUA_ENDPOINT_URL, no node map), a gateway that boots before its controller never
finds it. The start-up scan runs while the controller is still coming up, finds nothing,
falls back to opc.tcp://localhost:4840 and retries that endpoint until someone
restarts it. Three more things read wrong beside it: the gateway lists its three helper nodes
as apps, a plugin entity's freeze-frame names no origin, and a
PLC_COMMS_LOST raised before a restart stays CONFIRMED, because only the process that
raised it would clear it.

CI also showed the gateway listing a node after its process exited. The DDS RMWs can
re-create a removed participant from a late discovery message, and the graph then lists
the node, with no enclave and no endpoints, until the gateway stops. graph_watchdog reads
presence from the same list, so it can miss that node's death. The parameter_beacon and
topic_beacon configuration checks let NaN, infinite and out-of-range values through; they
are fixed here too.

What changes

Discovery and reconnect

  1. With no session up and no endpoint_url configured, the reconnect arm rescans on
    discovery.interval_s, adopts a server that appeared after start-up and resets the
    backoff. The cadence is measured from the end of a sweep, the backoff is capped at it
    so the documented interval is the real one, and an unchanged outcome reports at DEBUG.
    The plugin is bound to the ApplicationUri of the server a session reached: the start-up
    sweep and every rescan look for that server at any address and refuse every other, and a
    different server answering at the bound address is dropped at connect with the outage
    left standing. The binding is written to discovery.binding_file (default
    /var/lib/ros2_medkit/opcua/binding, env OPCUA_DISCOVERY_BINDING_FILE, empty
    disables) when it is established and read at start, so a restart keeps the identity;
    removing the file and restarting is the recommissioning gesture. The first adoption of a
    process with no file, a configured endpoint_url and a server that publishes no
    ApplicationUri are outside the binding. One file belongs to one plugin instance.
    The reconnect arm owns every reconnect: after each client iterate the session state is
    read, a session the library re-opened underneath is dropped and handed to the arm, which
    connects through its own path, runs the binding check and re-creates the alarm
    subscription. Every path that marks the client disconnected drops the session's
    subscription bookkeeping, so the re-created monitored item delivers again.
  2. Both sweeps take a cancel predicate that reads the node's own context. A rescan that
    throws is caught on the poll thread, logged at WARN, and still stamps the cadence.
  3. With no node map, the component identity is re-derived from the device once a session
    is up, before the session subscribes to events, so the provisional opcua-<host> name
    goes and the conditions the server replays land on the new entity. A name the device
    gave itself is never replaced by the fallback; an empty read is retried a bounded
    number of times per session (one read at connect, up to three more in the connect
    hook, then at most five on later polls). Conditions the poller already pinned under
    the old alarms entity are moved to the new one; the event path reads a copy of the
    alarm routing owned by the poller, refreshed from the poll thread.

Comms-lost clears and the pending buffer

  1. On every successful connect the plugin asks the fault manager who reported a standing
    PLC_COMMS_LOST and clears it only when every reporting source is an id this bridge
    reports under: the current component id and the opcua-<host> stand-ins it assigned
    in this process. A row another bridge holds is left standing. The decision runs on the
    poll thread, only while the link is up, with one fault-store read outstanding at a
    time; a read the store does not answer within fault_service_timeout_ms (new key,
    default 5000) is dropped and taken again, and an answer that arrives after the link
    dropped is held until the next connect. A link-state clear the bounded buffer could
    not keep is owed and decided again once the sink is ready.
  2. Link-state clears and the scoped operator clear set skip_correlation_auto_clear. A
    clear the device itself reports still cascades.
  3. The buffer used while fault_manager is unreachable keeps the PLC's order: a clear is
    coalesced onto a pending clear for the same code only when nothing was reported for
    that code in between, so report, clear, report, clear flushes in that order. A device
    alarm's inactive edge ages out like a report.

Gateway entity listing

  1. One predicate names the gateway's three helper nodes (<node>_sub,
    <node>_fault_clients, <node>_lifecycle_state_reader) for the peer count and the app
    filter, matched per exact suffix so a peer named <node>_monitor or <node>2 stays
    visible. The gateway's own node stays an App: its parameters are that App's
    configurations. _sub follows the gateway's namespace; the other two are created in the
    root namespace, so a helper-named node there counts as plumbing whichever gateway created
    it. While discovery.runtime.filter_internal_nodes is true the same predicate keeps the
    helpers out of /health's linking.orphan_count and out of the unmanifested_nodes
    warning; with the flag false they are served as Apps and counted there like any other
    undeclared node. A manifest App bound to a helper is dropped with one warning per
    distinct set. The peer count excludes _lifecycle_state_reader. Remote entities are
    untouched.

Freeze-frame source

  1. An entity frame carries x-medkit.source, plugin_data_provider or
    plugin_x_plc_data_route. topic and message_type stay empty, and the field is
    omitted when the capture named no path.

Docs

  1. Plugin README, docs/api/rest.rst, docs/tutorials/snapshots.rst,
    docs/config/discovery-options.rst.

Graph leftovers

  1. The node-list reader (ros2_common/graph_node_list.hpp, header-only) leaves out an
    entry without an enclave for a node it saw running and that has departed, when the
    graph resolves no publisher or subscriber for it. A node it never saw running is
    listed, so nodes behind a DDS router and micro-ROS nodes read as they do on main. A
    departed name is remembered until it runs again, or until a read finds no entry of it
    more than 10 s after the first read that found none. At most 1024 departed names are
    remembered. Discovery logs one warning when it starts leaving a node out. Discovery and
    the startup peer count share one reader; parameter_beacon has its own.
  2. A leftover also stays out of the Functions derived from namespaces and out of the
    bare-name collision check, so it cannot rename a live node that shares its name.

Discovery plugins

  1. parameter_beacon keeps one parameter client per target across cycles, on its own
    executor. A request that times out is removed from its client, so a node that never
    answers leaves nothing pending. The list and get waits share one deadline,
    param_timeout_sec. A get answer with no values (rclpy does this for a typed parameter
    without a value) stores no hint and causes no backoff. The gateway's own node and its
    helper nodes are not polled. A graph read with no targets drops every client.
  2. Duration keys of both beacons: NaN, -inf and values below the minimum become the
    minimum; values above 2147483647 s, +inf included, become 2147483647 s, because Fast
    DDS keeps the seconds of a wait in a signed 32-bit field. Each replacement is logged.
  3. topic_beacon max_messages_per_second is limited to 1..10000.
  4. max_hints is read as a 64-bit integer before it is narrowed: below 1 becomes 1,
    above 2147483647 becomes 2147483647, with a warning. The parameter parser reads an
    integer that does not fit in 64 bits as a double. A double, such as 1e12 or NaN, keeps
    the default of 10000.
  5. Docs: docs/config/server.rst (departure section), docs/config/discovery-options.rst,
    the READMEs of the gateway, parameter_beacon, topic_beacon and graph_watchdog, and the
    two beacon design pages.

Behaviour changes to know about

  1. interval_s unset means the 30 s default, an explicit 0 means discovery on with no
    rescan. The old README line was interval_s: 0, so a config copied from it gains no
    rescanning on upgrade. Drop the key for the default. A negative value is refused.
    This one is breaking.
  2. A per-entity DELETE /{entity}/faults/{code} served by this plugin now sets
    skip_correlation_auto_clear, as the ClearFault contract already promised.
  3. Three /api/v1/apps entries disappear with the default filter_internal_nodes: true:
    the gateway's helper nodes. The gateway's own node stays.
  4. A SIGTERM ends a start-up or rescan sweep within one probe.
  5. A PLC_COMMS_LOST that two bridges raised into one fault manager is not cleared by
    either link coming back; an operator clears it. The fault manager has no per-source
    de-assert today.
  6. New plugin config key fault_service_timeout_ms (default 5000, out of range refused
    with a warning). comms_lost_debounce_ms logs a warning when it clamps.
  7. New plugin config key discovery.binding_file. The identity survives a restart; a
    replaced PLC at the bound address is refused until the file is removed. In the shipped
    image the directory exists but persists across a re-created container only when mounted.
  8. A PLC swapped or rebooted under a config-less session with native alarms is now
    noticed: the outage is reported and the alarm subscription is re-created on the bound
    server when it returns. Before, the client library re-opened the session silently and
    device alarms stayed dead until a restart.
  9. GET /apps stops listing a node the gateway saw running once only a leftover of it
    remains, and the gateway logs one warning for it.
  10. topic_beacon max_messages_per_second above 10000 is lowered to 10000 with a warning.
    A config with a higher rate, or with .inf to remove the limit, now gets 10000 per
    second.
  11. parameter_beacon no longer polls the gateway's own node and helper nodes.
  12. Out-of-range beacon values are replaced as described in "What changes", items 13-15,
    with a warning. On main a
    NaN passed the topic_beacon checks: it then dropped every beacon or kept hints forever.

Testing

  1. Jazzy, clean tree: the opcua unit suite (405 tests) and the gateway unit suite pass with 0 failures, and the full integration package passes 1285 tests with 0 failures; the four
    contract tests that failed on every distro (test_configuration_api,
    test_locking_disabled_contract, test_relay_peer_credential,
    test_unmanifested_policy) pass, with three new feature tests beside them
    (test_own_node_apps, test_own_node_apps_namespaced, test_own_node_undeclared)
    that fail when the helper filter or the own-node rule is broken on purpose.
    test_operation_handlers pins the stop response on the contract (goal CANCELING or
    CANCELED), 10 of 10. The alarm-server test fixture runs every
    UA_Server_* call on the server's own thread, as UA_MULTITHREADING 0 requires, and
    the identity tests tear their executor down on every exit path.
  2. Plugin e2e against the fixture server with stub fault services: a fault raised under
    the stand-in heals after the device names itself; a row two bridges hold is left
    standing; no decision is taken while the link is down; a probe the store never answers
    is dropped and taken again; an answer to a timed-out probe is not read as the next
    one's; a link-state clear the buffer refused is sent last once the sink is ready; the
    connect hook runs before the event routing is copied; a rename moves the conditions
    already pinned. Unit tests pin the gate predicate, the decision predicates, the buffer
    order (a clear never merges past a report for the same code), the identity rule, the read
    budget and the burst cadence, the guarded rescan, the node-context cancel, and the
    linker skip in both flag positions. Each new test was made to fail first. The binding
    file has unit tests for its format (one line, BOM, control characters, CRLF, a second
    line, a symlinked path, an unwritable parent) and e2e tests against the fixture: a
    restarted plugin keeps the binding and refuses a foreign server at the bound address, a
    configured endpoint_url ignores the file, the start-up sweep selects the bound server
    and not the lowest address, a swap and an outage under a config-less session are caught,
    and a device alarm is delivered after the bound server reboots.
    test_openapi_contract sweeps every entity, requires a built item for every listed
    resource, checks the item each one names, and waits for calibration's operation through
    the shared class wait.
  3. Docker: run_integration_tests.sh Total: 18 / Passed: 18, run_alarm_tests.sh
    passed, run_discovery_race_test.sh passed. That race scenario fails on the merge
    base at step 6 of 9, the adoption assertion (endpoint still 'opc.tcp://localhost:4840' (connected='False') after 50s), and passes on the branch
    through step 9 of 9, the config-less rename, which now also asserts the stand-in
    component left /components.
  4. Scoped clear live: PLC_TANK_HIGH still CONFIRMED after PLC_COMMS_LOST was cleared
    through the per-entity route. The control with the flag off cascades.
  5. SIGTERM 14 s into a start-up sweep: container gone in 506 ms, against 98 s
    uncancelled.
  6. TSan is verified by CI only: a sanitizer build of this package alone links against an
    uninstrumented rclcpp and is not usable on the development machine.
  7. Graph leftovers are reproduced end to end. A fixture node runs in an rclcpp context of
    its own; its participant is removed, and its captured discovery message is published
    again afterwards, as the late message does. Every case first checks that the test's own
    graph lists the leftover. test_graph_leftover_nodes, test_graph_leftover_nodes_scale
    (past the 1024 cap) and test_graph_leftover_nodes_startup check discovery, the startup
    count and parameter_beacon. graph_watchdog's ghost_departure e2e checks that the
    node's death is still raised. Each rule element was mutated and turned a test red.
  8. test_param_beacon_out_of_range_config and test_topic_beacon_config_bounds sweep
    NaN, +inf, -inf, below the minimum, the minimum, the maximum, above it and 3e9 on a real
    gateway, plus max_hints 0, -5, 1, 2147483647, 2147483648, 2^32 + 1, 1e12 and NaN. A
    beacon with NaN TTL and expiry is observed going stale and then removed.
  9. Jazzy with Fast DDS on the final tree: gateway 4342 tests, integration 1285,
    parameter_beacon 44, topic_beacon 30, all with 0 failures. The changed tests also pass
    on Cyclone DDS.

Not in this PR

  1. A docker scenario that restarts the gateway with a standing PLC_COMMS_LOST. That
    case has plugin-level e2e coverage with stub fault services.
  2. An empty source_id on PLC_COMMS_LOST in the /api/v1/faults list view while the
    fault manager logged a source. Unverified, filed on its own if it holds.
  3. gateway_node exiting 139 on shutdown with the plugin loaded. It reproduces on main,
    see [BUG] gateway_node segfaults on shutdown when the OPC UA plugin is loaded #659.
  4. A per-source de-assert in the fault manager, which is what would let two bridges heal
    one shared PLC_COMMS_LOST row.
  5. A node restarted while a leftover of its previous instance is still listed can show no
    services, and so no operations, because the graph answers per-node queries from
    whichever of the two participants sorts first. docs/config/server.rst states it.

Issue

Type

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

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

Comment thread src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp
Comment thread src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp Outdated
Comment thread src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp Outdated
Comment thread src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp
Comment thread src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp Outdated
Comment thread src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp Outdated
Comment thread src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp
Comment thread src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp Outdated
Comment thread src/ros2_medkit_gateway/src/discovery/manifest/runtime_linker.cpp Outdated
The plugin decided two things once, at startup, and never revised them
while it ran. Both now recover after a bad start.

Rescan while no session is up. Config-less discovery ran one scan a few
seconds after start. A gateway that boots together with its PLC scans
while the PLC is still coming up. It finds nothing, falls back to
opc.tcp://localhost:4840 and retries that endpoint until someone
restarts it. The reconnect arm now asks the plugin for a fresh scan. It
adopts a newly found server for its next connect attempt and resets the
backoff, so the new endpoint is tried at once. The rules that make
discovery safe are unchanged. An explicitly configured endpoint_url still
wins and is never rescanned. The scan stays a bounded read-only TCP
sweep plus GetEndpoints. Nothing is scanned while a session is up.

Cadence. discovery.interval_s sets the rescan cadence, 30 s by default.
The cadence is stamped when a sweep ends, so a sweep of a /16, which
takes minutes at the defaults, does not make the next one due at once.
While discovery rescans, the reconnect backoff is capped at the cadence,
so the real cadence is interval_s. An unset interval_s takes the
default. An explicit 0 keeps discovery on with a one-shot start-up
scan. A negative value is refused with a warning. The warning and the
start-up line describe the cadence that applies.
NetworkDiscovery::run() takes a cancel predicate, bound to the shutdown
flag. It is checked before each probe and between the sweep and the
identify phase, so shutdown() does not wait out a sweep.

Discovery report. A pass whose outcome matches the previous pass
reports at DEBUG. The first pass and every changed outcome report at
INFO or WARN. A site with a secured-only server otherwise logs the same
WARN every 30 s.

Component identity. With no node map, the SOVD component is named from
the device. When the start-up connect fails there is no device to ask,
so the name comes from the fallback endpoint and an empty DeviceInfo.
The identity is now derived again on the first poll of a new session,
in config-less mode only. An explicit node map still owns the name. The
derived alarms entity follows the rename, and the rename is logged at
INFO.

Clear PLC_COMMS_LOST on every successful connect. The fault manager
keys faults by fault code and persists them. A fault raised before a
gateway restart stays in the store, and the new process has no memory
of raising it. The initial connect and every reconnect now send the
clear, whatever this process raised. The clear is fire and forget, so
a clear for a fault that is not there costs nothing. The debounce that
governs raising is unchanged. Both clears set
skip_correlation_auto_clear. They report the link state, so they must
not cascade-clear the symptom faults that a rule attributes to
PLC_COMMS_LOST.

Pending buffer. While the fault manager is unmatched, the buffer keeps
at most one pending clear per fault code. When it is full, it evicts a
clear before a report. A clear that meets a buffer full of reports is
refused, and the reports stay. A flapping link otherwise pushes real
alarm reports out of the bounded buffer.

Tests. Unit tests drive the discovery pass and the endpoint adoption
rule with injected probes. They include the positive control that a
configured endpoint refuses the server an unconfigured one accepts. The
comms-lost heal runs against the live test server, because only a
connect that succeeds reaches that arm. A docker scenario starts the
gateway first, with discovery on and no endpoint configured. It asserts
that the gateway settled on the fallback endpoint with no session. It
then starts an OPC UA server on the same subnet and asserts that the
endpoint is adopted within two rescan intervals. It asserts that the
container never restarted, because a restart would pass the endpoint
check. A config-less pass asserts the rename against a real server. The
network has an explicit /24, so the sweep covers 254 hosts and ends in
seconds. The README documents the rescan, the cadence rules and the
scenario.
A freeze-frame captured for a plugin-backed entity reaches a client
with an empty topic and an empty message_type. Both are correct. The
values are the plugin's live entity data, and no ROS message carries
them. But no field names the origin of the snapshot. Two such frames
from different bridges look the same. A frame also looks like a topic
capture that lost its metadata.

Frame now carries the capture path that read it, and the gateway
serves it as x-medkit.source. The value is plugin_data_provider for a
read through the owning plugin's DataProvider. It is
plugin_x_plc_data_route for the in-process dispatch of the plugin's own
x-plc-data route, which bridges without a DataProvider use. topic and
message_type stay empty, because neither names a ROS topic here.

When the capture names no path, the key is absent from the payload. A
fault manager freeze-frame taken from a real topic is unchanged and
carries its topic and message_type as before. The snapshots tutorial
describes the field.
The gateway runs four nodes in its own process, all named after itself:
the gateway node, "<node>_sub" for the subscription executor,
"<node>_fault_clients" for the fault-service transport, and
"<node>_lifecycle_state_reader" for the lifecycle reader. None of them
starts with an underscore, so the ROS 2 hidden-node convention does not
cover them. Runtime introspection returned all four as ordinary Apps.
An operator who browsed /api/v1/apps saw four entries that answer
nothing useful.

count_peer_nodes knew the gateway's own FQN plus "_sub" and
"_fault_clients", but not the lifecycle reader. The app filter knew
only the underscore rule, so it dropped none of the four. Both now use
one predicate, is_own_gateway_node, so a fifth helper is declared in
one place.

The match is exact for each suffix. A real peer named "<node>_monitor"
or "<node>2" stays visible, because hiding a real node is the worse
error. A fault_manager in the same process is not ours and stays
listed. Remote entities are left alone. A peer's helper nodes carry the
same fully qualified names, and the peer's own filter handles them.
The route and DataProvider loss-of-comms tests now each assert the
Frame::source constant that their own capture path must produce. They
also assert the literal wire value. A comparison of symbol against
symbol stays equal when the two constants swap values, and the string
is what every consumer of x-medkit.source reads.

The merge test that omits the key stays. It covers the helper contract
for a frame that a caller built without naming a path. The capture
paths always name one, and the test comment says so.

The peer-node count test lists all three helper nodes that the gateway
creates in its own process, the lifecycle reader included. A lone
gateway with a lifecycle reader must count zero peers and give the
empty-graph warning.

The REST fault snapshot reference documents the source field of an
entity frame. An entity frame carries no topic or message type, so
source is the only provenance a consumer gets.
…uffer

Scoped clear. The per-entity route DELETE /{entity}/faults/{code} calls
FaultProvider::clear_fault when a plugin owns the entity. On its own
clear path the gateway sets skip_correlation_auto_clear. An operator
scoped to one entity must not cascade-clear correlated symptoms that
apps in other entities reported, and the ClearFault contract documents
that guarantee. This plugin sent the flag as false, so the guarantee
failed wherever a PLC was involved. The plugin now sets the flag. A
ClearOrigin travels with every clear and says why:
- the device reported the condition inactive. This is a real
  resolution, so the cascade stays.
- the link came back.
- an operator cleared the fault through the scoped route.
The poller's own clear and a device alarm use the same callback.
clear_origin_for_signal tells them apart by an exact fault-code match.

Pending buffer. The buffer gave up any clear before any report. Only the
link-state clear can be derived again, because the next reconnect sends
it again. A device alarm's inactive edge is as one-shot as its raise.
When the buffer evicted it, the flush replayed the raise alone, and the
fault stood while the device said inactive. A full buffer now gives up
the link-state clear first. Everything else ages out oldest-first.

Sweep cancellation. The start-up sweep runs inside set_context(),
during node construction, before the gateway's executor spins. Nothing
can set the shutdown flag then, so a SIGTERM during a wide sweep waited
for the sweep to finish. Both sweeps now ask discovery_cancelled(). It
reads rclcpp::ok() next to the shutdown flag and applies the rule in
discovery_cancelled_for. rclcpp's own signal handler turns rclcpp::ok()
false, so the start-up sweep ends on the signal. The rescan still ends
on shutdown(). The README says what ends each sweep.

A rescan sweep that throws now stamps the cadence on its way out, so
the next poll iteration does not start another sweep at once. The
"scanning [subnets]" line is logged before the sweep, at INFO on the
first pass and at DEBUG on a rescan. A long sweep with no output looks
like a hung process. The comment on the start-up scan gives the right
reason why its report comes out in full.

Tests. The two clear-origin sites that need a live session run against
the test_alarm_server fixture, with stub fault-manager services on the
ROS graph, so the tests read the flag off the wire. One test requires a
successful connect to clear PLC_COMMS_LOST without a cascade. A second
fires and clears a condition through the fixture's CLI and requires
that clear to keep the cascade, next to the connect-time clear of the
same run. The fixture harness gained a stdin pipe for these commands.
clear_origin_for_signal and discovery_cancelled_for are tested on both
branches. A test shuts a private rclcpp context down and reads
rclcpp::ok() back. The comment on the cancellation test states what it
exercises.

Prose semicolons become periods and commas in plugin comments,
operator-visible log strings, the README, the docker scenario script,
rest.rst, gateway_node.hpp and test comments. The doc comment of the
Refused outcome says what it means.
The app filter now removes only the helper nodes that the gateway runs
in its own process: "<gateway>_sub", "<gateway>_fault_clients" and
"<gateway>_lifecycle_state_reader". The gateway's own node stays an
App. SOVD serves its ROS parameters as that App's configurations, and
two gateways that watch one graph have to agree on what is on it. The
predicate becomes is_own_gateway_helper_node, and the peer count still
skips the gateway's own node.

A feature test launches a gateway with its real process node names. The
suite's usual launch remaps all four nodes to one name. The test pins
both halves: the gateway is listed, addressable and configurable, and
each helper is on the graph and absent from the app list.
…ning goal

An accepted stop leaves the goal CANCELING or CANCELED. Which one a
caller sees depends on how fast the server winds down. The body renders
the status that the handler read. That status is at or before the one
the test reads afterwards. A goal still CANCELING therefore pins the
body exactly, and a goal already CANCELED allows either rendering. The
test still rules out a goal that keeps running or completes.

The fixture server succeeds at the requested sequence length, one
element per 100 ms tick, so the order sets how long the goal stays
cancellable. At 20 the goal finishes on its own in under two seconds.
A loaded or instrumented runner can spend that time on the create and
the cancel round trip, and the assertions then describe a completed
goal. The test uses 50, the largest order the server accepts.

The tracked-goal helper returns its optional, and the caller asserts,
so a missing goal gives a readable failure. Dereferencing it after a
non-fatal expectation would be undefined behaviour.
The e2e tests that drive the plugin against a live fault-manager stub
spin a MultiThreadedExecutor on a thread of their own. A gtest ASSERT_*
returns from the middle of the test body. A std::thread destroyed
while it is still joinable calls std::terminate. The run then ends in
SIGABRT, the assertion message never reaches the report, and the
alarm-server child outlives the test and holds its port.

An RAII guard now owns the thread. stop() cancels the executor and then
joins the thread. A cancel issued before spin() has begun is refused
and lost, so the guard issues the cancel again until the spin function
reports that it returned, and gives up with a message after 10 s. A
cancel that throws is contained and issued
again, and the join runs whatever the cancel did. stop() is idempotent.
It sets its flag after the join, so a failed attempt can run again. The
destructor catches what cancel() or join() throw and prints it,
because an exception that escapes a destructor also ends in
std::terminate. The test has recorded its verdict by then.

The guard takes the cancel as a callable that defaults to the
executor's own. rclcpp::Executor::cancel() is virtual on Jazzy and
later but not on Humble. There, a derived executor's cancel() does not
compile with override, and a call through a base reference would not
reach it. A test passes a cancel that throws after it stops the spin,
and the test reaches the end of its body.
The fixture's open62541 is built with UA_MULTITHREADING 0, so only one
thread may use the server. The command handler ran on a second thread.
It wrote condition fields and triggered condition events there while
UA_Server_run iterated on the main thread. The mutex it held was taken
nowhere else, so it protected nothing against the server.

The reader thread now only queues command lines. The main thread drives
the server with UA_Server_run_startup, run_iterate and run_shutdown,
and it runs each queued command between iterations. The address space
and the Alarms & Conditions subsystem have a single user. The reader
ends on EOF. The harness produces it by closing its write end of the
pipe before it signals the server. The quit branch does not close the
process's own stdin, because close(2) does not unblock a reader that
already waits in read(2).

Startup and shutdown each report their own bad status on exit. The
return value of run_iterate is how long the server may idle, so it
bounds the pause between iterations.
The three helper FQNs are not built the same way. The subscription
executor passes the gateway's own namespace, so its node follows the
gateway. The fault-client and lifecycle-reader nodes are built from the
gateway's node name alone and take the process default namespace. A
remap that names only the gateway, "-r <gateway>:__ns:=/x", moves the
gateway and the subscription node and leaves the other two behind. The
gateway then served both of them as diagnosable apps and counted them
as peers.

The predicate derives each helper's FQN from its own creation site. It
matches both spellings for the two nodes that do not follow the
namespace.

A declared app that is bound to a helper node is dropped with a
warning that names the app and the node. An app is declared when its
source is the manifest, the inventory or a plugin.
…ared

The undeclared-node warning on GET /health is an instruction: "Declare
them in the manifest". It named the three nodes that the gateway runs
in its own process. A manifest app declared for one of them is then
removed by the app filter, so the two halves contradicted each other.

The orphan scan in RuntimeLinker skips the helper nodes with the same
predicate that the app filter uses. A linker built without a node has
an empty self FQN, which matches nothing. A feature test drives a
hybrid gateway with "unmanifested_nodes: error" and pins both halves:
the report names an undeclared node of the test's own and none of the
helpers.
The gateway creates its helper nodes at three points of start-up. Two
of them come after the first refresh and after /health starts to
answer. A settle gate that waits for the gateway's own node can
therefore read a list built from a graph with no helper at all. The
absence assertions then pass whatever the filter does.

The settle sequence now proves its own order. First all three helpers
are on the graph. Then the test creates a witness node. Then it reads
an /apps snapshot that lists the witness, so that snapshot came from a
graph that held the helpers too. The three stage budgets add up to well
inside the file's ctest timeout, so a failure reports its assertion
before ctest kills the test.

The config reference documents the rule. It lists the filtered nodes
and says why the gateway's own node stays an App. It explains the
namespaces of the helper nodes and what the switch exposes again when
it is off. It says that the helpers stay out of the unmanifested_nodes
report, and that a declared App bound to one is dropped with a warning.
An app declared against one of the gateway's helper nodes is a static
misconfiguration. refresh_cache() runs on every graph event and on the
backstop cadence, which the integration fixtures set to one second, so
the warning repeated for the life of the process. It now fires only
when the set of offending apps changes, through
remember_dropped_declared_apps. The entity-cache warning in the same
function fires once per run. This one also fires again when the set
changes.

The predicate's doc comment and the config reference describe what the
root-namespace spelling costs. A helper-named node at the root counts
as plumbing whichever gateway created it. Two gateways that keep the
default node name and differ only in namespace build the same fully
qualified name for it.

The comment in the /health handler states that the orphan list
excludes the helper nodes. Each app-filter declaration sits under its
own doc block, so doxygen attaches every block to the right function.
…ateway

The three helper node names take two shapes. A remap that names only
the gateway moves the gateway and the subscription node into a
namespace. The fault-client and lifecycle-reader nodes stay at the
root. The predicate builds that second shape from the gateway's name.

A new feature test launches exactly that invocation. It asserts that
the split is on the graph. It pins the app list to the gateway plus the
test's own two nodes.
…p filter is on

RuntimeLinker takes discovery.runtime.filter_internal_nodes. While it
is true, the gateway's own helper nodes stay out of orphan_nodes, which
feeds linking.orphan_count and the unmanifested_nodes warning on
/health. While it is false, the helpers are served as Apps and counted
like any other undeclared node. discovery-options.rst and the comment
in the health handler describe this coupling. A unit test links the
same runtime apps with the setting on and off and checks a helper node
against a real unmanifested node.

Comments in fault_handlers.cpp and in six gateway and integration-test
files are reworded to state what the code does.
…ep the PLC's order

Comms-lost clear. The plugin decides the PLC_COMMS_LOST clear on the
poll thread, and only while the link is up. It asks the fault manager
who reported the standing row. It clears the row only when every source
is an id that this bridge reports under: the current component id,
the stand-ins it assigned and the NodeMap default. A row that two
bridges hold stays for an operator.
- One fault-store read is outstanding at a time. It is stamped with
  the probe and the connection generation.
- A read that the store does not answer within
  fault_service_timeout_ms is dropped and taken again.
- An answer from before a link drop is discarded, and the decision is
  owed again.
- A link-state clear that the bounded buffer could not keep is owed. It
  is decided once the sink is ready.

Order. The pending buffer merges a clear into an earlier pending clear
for the same code only when nothing was reported for that code in
between. So report, clear, report, clear flushes in that order.

Identity in config-less mode:
- The connect hook derives the component name from the device again
  before the session subscribes, so replayed conditions land on the new
  entity.
- A nameplate replaces a stand-in. Nothing replaces a nameplate.
- An empty read renames a stand-in only when the endpoint changed.
- Reads are budgeted per session.
- Conditions that the poller already pinned move to the new entity.
- The event path reads a copy of the alarm routing that the poller
  owns.

Binding. The plugin remembers the ApplicationUri of the server a
session reached. A rescan while disconnected looks for that server at
any address and refuses every other server. A different server at the
bound address is dropped at connect. The first adoption, a configured
endpoint_url and a server without an ApplicationUri have no binding
constraint. The README says so.

A rescan that throws is caught on the poll thread. The cancel predicate
reads the node's own context.

Tests. End-to-end tests against the fixture cover:
- a row that two bridges hold, left standing
- an owed clear, sent after the buffer drains
- no decision while the link is down
- an unanswered probe, dropped and taken again
- a stale answer after a probe timeout and after an outage
- a fault raised under a stand-in, healed once the device names itself
- the binding across an address change and a swap
- the connect hook, run before the event routing is copied
Unit tests cover the clear gate, the probe conditions, the identity
rules and read budget, the buffer order, bound selection, the guarded
rescan, the context-aware cancel, and the poller's routing copy and
repin. The fixture takes --app-uri, so two fixtures can stand for two
PLCs.
…e restart checks

The config-less pass asserts that the stand-in component left /components
in the same sample that shows the renamed one. The containers run with no
restart policy, so a gateway that dies is reported by the endpoint and
component assertions; the RestartCount checks are gone. The deadline
follows RESCAN_INTERVAL_S like its siblings.
…, and own every reconnect

Binding file. When the binding is established, the plugin writes the
bound ApplicationUri into discovery.binding_file. The default path is
/var/lib/ros2_medkit/opcua/binding. OPCUA_DISCOVERY_BINDING_FILE sets
it, and an empty value turns it off. configure() reads the file, so a
restart keeps the identity. The start-up sweep and every rescan then
select only the bound server, at any address. A different server at the
bound address is dropped at connect, and the outage stays standing. The
refusal names the file as the way to recommission the box. A configured
endpoint_url neither reads nor writes the file. A server that publishes
no ApplicationUri is never persisted.

The file is written to a temporary file, fsynced and renamed over the
resolved target. It holds one line. On read, a UTF-8 BOM and the blanks
around the URI are stripped, and anything after the first line is
ignored. A control character in the first line is refused with a
warning and reads as no binding. A URI with a line break is refused at
the write.

Reconnects. The reconnect arm owns every reconnect. After each client
iterate the client reads its session state. A session that the library
opened again on its own is dropped and handed to the arm. The arm
connects through its own path, runs the binding check and creates the
alarm subscription again. Every path that marks the client disconnected
drops the subscription bookkeeping of the dead session. The new
monitored item therefore delivers events again after a PLC reboot.

Tests. Unit tests cover the file format and the write contract.
End-to-end tests against the fixture cover:
- a restarted plugin that refuses a foreign server at the bound address
- a configured endpoint_url that ignores the file
- the start-up sweep that picks the bound server over a lower address
- a swap and an outage under a config-less session
- a device alarm delivered after the bound server reboots
The ctest timeout of the identity E2E is twice the idle pace plus one
stuck test's wait budget, net of its passing time. The registration
shows the arithmetic.
…check each built item

test_a_scoped_item_says_what_its_templated_sibling_says compares every entity
in both listings, counts built items per entity type, collection and scope,
requires a built item for every listed resource, and checks that the item at
each key names that resource. test_every_advertised_collection_is_served
follows every app's hrefs and requires a cache-built href per entity type.
The class waits for calibration's operation through REQUIRED_OPERATIONS and
polls temp_sensor's data with the discovery budget before comparing, so the
counters do not depend on how fast the runner propagates a service.
…dentity check covers

The binding section of the README covers:
- the path and the format of the file
- the rule that only discovery uses it
- how to clear it
- the start-up sweep and the rescan behaviour
- who owns a reconnect, and the cases the end-to-end tests cover
- the probing cadence at a refused address
- the last eight refused URIs
- one file per plugin instance
- the mount that a re-created container needs
- the configuration and environment rows
… the plugin's own threads

The ReportFault, ClearFault and GetFault clients live in a callback group
that is not added to an executor together with the node. A single-threaded
executor owned by the plugin serves that group from a thread the plugin
starts in set_context. shutdown stops the poller, stops and joins that
thread, and then drops all three clients on the calling thread, so a client
destructor never runs on a gateway executor thread while another plugin or
the gateway creates an entity on the same node. The GetFault answer is
parked from the plugin's thread under the probe state's mutex, as before.

The client thread also ends once the node's context has shut down.
set_context is documented as called once per plugin instance.

A new end-to-end case builds two plugin instances in sequence on one node
while the gateway's executor spins, and checks that the first shutdown
joins the client thread and that the second instance's clients reach the
fault services.
EntityDetailStatusLinkTest and LifecycleHandlersWithProviderTest initialise
rclcpp when it is not running, and their TearDownTestSuite now shuts it down
the way LifecycleHandlersTest does. The test process exits with no rclcpp
context left initialised, so static destruction does not run alongside live
middleware threads.
…and name the test stubs' parameters

The GetFault answer and the fault_service_timeout_ms config value are read
through const references, and the persisted-binding and rescan results are
returned from non-const locals so they are moved out. The severity band
order check is written as the direct comparison of the integer bands.

The GetFault response callback keeps its future by value, because rclcpp
offers the response-callback overload of async_send_request only to a
callable taking exactly SharedFuture; the check is suppressed at that line
with the reason next to it.

In the identity tests, the stub definitions name every parameter, the child
process fixture and the rclcpp scope guard are neither copyable nor movable,
and the fault store stub's service callbacks take their arguments by const
reference.
…ning

The DDS RMWs take a participant's nodes from the ros_discovery_info topic
on a listener thread, and its enclave from participant discovery. When the
listener thread takes a discovery sample only after its participant was
removed, the sample re-creates the participant entry and nothing removes it
again: the graph keeps listing the node, with an empty enclave and none of
its endpoints, for as long as the gateway runs.

An empty enclave alone does not identify such a leftover, because nodes
behind a DDS router and nodes whose participant was created outside rcl
read the same way. The node-list reader leaves an entry out only for a
name it saw running and that has departed, when no other entry of that
name has an enclave and the graph resolves no publisher or subscriber for
the node. Names it never saw running are listed.

The names running on the previous read are that read's own list and cost
nothing. A name that ran on the previous read and does not run now has
departed. The reader forgets a departed name when it runs again, or on the
first read that finds no entry of it more than 10 s after the first read
that found none; while a leftover of it is listed, that time does not
start. A late sample that arrives before the read that forgets the name
stays hidden, however late it comes. The 10 s cover the listener thread's
delay in taking a sample it already holds. The reader remembers at most
1024 departed names; past that it forgets the names no entry has listed
for longest first, then the names that departed longest ago.

Endpoints are read with the publisher and subscriber queries without
demangling, which on these RMWs also return service and client endpoints.
Only rclcpp's error for a node the graph no longer lists counts as no
endpoints; any other error, such as the one a shut-down context raises,
reaches the caller.

Discovery owns one reader and the startup peer count reads through it, so
both count the same nodes. Discovery logs a warning when it starts leaving
a node out. The check for the gateway's in-process helper nodes moves into
the same header-only file, so the discovery plugins can use it.

parameter_beacon reads the graph through a reader of its own, and skips
the gateway's own node and its helper nodes, which carry no beacon. It
keeps one parameter client per poll target across cycles: an rclcpp client
for list_parameters and one for get_parameters, spun on the parameter
client's own executor for the length of a call. Every call gives up after
param_timeout_sec and removes the unanswered request from its client, so a
node whose parameter services never answer neither stalls polling nor
leaves requests pending. The waits for the list and get services share one
deadline, and each wait gets the time left, at least zero: rclcpp waits
forever when it is given a negative time. A timeout puts the node on
backoff and any answer clears it, including a get answer without values:
rclpy answers so when a typed parameter has no value, and the plugin then
stores no hint. A node that is no longer a target, also when no node is,
loses its client and its backoff. Only the poll thread spins the plugin's
node. The poll thread destroys the clients, and so does shutdown() after
it has joined that thread. The plugin's node joins rclcpp's graph listener
when it is created: a first join after rclcpp shuts down fails half-way,
and the node's destructor then terminates the process. A try/catch around
reset() cannot stop that, because reset() and the destructors it runs are
noexcept, so both beacons drop the try/catch they had there in shutdown().
parameter_beacon's destructor still catches what shutdown() throws,
because locking clients_mutex_ can throw std::system_error. topic_beacon's
shutdown() throws nothing, and its destructor calls it directly.

Every duration parameter_beacon reads takes its minimum up to 2147483647 s.
Fast DDS keeps the seconds of a wait in a signed 32-bit field, and a longer
param_timeout_sec makes the poll thread spin. A larger value, +inf
included, becomes the maximum; NaN, -inf and a smaller value become the
minimum. Each replacement is logged with the value that replaced it.
topic_beacon applies the same rules to beacon_ttl_sec and beacon_expiry_sec
(up to 2147483647 s) and to max_messages_per_second (1 to 10000), so NaN
no longer drops every beacon or keeps hints forever, and +inf no longer
removes the rate limit.

Both beacons read max_hints as a 64-bit integer before they narrow it. A
64-bit integer below 1 becomes 1, and one above 2147483647 becomes
2147483647, each with a warning, so a value such as 4294967297 no longer
wraps to a capacity of 1. The parameter parser reads an integer that does
not fit in 64 bits as a double. A double, such as 1e12 or NaN, is refused
with a warning and the default of 10000 stays.
@bburda
bburda force-pushed the fix/opcua-rescan-comms-lost-freeze-source branch from 966de76 to 7bb8958 Compare September 18, 2026 18:09
…der exposes

ghost_node_injector gains --leftover: it runs a node in an rclcpp context
of its own, captures the discovery message that node's participant
publishes, removes the participant on request and, after a given delay or
on a publish command, publishes the captured message again, the way a late
sample re-creates the node. The participant GID is derived from the node's
publisher GID and has to match a received discovery message before the
node is reported ready. --announce adds more nodes to that participant's
message. --ghost and --backed GIDs now sort before those of real
participants, and their messages go out one per node in command-line
order.

test_graph_leftover_nodes checks discovery on one gateway: the leftover of
a listed node is hidden when its late sample arrives inside the hold;
nodes never seen running without an enclave, next to a ghost of their
name, and a running node without endpoints are listed; a restarted node is
listed once; a leftover does not rename a live node sharing its bare name;
a node seen running that stays behind an endpoint is listed; 50 leftovers
leave live nodes and a node started afterwards listed. The endpoint case
stops the node only once GET /apps lists a ghost sent after the backed
entry of its name, so the gateway's graph holds that entry when the node
exits. The last case checks that leftovers the earlier cases hid are still
hidden once they have been listed for longer than the hold, and that a
late sample for a node that departed with no entry of it left is listed
once a refresh ran past the hold. Every case first checks that the test's
own graph lists the leftover, and cases that rely on the gateway's graph
holding it check the gateway's warning.

test_graph_leftover_nodes_scale announces more running nodes than
discovery remembers and checks that a node departing next to them is still
hidden. It then leaves all of them over and checks how many stay hidden,
that a node departing with no entry of it left is the first name forgotten
past the capacity, and that a new node is listed.

test_graph_leftover_nodes_startup checks the startup peer count, and that
parameter_beacon polls a node it never saw running and does not poll the
leftover of a node it polled, after checking that the plugin's node shares
the gateway's participant and that discovery warned about the leftover.

test_param_beacon_out_of_range_config loads parameter_beacon once per
sweep point: NaN, -inf, below the minimum, the minimum, the maximum, just
above it, +inf and 3e9, plus one instance with a working configuration. It
checks every logged clamp and that in-range values log none. Instances at
the low points keep polling past nodes that never answer; instances whose
timeout sits at the high points send one request to such a node and wait
there, and no gateway thread uses more than half a core meanwhile;
instances whose interval sits at the high points run at most one cycle.
The working instance polls an rclpy node whose typed, unset parameter
makes its get answers carry no values on every cycle, backs off a node
whose get requests go unanswered, and keeps the request writer of its
client for a node that never answers across cycles. The test counts a
list answer when it is sent and a get request when a polling thread takes
it, so it compares the two only on samples where every list answer has its
get counted, and fails when the two counts do not settle to the same
number.

test_topic_beacon_config_bounds sweeps the same points over beacon_ttl_sec,
beacon_expiry_sec and max_messages_per_second, checks every logged clamp,
that each instance stores a beacon, and that a burst of ten is cut to one
or two at 1 per second and passes whole at 10000 per second.

Both tests load one more instance per max_hints point: 0, -5, 1,
2147483647, 2147483648, 2^32 + 1, 1e12 and NaN. They check each warning,
that the instances whose max_hints ends at 1 keep one of two hints and are
the only ones that reach capacity, and that the others keep both. The
instance each test loads first answers the beacon endpoint and has NaN TTL
and expiry. The tests give it one beacon for a node the gateway lists,
stop refreshing it and check that the endpoint reports it stale and then
removes it. topic_beacon's test checks that the removal comes no earlier
than 1 s and within a few seconds. parameter_beacon's test stops
refreshing while the beacon is active and times each change from the
node's last get answer. The beacon must go stale between 0.3 s, its TTL,
and 0.3 s later, and be removed between 1 s, its expiry, and 0.3 s later.
The 0.3 s margin grows with the test time scale.

The unit tests cover the reader's decisions, the hold, the eviction order
and that running names do not count against the capacity, and
graph_node_has_endpoints against a real rcl node before and after its
context is shut down. The parameter_beacon unit tests run the real
parameter client against parameter services that do not answer, answer
without values or answer normally: a timed-out request leaves nothing
pending, a node keeps one client across cycles, a graph read without
targets drops every client, the gateway's helper nodes are not polled, a
wait with no time left returns within 1 s for a service that does not
exist,
and the plugin's node can be destroyed after rclcpp shuts down. The
leftover tests scale their default wait for the gateway's warning with the
test time scale.

The graph_watchdog ghost_departure scenario runs the watched node inside
the fixture and expects GRAPH_NODE_DISAPPEARED once it leaves a leftover
the gateway warned about. test_node_death_integration's X2 case attributes
a fault report to a tick only after the fake fault service has recorded it.
@bburda
bburda force-pushed the fix/opcua-rescan-comms-lost-freeze-source branch from 7bb8958 to f6dc898 Compare September 19, 2026 09:49
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.

opcua: rescan while disconnected, heal PLC_COMMS_LOST on connect, and name a plugin freeze-frame's source

2 participants