fix(abcip): write-side health neither latches nor overwrites

H19: Format/InvalidCast/Overflow on a client value never touched the wire but
latched Degraded; a good write set Healthy and so undid a Degraded the poll
loop had just recorded for other tags (the batch read path is worst-wins for
exactly this reason).

- The three conversion failures map to BadTypeMismatch/BadOutOfRange with a
  once-per-tag Warning and no health write (AbLegacy's rule).
- A good write (scalar or bit RMW) advances LastSuccessfulRead only - the
  device answered - and leaves the state alone.
- A write comms fault (thrown, or a non-refusal status word, which used to
  write no health at all) is Degraded with LastSuccessfulRead preserved and
  never downgrades Faulted.

Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:24:06 -04:00
parent cb16ebc75e
commit 895cc28886
2 changed files with 142 additions and 16 deletions
@@ -1390,7 +1390,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken)
.ConfigureAwait(false));
if (results[i].StatusCode == AbCipStatusMapper.Good)
_health = new DriverHealth(DriverState.Healthy, now, null);
RecordWriteAnswered(now);
continue;
}
@@ -1455,6 +1455,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
EvictRuntime(device, def.Name);
results[i] = new WriteResult(AbCipStatusMapper.MapStatus(status));
RecordWriteCommsFault($"transport status {status} writing {w.FullReference}");
_logger.LogWarning(
"AbCip write returned non-zero transport status {Status} for tag {Tag} on device {Device}; " +
"evicting cached runtime so next call re-creates it",
@@ -1463,7 +1464,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
else
{
results[i] = new WriteResult(AbCipStatusMapper.Good);
_health = new DriverHealth(DriverState.Healthy, now, null);
RecordWriteAnswered(now);
}
}
catch (OperationCanceledException)
@@ -1481,30 +1482,29 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
"AbCip write not supported for tag {Tag} on device {Device}: {Reason} (logged once per tag)",
w.FullReference, def.DeviceHostAddress, nse.Message);
}
// H19: the three client-value conversion failures below never touched the wire — they say nothing about
// the device, so they write no health (a Degraded here latched until the next good read, and AbLegacy
// already maps the same three with no health write). Not a transport fault either: no evict.
catch (FormatException fe)
{
// Value conversion error — not a transport fault; don't evict.
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
_logger.LogWarning(fe,
"AbCip write value-conversion error for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
WarnWriteRefusedOnce(def.Name,
"AbCip write to {Tag} on device {Device}: the value cannot be converted to {DataType}: {Reason} (logged once per tag)",
w.FullReference, def.DeviceHostAddress, def.DataType, fe.Message);
}
catch (InvalidCastException ice)
{
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
_logger.LogWarning(ice,
"AbCip write type-cast error for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
WarnWriteRefusedOnce(def.Name,
"AbCip write to {Tag} on device {Device}: the value cannot be converted to {DataType}: {Reason} (logged once per tag)",
w.FullReference, def.DeviceHostAddress, def.DataType, ice.Message);
}
catch (OverflowException oe)
{
results[i] = new WriteResult(AbCipStatusMapper.BadOutOfRange);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
_logger.LogWarning(oe,
"AbCip write value out of range for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
WarnWriteRefusedOnce(def.Name,
"AbCip write to {Tag} on device {Device}: the value is out of range for {DataType}: {Reason} (logged once per tag)",
w.FullReference, def.DeviceHostAddress, def.DataType, oe.Message);
}
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
{
@@ -1549,7 +1549,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (rmwParent is not null) EvictParentRuntime(device, rmwParent);
else EvictRuntime(device, def.Name);
results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
RecordWriteCommsFault(ex.Message);
_logger.LogWarning(ex,
"AbCip write transport exception for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
@@ -1559,6 +1559,19 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return results;
}
/// <summary>
/// A good write proves the device answered: advance <c>LastSuccessfulRead</c> but leave the STATE alone. Writing
/// <c>Healthy</c> here (H19) overwrote a <c>Degraded</c> the poll loop had just recorded for other tags.
/// </summary>
private void RecordWriteAnswered(DateTime now) =>
UpdateHealth(h => h with { LastSuccessfulRead = now });
/// <summary>A write-side comms fault: <c>Degraded</c> (never downgrading <c>Faulted</c>), <c>LastSuccessfulRead</c> preserved.</summary>
private void RecordWriteCommsFault(string error) =>
UpdateHealth(h => h.State == DriverState.Faulted
? h
: new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, error));
/// <summary>
/// Read-modify-write one bit within an integer parent (SINT / INT / DINT / LINT — Logix allows <c>.n</c> on
/// each). Creates / reuses a parallel parent runtime (distinct from the bit-selector handle) + serialises