DriverInstanceActor.DisconnectObserved has no production sender — a driver whose device dies mid-run is never detected, never reconnects, and never reports comms loss #527
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found while running the #481 live gate on docker-dev. This is a pre-existing defect, not a regression from #481, but it makes #477 Layer 2 and #481 Layer 4 unreachable in the scenario both were written for.
What is true today
DriverInstanceActor.DisconnectObservedis never sent by anything insrc/— the only senders in the tree are four test files. The three paths into theReconnectingstate are therefore:InitializeFailedwhile Connecting — the driver never connected in the first place;DisconnectObserved— no production sender;ForceReconnect— an operator clicking Reconnect.So for a driver that is already Connected, nothing transitions it out.
PublishHealthSnapshotdoes call_driver.GetHealth(), but only to publish a status snapshot — it never acts on a driver that reports itself unhealthy.Live evidence (docker-dev, 2026-07-31)
Seeded Modbus driver
MAIN-modbusagainst theotopcua-pymodbus-standardfixture, connected and polling Good.docker pausethe fixture -> the driver keeps publishing per-tag0x80050000(BadCommunicationError). Stays Connected. Nodisconnect observedlog line, noConnectivityChanged.docker stopthe fixture -> identical. Waited >10 minutes. Still Connected, still publishing Bad.docker startthe fixture -> the driver did not recover. Tag stayed Bad for ~5 more minutes while a second Modbus driver against a different fixture (rtu-gate/Device1/HR200) read Good throughout, ruling out anything server-wide.Value: 2, Status: 0x00000000).The socket is dead, the poll loop fails forever, and nothing re-initializes it.
Why this matters beyond the recovery bug
Both condition-Quality comms-loss paths hang off
ConnectivityChanged:DriverHostActor.OnDriverConnectivityChangedannotates every native condition the driver owns Bad. Never fires for a mid-run device loss.FanCommsLossToDependencyMuxpushes Bad into the dependency mux for scripted alarms. Same.Both are correctly wired to the signal; the signal just never arrives for the case their issues describe ("a poll driver whose device goes unreachable emits only
ConnectivityChanged(false)" — it does not emit it at all). #481's unit tests drive the message directly and are unaffected.Partial mitigation that already exists: Modbus publishes Bad per tag on poll failure, so #478 Layer 3 does carry Bad into scripted-alarm input quality for this driver. That is not general — it depends on each driver choosing to publish Bad rather than go silent, and it does nothing for the native-condition path or for recovery.
What a fix probably looks like
The driver-side signal already exists and is already read:
IDriver.GetHealth()returnsDriverState.Reconnecting/Faulted. The smallest honest fix is for the Connected-stateHealthPollTick— which already callsGetHealth()and already runsReconcileSubscription()— to also self-TellDisconnectObservedwhen the driver reports itself non-Healthy for N consecutive polls. That reuses the existing retry/backoff machinery inReconnectingand gets recovery,ConnectivityChanged, and both quality paths in one change.Worth checking per driver whether
GetHealth()actually goes non-Healthy on sustained read failure before relying on it — that is the load-bearing assumption, and #517 already found one driver (CalculationDriver) hard-codingHealthy.Acceptance
ConnectivityChanged(false).Fixed and merged as
916f0f79(feature commit5a7a08de). Live gate PASSED, run twice.What changed
The
Connectedhealth tick now evaluates a comms-loss watchdog and self-sendsDisconnectObserved.GetHealth()went from reporting-only to load-bearing.The issue's proposed trip condition is wrong, and this is the substantive correction
This issue suggested tripping when the driver "reports itself non-Healthy for N consecutive polls". That would have caused a worse regression than the bug.
DriverState.Degradedmeans connected but some data is bad — the enum's own doc says so. Several drivers publish health per tag inside their read loop:ModbusDriver.ReadAsyncwritesHealthyorDegradedonce per reference, so a single unreadable address leaves the driver latchedDegradedwhenever it happens to be read last — indefinitely, against a device that is perfectly reachable. A state-only watchdog would tear that driver down every 90 s forever.The implemented trip condition is a conjunction:
Healthy, andDriverHealth.LastSuccessfulReadhas not advanced for 90 s (measured from the Connected entry when the driver has never reported a successful read).LastSuccessfulReadis the honest liveness evidence — every driver advances it on any success and preserves it acrossDegraded/Faultedwrites, so one bad tag among good ones keeps it moving while a genuinely dead device freezes it. The state check stays as the second conjunct because aHealthydriver with nothing subscribed legitimately never reads and must not be declared dead for being idle.Each conjunct has a test that goes red when the other is deleted, so neither is redundant.
The load-bearing assumption, checked
The issue flagged it, so all 12 drivers were surveyed rather than assumed: nine poll drivers reach
Degradedon sustained failure, MQTT derivesReconnecting/Faultedfrom its live connection state, Galaxy reportsDegradedvia its reconnect supervisor.CalculationDriverreportsHealthywhile running and therefore can never trip — correct, since it has no transport to reconnect to.Worth noting:
OpcUaClientDriver's reconnect-exhausted branch already carried the comment "the Core never learns it must offer an operator reinitialize". It was written against a mechanism that did not exist. It does now.A second defect found while fixing this
The
Reconnectingretry now callsShutdownAsyncbeforeInitializeAsync.ModbusDriver(_transport) andMqttDriver(_connection) overwrite anIAsyncDisposablefield insideInitializeAsyncwithout disposing the previous one, so re-initialising without a teardown leaks a socket per attempt. That was latent while only a manual Force Reconnect reached the path — this fix makes it automatic and unbounded, so a device down for an hour would have leaked ~360 transports at the 10 s retry interval.Verification
8 new tests in
DriverInstanceActorCommsLossWatchdogTests. Every absence assertion carries a positive control (a proven count of processed health-poll ticks), so none can pass merely because nothing ran. Falsified five ways — dropping either conjunct reds exactly the test written for it; removing the teardown, the null baseline, or the watchdog call each reds its own guard.Full suite green apart from 3
Host.IntegrationTestsfailures verified failing identically on cleanmasterin a separate worktree (PrimaryGateFailoverTests.Delivered_redundancy_snapshot_drives_the_primary_write_gate_across_the_cluster,DriverProbeHandshakeE2eTests.AbCip_Green_AgainstSim,DriverProbeRegistrationTests.AddOtOpcUaDriverProbes_is_idempotent).Live gate — docker-dev, Modbus
MAIN-modbusvsotopcua-pymodbus-standardBaseline: raw
ns=2;s=pymodbus/plc/HR200Xand UNSns=3;s=gate481/line1/press1/hr200both Good.Both NodeIds read Good with fresh timestamps immediately after — no node restart, which is precisely what the original repro required.
Re-run at Debug to observe the consumers rather than infer them:
(The
annotatedline appears forDRV-opcua1rather thanMAIN-modbusbecause only that driver owns native conditions; it is the same handler.) So both quality paths this issue reported as unreachable are now reached.The one clause not asserted on the wire is condition
Qualityitself — Client.CLI has no select clause for it in its alarm event filter and itsreadthrows on a Bad DataValue before printing. That is a CLI observability gap, not a gap in this fix; the fan-out is observed one layer in.Docs:
docs/DriverLifecycle.md§"The comms-loss watchdog",docs/AlarmTracking.md(stale caveat replaced), and CLAUDE.md.