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/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/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.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()
{
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.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.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);
+ }
}
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/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
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)
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);