From b37e9613f7c01adb4b9a5ecee6779d06bc68b1db Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 06:45:51 -0400 Subject: [PATCH 1/5] test(ab): default AB matrices to one profile when unset; PLC-5/status-bit refusal fixture tests (#594, #593) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- .../TransportMatrix.cs | 10 +- .../AbLegacyUnsupportedPathTests.cs | 107 ++++++++++++++++++ .../TransportMatrix.cs | 8 +- 3 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyUnsupportedPathTests.cs diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs index 55b12b85..9a3f5fcf 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs @@ -9,7 +9,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; /// Every AbCip compose profile binds host :44818, so only one can run at a time. When /// AB_SERVER_COMPOSE_PROFILE is set, keeps only that profile, so the /// rows match the running container instead of driving, say, Micro800 semantics at a ControlLogix. -/// Unset, every profile is crossed (the original behaviour). +/// Unset, only the default ControlLogix profile is crossed: ab_server does not self-identify its +/// --plc mode, so crossing every family at whichever container holds :44818 failed the +/// non-matching rows instead of skipping them. Set AB_SERVER_COMPOSE_PROFILE to the running +/// profile (compactlogix / micro800 / guardlogix) to select another family. /// public static class TransportMatrix { @@ -24,8 +27,9 @@ public static class TransportMatrix { var only = Environment.GetEnvironmentVariable("AB_SERVER_COMPOSE_PROFILE"); return from p in KnownProfiles.All - where string.IsNullOrEmpty(only) || - string.Equals(p.ComposeProfile, only, StringComparison.OrdinalIgnoreCase) + where string.Equals(p.ComposeProfile, + string.IsNullOrEmpty(only) ? KnownProfiles.ControlLogix.ComposeProfile : only, + StringComparison.OrdinalIgnoreCase) from t in AllTransports select new object[] { p, t }; } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyUnsupportedPathTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyUnsupportedPathTests.cs new file mode 100644 index 00000000..7f04d749 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyUnsupportedPathTests.cs @@ -0,0 +1,107 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; + +/// +/// Wire-adjacent coverage of the native transport's deliberate refusals (#571): PLC-5 non-N reads, PLC-5 +/// writes and status-bit sub-element writes (T4:0.DN). Each must surface as +/// and must not count as a comms fault +/// (health stays Healthy — the comms-loss watchdog reads it). +/// +/// +/// ⚠️ Which layer refuses. These refusals are the DRIVER's, raised by NativeAbLegacyTagRuntime +/// before anything reaches the wire — the simulator is only needed so the driver has a device to hold a +/// handle against and so the positive control (an N-file read on the same driver) can prove the refusal is +/// per-tag rather than a dead connection. ab_server's PLC/5 mode covers N/F/B only, so a refusal that +/// came from the simulator would look the same from the outside; the positive control and the health +/// assertion are what tell the layers apart. Each test skips when its family's simulator is unreachable. +/// +[Collection(AbLegacyServerCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Simulator", "ab_server-PCCC")] +public sealed class AbLegacyUnsupportedPathTests(AbLegacyServerFixture sim) +{ + private AbLegacyDriver NativeDriver(AbLegacyServerProfile profile, string id, params AbLegacyTagDefinition[] tags) + { + var deviceUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}"; + return new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)], + RawTags = AbLegacyRawTags.Entries(tags.Select(t => t with { DeviceHostAddress = deviceUri }).ToArray()), + Timeout = TimeSpan.FromSeconds(5), + Probe = new AbLegacyProbeOptions { Enabled = false }, + Transport = AbLegacyTransport.Native, + }, driverInstanceId: id); + } + + private static AbLegacyTagDefinition Tag(string name, string address, AbLegacyDataType type, bool writable = false) => + new(Name: name, DeviceHostAddress: "", Address: address, DataType: type, Writable: writable); + + /// A native PLC-5 non-N read is refused locally; an N read on the same driver still works. + [AbLegacyFact] + public async Task Native_Plc5_non_N_read_is_BadNotSupported_and_leaves_health_Healthy() + { + if (sim.SkipReasonFor(KnownProfiles.Plc5) is { } skip) Assert.Skip(skip); + await using var drv = NativeDriver(KnownProfiles.Plc5, "ablegacy-plc5-unsupported-read", + Tag("Float", "F8:0", AbLegacyDataType.Float), + Tag("Int", "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + // Positive control first: the connection and the handle path work, so the refusal below is per-tag. + var control = await drv.ReadAsync(["Int"], TestContext.Current.CancellationToken); + control.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good, "PLC-5 N-file read is the verified path"); + + var refused = await drv.ReadAsync(["Float"], TestContext.Current.CancellationToken); + refused.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); + drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a driver-side refusal is not a comms fault"); + + // The refusal kept the connection: the N read still succeeds afterwards. + (await drv.ReadAsync(["Int"], TestContext.Current.CancellationToken)).Single().StatusCode + .ShouldBe(AbLegacyStatusMapper.Good); + } + + /// A native PLC-5 write is refused locally before anything reaches the device. + [AbLegacyFact] + public async Task Native_Plc5_write_is_BadNotSupported_and_leaves_health_Healthy() + { + if (sim.SkipReasonFor(KnownProfiles.Plc5) is { } skip) Assert.Skip(skip); + IntegrationWriteGate.SkipUnlessWritesAllowed(); // the write is refused, but never aim a write test at an unvetted endpoint + await using var drv = NativeDriver(KnownProfiles.Plc5, "ablegacy-plc5-unsupported-write", + Tag("Scratch", "N7:5", AbLegacyDataType.Int, writable: true)); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var results = await drv.WriteAsync([new WriteRequest("Scratch", (short)0x1234)], TestContext.Current.CancellationToken); + + results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); + drv.GetHealth().State.ShouldNotBe(DriverState.Degraded); + drv.GetHealth().State.ShouldNotBe(DriverState.Faulted); + } + + /// A native status-bit sub-element write is refused locally on the families that carry T/C/R files. + /// The status-bit address. + [AbLegacyTheory] + [InlineData("T4:0.DN")] + [InlineData("C5:0.CU")] + [InlineData("R6:0.EN")] + public async Task Native_status_bit_write_is_BadNotSupported_and_leaves_health_Healthy(string address) + { + if (sim.SkipReasonFor(KnownProfiles.Slc500) is { } skip) Assert.Skip(skip); + IntegrationWriteGate.SkipUnlessWritesAllowed(); + await using var drv = NativeDriver(KnownProfiles.Slc500, $"ablegacy-statusbit-{address}", + Tag("Scratch", "N7:5", AbLegacyDataType.Int), + Tag("Bit", address, AbLegacyDataType.Bit, writable: true)); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + // Positive control: the connection is live, so a BadNotSupported below is the driver's refusal. + (await drv.ReadAsync(["Scratch"], TestContext.Current.CancellationToken)).Single().StatusCode + .ShouldBe(AbLegacyStatusMapper.Good); + + var results = await drv.WriteAsync([new WriteRequest("Bit", true)], TestContext.Current.CancellationToken); + + results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs index fbfdc7d1..eefe2934 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs @@ -7,7 +7,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; /// /// Preserves the AB_LEGACY_COMPOSE_PROFILE filter the smoke theory always applied: when set, /// is narrowed to that profile before it is crossed with the -/// transports, so the rows match the running container. +/// transports, so the rows match the running container. Unset, every family is crossed because each +/// has its own port and skips itself when unreachable — unless AB_LEGACY_ENDPOINT pins +/// one device, in which case the family is a property of that device and cannot be detected, so only +/// the default SLC500 profile is crossed (set AB_LEGACY_COMPOSE_PROFILE to pick another). /// public static class TransportMatrix { @@ -21,6 +24,9 @@ public static class TransportMatrix get { var only = Environment.GetEnvironmentVariable("AB_LEGACY_COMPOSE_PROFILE"); + if (string.IsNullOrEmpty(only) && + Environment.GetEnvironmentVariable("AB_LEGACY_ENDPOINT") is { Length: > 0 }) + only = KnownProfiles.Slc500.ComposeProfile; return from p in KnownProfiles.All where string.IsNullOrEmpty(only) || string.Equals(p.ComposeProfile, only, StringComparison.OrdinalIgnoreCase) From 7e91c37a52e9f13e85aa8f6c24d095ac740ccb2e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 06:48:34 -0400 Subject: [PATCH 2/5] test(opcuaclient-browser): skip live browse tests when opc-plc is unreachable; move out of unit project (#594) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- .../BrowseRoundTripTests.cs | 4 +- .../OpcPlcProbe.cs | 53 +++++++++++++++++++ .../OpcUaClientBrowseSessionTests.cs | 14 ++--- 3 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/OpcPlcProbe.cs rename tests/Drivers/{ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests => ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests}/OpcUaClientBrowseSessionTests.cs (85%) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/BrowseRoundTripTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/BrowseRoundTripTests.cs index f1e1b8ab..100b9945 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/BrowseRoundTripTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/BrowseRoundTripTests.cs @@ -16,8 +16,8 @@ public class BrowseRoundTripTests [Fact] public async Task Three_level_expand_round_trips_resolve_back() { - var endpoint = Environment.GetEnvironmentVariable("OPCUA_SIM_ENDPOINT") - ?? "opc.tcp://10.100.0.35:50000"; + OpcPlcProbe.SkipUnlessReachable(); + var endpoint = OpcPlcProbe.Endpoint; // Numeric ordinals because the production browser uses default System.Text.Json // without a JsonStringEnumConverter (SecurityPolicy.None=0, SecurityMode.None=0, // AuthType.Anonymous=0). diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/OpcPlcProbe.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/OpcPlcProbe.cs new file mode 100644 index 00000000..183bcd37 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/OpcPlcProbe.cs @@ -0,0 +1,53 @@ +using System.Net; +using System.Net.Sockets; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests; + +/// +/// Reachability gate for the shared opc-plc fixture (OPCUA_SIM_ENDPOINT, default +/// opc.tcp://10.100.0.35:50000). A small copy of the probe in +/// Driver.OpcUaClient.IntegrationTests/OpcPlcFixture rather than a cross-project reference — +/// that project drags the whole driver in. Tests call first, so a +/// machine without the simulator reports Skipped instead of a connect failure. +/// +internal static class OpcPlcProbe +{ + private const string EndpointEnvVar = "OPCUA_SIM_ENDPOINT"; + private const string DefaultEndpoint = "opc.tcp://10.100.0.35:50000"; + + /// The simulator endpoint the tests should connect to. + public static string Endpoint => + Environment.GetEnvironmentVariable(EndpointEnvVar) is { Length: > 0 } e ? e : DefaultEndpoint; + + private static readonly Lazy Reason = new(Probe); + + /// Skips the calling test when opc-plc does not accept a TCP connection (probed once per run). + public static void SkipUnlessReachable() + { + if (Reason.Value is { } reason) Xunit.Assert.Skip(reason); + } + + private static string? Probe() + { + var body = Endpoint.StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase) + ? Endpoint["opc.tcp://".Length..] + : Endpoint; + var slash = body.IndexOf('/'); + if (slash >= 0) body = body[..slash]; + var colon = body.LastIndexOf(':'); + var host = colon >= 0 ? body[..colon] : body; + var port = colon >= 0 && int.TryParse(body[(colon + 1)..], out var p) ? p : 4840; + try + { + using var client = new TcpClient(AddressFamily.InterNetwork); + var addr = Dns.GetHostAddresses(host).FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) + ?? IPAddress.Loopback; + if (client.ConnectAsync(addr, port).Wait(TimeSpan.FromSeconds(2)) && client.Connected) return null; + return $"opc-plc simulator at {host}:{port} did not accept a TCP connection within 2s. Start it or override {EndpointEnvVar}."; + } + catch (Exception ex) + { + return $"opc-plc simulator at {host}:{port} unreachable: {ex.GetType().Name}: {ex.Message}. Start it or override {EndpointEnvVar}."; + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/OpcUaClientBrowseSessionTests.cs similarity index 85% rename from tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionTests.cs rename to tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/OpcUaClientBrowseSessionTests.cs index 7ea07a5e..d84794b6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/OpcUaClientBrowseSessionTests.cs @@ -3,21 +3,20 @@ using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; -namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests; +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests; /// /// Live-server tests against the opc-plc Docker fixture. Bring up with /// `lmxopcua-fix up opcuaclient` from PowerShell before running. These tests /// drive the internal OpcUaClientBrowseSession through its public factory /// so no InternalsVisibleTo is needed. -/// Filter out with --filter "Category!=RequiresOpcPlc" when the fixture -/// is unreachable. +/// Each test skips () when the fixture is unreachable. Moved here from the +/// unit project, which must hold no live-server tests (#594). /// -[Trait("Category", "RequiresOpcPlc")] +[Trait("Category", "Integration"), Trait("Fixture", "opc-plc")] public sealed class OpcUaClientBrowseSessionTests { - private static string Endpoint => - Environment.GetEnvironmentVariable("OPCUA_SIM_ENDPOINT") ?? "opc.tcp://10.100.0.35:50000"; + private static string Endpoint => OpcPlcProbe.Endpoint; // SecurityPolicy.None, SecurityMode.None, OpcUaAuthType.Anonymous are all // index 0 and can also be written as string names now that the browser carries @@ -40,6 +39,7 @@ public sealed class OpcUaClientBrowseSessionTests [Fact] public async Task RootAsync_returns_at_least_one_node() { + OpcPlcProbe.SkipUnlessReachable(); var ct = TestContext.Current.CancellationToken; var browser = new OpcUaClientDriverBrowser(); await using var session = await browser.OpenAsync(ConfigJson, ct); @@ -52,6 +52,7 @@ public sealed class OpcUaClientBrowseSessionTests [Fact] public async Task ExpandAsync_round_trips_stable_NodeId() { + OpcPlcProbe.SkipUnlessReachable(); var ct = TestContext.Current.CancellationToken; var browser = new OpcUaClientDriverBrowser(); await using var session = await browser.OpenAsync(ConfigJson, ct); @@ -67,6 +68,7 @@ public sealed class OpcUaClientBrowseSessionTests [Fact] public async Task AttributesAsync_is_empty_for_opcuaclient() { + OpcPlcProbe.SkipUnlessReachable(); var ct = TestContext.Current.CancellationToken; var browser = new OpcUaClientDriverBrowser(); await using var session = await browser.OpenAsync(ConfigJson, ct); From 21267750c708e9b36a65f10615d7bbf79f09a221 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 06:50:40 -0400 Subject: [PATCH 3/5] test(ab-transport): blocked-write timeout fault path via a stream seam (#565, #593) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- .../Eip/EncapsulationClient.cs | 11 ++- .../Eip/EncapsulationClientTests.cs | 68 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationClient.cs index 571b6ce2..2412d8d0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationClient.cs @@ -39,7 +39,7 @@ public sealed class EncapsulationClient : IAsyncDisposable private static readonly TimeSpan UnregisterBudget = TimeSpan.FromMilliseconds(500); private readonly TcpClient _tcp; - private readonly NetworkStream _stream; + private readonly Stream _stream; // Never disposed: an abandoned exchange may release it after DisposeAsync returns, and // SemaphoreSlim.Release on a disposed instance throws. It holds no unmanaged resource unless @@ -56,12 +56,17 @@ public sealed class EncapsulationClient : IAsyncDisposable /// False once the connection has faulted or been disposed. public bool IsConnected => !_faulted && Volatile.Read(ref _disposed) == 0 && _tcp.Connected; - private EncapsulationClient(TcpClient tcp) + private EncapsulationClient(TcpClient tcp, Stream? stream = null) { _tcp = tcp; - _stream = tcp.GetStream(); + _stream = stream ?? tcp.GetStream(); } + /// Test seam: wraps an already-connected but routes all frame I/O through + /// (e.g. one whose write never completes), skipping RegisterSession. The TCP + /// client is still what closes and consults. + internal static EncapsulationClient ForStream(TcpClient tcp, Stream stream) => new(tcp, stream); + /// Open the TCP connection and register an encapsulation session. /// bounds the TCP connect and the RegisterSession exchange separately. public static async Task ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken ct) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs index e70fb159..dc806427 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs @@ -295,6 +295,74 @@ public sealed class EncapsulationClientTests client.IsConnected.ShouldBeFalse(); } + /// A stream whose write never completes until released, standing in for a peer that stopped reading. + private sealed class BlockedWriteStream : Stream + { + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _writeStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Task WriteStarted => _writeStarted.Task; + public void FailPendingWrite() => _release.TrySetException(new IOException("socket closed")); + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + _writeStarted.TrySetResult(); + await _release.Task.ConfigureAwait(false); // ignores cancellation, like a stuck socket send + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => + throw new InvalidOperationException("The exchange must never reach the read side while its write is blocked."); + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + [Fact] + public async Task A_blocked_write_times_out_faults_the_session_and_leaves_no_unobserved_exception() + { + // Gitea #565 / #593: the write ignores cancellation (a half-written frame would desync the stream) but is + // bounded. A stream whose write never completes is the deterministic form of "peer stopped reading". + var unobserved = await UnobservedTaskProbe.CollectAsync(AbandonABlockedWrite, Guard); + unobserved.ShouldNotContain(x => x.Message.Contains("socket closed")); + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async Task AbandonABlockedWrite() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + using var tcp = new TcpClient(); + var accept = listener.AcceptTcpClientAsync(TestCt).AsTask(); + await tcp.ConnectAsync(IPAddress.Loopback, port, TestCt); + using var peer = await accept; + + var stream = new BlockedWriteStream(); + await using var client = EncapsulationClient.ForStream(tcp, stream); + client.IsConnected.ShouldBeTrue(); + + var call = client.SendRRDataAsync(new byte[] { 0x01 }, TimeSpan.FromMilliseconds(250), TestCt); + await stream.WriteStarted.WaitAsync(Guard, TestCt); // positive control: the write really was attempted + var ex = await Should.ThrowAsync(call.WaitAsync(Guard, TestCt)); + ex.Status.ShouldBe(AbStatus.Timeout); + client.IsConnected.ShouldBeFalse(); + + // Faulted: the next call is refused up front rather than queued behind the stuck write. + var next = await Should.ThrowAsync( + client.SendRRDataAsync(new byte[] { 0x01 }, Generous, TestCt)); + next.Status.ShouldBe(AbStatus.BadConnection); + + // Closing the socket is what fails the pending write in production; the abandoned write task then faults + // and must have been observed. + stream.FailPendingWrite(); + } + [Fact] public async Task Connected_sequence_is_assigned_inside_the_gate() { From 6a6ac9a11f43a887c8be87d9bb1efe89b44c5ab8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 06:52:47 -0400 Subject: [PATCH 4/5] fix(abcip): libplctag ErrorBadParam at tag create is a per-tag config error, no device backoff (#570, #593) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- .../LibplctagStatusTranslation.cs | 26 +++++++++++++ .../LibplctagTagRuntime.cs | 2 +- .../LibplctagStatusTranslationTests.cs | 37 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs index 3d695bdd..059dbc87 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs @@ -37,6 +37,32 @@ internal static class LibplctagStatusTranslation ? ToAbStatus(s) : AbStatus.BadConnection; + /// + /// True when is libplctag refusing the tag's own attributes (ErrorBadParam: + /// the name or a create attribute does not parse), as opposed to a transport failure. + /// + /// The exception. + public static bool IsTagParameterRefusal(LibPlcTagException ex) => + Enum.TryParse((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out var s) && s == Status.ErrorBadParam; + + /// + /// Runs a tag's initialisation: ErrorBadParam there means libplctag rejected the tag's own + /// name before any I/O, so it is raised as (per-tag + /// BadConfigurationError, no backoff, no health signal — Gitea #570/#593) instead of falling through + /// to the generic default that backs the whole device off. Only the + /// create phase: a BadParam from a read or write on a live handle is not a name problem. + /// + /// The libplctag initialisation. + /// A task completing when the initialisation does. + public static async Task WrapInitialize(Func op) + { + try { await op().ConfigureAwait(false); } + catch (LibPlcTagException ex) when (IsTagParameterRefusal(ex)) + { + throw new AbCipTagConfigurationException($"libplctag rejected the tag's name or attributes: {ex.Message}", ex); + } + } + /// Runs , rethrowing any as . /// The libplctag operation. /// A task completing when the operation does. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs index a12885ff..1b4d051e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs @@ -52,7 +52,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime /// public Task InitializeAsync(CancellationToken cancellationToken) => - RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken)); + RunReleasingOrphanAsync(() => LibplctagStatusTranslation.WrapInitialize(() => _tag.InitializeAsync(cancellationToken))); /// /// libplctag initialises an uninitialised tag on its first read, so this path can orphan one too. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs index 39bc958a..fba8f7ea 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs @@ -111,4 +111,41 @@ public sealed class LibplctagStatusTranslationTests ex.Status.ShouldBe(AbStatus.Timeout); ex.InnerException.ShouldBeSameAs(inner); } + + /// + /// #593 / #570: ErrorBadParam during initialisation is libplctag refusing the tag's own name — a + /// per-tag configuration error (the driver's no-backoff, no-health path), not a device connect failure. + /// + [Fact] + public async Task WrapInitialize_maps_ErrorBadParam_to_a_tag_configuration_exception() + { + var thrown = await Should.ThrowAsync(() => + LibplctagStatusTranslation.WrapInitialize(() => throw new LibPlcTagException(Status.ErrorBadParam))); + thrown.InnerException.ShouldBeOfType(); + } + + /// + /// Falsification pair for the above: every OTHER initialisation failure is left alone (it still reaches + /// Wrap and becomes a transport status that opens the backoff window). + /// + /// The libplctag status. + [Theory] + [InlineData(Status.ErrorTimeout)] + [InlineData(Status.ErrorBadGateway)] + [InlineData(Status.ErrorNotFound)] + public async Task WrapInitialize_leaves_other_statuses_untouched(Status status) + { + var thrown = await Should.ThrowAsync(() => + LibplctagStatusTranslation.WrapInitialize(() => throw new LibPlcTagException(status))); + thrown.ShouldNotBeOfType(); + } + + /// A BadParam on the read/write path (through Wrap) is not reclassified as configuration. + [Fact] + public async Task Wrap_still_maps_ErrorBadParam_to_a_transport_status() + { + var thrown = await Should.ThrowAsync(() => + LibplctagStatusTranslation.Wrap(() => throw new LibPlcTagException(Status.ErrorBadParam))); + thrown.Status.ShouldBe(AbStatus.BadConnection); + } } From 7e36842556b2941f359f6aad2a2498c932b1a08b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 06:58:25 -0400 Subject: [PATCH 5/5] test(abcip): ab_server soak of ConnectionLease.ReleaseInBackground; fixture docs (#579, #593, #594) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- docs/drivers/AbLegacy-Test-Fixture.md | 5 +- docs/drivers/AbServer-Test-Fixture.md | 2 +- .../NativeLeaseReleaseSoakTests.cs | 77 +++++++++++++++++++ .../Docker/README.md | 2 +- 4 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/NativeLeaseReleaseSoakTests.cs diff --git a/docs/drivers/AbLegacy-Test-Fixture.md b/docs/drivers/AbLegacy-Test-Fixture.md index 2c7f459a..ecf9894d 100644 --- a/docs/drivers/AbLegacy-Test-Fixture.md +++ b/docs/drivers/AbLegacy-Test-Fixture.md @@ -36,8 +36,9 @@ supplies a `FakeAbLegacyTag`. The `AbLegacy.IntegrationTests` suites now run **once per transport** (`TransportMatrix`: `Libplctag` and `Native`). Set `AB_LEGACY_COMPOSE_PROFILE` to the running profile. On the 2026-10-07 run native passed -everything libplctag passed on `slc500`, `micrologix` and `plc5` (the PLC-5 test reads N7 only, so the PLC-5 -refusal paths and status-bit writes are not exercised by it). Native PCCC is **simulator-only**. Write-capable +everything libplctag passed on `slc500`, `micrologix` and `plc5` (the smoke tests read N7 only; the PLC-5 +refusal paths and status-bit write refusals are exercised by `AbLegacyUnsupportedPathTests`, native only, #593 — the +refusals are the driver's own, raised before the wire, so each test carries a positive-control read). Native PCCC is **simulator-only**. Write-capable tests (the SLC500 N7:5 round trip) require `AB_SERVER_ALLOW_WRITES=1`. Golden frames: `scripts/ab-capture/capture.sh`, fixtures under `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/`. Evidence: diff --git a/docs/drivers/AbServer-Test-Fixture.md b/docs/drivers/AbServer-Test-Fixture.md index 74a071e8..7c22622e 100644 --- a/docs/drivers/AbServer-Test-Fixture.md +++ b/docs/drivers/AbServer-Test-Fixture.md @@ -37,7 +37,7 @@ quirk. UDT / alarm / quirk behavior is verified only by unit tests with Every integration test in `AbCip.IntegrationTests` now runs **once per transport** (`TransportMatrix`: `Libplctag` and `Native`). Run one compose profile at a time and name it with `AB_SERVER_COMPOSE_PROFILE` -(all profiles bind `:44818`, so set it to the running profile). On the 2026-10-07 run native passed everything +(all profiles bind `:44818`, so set it to the running profile; **unset, only the default `controllogix` rows run** — ab_server does not self-identify its `--plc` mode, so crossing every family at one container failed the non-matching rows instead of skipping them, #594). `NativeLeaseReleaseSoakTests` is the ab_server soak of the background lease release (`AB_LEASE_SOAK_CYCLES`, default 100; read-only, skips without the fixture, #593). On the 2026-10-07 run native passed everything libplctag passed on all seven profiles; on `micro800` libplctag failed on the empty-path defect described in [AbCip.md](AbCip.md#known-gaps) and native passed. That defect is now fixed: a re-run of the `micro800` smoke test passes on both transports. Evidence: diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/NativeLeaseReleaseSoakTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/NativeLeaseReleaseSoakTests.cs new file mode 100644 index 00000000..0624ebb8 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/NativeLeaseReleaseSoakTests.cs @@ -0,0 +1,77 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; + +/// +/// Gitea #579 / #593: soak of ConnectionLease.ReleaseInBackground against a real ab_server. +/// The offline NativeAbCipLeaseReleaseTests pin the ordering against a fake CIP target for 25 cycles; +/// this drives the same release-then-immediately-recreate loop over a real socket and the simulator's own +/// connection bookkeeping, where a Forward Open racing the predecessor's still-in-flight Forward Close would +/// be refused or would leak a connection slot. +/// +/// +/// Skips when ab_server is unreachable (). Cycle count: +/// AB_LEASE_SOAK_CYCLES (default 100). Read-only against the target (one DINT read per cycle), so it +/// needs no write opt-in. +/// +[Trait("Category", "Integration")] +[Trait("Requires", "AbServer")] +public sealed class NativeLeaseReleaseSoakTests +{ + private static readonly TimeSpan Guard = TimeSpan.FromSeconds(60); + + private static int Cycles => + int.TryParse(Environment.GetEnvironmentVariable("AB_LEASE_SOAK_CYCLES"), out var n) && n > 0 ? n : 100; + + /// + /// Each cycle: create a native runtime, initialise, read, then Dispose (the background release) and + /// re-create at once. Every read must be Good, exactly one Forward Open and one Forward Close per cycle + /// must hit the wire (a re-create that overlapped the close, or a close that never ran, shows as a count + /// mismatch), and the pool must end with no live entry. + /// + [AbServerFact] + public async Task Release_in_background_then_immediate_recreate_never_overlaps_or_leaks_against_ab_server() + { + var fixture = new AbServerFixture(KnownProfiles.ControlLogix); + var pool = new DeviceSessionPool(); + var factory = new NativeAbCipTagFactory(pool); + var cycles = Cycles; + var teardowns = new List(cycles); + + var createParams = new AbCipTagCreateParams( + Gateway: fixture.Host, + Port: fixture.Port, + CipPath: "1,0", + LibplctagPlcAttribute: "controllogix", + TagName: "TestDINT", + Timeout: TimeSpan.FromSeconds(5), + AllowPacking: false, + ElementCount: 1, + IsArray: false, + AuthoredType: AbCipDataType.DInt, + UseConnectedMessaging: true); + + using var budget = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + budget.CancelAfter(TimeSpan.FromSeconds(120)); + + for (var i = 0; i < cycles; i++) + { + var runtime = (NativeAbCipTagRuntime)factory.Create(createParams); + await runtime.InitializeAsync(budget.Token); + await runtime.ReadAsync(budget.Token); + runtime.GetStatus().ShouldBe(0, $"cycle {i}: read status"); + runtime.DecodeValue(AbCipDataType.DInt, null).ShouldNotBeNull($"cycle {i}: decoded value"); + + runtime.Dispose(); // background release; the next Create must queue behind its close + teardowns.Add(runtime.TeardownTask ?? Task.CompletedTask); + } + + await Task.WhenAll(teardowns).WaitAsync(Guard, TestContext.Current.CancellationToken); + + pool.EntryCount.ShouldBe(0, "every lease was released, so no pooled connection may remain"); + pool.Diagnostics.ForwardOpens.ShouldBe(cycles, "one Forward Open per cycle: a re-create must wait for its predecessor's close"); + pool.Diagnostics.ForwardClosesSent.ShouldBe(cycles, "one Forward Close per cycle: none dropped, none doubled"); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md index 3aec0909..18b8aef2 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md @@ -54,7 +54,7 @@ families stop the current service + start another. | `AB_LEGACY_ENDPOINT` | *unset* — per-family ports on `10.100.0.35` | `host:port` of a single PCCC device, pinning **every** family to it (real-hardware mode). | | `AB_LEGACY_CIP_PATH` | `1,0` | CIP routing path portion of the `ab://host:port/` URI. ab_server rejects empty paths at the CIP unconnected-send layer; real SLC/MicroLogix/PLC-5 hardware accepts empty (no backplane). Set to empty (`AB_LEGACY_CIP_PATH=`) when pointing at real hardware. | | `AB_SERVER_ALLOW_WRITES` | *unset* | **Write opt-in.** Every write-capable test (today: the SLC500 N7:5 write-then-read round trip) skips unless this is `1`. Set it for simulator runs; **never** set it when an endpoint variable points at production hardware. Write tests also refuse while `AB_DIFF_ENDPOINT` (the AbCip read-only differential) is set. | -| `AB_LEGACY_COMPOSE_PROFILE` | *unset* | Only consulted when `AB_LEGACY_ENDPOINT` pins an endpoint: names which family that one device is, so the SLC500-only write test skips against other hardware. Against the Docker fixture it is unnecessary — each family has its own port, and each test skips itself if its own port is down. | +| `AB_LEGACY_COMPOSE_PROFILE` | *unset* | Only consulted when `AB_LEGACY_ENDPOINT` pins an endpoint: names which family that one device is, so the SLC500-only write test skips against other hardware. When `AB_LEGACY_ENDPOINT` is set and this is not, only the `slc500` rows run (the family of a pinned device cannot be detected). Against the Docker fixture it is unnecessary — each family has its own port, and each test skips itself if its own port is down. | ## Run the integration tests