fix(ablegacy): a status the PLC returned is an answer, never a comms fault

Review r0 I1. On the default libplctag transport a PLC refusal (non-zero PCCC
STS, e.g. an element past the end of the file; or a CIP error) arrives as
ERR_REMOTE_ERR / ERR_BAD_STATUS, which fell into the BadConnection default: a
no-reply comms fault. A coalesced block with one such member then failed as a
group every poll, and two refused tags in a row opened the device's fail-fast
window against a device that was answering.

- LibplctagLegacyStatusTranslation maps ERR_REMOTE_ERR / ERR_BAD_STATUS to the
  existing AbStatus.DeviceStateConflict (BadDeviceFailure): "reachable, refused
  the operation". No new AbStatus value (Driver.Ab.Transport is Lane K's).
- IsNoReply (Timeout / BadConnection / Abort / Pending / unknown codes) is now
  the ONLY comms-fault class; IsDeviceRefusal is every other non-Ok status
  except Unsupported (BadReply / Partial included: a reply arrived).
- A coalesced block refused with ANY returned status falls back to per-member
  reads in the same pass; the shape is remembered only after a repeat, and the
  refused block's handle is always evicted (M5).
- A runtime status Unsupported is neutral (keeps the handle); the probe no
  longer counts it as a fault.
- Refusal warnings log the status actually answered (M8).

