fix(opcuaclient): writes feed health; a good keep-alive clears a stale Degraded

Health contract: a write response (per-node refusals included) is the server
answering, so it restores Healthy and advances LastSuccessfulRead; a write with no
reply (transport failure / timeout) writes Degraded with the stamp preserved; a
caller-cancelled write touches nothing. A good keep-alive with no outage or dead
subscription latched now clears a Degraded left by a failed read/write instead of
only moving the stamp (a subscription-only driver otherwise stayed Degraded until
its next re-initialise). All writes are generation-stamped.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:26:43 -04:00
parent 66d100a9e9
commit cba3b2e960
3 changed files with 142 additions and 3 deletions
@@ -1437,6 +1437,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
_ = RequireSession();
var results = new WriteResult[writes.Count];
var generation = Volatile.Read(ref _lifecycleGeneration);
var session = Session;
if (session is null)
{
@@ -1483,8 +1484,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// the severity bits set is a Bad.
results[r] = new WriteResult(codes[w].Code);
}
// A response — refusals included (BadNotWritable, BadTypeMismatch, ...) — is the server answering.
RecordDeviceAnswered(generation, Clock.GetUtcNow().UtcDateTime);
}
catch (OperationCanceledException)
catch (OperationCanceledException ex)
{
// Timeout / cancellation after the wire request may have been dispatched.
// Writes are non-idempotent — BadTimeout ("outcome unknown, do not blindly
@@ -1494,12 +1497,15 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
const uint StatusBadTimeout = 0x800A0000u;
for (var w = 0; w < indexMap.Count; w++)
results[indexMap[w]] = new WriteResult(StatusBadTimeout);
// No reply arrived. Only a timeout is a comms fault — a caller that cancelled says nothing about upstream.
if (!cancellationToken.IsCancellationRequested) RecordCommsFault(generation, $"write: {ex.Message}");
}
catch (Exception)
catch (Exception ex)
{
// Pre-wire transport failure — the write definitely did not reach the server.
for (var w = 0; w < indexMap.Count; w++)
results[indexMap[w]] = new WriteResult(StatusBadCommunicationError);
RecordCommsFault(generation, $"write: {ex.Message}");
}
return results;
}
@@ -3125,7 +3131,15 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
return;
}
UpdateHealth(generation, h => h.State is DriverState.Healthy or DriverState.Degraded ? h with { LastSuccessfulRead = now } : null);
// A good keep-alive is the server answering. With no outage and no dead subscription latched, a Degraded
// here can only be a failed read/write the server has since contradicted, so it clears too — otherwise a
// subscription-only driver (which never reads again) stayed Degraded until its next re-initialise.
UpdateHealth(generation, h => h.State switch
{
DriverState.Healthy => h with { LastSuccessfulRead = now },
DriverState.Degraded => new DriverHealth(DriverState.Healthy, now, null),
_ => null,
});
return;
}