f37cb2a4a3
N1: a refusal of the DWORD-addressed request permanently settled the layout as per-element, so a tag deployed before the online edit that creates its BOOL array later read DWORD n and published byte 0 as Good. The native runtime now keeps an explicit layout (Unsettled / Packed / PerElement) that ONLY a reply type changes: 0xD3 settles packed (Bits[n/32], bit n%32), 0xC1 settles per-element (Bits[n]). Refusals and comms faults leave it unsettled, so the next read asks again. A reply whose type disagrees with the addressing it was asked under is discarded and re-read once under the shown layout (it would be some other element), also when an online edit re-types a settled array. A scalar BOOL answered 0xD3 with no element bit, or not settled packed, is refused BadTypeMismatch instead of decoding byte 0. m1: the write-owned Degraded marker is set together with the CAS that installs it, under one lock, so concurrent failing writes cannot leave it naming a different instance (a good write then clears it); a refusal-only read batch still keeps it write-owned. m2/m3/m5: documented (breaker accounting of a superseded init; libplctag throw-only NotImplemented assumption; the TypeMismatch write status word is libplctag-only). m4: test for a write on an unsettled layout (settles by a read first). Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
176 lines
7.7 KiB
C#
176 lines
7.7 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
|
|
|
/// <summary>
|
|
/// Review 07 H19: write-side health. A client value that cannot be converted never touched the wire, so it must
|
|
/// not latch <c>Degraded</c>; and a good write must not overwrite a <c>Degraded</c> the read path just recorded
|
|
/// with <c>Healthy</c> (the batch read path is worst-wins for exactly this reason). A good write proves the device
|
|
/// answered, so it advances <c>LastSuccessfulRead</c> without touching the state.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class AbCipWriteHealthTests
|
|
{
|
|
private const string Device = "ab://10.0.0.5/1,0";
|
|
|
|
private sealed class ThrowingWriteTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
|
{
|
|
public override Task WriteAsync(CancellationToken cancellationToken) =>
|
|
throw new InvalidOperationException("socket reset");
|
|
}
|
|
|
|
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync(
|
|
Func<AbCipTagCreateParams, FakeAbCipTag>? make = null)
|
|
{
|
|
var factory = new FakeAbCipTagFactory
|
|
{
|
|
Customise = make ?? (p => p.TagName == "Broken"
|
|
? new FakeAbCipTag(p) { ThrowOnRead = true }
|
|
: new RealEncodingTag(p) { Value = 0 }),
|
|
};
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions(Device)],
|
|
RawTags = AbCipRawTags.From(
|
|
new AbCipTagDefinition("D", Device, "D", AbCipDataType.DInt),
|
|
new AbCipTagDefinition("I", Device, "I", AbCipDataType.Int),
|
|
new AbCipTagDefinition("Broken", Device, "Broken", AbCipDataType.DInt)),
|
|
Probe = new AbCipProbeOptions { Enabled = false },
|
|
}, "abcip-write-health", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
return (drv, factory);
|
|
}
|
|
|
|
/// <summary>A fake whose encode converts like the real runtimes do (Convert.ToInt32 / ToInt16).</summary>
|
|
private sealed class RealEncodingTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
|
{
|
|
public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value) =>
|
|
Value = type switch
|
|
{
|
|
AbCipDataType.Int => Convert.ToInt16(value),
|
|
_ => Convert.ToInt32(value),
|
|
};
|
|
}
|
|
|
|
/// <summary>Unconvertible / out-of-range client values: refused per tag, health untouched, nothing sent.</summary>
|
|
[Fact]
|
|
public async Task A_client_conversion_error_writes_no_health()
|
|
{
|
|
var (drv, factory) = await BuildAsync();
|
|
var before = drv.GetHealth();
|
|
|
|
var r = await drv.WriteAsync(
|
|
[
|
|
new WriteRequest("D", "not a number"), // FormatException
|
|
new WriteRequest("D", new object()), // InvalidCastException
|
|
new WriteRequest("I", 1_000_000), // OverflowException
|
|
], CancellationToken.None);
|
|
|
|
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
|
r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
|
r[2].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
|
|
drv.GetHealth().ShouldBe(before);
|
|
factory.Tags["D"].WriteCount.ShouldBe(0);
|
|
factory.Tags["I"].WriteCount.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>A good write keeps a Degraded the read path recorded, and advances LastSuccessfulRead.</summary>
|
|
[Fact]
|
|
public async Task A_good_write_does_not_overwrite_Degraded_with_Healthy()
|
|
{
|
|
var (drv, _) = await BuildAsync();
|
|
await drv.ReadAsync(["Broken"], CancellationToken.None);
|
|
var degraded = drv.GetHealth();
|
|
degraded.State.ShouldBe(DriverState.Degraded);
|
|
await Task.Delay(20);
|
|
|
|
var r = await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None);
|
|
|
|
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
|
var after = drv.GetHealth();
|
|
after.State.ShouldBe(DriverState.Degraded);
|
|
after.LastError.ShouldBe(degraded.LastError);
|
|
after.LastSuccessfulRead!.Value.ShouldBeGreaterThan(degraded.LastSuccessfulRead!.Value); // the device answered
|
|
}
|
|
|
|
/// <summary>Positive control: a genuine write transport fault still degrades and preserves LastSuccessfulRead.</summary>
|
|
[Fact]
|
|
public async Task A_write_transport_fault_still_degrades()
|
|
{
|
|
var (drv, factory) = await BuildAsync(p => new ThrowingWriteTag(p));
|
|
var before = drv.GetHealth();
|
|
|
|
var r = await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None);
|
|
|
|
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
|
var after = drv.GetHealth();
|
|
after.State.ShouldBe(DriverState.Degraded);
|
|
after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
|
|
factory.Tags["D"].Disposed.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>M3: a good write clears a Degraded that a WRITE comms fault recorded.</summary>
|
|
[Fact]
|
|
public async Task A_good_write_clears_a_write_side_Degraded()
|
|
{
|
|
var fail = true;
|
|
var (drv, _) = await BuildAsync(p => fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
|
|
(await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None))[0].StatusCode
|
|
.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
|
|
fail = false; // the evicted runtime is re-created healthy
|
|
(await drv.WriteAsync([new WriteRequest("D", 6)], CancellationToken.None))[0].StatusCode
|
|
.ShouldBe(AbCipStatusMapper.Good);
|
|
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
drv.GetHealth().LastError.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// m1: many concurrent failing writes leave the write-owned marker naming the installed Degraded, so one good
|
|
/// write still clears it.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Concurrent_failing_writes_then_a_good_write_clear_the_write_side_Degraded()
|
|
{
|
|
var fail = true;
|
|
var (drv, _) = await BuildAsync(p => fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
|
|
for (var round = 0; round < 20; round++)
|
|
{
|
|
fail = true;
|
|
await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() =>
|
|
drv.WriteAsync([new WriteRequest("D", 1), new WriteRequest("I", 2)], CancellationToken.None))));
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
|
|
fail = false;
|
|
(await drv.WriteAsync([new WriteRequest("D", 3)], CancellationToken.None))[0].StatusCode
|
|
.ShouldBe(AbCipStatusMapper.Good);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy, $"round {round}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// M3: a refusal-only read batch in between (no read comms fault happened) keeps the Degraded write-owned, so a
|
|
/// good write still clears it.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_refusal_only_read_between_keeps_the_Degraded_write_owned()
|
|
{
|
|
var fail = true;
|
|
var (drv, _) = await BuildAsync(p => p.TagName == "Broken"
|
|
? new FakeAbCipTag(p) { Status = (int)ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbStatus.NotFound }
|
|
: fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p));
|
|
await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
(await drv.ReadAsync(["Broken"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
|
|
|
fail = false;
|
|
await drv.WriteAsync([new WriteRequest("D", 6)], CancellationToken.None);
|
|
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
}
|