fix(abcip): classify every controller refusal as per-tag Bad, keep the runtime
H1/L4: one classification (AbCipRefusals.IsDeviceRefusal) of the statuses
that prove the controller answered - NotFound, OutOfBounds, NotAllowed,
TooLarge, TooSmall, TypeMismatch, Unsupported - shared by the data path, the
Test-Connect probe (IsReachableStatus widened) and the native runtime.
- A refused read/write (thrown or as a status word) is per-tag Bad with a
once-per-tag Warning, keeps the runtime (no evict, so no lease/Forward-Open
churn for a sole lease holder), writes no Degraded and, since the device
answered, advances LastSuccessfulRead without touching the state.
- A refused cold create records the backoff as reachable, so siblings are no
longer throttled for up to 30 s by one Arr[999].
- The native runtime completes InitializeAsync on any refusal (it used to on
TypeMismatch/Unsupported only) and reports it on every later read.
- A refused READ of NotAllowed reads BadNotReadable, not BadNotWritable.
- Test Connect names the refusal ("probe tag refused: OutOfBounds").
0x1C/0x20/0xFF-0x2104 still fold into BadReply inside Driver.Ab.Transport
(Lane K follow-up); they remain comms faults until mapped there.
Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -311,8 +311,9 @@ public sealed class AbCipAlmaProjectionTests
|
||||
// A non-zero libplctag status ⇒ Bad on every member read; the driver evicts the handle and re-creates it, so
|
||||
// every re-created runtime must fail too (a fresh fake would read as "all clear").
|
||||
var recreations = 0;
|
||||
r.Factory.Customise = p => { Interlocked.Increment(ref recreations); return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound }; };
|
||||
foreach (var t in r.Factory.Tags.Values.ToList()) t.Status = (int)AbStatus.NotFound;
|
||||
// A comms status (not a device refusal, which keeps the handle since review 07 H1) so the handle is evicted.
|
||||
r.Factory.Customise = p => { Interlocked.Increment(ref recreations); return new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection }; };
|
||||
foreach (var t in r.Factory.Tags.Values.ToList()) t.Status = (int)AbStatus.BadConnection;
|
||||
await WaitFor(() => Volatile.Read(ref recreations) >= 6);
|
||||
await r.StopAsync();
|
||||
Snapshot(r).Any(x => x.Kind == AlarmTransitionKind.Clear).ShouldBeFalse();
|
||||
@@ -334,9 +335,9 @@ public sealed class AbCipAlmaProjectionTests
|
||||
{
|
||||
if (!p.TagName.EndsWith("HAcked", StringComparison.Ordinal)) return null!;
|
||||
Interlocked.Increment(ref bad);
|
||||
return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound };
|
||||
return new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection }; // comms ⇒ evicted + re-created
|
||||
};
|
||||
r.Factory.Tags[$"{Tag}.HAcked"].Status = (int)AbStatus.NotFound;
|
||||
r.Factory.Tags[$"{Tag}.HAcked"].Status = (int)AbStatus.BadConnection;
|
||||
await WaitFor(() => Volatile.Read(ref bad) >= 4);
|
||||
// The link recovers and HAcked reads true again.
|
||||
r.Factory.Customise = p => new FakeAbCipTag(p) { Value = true };
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 H1 / L4: a status that proves the controller ANSWERED (NotFound, OutOfBounds, NotAllowed,
|
||||
/// TooLarge, TooSmall, TypeMismatch, Unsupported) is a deterministic per-tag refusal, not a comms fault. It
|
||||
/// keeps the tag runtime (no evict → no lease / Forward-Open churn), writes no Degraded, opens no device-wide
|
||||
/// backoff window for siblings, warns once, and — since the device answered — advances LastSuccessfulRead.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipDeviceRefusalTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
/// <summary>A fake whose reads/writes throw a chosen transport status (init succeeds unless told otherwise).</summary>
|
||||
private sealed class RefusingTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
public AbStatus? ReadThrows { get; set; }
|
||||
public AbStatus? WriteThrows { get; set; }
|
||||
|
||||
public override async Task ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.ReadAsync(cancellationToken);
|
||||
if (ReadThrows is { } s) throw new AbTransportException(s, s.ToString());
|
||||
}
|
||||
|
||||
public override async Task WriteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.WriteAsync(cancellationToken);
|
||||
if (WriteThrows is { } s) throw new AbTransportException(s, s.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private static (AbCipDriver Driver, FakeAbCipTagFactory Factory) Build(
|
||||
Func<AbCipTagCreateParams, FakeAbCipTag> make)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = make };
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(
|
||||
new AbCipTagDefinition("Bad", Device, "Arr[999]", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("Good", Device, "Real1", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
MaxConcurrentReads = 1,
|
||||
}, "abcip-refusal", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
/// <summary>The classification itself: every answered refusal is reachable; transport failures are not.</summary>
|
||||
/// <param name="status">The transport status.</param>
|
||||
/// <param name="refusal">Whether it is a device refusal.</param>
|
||||
[Theory]
|
||||
[InlineData(AbStatus.NotFound, true)]
|
||||
[InlineData(AbStatus.OutOfBounds, true)]
|
||||
[InlineData(AbStatus.NotAllowed, true)]
|
||||
[InlineData(AbStatus.TooLarge, true)]
|
||||
[InlineData(AbStatus.TooSmall, true)]
|
||||
[InlineData(AbStatus.TypeMismatch, true)]
|
||||
[InlineData(AbStatus.Unsupported, true)]
|
||||
[InlineData(AbStatus.Ok, false)]
|
||||
[InlineData(AbStatus.Timeout, false)]
|
||||
[InlineData(AbStatus.BadConnection, false)]
|
||||
[InlineData(AbStatus.BadReply, false)]
|
||||
[InlineData(AbStatus.Partial, false)]
|
||||
[InlineData(AbStatus.Abort, false)]
|
||||
public void Answered_statuses_are_device_refusals(AbStatus status, bool refusal)
|
||||
{
|
||||
AbCipRefusals.IsDeviceRefusal(status).ShouldBe(refusal);
|
||||
AbCipDriverProbe.IsReachableStatus(status).ShouldBe(refusal || status == AbStatus.Ok);
|
||||
}
|
||||
|
||||
/// <summary>A read the controller refuses keeps its runtime, stays Healthy, and advances LastSuccessfulRead.</summary>
|
||||
/// <param name="status">The refusal the controller answers with.</param>
|
||||
/// <param name="expected">The OPC UA status the tag reads.</param>
|
||||
[Theory]
|
||||
[InlineData(AbStatus.NotFound, AbCipStatusMapper.BadNodeIdUnknown)]
|
||||
[InlineData(AbStatus.OutOfBounds, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData(AbStatus.TooLarge, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData(AbStatus.TooSmall, AbCipStatusMapper.BadOutOfRange)]
|
||||
[InlineData(AbStatus.NotAllowed, AbCipStatusMapper.BadNotReadable)]
|
||||
public async Task A_refused_read_keeps_the_runtime_and_writes_no_Degraded(AbStatus status, uint expected)
|
||||
{
|
||||
var (drv, factory) = Build(p => new RefusingTag(p) { ReadThrows = p.TagName == "Arr[999]" ? status : null, Value = 1 });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var before = drv.GetHealth().LastSuccessfulRead!.Value;
|
||||
await Task.Delay(20);
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var r = await drv.ReadAsync(["Bad"], CancellationToken.None);
|
||||
r[0].StatusCode.ShouldBe(expected);
|
||||
}
|
||||
|
||||
var bad = factory.Tags["Arr[999]"];
|
||||
bad.InitializeCount.ShouldBe(1); // never evicted → never re-created
|
||||
bad.Disposed.ShouldBeFalse();
|
||||
bad.ReadCount.ShouldBe(3);
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Healthy);
|
||||
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before); // the device answered
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a genuine transport failure on the same tag still evicts and degrades.</summary>
|
||||
[Fact]
|
||||
public async Task A_transport_failure_still_evicts_and_degrades()
|
||||
{
|
||||
var (drv, factory) = Build(p => new RefusingTag(p) { ReadThrows = p.TagName == "Arr[999]" ? AbStatus.Timeout : null });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["Bad"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
|
||||
factory.Tags["Arr[999]"].Disposed.ShouldBeTrue();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
/// <summary>A refusal returned as a status word (no throw) is classified the same way.</summary>
|
||||
[Fact]
|
||||
public async Task A_refusal_status_word_keeps_the_runtime()
|
||||
{
|
||||
var (drv, factory) = Build(p => new FakeAbCipTag(p) { Status = p.TagName == "Arr[999]" ? (int)AbStatus.OutOfBounds : 0, Value = 1 });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["Bad"], CancellationToken.None);
|
||||
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
|
||||
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
factory.Tags["Arr[999]"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["Arr[999]"].Disposed.ShouldBeFalse();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A cold create the controller refuses with OutOfBounds proves the session works: it must not open the
|
||||
/// device-wide backoff window, so a sibling cold tag still connects and reads Good.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_refused_create_does_not_throttle_sibling_cold_creates()
|
||||
{
|
||||
var (drv, _) = Build(p => p.TagName == "Arr[999]"
|
||||
? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = new AbTransportException(AbStatus.OutOfBounds, "OutOfBounds") }
|
||||
: new FakeAbCipTag(p) { Value = 42 });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
|
||||
r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
r[1].Value.ShouldBe(42);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>A write the controller refuses keeps the runtime and writes no health.</summary>
|
||||
[Fact]
|
||||
public async Task A_refused_write_keeps_the_runtime_and_writes_no_Degraded()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new RefusingTag(p) { WriteThrows = AbStatus.NotAllowed } };
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Ro", Device, "ReadOnlyTag", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "abcip-refusal", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var w1 = await drv.WriteAsync([new WriteRequest("Ro", 5)], CancellationToken.None);
|
||||
var w2 = await drv.WriteAsync([new WriteRequest("Ro", 6)], CancellationToken.None);
|
||||
|
||||
w1[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
w2[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
factory.Tags["ReadOnlyTag"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["ReadOnlyTag"].Disposed.ShouldBeFalse();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ public sealed class AbCipTypeMismatchTests
|
||||
creates().ShouldBe(1);
|
||||
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||
factory.Tags["T"].ReadCount.ShouldBe(Polls); // positive control: every poll did reach the runtime
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
AssertStateUnchanged(drv.GetHealth(), health);
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ public sealed class AbCipTypeMismatchTests
|
||||
Volatile.Read(ref creates).ShouldBe(1);
|
||||
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||
factory.Tags["T"].ReadCount.ShouldBe(Polls); // positive control: every poll did reach the runtime
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
AssertStateUnchanged(drv.GetHealth(), health);
|
||||
drv.GetHealth().State.ShouldNotBe(DriverState.Degraded);
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
@@ -121,7 +121,7 @@ public sealed class AbCipTypeMismatchTests
|
||||
|
||||
creates().ShouldBe(1);
|
||||
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
AssertStateUnchanged(drv.GetHealth(), health);
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ public sealed class AbCipTypeMismatchTests
|
||||
target.ForwardOpens.ShouldBe(1);
|
||||
target.ForwardCloses.ShouldBe(0);
|
||||
target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(Polls); // positive control: polls hit the wire
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
AssertStateUnchanged(drv.GetHealth(), health);
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
@@ -226,7 +226,7 @@ public sealed class AbCipTypeMismatchTests
|
||||
target.ForwardOpens.ShouldBe(1);
|
||||
target.ForwardCloses.ShouldBe(0);
|
||||
target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(Polls); // positive control: polls hit the wire
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
AssertStateUnchanged(drv.GetHealth(), health);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -267,12 +267,23 @@ public sealed class AbCipTypeMismatchTests
|
||||
Volatile.Read(ref creates).ShouldBe(1);
|
||||
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||
factory.Tags["T"].ReadCount.ShouldBe(Polls - 1); // positive control: later polls reached the kept runtime
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
AssertStateUnchanged(drv.GetHealth(), health);
|
||||
}
|
||||
|
||||
private sealed class CountingFactory(Func<AbCipTagCreateParams, IAbCipTagRuntime> create) : IAbCipTagFactory
|
||||
{
|
||||
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) => create(createParams);
|
||||
}
|
||||
/// <summary>
|
||||
/// A refusal leaves the health STATE and error alone; since the controller answered, LastSuccessfulRead may
|
||||
/// only move forward (review 07 H1 — any data proving the device answered advances it).
|
||||
/// </summary>
|
||||
private static void AssertStateUnchanged(DriverHealth actual, DriverHealth before)
|
||||
{
|
||||
actual.State.ShouldBe(before.State);
|
||||
actual.LastError.ShouldBe(before.LastError);
|
||||
(actual.LastSuccessfulRead ?? DateTime.MinValue).ShouldBeGreaterThanOrEqualTo(before.LastSuccessfulRead ?? DateTime.MinValue);
|
||||
}
|
||||
|
||||
#pragma warning restore OTOPCUA0001
|
||||
}
|
||||
|
||||
@@ -129,14 +129,35 @@ public sealed class NativeAbCipTagRuntimeTests
|
||||
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_tag_fails_initialize_with_NotFound()
|
||||
/// <summary>
|
||||
/// Review 07 L4: a controller refusal on the existence read completes the initialization (the session works —
|
||||
/// throwing made the driver dispose + re-create the runtime, re-leasing the connection, every poll); the
|
||||
/// refusal stays readable and every later read reports it again.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("D2000400", AbStatus.NotFound)]
|
||||
[InlineData("D2000500", AbStatus.NotFound)]
|
||||
[InlineData("D2000A00", AbStatus.OutOfBounds)]
|
||||
[InlineData("D2000F00", AbStatus.NotAllowed)]
|
||||
[InlineData("D200FF010521", AbStatus.OutOfBounds)]
|
||||
public async Task A_refused_tag_completes_initialize_and_reports_the_refusal_on_read(string reply, AbStatus expected)
|
||||
{
|
||||
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("D2000400")) };
|
||||
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString(reply)) };
|
||||
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt));
|
||||
await rt.InitializeAsync(CancellationToken.None);
|
||||
rt.GetStatus().ShouldBe((int)expected);
|
||||
(await Should.ThrowAsync<AbTransportException>(() => rt.ReadAsync(CancellationToken.None)))
|
||||
.Status.ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a reply the transport cannot classify as a refusal still fails initialize.</summary>
|
||||
[Fact]
|
||||
public async Task An_unclassified_reply_still_fails_initialize()
|
||||
{
|
||||
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("D2000100")) };
|
||||
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt));
|
||||
(await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None)))
|
||||
.Status.ShouldBe(AbStatus.NotFound);
|
||||
rt.GetStatus().ShouldBe((int)AbStatus.NotFound);
|
||||
.Status.ShouldBe(AbStatus.BadReply);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user