Live: the new past-end-of-file ab_server case fails on libplctag with the old
translation and passes with it, on slc500/micrologix/plc5 x both transports.

Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:12:21 -04:00
parent 20875ea939
commit 8a80dcc787
7 changed files with 240 additions and 35 deletions
@@ -19,8 +19,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <para><b>The span is safe to read.</b> A block runs from its lowest to its highest member, so every element
/// it reads lies between two authored elements of the same file — inside the file whenever the authored tags
/// are. A device REFUSAL of a block (an authored element past the end of the file, a PLC-5 native non-N file)
/// falls back to single reads for that block's members, so one bad tag never blackens its neighbours, and the
/// block shape is remembered so it is not retried every poll. A comms fault fails the members together — they
/// — or ANY other status the device returned, incl. libplctag's remote error — falls back to single reads for that
/// block's members in the same pass, so one bad tag never blackens its neighbours; a shape refused twice in a row
/// is remembered so it is not retried every poll. Only a NO-REPLY failure fails the members together — they
/// share the connection — exactly as their single reads would have.</para>
/// <para>Turned off with the driver-config key <c>coalesceReads: false</c>.</para>
/// </remarks>
@@ -133,10 +134,19 @@ public sealed partial class AbLegacyDriver
(BlockOutcome, OpVerdict, string?) Refused(string why)
{
device.RefusedBlocks.TryAdd(block.Key, 0);
_logger.LogInformation(
"AbLegacy coalesced read {Block} refused ({Reason}); its {Count} tags read singly from now on. Driver={DriverInstanceId} Device={DeviceHostAddress}",
block.StartName + "+" + block.Count, why, block.Members.Count, _driverInstanceId, device.Options.HostAddress);
// The handle is never kept for a refused shape (review 07 r0 M5): it may never be planned again.
EvictRuntime(device, block.Key);
// Every refusal falls back to per-member reads THIS pass, so each member gets its own answer and a bad
// member can never black out its neighbours. The shape is only REMEMBERED after a repeat — one refusal can
// be transient (program mode, a download in progress), two in a row are deterministic.
var refusals = device.BlockRefusals.AddOrUpdate(block.Key, 1, (_, n) => n + 1);
if (refusals >= 2 && device.RefusedBlocks.TryAdd(block.Key, 0))
{
device.BlockRefusals.TryRemove(block.Key, out _);
_logger.LogInformation(
"AbLegacy coalesced read {Block} refused twice ({Reason}); its {Count} tags read singly from now on. Driver={DriverInstanceId} Device={DeviceHostAddress}",
block.StartName + "+" + block.Count, why, block.Members.Count, _driverInstanceId, device.Options.HostAddress);
}
return (BlockOutcome.Fallback, OpVerdict.Neutral, null);
}
@@ -170,11 +180,10 @@ public sealed partial class AbLegacyDriver
if (status != 0)
{
if (IsDeviceRefusal((AbStatus)status) || (AbStatus)status == AbStatus.Unsupported)
{
EvictRuntime(device, block.Key);
// ANY status the device returned (a refusal, a remote error, an unmapped reply, Unsupported) falls back
// to per-member reads; only a no-reply status fails the block's members together (review 07 r0 I1).
if (!IsNoReply((AbStatus)status))
return Refused($"device status {(AbStatus)status}");
}
EvictRuntime(device, block.Key);
FailAll(AbLegacyStatusMapper.MapStatus(status));
return (BlockOutcome.Done, OpVerdict.Fault, $"transport status {status} reading {block.StartName}+{block.Count}");
@@ -184,6 +193,7 @@ public sealed partial class AbLegacyDriver
foreach (var m in block.Members)
results[m.Index] = new DataValueSnapshot(DecodeMember(block, m, data), AbLegacyStatusMapper.Good, now, now);
device.BlockRefusals.TryRemove(block.Key, out _);
device.FirstNonZeroStatusLogged = false;
return (BlockOutcome.Done, OpVerdict.Answered, null);
}
@@ -193,14 +203,12 @@ public sealed partial class AbLegacyDriver
FailAll(AbLegacyStatusMapper.BadCommunicationError);
return (BlockOutcome.Done, OpVerdict.Throttled, tex.Message);
}
catch (AbTransportException ex) when (IsLocalRefusal(ex) || IsDeviceRefusal(ex.Status))
catch (AbTransportException ex) when (!IsNoReply(ex.Status))
{
EvictRuntime(device, block.Key);
return Refused(ex.Message);
}
catch (NotSupportedException nse)
{
EvictRuntime(device, block.Key);
return Refused(nse.Message);
}
catch (Exception ex)
@@ -677,12 +677,18 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(status), null, now);
if (IsDeviceRefusal((AbStatus)status))
{
// The device ANSWERED with a deterministic refusal (file/element missing, out of bounds…): per-tag
// Bad, the handle is kept (re-creating it would only re-pay the init read), warned once per tag,
// and it is evidence of reachability rather than a comms fault.
WarnRefusalOnce(device, def, "read", $"device status {(AbStatus)status}");
// The device ANSWERED with a refusal (file/element missing, out of bounds, a remote error…):
// per-tag Bad, the handle is kept (re-creating it would only re-pay the init read), warned once
// per tag, and it is evidence of reachability rather than a comms fault.
WarnRefusalOnce(device, def, "read", $"device status {(AbStatus)status}", results[item.Index].StatusCode);
return (OpVerdict.Answered, null);
}
if ((AbStatus)status == AbStatus.Unsupported)
{
// Refused locally / not supported: says nothing about reachability. Keep the handle.
WarnRefusalOnce(device, def, "read", "status Unsupported", AbLegacyStatusMapper.BadNotSupported);
return (OpVerdict.Neutral, null);
}
// Evict the stale handle so the next call re-creates it (mirrors AbCip).
EvictRuntime(device, def.Name);
if (!device.FirstNonZeroStatusLogged)
@@ -711,14 +717,14 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
{
// The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read).
// Not a comms fault: BadNotSupported, keep the handle, leave health alone.
WarnRefusalOnce(device, def, "read", ex.Message);
WarnRefusalOnce(device, def, "read", ex.Message, AbLegacyStatusMapper.BadNotSupported);
results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
return (OpVerdict.Neutral, null);
}
catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status))
{
// The native runtime raises a device's refusal (e.g. STS 0x20 "file not found") as an exception.
WarnRefusalOnce(device, def, "read", ex.Message);
WarnRefusalOnce(device, def, "read", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status));
results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(ex.Status), null, now);
return (OpVerdict.Answered, null);
}
@@ -726,7 +732,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
{
// A deterministic decode refusal (review 07 H16) — the same answer every poll. BadNotSupported, keep
// the handle (re-creating it only re-paid the init read), warn once, and NO health write.
WarnRefusalOnce(device, def, "read", nse.Message);
WarnRefusalOnce(device, def, "read", nse.Message, AbLegacyStatusMapper.BadNotSupported);
results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
return (OpVerdict.Neutral, null);
}
@@ -740,17 +746,34 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
}
/// <summary>
/// True for a status that proves the DEVICE answered and refused this one operation deterministically — the
/// file or element does not exist, the access is not allowed, the data type or size is wrong. Such a status
/// is per-tag, keeps the handle, and is not a comms fault. Everything else non-zero (timeouts, dropped or
/// refused connections, malformed or partial replies, unknown codes) is treated as a comms fault.
/// <see cref="AbStatus.Unsupported"/> is classified separately (<see cref="IsLocalRefusal"/>).
/// True for a status meaning NO REPLY arrived: a timeout, a dropped / refused connection, an aborted request, a
/// request still pending, or a code this build does not know. Only these are comms faults — they evict the
/// handle, degrade health and count toward <see cref="FaultsToMarkDeviceDown"/> (review 07 r0 I1: fail-fast
/// must trip only when nothing came back).
/// </summary>
/// <param name="status">The transport status.</param>
/// <returns><see langword="true"/> for a deterministic device refusal.</returns>
internal static bool IsDeviceRefusal(AbStatus status) => status is
AbStatus.NotFound or AbStatus.NotAllowed or AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall
or AbStatus.TypeMismatch or AbStatus.DeviceStateConflict;
/// <returns><see langword="true"/> when no reply arrived.</returns>
internal static bool IsNoReply(AbStatus status) =>
!Enum.IsDefined(status) || status is AbStatus.Timeout or AbStatus.BadConnection or AbStatus.Abort or AbStatus.Pending;
/// <summary>
/// True for a non-zero status that proves the DEVICE answered and refused this one operation — the file or
/// element does not exist, the access is not allowed, a size / type is wrong, a remote error (libplctag
/// <c>ERR_REMOTE_ERR</c>, translated to <see cref="AbStatus.DeviceStateConflict"/>), or a reply the transport
/// could not map (<see cref="AbStatus.BadReply"/>, <see cref="AbStatus.Partial"/>). Such a status is per-tag Bad,
/// keeps the handle, and is evidence of reachability. <see cref="AbStatus.Unsupported"/> is classified
/// separately (<see cref="IsLocalRefusal"/>: it says nothing about reachability).
/// </summary>
/// <remarks>
/// ⚠️ Depends on the transport's PCCC STS table (<c>AbStatusCodes.FromPccc</c>, Lane K): today it maps STS 0x30
/// ("remote node missing" — a dead DH+/DH-485 node behind a bridge) and 0x20 to answered values, so on the
/// NATIVE transport such a dead node reads as answered and never trips the backoff or the comms-loss watchdog.
/// The remap is follow-up F8 (review 07 r0 I4); this classification needs no change once it lands.
/// </remarks>
/// <param name="status">The transport status.</param>
/// <returns><see langword="true"/> for a device refusal.</returns>
internal static bool IsDeviceRefusal(AbStatus status) =>
status is not (AbStatus.Ok or AbStatus.Unsupported) && !IsNoReply(status);
// ---- IWritable ----
@@ -1029,7 +1052,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
// The probe's verdict feeds the data path's backoff (review 07 P7): an answer resets the window so a
// recovered device is read again at once; a comms fault counts toward marking it down.
if (success || IsDeviceRefusal((AbStatus)status)) state.RecordAnswered();
else state.RecordCommsFault(DateTime.UtcNow);
else if ((AbStatus)status != AbStatus.Unsupported) state.RecordCommsFault(DateTime.UtcNow);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch (Exception ex)
@@ -1456,12 +1479,14 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
/// A deterministic refusal (BadNotSupported) is answered the same way every time, so it is logged ONCE per
/// tag and operation — a polled tag must not flood the log, and the refusal never touches driver health.
/// </summary>
private void WarnRefusalOnce(DeviceState device, AbLegacyTagDefinition def, string operation, string reason)
private void WarnRefusalOnce(DeviceState device, AbLegacyTagDefinition def, string operation, string reason,
uint status = AbLegacyStatusMapper.BadNotSupported)
{
if (!device.RefusalLogged.TryAdd($"{operation}:{def.Name}", 0)) return;
// The status the operation is actually answered with (review 07 r0 M8) — not always BadNotSupported.
_logger.LogWarning(
"AbLegacy {Operation} refused (BadNotSupported). Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Address={Address} Reason={Reason}",
operation, _driverInstanceId, def.DeviceHostAddress, def.Name, def.Address, reason);
"AbLegacy {Operation} refused (0x{Status:X8}). Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Address={Address} Reason={Reason}",
operation, status, _driverInstanceId, def.DeviceHostAddress, def.Name, def.Address, reason);
}
/// <summary>The create parameters for a tag's own runtime — shared by creation and the write-refusal check, so
@@ -1718,6 +1743,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
/// distinct subscription shapes, released with the state).</summary>
public System.Collections.Concurrent.ConcurrentDictionary<string, byte> RefusedBlocks { get; } = new(StringComparer.Ordinal);
/// <summary>Consecutive refusals per coalesced block shape not yet remembered in <see cref="RefusedBlocks"/>
/// (a success clears the count).</summary>
public System.Collections.Concurrent.ConcurrentDictionary<string, int> BlockRefusals { get; } = new(StringComparer.Ordinal);
/// <summary>Tag operations already warned about for a deterministic refusal (once-per-tag-and-operation log).</summary>
public System.Collections.Concurrent.ConcurrentDictionary<string, byte> RefusalLogged { get; } = new(StringComparer.Ordinal);
@@ -23,6 +23,14 @@ internal static class LibplctagLegacyStatusTranslation
Status.ErrorPartial => AbStatus.Partial,
Status.ErrorAbort => AbStatus.Abort,
Status.ErrorBadReply => AbStatus.BadReply,
// The PLC (or a CIP hop) ANSWERED with an error: libplctag's PCCC layer turns a non-zero STS — a file or
// element that does not exist, a write the processor refuses — into PLCTAG_ERR_REMOTE_ERR (and a non-zero
// CIP general status into ERR_REMOTE_ERR / ERR_BAD_STATUS). AbStatus has no dedicated "remote error" value and
// Driver.Ab.Transport is not this driver's to extend (Lane K), so this maps to the existing value whose meaning
// is closest — "the device is reachable but refused the operation" — DeviceStateConflict (OPC UA
// BadDeviceFailure). It MUST NOT fall into the BadConnection default: that is a no-reply comms fault, which
// evicts handles, degrades health and counts toward marking the device down (review 07 r0 I1).
Status.ErrorRemoteErr or Status.ErrorBadStatus => AbStatus.DeviceStateConflict,
_ => AbStatus.BadConnection,
};