fix(abcip): final review I4 — a type mismatch is a refusal, not a comms fault

A native TypeMismatch (read status, write gate, or CIP 0xFF/0x2107) used
to evict the runtime and degrade health on every poll; for a sole lease
holder that meant Forward Close + Forward Open per poll, and an all-
mismatched driver tripped the comms-loss watchdog. It now reports
BadTypeMismatch, keeps the handle, leaves health alone and warns once per
tag per instance — reads, whole-UDT reads and writes, plus the libplctag
width check. Tests (red against the pre-fix driver): single create over N
polls, unchanged health, one warning, and one Forward Open / zero Forward
Close over the real native runtime against FakeCipTarget. AbLegacy has no
TypeMismatch path (its native runtime has no type check).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 21:40:04 -04:00
parent 2cea1a0687
commit 352ea449e9
5 changed files with 247 additions and 8 deletions
@@ -0,0 +1,159 @@
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
using CapturingLogger = ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.AbCipLoggingTests.CapturingLogger<ZB.MOM.WW.OtOpcUa.Driver.AbCip.AbCipDriver>;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Final review I4: a type mismatch (the controller answered with a type that disagrees with the authored
/// one) is a deterministic configuration refusal, handled like <see cref="AbStatus.Unsupported"/>:
/// <c>BadTypeMismatch</c>, the handle is KEPT (one create across N polls), health is unchanged, and the
/// Warning is logged once per tag — not per poll. Before the fix each poll evicted the runtime, so a sole
/// lease holder cost a Forward Close + Forward Open on every poll and the driver sat Degraded. The
/// control tests (a genuine transport failure still evicts and degrades) live in
/// <see cref="AbCipLocalRefusalTests"/> and below.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipTypeMismatchTests
{
private const string Host = "ab://10.0.0.5/1,0";
private const int Polls = 4;
/// <summary>A fake whose read completes but reports <see cref="AbStatus.TypeMismatch"/> (the native
/// runtime's same-width check), and whose write throws it (the native write gate / CIP 0xFF 0x2107).</summary>
private sealed class MismatchTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
public override async Task ReadAsync(CancellationToken cancellationToken)
{
await base.ReadAsync(cancellationToken);
Status = (int)AbStatus.TypeMismatch;
}
public override Task WriteAsync(CancellationToken cancellationToken) =>
Task.FromException(new AbTransportException(AbStatus.TypeMismatch, "REAL on the controller, DINT authored"));
}
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory, Func<int> Creates, CapturingLogger Log)>
NewAsync()
{
var creates = 0;
var factory = new FakeAbCipTagFactory
{
Customise = p => { Interlocked.Increment(ref creates); return new MismatchTag(p) { Value = 1 }; },
};
var log = new CapturingLogger();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Host)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", AbCipDataType.DInt, Writable: true)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-i4", factory, logger: log);
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, factory, () => Volatile.Read(ref creates), log);
}
private static int MismatchWarnings(CapturingLogger log) =>
log.Entries.Count(e => e.Level == LogLevel.Warning && e.Message.Contains("logged once per tag"));
#pragma warning disable OTOPCUA0001 // the driver's own status handling is under test, not resilience wrapping
[Fact]
public async Task A_type_mismatched_read_keeps_the_handle_leaves_health_alone_and_warns_once()
{
var (drv, factory, creates, log) = await NewAsync();
var health = drv.GetHealth();
for (var n = 0; n < Polls; n++)
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode
.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
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);
MismatchWarnings(log).ShouldBe(1);
}
[Fact]
public async Task A_type_mismatched_write_keeps_the_handle_leaves_health_alone_and_warns_once()
{
var (drv, factory, creates, log) = await NewAsync();
var health = drv.GetHealth();
for (var n = 0; n < Polls; n++)
(await drv.WriteAsync([new WriteRequest("T", 5)], CancellationToken.None)).Single().StatusCode
.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
creates().ShouldBe(1);
factory.Tags["T"].Disposed.ShouldBeFalse();
drv.GetHealth().ShouldBe(health);
MismatchWarnings(log).ShouldBe(1);
}
/// <summary>Control: a genuine transport failure on a READ still evicts and degrades.</summary>
[Fact]
public async Task A_transport_failure_read_still_evicts_and_degrades()
{
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection } };
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Host)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-i4", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode
.ShouldNotBe(AbCipStatusMapper.BadTypeMismatch);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
factory.Tags["T"].Disposed.ShouldBeTrue();
}
/// <summary>
/// End to end over the REAL native runtime + a private <see cref="DeviceSessionPool"/> against a fake
/// CIP target that answers every read with a REAL for a DINT-authored tag. The tag is the only lease
/// holder (probe off), so an evict would be the LAST release: Forward Close, then a throttled Forward
/// Open on the next poll. One Forward Open across N polls and no Forward Close proves there is no
/// connection churn.
/// </summary>
[Fact]
public async Task A_native_type_mismatch_causes_no_connection_churn()
{
await using var target = new FakeCipTarget();
// Read Tag Fragmented (0x52) → success reply typed REAL (0x00CA) with 4 data bytes.
target.ConnectedHandler = req => Task.FromResult<byte[]?>(
req[0] == 0x52 ? [0xD2, 0x00, 0x00, 0x00, 0xCA, 0x00, 0x00, 0x00, 0x80, 0x3F] : target.ServiceReply(req));
var pool = new DeviceSessionPool();
var host = $"ab://127.0.0.1:{target.Port}/1,0";
var log = new CapturingLogger();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", host, "T", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(5),
}, "drv-i4-native", new NativeAbCipTagFactory(pool), logger: log);
await drv.InitializeAsync("{}", CancellationToken.None);
var health = drv.GetHealth();
try
{
for (var n = 0; n < Polls; n++)
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode
.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
target.ForwardOpens.ShouldBe(1);
target.ForwardCloses.ShouldBe(0);
target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(Polls); // positive control: polls hit the wire
drv.GetHealth().ShouldBe(health);
MismatchWarnings(log).ShouldBe(1);
}
finally
{
await drv.ShutdownAsync(CancellationToken.None);
}
}
#pragma warning restore OTOPCUA0001
}
@@ -34,6 +34,8 @@
<ItemGroup>
<!-- Native transport test fake, source-linked so it stays internal (Task 12). -->
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Fakes\FakeCipChannel.cs" Link="Fakes\FakeCipChannel.cs" />
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Fakes\FakeEipServer.cs" Link="Fakes\FakeEipServer.cs" />
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Fakes\FakeCipTarget.cs" Link="Fakes\FakeCipTarget.cs" />
</ItemGroup>
</Project>