Skip to content

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

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

opcua: rescan while disconnected, heal PLC_COMMS_LOST on connect, and name a plugin freeze-frame's source#660
bburda wants to merge 8 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 the four nodes in
its own process 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.

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.
  2. Both sweeps take a cancel predicate that also reads rclcpp::ok(). A rescan that
    throws still stamps the cadence.
  3. With no node map, the component identity is re-derived from the device on the first
    session after an adoption, so the provisional opcua-<host> name goes. The alarms
    entity follows.

Comms-lost clears and the pending buffer

  1. PLC_COMMS_LOST is cleared on every successful connect, the first included, so a
    persisted fault does not stand against a healthy link.
  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 ranks by what is re-derivable.
    The link-state clear goes first, one clear per code is pending at most, and a device
    alarm's inactive edge ages out like a report.

Gateway entity listing

  1. One predicate serves the peer count and the app filter, matched per exact suffix so a
    peer named <node>_monitor or <node>2 stays visible. 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, not emptied, when the capture named no path.

Docs and changelog

  1. Plugin README, docs/api/rest.rst, docs/tutorials/snapshots.rst, plugin
    CHANGELOG.rst.

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.
    The plugin CHANGELOG.rst marks this one 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. Four /api/v1/apps entries disappear with the plugin loaded, the gateway's own node
    among them.
  4. A SIGTERM ends a start-up or rescan sweep within one probe, not after the subnet.
  5. fault_manager still logs Attempted to clear non-existent fault at WARN on every
    connect where nothing was standing.

Testing

  1. Jazzy dev image, clean tree: 11 packages finished, 4974 tests, 0 errors, 0 failures, 539 skipped. Linters on the built packages: 1140 tests, 0 failures.
  2. Docker: run_integration_tests.sh Total: 18 / Passed: 18, run_alarm_tests.sh
    passed, new run_discovery_race_test.sh passed.
  3. 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.
  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.

Not in this PR

  1. Downgrading the fault_manager WARN for a clear of a fault that is not there.
  2. A docker scenario that restarts the gateway with a standing PLC_COMMS_LOST. That
    half has unit coverage only.
  3. 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.
  4. 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.

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

Two failures share the same shape: the plugin decides something once, at
startup, and can never revise it while it runs.

