fix(abcip): an unknown tag no longer throttles its whole device or reads as a comms error

Live read-only check against a production CompactLogix press: with one nonexistent tag
first in the read list, libplctag's create threw LibPlcTagException("ErrorNotFound"); the
driver counted it as a failed Forward-Open, opened the device's 05/STAB-8 connect-backoff
window, and every other cold tag on the device - all real (WW_InCycle, WW_ActualPress,
WW_WorkOrderNum) - read BadCommunicationError. In steady polling the bad tag re-opens
the window each attempt, so siblings ordered after it may never connect.

- A create that fails with a "controller answered" status (NotFound/NoMatch/BadDevice,
  the probe's existing reachable classification) records backoff SUCCESS, not failure.
  Genuine connection failures still open the window (falsification control test).
- Read exceptions map through the libplctag Status they carry: unknown tag ->
  BadNodeIdUnknown, decode past the buffer -> BadOutOfRange, timeout -> BadTimeout,
  instead of a blanket BadCommunicationError. One status-name parser
  (AbCipStatusMapper.TryGetStatus) now serves both the read path and the probe.
This commit is contained in:
Joseph Doherty
2026-10-07 14:09:43 -04:00
parent a0a024c80b
commit 0ca56cb1f6
4 changed files with 120 additions and 6 deletions
@@ -1,3 +1,4 @@
using libplctag;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
@@ -818,10 +819,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
catch (Exception ex)
{
// Transport exception — evict so the next read creates a fresh handle.
// Transport exception — evict so the next read creates a fresh handle. A libplctag
// exception carries its Status, so a tag the controller does not have reads
// BadNodeIdUnknown and a decode past the buffer BadOutOfRange, not a comms error.
EvictRuntime(device, def.Name);
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.BadCommunicationError, null, now);
AbCipStatusMapper.MapException(ex), null, now);
_logger.LogWarning(ex,
"AbCip read transport exception for tag {Tag} on device {Device}",
reference, def.DeviceHostAddress);
@@ -891,7 +894,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
catch (Exception ex)
{
EvictRuntime(device, parent.Name);
StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError);
StampGroupStatus(group, results, now, AbCipStatusMapper.MapException(ex));
_logger.LogWarning(ex,
"AbCip whole-UDT read transport exception for parent {Parent} on device {Device}",
group.ParentName, parent.DeviceHostAddress);
@@ -1158,10 +1161,20 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch
catch (Exception ex)
{
runtime.Dispose();
device.Backoff.RecordFailure(DateTime.UtcNow); // 05/STAB-8: open the backoff window
// 05/STAB-8: a failed create opens the backoff window — but only when the device did
// not answer. "Tag not found" means the controller replied over a working session; it
// is a config error on this one tag, and counting it as a failed Forward-Open throttled
// every sibling tag on the device (seen live: one typo blacked out the whole device).
if (ex is LibPlcTagException lpe
&& AbCipStatusMapper.TryGetStatus(lpe, out var status)
&& status != Status.Ok
&& AbCipDriverProbe.IsReachableStatus(status))
device.Backoff.RecordSuccess();
else
device.Backoff.RecordFailure(DateTime.UtcNow);
throw;
}
device.Backoff.RecordSuccess(); // 05/STAB-8: Forward-Open succeeded — reset the window
@@ -200,7 +200,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
internal static bool IsReachableException(LibPlcTagException ex)
{
var msg = ex.Message ?? string.Empty;
if (Enum.TryParse<Status>(msg.Trim(), ignoreCase: true, out var status) && Enum.IsDefined(status))
if (AbCipStatusMapper.TryGetStatus(ex, out var status))
return IsReachableStatus(status);
return msg.Contains("NOT_FOUND", StringComparison.OrdinalIgnoreCase)
@@ -76,6 +76,34 @@ public static class AbCipStatusMapper
/// <returns>The mapped OPC UA StatusCode value.</returns>
public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status);
/// <summary>
/// Recover the libplctag <see cref="Status"/> a <see cref="LibPlcTagException"/> was thrown on.
/// The wrapper builds the exception from <c>Status.ToString()</c>, so the message is the enum
/// name (<c>"ErrorNotFound"</c>).
/// </summary>
/// <param name="ex">The exception.</param>
/// <param name="status">The recovered status.</param>
/// <returns><c>true</c> when the message names a defined <see cref="Status"/>.</returns>
public static bool TryGetStatus(LibPlcTagException ex, out Status status)
{
ArgumentNullException.ThrowIfNull(ex);
return Enum.TryParse((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out status)
&& Enum.IsDefined(status);
}
/// <summary>
/// Status for an exception thrown while creating / reading a tag: a libplctag exception maps
/// through its <see cref="Status"/> (so a tag the controller does not have reads
/// <see cref="BadNodeIdUnknown"/>, not a comms error); anything else is
/// <see cref="BadCommunicationError"/>.
/// </summary>
/// <param name="ex">The exception.</param>
/// <returns>The OPC UA status code.</returns>
public static uint MapException(Exception ex) =>
ex is LibPlcTagException lpe && TryGetStatus(lpe, out var status)
? MapLibplctagStatus(status)
: BadCommunicationError;
/// <summary>
/// Map a libplctag.NET <see cref="Status"/> enum value to an OPC UA StatusCode. This is
/// the strongly-typed core of the mapper; the <c>int</c> overload exists only for the
@@ -0,0 +1,73 @@
using libplctag;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// A tag the controller does not have is a configuration error, not a lost connection. Seen live
/// against a production CompactLogix: with one misspelled tag first in the read list, libplctag's
/// create threw <c>LibPlcTagException("ErrorNotFound")</c>, the driver counted that as a failed
/// Forward-Open, opened the device's connect-backoff window, and every other not-yet-connected tag
/// on the device — all of them real — came back <c>BadCommunicationError</c> (throttled). The bad
/// tag itself also read <c>BadCommunicationError</c> instead of <c>BadNodeIdUnknown</c>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipUnknownTagReadTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static (AbCipDriver Driver, FakeAbCipTagFactory Factory) Build(Func<string, Exception?> initFault)
{
var factory = new FakeAbCipTagFactory
{
Customise = p => initFault(p.TagName) is { } ex
? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = ex }
: new FakeAbCipTag(p) { Value = 42 },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Typo", Device, "NoSuchTag_xyz", AbCipDataType.DInt),
new AbCipTagDefinition("Real1", Device, "WW_InCycle", AbCipDataType.DInt),
new AbCipTagDefinition("Real2", Device, "WW_ActualPress", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
MaxConcurrentReads = 1,
}, "abcip-1", factory);
return (drv, factory);
}
/// <summary>The unknown tag reads BadNodeIdUnknown and its siblings still connect and read Good.</summary>
[Fact]
public async Task Unknown_tag_does_not_throttle_sibling_tags_and_reads_BadNodeIdUnknown()
{
var (drv, _) = Build(name => name == "NoSuchTag_xyz" ? new LibPlcTagException("ErrorNotFound") : null);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var r = await drv.ReadAsync(["Typo", "Real1", "Real2"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
r[1].Value.ShouldBe(42);
r[2].StatusCode.ShouldBe(AbCipStatusMapper.Good);
}
/// <summary>
/// Falsification control: a genuine connection failure still opens the backoff window, so a
/// sibling cold tag is throttled exactly as before (05/STAB-8 is unchanged).
/// </summary>
[Fact]
public async Task A_connection_failure_still_opens_the_backoff_window()
{
var (drv, factory) = Build(name => name == "NoSuchTag_xyz" ? new LibPlcTagException("ErrorTimeout") : null);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var r = await drv.ReadAsync(["Typo", "Real1"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
factory.Tags.ContainsKey("WW_InCycle").ShouldBeFalse(); // throttled before any create
}
}