Re-scan while no session is up. Config-less discovery (#544, #509) ran a
single scan a couple of seconds after start. A gateway that boots
alongside its PLC scans while the PLC is still coming up, finds nothing,
falls back to opc.tcp://localhost:4840 and retries that endpoint for as
long as it runs. Only a restart found the PLC. The poller's reconnect arm
now asks the plugin for a fresh scan, rate limited by discovery.interval_s
(default 30 s), and adopts a newly found server for its next connect
attempt, resetting the backoff so the new endpoint is tried at once rather
than after the dead one's accumulated wait. The rules that made 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, and nothing is scanned while a session is up. interval_s
stops being an accepted-but-ignored knob.

Clear PLC_COMMS_LOST on every successful connect. The fault raised for a
sustained outage (#496) was cleared only when the running process still
remembered raising it. The fault manager keys faults by fault code and
persists them, so a fault raised before a gateway restart is standing in
the store with nothing in memory to remember it, and the arm that would
clear it is never entered when the first connect succeeds. The fault then
stayed CONFIRMED for good. Both connect paths, the initial one and every
reconnect, now send the clear regardless of what 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 untouched.

Tests: the discovery pass and the endpoint adoption rule are exercised
with injected probes, including the positive control that a configured
endpoint is refused the very server an unconfigured one accepts. The
comms-lost heal runs against the live test server, because only a connect
that actually succeeds reaches that arm.
A freeze-frame captured for a plugin-backed entity (#564) reaches a client
with an empty topic and an empty message_type. That is correct, the values
are the plugin's live entity data and not a ROS message, but it leaves the
snapshot with no field at all naming its origin. Two of these frames from
different bridges are indistinguishable, and a frame is indistinguishable
from a topic capture whose metadata went missing.

Frame now carries the capture path that read it, and it is served as
x-medkit.source: plugin_data_provider for a read through the owning
plugin's DataProvider, plugin_x_plc_data_route for the in-process dispatch
of the plugin's own x-plc-data route (bridges that export no
DataProvider). topic and message_type are left empty rather than
overloaded, since neither names a ROS topic here.

The field is omitted, not emptied, when the capture named no path, so a
fault-manager freeze-frame taken from a real topic is unaffected and
carries its topic and message_type as before.
The gateway runs four nodes inside 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, and runtime introspection returned all four as ordinary Apps.
The gateway advertised its own plumbing as diagnosable entities, and an
operator browsing /api/v1/apps saw four entries that answer nothing
useful.

There were two half-answers to the same question. count_peer_nodes knew
the gateway's own FQN plus "_sub" and "_fault_clients" but not the
lifecycle reader, and the app filter knew only the underscore rule, so it
dropped none of the four. Both now go through one predicate,
is_own_gateway_node, so a fifth helper is declared in one place instead
of two.

The match is exact per suffix, never a prefix test: a genuine peer named
"<node>_monitor" or "<node>2" must stay visible, and hiding a real node is
the worse error. A fault_manager sharing the process is not ours either
and stays listed. Remote entities are left alone: a peer's helper nodes
carry the same fully qualified names and are the peer's own filter's
business.
Every existing opcua docker scenario pins OPCUA_ENDPOINT_URL, which
short-circuits discovery, so none of them can reach the failure this
covers: the gateway and the PLC power on together, the start-up scan runs
while the PLC is still booting, and the plugin is left retrying its
fallback endpoint.

The scenario starts the gateway first with discovery on and no endpoint
configured, asserts it settled on the fallback endpoint with no session,
then brings an OPC-UA server up on the same subnet and asserts the
endpoint is adopted within two re-scan intervals. It also asserts the
container never restarted, since a restart would satisfy the endpoint
check while proving nothing: a restart is exactly what used to be needed.

The network is created with an explicit /24 so the read-only sweep stays
254 hosts and finishes in seconds.
…cted

A gateway that starts before its PLC runs the discovery rescan for the
life of the outage, and several things it does in that state were wrong.

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,
and it was then pinned forever: after discovery adopted the real PLC the
component still served opcua-<fallback host> while introspect() reported
the adopted endpoint. The identity is now re-derived on the first poll
of a new session (config-less mode only, an explicit node map still owns
the name), the derived alarms entity follows the rename, and the change
is logged at INFO. The docker race scenario gained a config-less pass
that asserts the rename against a real server.

Rescan cadence. The cadence was stamped when a sweep STARTED, so a sweep
of a legal /16 (minutes at the defaults) made the next one due the
moment it returned: the poll thread swept back to back and the reconnect
attempt dropped to one per sweep. It is now stamped when the sweep ends.
NetworkDiscovery::run() also takes a cancel predicate, bound to the
shutdown flag and checked before each probe and between the sweep and
identify phases, so shutdown() no longer has to wait out a sweep.

Reconnect backoff. The rescan is consulted once per reconnect attempt
and attempts are spaced by the exponential backoff, so the real cadence
was max(interval_s, backoff) while the README, the header and the
start-up log all said "every interval_s". The backoff ceiling is now
capped at the rescan cadence while discovery is rescanning.

interval_s. An unset interval and an explicit 0 both mapped to the 30 s
default, so there was no way to keep discovery on and stop rescanning.
They are now distinct: unset takes the default, an explicit 0 leaves the
start-up scan one-shot, and a negative value is refused with a warning
that no longer claims the kept default is one-shot. The start-up line no
longer promises a loop that rescans "every 0s" either.

Discovery report. A pass re-emitted its whole report every rescan, so a
site with a secured-only server logged the same WARN every 30 s for the
life of the process. A pass whose outcome matches the previous one now
reports at DEBUG. The first pass, and every changed outcome, still
reports at INFO/WARN.

Connect-time clear. It is a link-state clear, not an operator resolving
a root cause, so it now sets skip_correlation_auto_clear and cannot
cascade-clear the symptom faults a rule attributes to PLC_COMMS_LOST.
The poller's own clear on a successful reconnect is the same event and
does the same. Those clears were also buffered unconditionally while the
fault manager was unmatched, so a flapping link pushed real alarm
reports out of the bounded buffer: the buffer now keeps at most one
pending clear per fault code, evicts a clear before a report, and
refuses a clear rather than dropping a report.
The two Frame::source constants had no assertion from a capture: only a
hand-built frame in the merge helper's test named one, so swapping the
DataProvider and route values left the whole suite green. The route and
DataProvider loss-of-comms tests now each assert the constant their own
path must produce, plus the literal wire value - symbol against symbol
stays equal when the two constants are swapped, and that string is what
every x-medkit.source consumer reads. The merge test that omits the key
keeps its place as a helper contract for a frame a caller built without
naming a path - the capture paths always name one - and says so instead
of standing in as a control for them.

The peer-node count test listed only two of the three helper nodes the
gateway creates in its own process, so a lone gateway with a lifecycle
reader would have counted a peer and skipped the empty-graph warning.

Document the entity-frame source field in the REST fault snapshot
reference: an entity frame carries no topic or message type, so source
is the only provenance a consumer gets.
…y what is re-derivable

The per-entity SOVD route DELETE /{entity}/faults/{code} lands on
FaultProvider::clear_fault for a plugin-owned entity, which is the branch
the gateway takes instead of its own. The gateway sets
skip_correlation_auto_clear there so an operator scoped to one entity
cannot cascade-clear correlated symptoms reported by apps in other
entities, and the ClearFault contract documents that guarantee, but this
plugin sent the flag off and reopened the hole wherever a PLC is
involved. It now sets the flag, and the reason each call site sets or
clears it travels with the call: a ClearOrigin says whether the device
reported the condition inactive (a real resolution, cascade kept), the
link came back, or an operator cleared through the scoped route.

The pending-dispatch buffer used that same distinction too bluntly. It
gave up any clear before any report, but only the link-state clear is
re-derivable - the next reconnect sends it again. A device alarm's
inactive edge is as one-shot as its raise, so evicting it left the flush
replaying the raise with nothing behind it and the fault standing while
the device said inactive. Ranking is now by re-derivability: the
link-state clear is what a full buffer gives up first, everything else
ages out oldest-first as it did before.

The start-up discovery sweep claimed to be cancellable through the
shutdown flag, but nothing can set that flag while it runs: it happens
inside set_context(), during node construction, before the executor the
gateway shuts down from ever spins. A SIGTERM during a wide sweep
therefore waited the sweep out. Both sweeps now ask one predicate that
also reads rclcpp::ok(), which rclcpp's own signal handler turns false,
so the start-up sweep ends on the signal and the rescan keeps ending on
shutdown() as well.

Also: a rescan sweep that throws now stamps the cadence on its way out,
or the next poll iteration would immediately start another one; the
start-up log line no longer states the wrong reason for reporting in
full; and the package changelog records this branch.

Measuring that start-up sweep also showed the gateway logging nothing at
all while it ran: the discovery report is buffered so a repeated rescan
can be reported at DEBUG, and the "scanning [subnets]" announcement had
been swept up with it. A minutes-long sweep with no output reads as a
hung process, so the announcement is sent before the sweep again, with
the same first-pass INFO / rescan DEBUG levelling on its own.
…e wire

Four call sites decide whether a ClearFault may cascade, and only the
scoped SOVD DELETE was pinned: swapping the origin at any of the other
three left the suite green. The two that need a live session are now
driven against the test_alarm_server fixture with a real fault manager on
the other end, so the flag is read off the wire. A successful connect
must clear PLC_COMMS_LOST without cascading, and the same test drives the
fixture's own CLI to fire and clear a condition, because a clear the
device itself reported is a resolution at the source and must keep the
cascade. Having both cases in one test is what makes each flag a decision
rather than a constant. The fixture harness gained a stdin pipe to send
those commands, the way the docker scenario already drives it through a
FIFO.

The poller's own clear travels the same callback as every device alarm,
so the rule that tells them apart moved into clear_origin_for_signal and
is tested on both branches, exact code match included.

The sweep's cancel predicate had the same shape of hole: it is private
and no test reached it, so reverting it to the shutdown flag alone left
everything green while a SIGTERM during the start-up sweep would again
have to wait the sweep out. The rule is now the static
discovery_cancelled_for, tested on both inputs, with the member reduced
to reading the two values off the process. A second test shows the input
is real by shutting a private rclcpp context down and reading
rclcpp::ok() back. The comment on the cancellation test no longer claims
it exercises the plugin's own predicate, which it never did.

Also: the remaining prose semicolons on this branch (two comments and
five operator-visible log strings) are periods and commas now, and the
Refused outcome's doc comment says what it means.
@bburda bburda self-assigned this Sep 6, 2026
@bburda
bburda marked this pull request as draft September 6, 2026 19:13
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

1 participant