Merge R2-09 Driver fleet batch (arch-review round 2) [PR #437]
v2-ci / build (push) Successful in 3m51s
v2-ci / unit-tests (push) Failing after 9m22s

Findings 05/STAB-3/4/5/8/12/16/17 + CONV-4 + onError (6 sub-batches, 29 tasks):
Modbus desync teardown, AbCip runtime lock, FOCAS concurrent caches, new
ConnectionBackoff + PollGroupEngine v2 (S7 poll fork deleted onto it, reconciled
with R2-01), fleet connect throttles, ResolveHost via resolver, TwinCAT replay
hardening. 29/29 tasks. S7 254/254 verified post-merge; build clean.
This commit is contained in:
Joseph Doherty
2026-07-13 12:48:12 -04:00
35 changed files with 2786 additions and 355 deletions
@@ -0,0 +1,98 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Covers <see cref="ConnectionBackoff"/> — the shared capped-exponential backoff extracted
/// from the S7 poll fork (05/STAB-8; the seam plan R2-01 wires into the S7 connect throttle).
/// Two surfaces: the static <see cref="ConnectionBackoff.ComputeDelay"/> schedule and the
/// per-device attempt-throttle instance.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ConnectionBackoffTests
{
private static readonly TimeSpan Base = TimeSpan.FromSeconds(1);
private static readonly TimeSpan Cap = TimeSpan.FromSeconds(30);
/// <summary>Zero (or negative) consecutive failures returns the base interval unchanged.</summary>
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void ComputeDelay_NoFailures_ReturnsBaseInterval(int failures)
=> ConnectionBackoff.ComputeDelay(Base, failures, Cap).ShouldBe(Base);
/// <summary>The delay doubles per consecutive failure (1×, 2×, 4×, 8×) until it saturates the cap.</summary>
[Theory]
[InlineData(1, 1)]
[InlineData(2, 2)]
[InlineData(3, 4)]
[InlineData(4, 8)]
[InlineData(5, 16)]
public void ComputeDelay_DoublesPerFailure(int failures, int expectedSeconds)
=> ConnectionBackoff.ComputeDelay(Base, failures, Cap)
.ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
/// <summary>Growth saturates at the cap and never exceeds it, even at a large failure count (overflow guard).</summary>
[Theory]
[InlineData(6)] // 32s would exceed 30s cap
[InlineData(30)]
[InlineData(1000)] // shift saturates; ticks overflow guard returns cap
public void ComputeDelay_SaturatesAtCap(int failures)
=> ConnectionBackoff.ComputeDelay(Base, failures, Cap).ShouldBe(Cap);
/// <summary>A fresh throttle permits the first attempt immediately.</summary>
[Fact]
public void ShouldAttempt_FreshInstance_AllowsImmediately()
{
var backoff = new ConnectionBackoff(Base, Cap);
backoff.ShouldAttempt(DateTime.UtcNow).ShouldBeTrue();
}
/// <summary>After a failure the throttle blocks inside the backoff window and reopens once it elapses.</summary>
[Fact]
public void RecordFailure_BlocksWithinWindow_ReopensAfter()
{
var backoff = new ConnectionBackoff(Base, Cap);
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
backoff.RecordFailure(t0); // window = 1s (first failure)
backoff.ShouldAttempt(t0).ShouldBeFalse(); // still inside window
backoff.ShouldAttempt(t0.AddMilliseconds(500)).ShouldBeFalse();
backoff.ShouldAttempt(t0.AddSeconds(1)).ShouldBeTrue(); // window elapsed
}
/// <summary>Consecutive failures widen the window (1s then 2s).</summary>
[Fact]
public void RecordFailure_ConsecutiveFailures_WidenWindow()
{
var backoff = new ConnectionBackoff(Base, Cap);
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
backoff.RecordFailure(t0); // 1s
backoff.RecordFailure(t0.AddSeconds(1)); // 2nd failure → 2s window
backoff.ShouldAttempt(t0.AddSeconds(2)).ShouldBeFalse(); // within the 2s window
backoff.ShouldAttempt(t0.AddSeconds(3)).ShouldBeTrue();
}
/// <summary>Success resets immediately — recovery is never delayed by a residual window.</summary>
[Fact]
public void RecordSuccess_ResetsWindowImmediately()
{
var backoff = new ConnectionBackoff(Base, Cap);
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
backoff.RecordFailure(t0);
backoff.RecordFailure(t0); // deep in a widened window
backoff.ShouldAttempt(t0).ShouldBeFalse();
backoff.RecordSuccess();
backoff.ShouldAttempt(t0).ShouldBeTrue(); // reset, no residual delay
// And the schedule restarts from the base window on the next failure.
backoff.RecordFailure(t0);
backoff.ShouldAttempt(t0.AddMilliseconds(999)).ShouldBeFalse();
backoff.ShouldAttempt(t0.AddSeconds(1)).ShouldBeTrue();
}
}
@@ -461,6 +461,143 @@ public sealed class PollGroupEngineTests
events.Count.ShouldBeGreaterThanOrEqualTo(1);
}
// ---- PollGroupEngine v2: failure backoff + caller-token-filtered OCE (CONV-1 / STAB-8 / STAB-14) ----
/// <summary>
/// Sustained reader failures must stretch the poll cadence (capped exponential backoff)
/// instead of hammering a dead device at the base interval every tick.
/// </summary>
[Fact]
public async Task Backoff_SustainedReaderFailures_StretchesCadenceToCap()
{
var readCount = 0;
Task<IReadOnlyList<DataValueSnapshot>> Reader(IReadOnlyList<string> refs, CancellationToken ct)
{
Interlocked.Increment(ref readCount);
throw new InvalidOperationException("dead device");
}
await using var engine = new PollGroupEngine(
Reader,
(_, _, _) => { },
minInterval: TimeSpan.FromMilliseconds(20),
onError: _ => { },
backoffCap: TimeSpan.FromMilliseconds(120));
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(20));
await Task.Delay(700, TestContext.Current.CancellationToken);
engine.Unsubscribe(handle);
// Backoff schedule from a 20 ms base: 20,40,80,120,120,... → ≈8-9 reads in 700 ms.
// A no-backoff 20 ms loop would fire ≈35. Assert the cadence is clearly stretched.
readCount.ShouldBeGreaterThanOrEqualTo(2);
readCount.ShouldBeLessThan(18);
}
/// <summary>A successful poll resets the failure count so the cadence snaps back to the base interval.</summary>
[Fact]
public async Task Backoff_SuccessResetsToInterval()
{
var readCount = 0;
var generation = 0;
Task<IReadOnlyList<DataValueSnapshot>> Reader(IReadOnlyList<string> refs, CancellationToken ct)
{
// First few reads fail (winding the backoff up toward the cap); thereafter every read
// returns a fresh changing value so each successful poll raises a change event.
if (Interlocked.Increment(ref readCount) <= 3)
throw new InvalidOperationException("warming up failures");
var gen = Interlocked.Increment(ref generation);
var now = DateTime.UtcNow;
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(
refs.Select(_ => new DataValueSnapshot(gen, 0u, now, now)).ToList());
}
var events = new ConcurrentQueue<string>();
await using var engine = new PollGroupEngine(
Reader,
(_, r, _) => events.Enqueue(r),
minInterval: TimeSpan.FromMilliseconds(20),
onError: _ => { },
backoffCap: TimeSpan.FromMilliseconds(300));
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(20));
// After recovery the cadence must be the fast 20 ms base, not the wound-up cap — so many
// change events accumulate quickly. A non-resetting impl stays near the 300 ms cap (≈3).
await WaitForAsync(() => events.Count >= 8, TimeSpan.FromSeconds(3));
engine.Unsubscribe(handle);
events.Count.ShouldBeGreaterThanOrEqualTo(8);
}
/// <summary>
/// STAB-14-class engine guard: a reader that throws <see cref="OperationCanceledException"/>
/// WITHOUT the caller/loop token being cancelled (a driver-internal timeout CTS) must be
/// treated as a normal failure — reported + retried — NOT as a loop teardown. Before the
/// fix the bare <c>catch (OCE) { return; }</c> killed the whole subscription on the first
/// such OCE.
/// </summary>
[Fact]
public async Task ReaderOperationCanceled_WithoutCallerCancellation_TreatedAsFailureNotTeardown()
{
var observed = new ConcurrentQueue<Exception>();
var events = new ConcurrentQueue<string>();
var readCount = 0;
Task<IReadOnlyList<DataValueSnapshot>> Reader(IReadOnlyList<string> refs, CancellationToken ct)
{
// A driver-internal timeout OCE — the loop token (ct) is NOT cancelled.
if (Interlocked.Increment(ref readCount) <= 2)
throw new OperationCanceledException("driver-internal read timeout");
var now = DateTime.UtcNow;
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(
refs.Select(_ => new DataValueSnapshot(1, 0u, now, now)).ToList());
}
await using var engine = new PollGroupEngine(
Reader,
(_, r, _) => events.Enqueue(r),
minInterval: TimeSpan.FromMilliseconds(30),
onError: ex => observed.Enqueue(ex),
backoffCap: TimeSpan.FromMilliseconds(120));
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(30));
// The loop must SURVIVE the reader OCEs and eventually deliver a change once the reader
// recovers — proving the OCE was not treated as teardown.
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(3));
engine.Unsubscribe(handle);
events.Count.ShouldBeGreaterThanOrEqualTo(1);
observed.Count.ShouldBeGreaterThanOrEqualTo(1);
observed.ShouldAllBe(e => e is OperationCanceledException);
}
/// <summary>Caller (loop-token) cancellation still exits the loop promptly — well under the drain timeout.</summary>
[Fact]
public async Task CallerCancellation_StillExitsPromptly()
{
// Reader blocks until the loop token is cancelled (a well-behaved cancellable read).
Task<IReadOnlyList<DataValueSnapshot>> Reader(IReadOnlyList<string> refs, CancellationToken ct)
=> Task.Delay(Timeout.Infinite, ct).ContinueWith(
_ => (IReadOnlyList<DataValueSnapshot>)new List<DataValueSnapshot>(), ct);
await using var engine = new PollGroupEngine(
Reader,
(_, _, _) => { },
minInterval: TimeSpan.FromMilliseconds(30),
onError: _ => { },
backoffCap: TimeSpan.FromMilliseconds(120));
var handle = engine.Subscribe(["X"], TimeSpan.FromMilliseconds(30));
await Task.Delay(60, TestContext.Current.CancellationToken); // let the reader begin blocking
var sw = System.Diagnostics.Stopwatch.StartNew();
engine.Unsubscribe(handle).ShouldBeTrue();
sw.Stop();
// Prompt exit: the caller-token OCE unwinds the loop immediately — nowhere near the 5 s
// StopState drain ceiling.
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2));
}
private sealed record DummyHandle : ISubscriptionHandle
{
/// <summary>Gets a diagnostic identifier for this handle.</summary>
@@ -0,0 +1,109 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// 05/STAB-8 — a dead AbCip device must not re-create + re-initialize (Forward-Open) a libplctag
/// runtime per tag per tick. The per-device <see cref="ConnectionBackoff"/> gates the
/// runtime-create path; cache hits are unaffected; the probe loop (its own runtime) drives
/// failure/success so a recovered device resets the window.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipConnectBackoffTests
{
private static AbCipDriverOptions Options(bool probeEnabled) => new()
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt)],
Probe = new AbCipProbeOptions
{
Enabled = probeEnabled,
ProbeTagPath = "ProbeTag",
Interval = TimeSpan.FromMilliseconds(50),
Timeout = TimeSpan.FromMilliseconds(250),
},
};
/// <summary>Rapid reads against a dead device attempt a single Forward-Open, then fail fast inside the backoff window.</summary>
[Fact]
public async Task DeadDevice_ConnectAttemptsFollowBackoffSchedule()
{
var creates = 0;
var factory = new FakeAbCipTagFactory
{
Customise = p =>
{
Interlocked.Increment(ref creates);
return new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = new InvalidOperationException("down") };
},
};
var drv = new AbCipDriver(Options(probeEnabled: false), "abcip-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
for (var i = 0; i < 6; i++)
await drv.ReadAsync(["Speed"], CancellationToken.None);
creates.ShouldBe(1); // one Forward-Open attempt; the rest fail fast inside the window
}
/// <summary>A cached healthy runtime is read without ever consulting the backoff or re-creating.</summary>
[Fact]
public async Task CacheHit_NeverConsultsBackoff()
{
var creates = 0;
var factory = new FakeAbCipTagFactory
{
Customise = p =>
{
Interlocked.Increment(ref creates);
return new FakeAbCipTag(p) { Value = 5 };
},
};
var drv = new AbCipDriver(Options(probeEnabled: false), "abcip-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
for (var i = 0; i < 5; i++)
{
var r = await drv.ReadAsync(["Speed"], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
}
creates.ShouldBe(1); // created once + cached; cache hits never re-create
}
/// <summary>The probe bypasses the backoff window and a successful probe resets it so the next read succeeds.</summary>
[Fact]
public async Task ProbeBypassesBackoff_AndSuccessResets()
{
var fail = true;
var factory = new FakeAbCipTagFactory
{
Customise = p => new FakeAbCipTag(p)
{
ThrowOnInitialize = fail,
Exception = new InvalidOperationException("down"),
Value = 7,
},
};
var drv = new AbCipDriver(Options(probeEnabled: true), "abcip-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var down = await drv.ReadAsync(["Speed"], CancellationToken.None);
down[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
fail = false; // device recovers; the probe bypasses the window and resets it
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(3);
DataValueSnapshot? good = null;
while (DateTime.UtcNow < deadline)
{
var r = await drv.ReadAsync(["Speed"], CancellationToken.None);
if (r[0].StatusCode == AbCipStatusMapper.Good) { good = r[0]; break; }
await Task.Delay(25, CancellationToken.None);
}
good.ShouldNotBeNull("probe-driven recovery must reset the backoff so reads recover");
}
}
@@ -0,0 +1,60 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// 05/STAB-9 — the poll engine's <c>onError</c> sink must be wired to the driver health
/// surface: a poll-loop reader failure degrades health while preserving
/// <see cref="DriverHealth.LastSuccessfulRead"/>, and must never downgrade a pre-existing
/// <see cref="DriverState.Faulted"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipPollErrorHealthTests
{
/// <summary>A poll reader failure degrades health, preserving the last successful read timestamp.</summary>
[Fact]
public async Task PollReaderFailure_DegradesHealth_PreservesLastSuccessfulRead()
{
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Probe = new AbCipProbeOptions { Enabled = false },
};
var drv = new AbCipDriver(opts, "abcip-1", new FakeAbCipTagFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var before = drv.GetHealth();
before.State.ShouldBe(DriverState.Healthy);
before.LastSuccessfulRead.ShouldNotBeNull();
drv.HandlePollError(new InvalidOperationException("poll boom"));
var after = drv.GetHealth();
after.State.ShouldBe(DriverState.Degraded);
after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
after.LastError.ShouldNotBeNull();
after.LastError.ShouldContain("poll boom");
}
/// <summary>A poll reader failure never downgrades a pre-existing Faulted state.</summary>
[Fact]
public async Task PollReaderFailure_NeverDowngradesFaulted()
{
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("not-a-valid-address")],
Probe = new AbCipProbeOptions { Enabled = false },
};
var drv = new AbCipDriver(opts, "abcip-1", new FakeAbCipTagFactory());
await Should.ThrowAsync<Exception>(() => drv.InitializeAsync("{}", CancellationToken.None));
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
drv.HandlePollError(new InvalidOperationException("poll boom"));
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
}
@@ -0,0 +1,43 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// CONV-4 — <see cref="AbCipDriver.ResolveHost"/> keys per-host resilience (bulkhead / circuit
/// breaker). It must resolve an equipment-tag reference (raw TagConfig JSON) to its OWN device
/// host, not fall back to the first device — otherwise a broken device B trips device A's
/// breaker. Authored tags and unknown refs keep the current fallback.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipResolveHostTests
{
private const string DeviceA = "ab://10.0.0.5/1,0";
private const string DeviceB = "ab://10.0.0.6/1,0";
private static AbCipDriver NewDriver() => new(
new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(DeviceA), new AbCipDeviceOptions(DeviceB)],
Probe = new AbCipProbeOptions { Enabled = false },
},
"abcip-1", new FakeAbCipTagFactory());
/// <summary>An equipment-tag ref naming device B resolves to device B's host, not the first device.</summary>
[Fact]
public void ResolveHost_EquipmentTagRef_ReturnsItsOwnDeviceHost()
{
var drv = NewDriver();
var json = $$"""{"deviceHostAddress":"{{DeviceB}}","tagPath":"Motor1.Speed","dataType":"DInt"}""";
drv.ResolveHost(json).ShouldBe(DeviceB);
}
/// <summary>An unresolvable ref keeps the current first-device fallback.</summary>
[Fact]
public void ResolveHost_UnknownRef_KeepsCurrentFallback()
{
var drv = NewDriver();
drv.ResolveHost("totally-unknown").ShouldBe(DeviceA);
}
}
@@ -0,0 +1,123 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Regression coverage for 05/STAB-4 — a per-tag libplctag runtime (a single <c>Tag</c>
/// handle) is cached in <c>DeviceState.Runtimes</c> and shared between the server read path,
/// every poll-group loop, and the alarm-projection loop. A <c>Tag</c> handle is not safe for
/// concurrent Read/GetStatus/Decode (or Encode/Write/GetStatus), so the driver must serialise
/// the whole sequence per runtime — mirrors <c>AbLegacyRuntimeConcurrencyTests</c>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipRuntimeConcurrencyTests
{
/// <summary>
/// A fake runtime that records the maximum number of operations in flight against the
/// <em>same</em> handle. If the driver fails to serialise, two callers overlap inside the
/// Read → GetStatus → Decode window and <see cref="MaxConcurrent"/> exceeds 1.
/// </summary>
private sealed class OverlapDetectingFake : FakeAbCipTag
{
private int _inFlight;
/// <summary>Gets the maximum number of concurrent operations detected on this handle.</summary>
public int MaxConcurrent { get; private set; }
/// <summary>Initializes a new instance of the <see cref="OverlapDetectingFake"/> class.</summary>
/// <param name="p">The tag creation parameters.</param>
public OverlapDetectingFake(AbCipTagCreateParams p) : base(p) { }
/// <inheritdoc />
public override async Task ReadAsync(CancellationToken cancellationToken)
{
EnterOp();
try
{
// Yield + small delay so an unserialised second caller is guaranteed to overlap.
await Task.Delay(15, cancellationToken).ConfigureAwait(false);
await base.ReadAsync(cancellationToken).ConfigureAwait(false);
}
finally { LeaveOp(); }
}
/// <inheritdoc />
public override async Task WriteAsync(CancellationToken cancellationToken)
{
EnterOp();
try
{
await Task.Delay(15, cancellationToken).ConfigureAwait(false);
await base.WriteAsync(cancellationToken).ConfigureAwait(false);
}
finally { LeaveOp(); }
}
private void EnterOp()
{
var n = Interlocked.Increment(ref _inFlight);
lock (this) { if (n > MaxConcurrent) MaxConcurrent = n; }
}
private void LeaveOp() => Interlocked.Decrement(ref _inFlight);
}
private static AbCipDriver NewDriver(FakeAbCipTagFactory factory, params AbCipTagDefinition[] tags)
{
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
};
return new AbCipDriver(opts, "drv-1", factory);
}
/// <summary>Verifies that concurrent reads of the same tag are serialised against the shared runtime.</summary>
[Fact]
public async Task Concurrent_reads_of_same_tag_are_serialised_against_the_shared_runtime()
{
OverlapDetectingFake? shared = null;
var factory = new FakeAbCipTagFactory
{
Customise = p => shared = new OverlapDetectingFake(p) { Value = 7 },
};
var drv = NewDriver(factory,
new AbCipTagDefinition("X", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
// Eight callers race for the same tag — mimics the server read path + poll loop(s) + the
// alarm-projection loop hitting one cached runtime at once.
var reads = Enumerable.Range(0, 8)
.Select(_ => drv.ReadAsync(["X"], CancellationToken.None))
.ToArray();
await Task.WhenAll(reads);
shared.ShouldNotBeNull();
shared!.MaxConcurrent.ShouldBe(1, "operations on a shared libplctag Tag must not overlap");
reads.ShouldAllBe(r => r.Result.Single().Value!.Equals(7));
}
/// <summary>Verifies that a concurrent read and write of the same tag do not overlap on the shared runtime.</summary>
[Fact]
public async Task Concurrent_read_and_write_of_same_tag_do_not_overlap()
{
OverlapDetectingFake? shared = null;
var factory = new FakeAbCipTagFactory
{
Customise = p => shared = new OverlapDetectingFake(p) { Value = 1 },
};
var drv = NewDriver(factory,
new AbCipTagDefinition("X", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
var readTask = drv.ReadAsync(["X"], CancellationToken.None);
var writeTask = drv.WriteAsync([new WriteRequest("X", 99)], CancellationToken.None);
await Task.WhenAll(readTask, writeTask);
shared.ShouldNotBeNull();
shared!.MaxConcurrent.ShouldBe(1);
}
}
@@ -0,0 +1,60 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// 05/STAB-9 — the poll engine's <c>onError</c> sink must be wired to the driver health
/// surface: a poll-loop reader failure degrades health while preserving
/// <see cref="DriverHealth.LastSuccessfulRead"/>, and must never downgrade a pre-existing
/// <see cref="DriverState.Faulted"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyPollErrorHealthTests
{
/// <summary>A poll reader failure degrades health, preserving the last successful read timestamp.</summary>
[Fact]
public async Task PollReaderFailure_DegradesHealth_PreservesLastSuccessfulRead()
{
var opts = new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Probe = new AbLegacyProbeOptions { Enabled = false },
};
var drv = new AbLegacyDriver(opts, "ablegacy-1", new FakeAbLegacyTagFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var before = drv.GetHealth();
before.State.ShouldBe(DriverState.Healthy);
before.LastSuccessfulRead.ShouldNotBeNull();
drv.HandlePollError(new InvalidOperationException("poll boom"));
var after = drv.GetHealth();
after.State.ShouldBe(DriverState.Degraded);
after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
after.LastError.ShouldNotBeNull();
after.LastError.ShouldContain("poll boom");
}
/// <summary>A poll reader failure never downgrades a pre-existing Faulted state.</summary>
[Fact]
public async Task PollReaderFailure_NeverDowngradesFaulted()
{
var opts = new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("not-a-valid-address")],
Probe = new AbLegacyProbeOptions { Enabled = false },
};
var drv = new AbLegacyDriver(opts, "ablegacy-1", new FakeAbLegacyTagFactory());
await Should.ThrowAsync<Exception>(() => drv.InitializeAsync("{}", CancellationToken.None));
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
drv.HandlePollError(new InvalidOperationException("poll boom"));
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
}
@@ -0,0 +1,42 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// CONV-4 — <see cref="AbLegacyDriver.ResolveHost"/> must resolve an equipment-tag reference
/// (raw TagConfig JSON) to its OWN device host so per-host breaker keys are correct for
/// equipment tags, not the first-device fallback.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyResolveHostTests
{
private const string DeviceA = "ab://10.0.0.5/1,0";
private const string DeviceB = "ab://10.0.0.6/1,0";
private static AbLegacyDriver NewDriver() => new(
new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(DeviceA), new AbLegacyDeviceOptions(DeviceB)],
Probe = new AbLegacyProbeOptions { Enabled = false },
},
"ablegacy-1", new FakeAbLegacyTagFactory());
/// <summary>An equipment-tag ref naming device B resolves to device B's host, not the first device.</summary>
[Fact]
public void ResolveHost_EquipmentTagRef_ReturnsItsOwnDeviceHost()
{
var drv = NewDriver();
var json = $$"""{"deviceHostAddress":"{{DeviceB}}","address":"N7:0","dataType":"Int"}""";
drv.ResolveHost(json).ShouldBe(DeviceB);
}
/// <summary>An unresolvable ref keeps the current first-device fallback.</summary>
[Fact]
public void ResolveHost_UnknownRef_KeepsCurrentFallback()
{
var drv = NewDriver();
drv.ResolveHost("totally-unknown").ShouldBe(DeviceA);
}
}
@@ -0,0 +1,86 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// 05/STAB-8 — a dead FOCAS CNC must not pay a full two-socket reconnect on every tick of each
/// read / probe / fixed-tree / recycle loop. The per-device <see cref="ConnectionBackoff"/>
/// gates the connect path; the probe bypasses it and a successful connect resets it. No FOCAS
/// fixture exists — the fake-client suite is authoritative.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasConnectBackoffTests
{
private static FocasDriverOptions Options(bool probeEnabled) => new()
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
Probe = new FocasProbeOptions
{
Enabled = probeEnabled,
Interval = TimeSpan.FromMilliseconds(50),
Timeout = TimeSpan.FromMilliseconds(250),
},
// FixedTree / HandleRecycle / AlarmProjection default off — keep only the data path (+ probe).
};
/// <summary>Rapid data calls against a dead CNC attempt a single connect, then fail fast inside the backoff window.</summary>
[Fact]
public async Task DeadDevice_ConnectAttemptsFollowBackoffSchedule()
{
var factory = new FakeFocasClientFactory
{
Customise = () => new FakeFocasClient
{
ThrowOnConnect = true,
Exception = new InvalidOperationException("cnc down"),
},
};
var drv = new FocasDriver(Options(probeEnabled: false), "focas-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
for (var i = 0; i < 6; i++)
await drv.ReadAsync(["R"], CancellationToken.None);
// One real connect attempt; the remaining five fail fast inside the window — no new socket
// is created (the fake factory records one client per attempted connect).
factory.Clients.Count.ShouldBe(1);
}
/// <summary>The probe bypasses the backoff window and a successful connect resets it so the next data call succeeds immediately.</summary>
[Fact]
public async Task ProbeBypassesBackoff_AndSuccessResets()
{
var fail = true;
var factory = new FakeFocasClientFactory
{
Customise = () => new FakeFocasClient
{
ThrowOnConnect = fail,
Exception = new InvalidOperationException("cnc down"),
},
};
var drv = new FocasDriver(Options(probeEnabled: true), "focas-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var down = await drv.ReadAsync(["R"], CancellationToken.None);
down[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
// Device recovers. The probe bypasses the still-open window, reconnects, and resets it so
// the following data read succeeds with no residual delay.
fail = false;
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(3);
DataValueSnapshot? good = null;
while (DateTime.UtcNow < deadline)
{
var r = await drv.ReadAsync(["R"], CancellationToken.None);
if (r[0].StatusCode == FocasStatusMapper.Good) { good = r[0]; break; }
await Task.Delay(25, CancellationToken.None);
}
good.ShouldNotBeNull("probe-driven reconnect must reset the backoff so data reads recover");
}
}
@@ -0,0 +1,99 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Regression coverage for 05/STAB-5 — the four per-device fixed-tree caches
/// (<c>LastFixedSnapshots</c>, <c>LastTimers</c>, <c>LastServoLoads</c>, <c>LastSpindleLoads</c>)
/// are written by the background fixed-tree loop while <c>ReadAsync</c> callers read them
/// concurrently. A plain <see cref="System.Collections.Generic.Dictionary{TKey,TValue}"/>
/// throws / corrupts when a resize races a read — undefined behaviour. They must be
/// <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasFixedTreeConcurrencyTests
{
private static FocasDriver.DeviceState NewDeviceState() =>
new(new FocasHostAddress("10.0.0.5", FocasHostAddress.DefaultPort),
new FocasDeviceOptions("focas://10.0.0.5"));
/// <summary>
/// Hammers concurrent writers (which force dictionary resizes) against concurrent readers on
/// all four caches. A plain <c>Dictionary</c> is not concurrency-safe: its structural-mutation
/// version check throws "operations that change non-concurrent collections must have exclusive
/// access to the collection", and its lookup collision guard throws "concurrent operations
/// are not supported" — deterministically under this load. A
/// <c>ConcurrentDictionary</c> never throws.
/// </summary>
/// <remarks>
/// Uses several writers, not one, so the fault is deterministic rather than timing-dependent
/// UB: the fixed-tree caches are shared mutable state with zero synchronization, so ANY
/// concurrent access is the defect — a driver that survives this survives the real
/// writer-loop-vs-reader-callers race the finding cites.
/// </remarks>
[Fact]
public async Task Concurrent_access_to_fixed_tree_caches_never_throws()
{
var state = NewDeviceState();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var faults = new System.Collections.Concurrent.ConcurrentQueue<Exception>();
const int rounds = 40;
const int growth = 4_000;
void Writer(int seed)
{
try
{
for (var round = 0; round < rounds && !cts.IsCancellationRequested; round++)
{
state.LastFixedSnapshots.Clear();
state.LastServoLoads.Clear();
state.LastSpindleLoads.Clear();
for (var i = 0; i < growth; i++)
{
// Distinct keys force repeated dictionary growth (resize = the race window);
// the Clear() each round keeps the caches churning through resizes.
state.LastFixedSnapshots[$"Axes/A{seed}_{i}/AbsolutePosition"] = i;
state.LastServoLoads[$"Servo{seed}_{i}"] = i;
state.LastSpindleLoads[seed * growth + i] = i;
state.LastTimers[(FocasTimerKind)(i % 4)] = new FocasTimer((FocasTimerKind)(i % 4), i, i);
}
}
}
catch (Exception ex) { faults.Enqueue(ex); }
}
void Reader()
{
try
{
while (!cts.IsCancellationRequested && faults.IsEmpty)
{
for (var i = 0; i < growth; i++)
{
state.LastFixedSnapshots.TryGetValue($"Axes/A0_{i}/AbsolutePosition", out _);
state.LastServoLoads.TryGetValue($"Servo0_{i}", out _);
state.LastSpindleLoads.TryGetValue(i, out _);
state.LastTimers.TryGetValue((FocasTimerKind)(i % 4), out _);
}
}
}
catch (Exception ex) { faults.Enqueue(ex); }
}
var tasks = new List<Task>
{
Task.Run(() => Writer(0)),
Task.Run(() => Writer(1)),
Task.Run(() => Writer(2)),
Task.Run(Reader),
Task.Run(Reader),
};
await Task.WhenAll(tasks);
faults.ShouldBeEmpty(
faults.TryPeek(out var first) ? $"concurrent cache access threw: {first}" : "");
}
}
@@ -0,0 +1,59 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// 05/STAB-9 — the poll engine's <c>onError</c> sink must be wired to the driver health
/// surface: a poll-loop reader failure degrades health while preserving
/// <see cref="DriverHealth.LastSuccessfulRead"/>, and must never downgrade a pre-existing
/// <see cref="DriverState.Faulted"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasPollErrorHealthTests
{
/// <summary>A poll reader failure degrades health, preserving the last successful read timestamp.</summary>
[Fact]
public async Task PollReaderFailure_DegradesHealth_PreservesLastSuccessfulRead()
{
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var before = drv.GetHealth();
before.State.ShouldBe(DriverState.Healthy);
before.LastSuccessfulRead.ShouldNotBeNull();
drv.HandlePollError(new InvalidOperationException("poll boom"));
var after = drv.GetHealth();
after.State.ShouldBe(DriverState.Degraded);
after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
after.LastError.ShouldNotBeNull();
after.LastError.ShouldContain("poll boom");
}
/// <summary>A poll reader failure never downgrades a pre-existing Faulted state.</summary>
[Fact]
public async Task PollReaderFailure_NeverDowngradesFaulted()
{
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:9999", "R100", FocasDataType.Byte)],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-1", new FakeFocasClientFactory());
await Should.ThrowAsync<Exception>(() => drv.InitializeAsync("{}", CancellationToken.None));
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
drv.HandlePollError(new InvalidOperationException("poll boom"));
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
}
@@ -0,0 +1,60 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// CONV-4 — <see cref="FocasDriver.ResolveHost"/> must resolve an equipment-tag reference (raw
/// TagConfig JSON) to its OWN device host so per-host breaker keys are correct for equipment
/// tags. An address-less equipment ref (FOCAS coalesces a missing <c>deviceHostAddress</c> to
/// <c>""</c>) must fall back rather than key an empty host.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasResolveHostTests
{
private const string DeviceA = "focas://10.0.0.5:8193";
private const string DeviceB = "focas://10.0.0.6:8193";
// FOCAS's equipment-ref resolver validates against the initialized Devices map, so — unlike
// the other drivers — the driver must be initialized (as it always is when ResolveHost runs at
// runtime) before an equipment ref resolves. Probe off / no connect: init just parses devices.
private static async Task<FocasDriver> NewDriverAsync()
{
var drv = new FocasDriver(
new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceA), new FocasDeviceOptions(DeviceB)],
Probe = new FocasProbeOptions { Enabled = false },
},
"focas-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
return drv;
}
/// <summary>An equipment-tag ref naming device B resolves to device B's host, not the first device.</summary>
[Fact]
public async Task ResolveHost_EquipmentTagRef_ReturnsItsOwnDeviceHost()
{
var drv = await NewDriverAsync();
var json = $$"""{"deviceHostAddress":"{{DeviceB}}","address":"R100","dataType":"Byte"}""";
drv.ResolveHost(json).ShouldBe(DeviceB);
}
/// <summary>An equipment ref with no deviceHostAddress falls back to the first device, not an empty host.</summary>
[Fact]
public async Task ResolveHost_EquipmentTagRef_WithoutDeviceHost_FallsBack()
{
var drv = await NewDriverAsync();
var json = """{"address":"R100","dataType":"Byte"}""";
drv.ResolveHost(json).ShouldBe(DeviceA);
}
/// <summary>An unresolvable ref keeps the current first-device fallback.</summary>
[Fact]
public async Task ResolveHost_UnknownRef_KeepsCurrentFallback()
{
var drv = await NewDriverAsync();
drv.ResolveHost("totally-unknown").ShouldBe(DeviceA);
}
}
@@ -0,0 +1,61 @@
using System.Net.Sockets;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// 05/STAB-9 — the poll engine's <c>onError</c> sink must be wired to the driver health
/// surface: a poll-loop reader failure degrades health while preserving
/// <see cref="DriverHealth.LastSuccessfulRead"/>, and must never downgrade a pre-existing
/// <see cref="DriverState.Faulted"/> (the S7 nuance ported fleet-wide).
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusPollErrorHealthTests
{
private sealed class NoopTransport : IModbusTransport
{
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
=> Task.FromResult(new byte[] { 0x03, 0x00 });
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>A poll reader failure degrades health, preserving the last successful read timestamp.</summary>
[Fact]
public async Task PollReaderFailure_DegradesHealth_PreservesLastSuccessfulRead()
{
var opts = new ModbusDriverOptions { Host = "fake", Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "modbus-1", _ => new NoopTransport());
await drv.InitializeAsync("{}", CancellationToken.None);
var before = drv.GetHealth();
before.State.ShouldBe(DriverState.Healthy);
before.LastSuccessfulRead.ShouldNotBeNull();
drv.HandlePollError(new InvalidOperationException("poll boom"));
var after = drv.GetHealth();
after.State.ShouldBe(DriverState.Degraded);
after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
after.LastError.ShouldNotBeNull();
after.LastError.ShouldContain("poll boom");
}
/// <summary>A poll reader failure never downgrades a pre-existing Faulted state.</summary>
[Fact]
public async Task PollReaderFailure_NeverDowngradesFaulted()
{
var opts = new ModbusDriverOptions { Host = "fake", Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "modbus-1", _ => throw new SocketException());
// Init faults (the throwing factory) → Faulted, then rethrows.
await Should.ThrowAsync<Exception>(() => drv.InitializeAsync("{}", CancellationToken.None));
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
drv.HandlePollError(new InvalidOperationException("poll boom"));
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
}
@@ -29,6 +29,23 @@ public sealed class ModbusTcpReconnectTests
private readonly CancellationTokenSource _stop = new();
private int _txCount;
// --- Scriptable STAB-3 desync behaviors (each fires for the first N transactions, then
// the server behaves normally). Decremented atomically because an old (torn-down) serve
// task and a fresh one can briefly coexist. ---
private int _stallRemaining;
private int _wrongTxRemaining;
private int _invalidLenRemaining;
private int _acceptedConnections;
/// <summary>The first N responses are withheld entirely — the client blocks until its per-op timeout / caller cancellation fires.</summary>
public int StallFirstNResponses { set => _stallRemaining = value; }
/// <summary>The first N responses echo a corrupted MBAP transaction id (framing violation).</summary>
public int WrongTxIdFirstNResponses { set => _wrongTxRemaining = value; }
/// <summary>The first N responses claim an MBAP length field of 0 (truncated-length framing violation).</summary>
public int InvalidLengthFirstNResponses { set => _invalidLenRemaining = value; }
/// <summary>Gets the number of TCP connections the listener has accepted (a reconnect increments this).</summary>
public int AcceptedConnectionCount => Volatile.Read(ref _acceptedConnections);
/// <summary>Initializes a new instance and starts listening on a loopback port.</summary>
public FlakeyModbusServer()
{
@@ -45,6 +62,7 @@ public sealed class ModbusTcpReconnectTests
try { client = await _listener.AcceptTcpClientAsync(_stop.Token); }
catch { return; }
Interlocked.Increment(ref _acceptedConnections);
_ = Task.Run(() => ServeAsync(client!));
}
}
@@ -63,8 +81,27 @@ public sealed class ModbusTcpReconnectTests
var pdu = new byte[len - 1];
if (!await ReadExactly(stream, pdu)) return;
// Stall: read the request but never answer it — simulate a hung / unreachable
// unit so the client's per-op CancelAfter (or caller token) fires.
if (Interlocked.Decrement(ref _stallRemaining) >= 0)
continue;
var fc = pdu[0];
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
// Truncated-length framing violation: a 7-byte header whose length field is 0
// (< the mandatory 1 unit-id byte) — trips the transport's `respLen < 1` guard.
if (Interlocked.Decrement(ref _invalidLenRemaining) >= 0)
{
var bad = new byte[7];
bad[0] = header[0]; bad[1] = header[1];
bad[4] = 0; bad[5] = 0; // length field = 0
bad[6] = header[6];
await stream.WriteAsync(bad);
await stream.FlushAsync();
continue;
}
var respPdu = new byte[2 + qty * 2];
respPdu[0] = fc;
respPdu[1] = (byte)(qty * 2);
@@ -73,6 +110,16 @@ public sealed class ModbusTcpReconnectTests
var respLen = (ushort)(1 + respPdu.Length);
var adu = new byte[7 + respPdu.Length];
adu[0] = header[0]; adu[1] = header[1];
// TxId-mismatch framing violation: emit an otherwise valid, full frame but with
// the transaction id flipped, so the transport reads the whole (unexpected)
// response and rejects it on the TxId guard.
if (Interlocked.Decrement(ref _wrongTxRemaining) >= 0)
{
adu[0] = (byte)~header[0];
adu[1] = (byte)~header[1];
}
adu[4] = (byte)(respLen >> 8); adu[5] = (byte)(respLen & 0xFF);
adu[6] = header[6];
Buffer.BlockCopy(respPdu, 0, adu, 7, respPdu.Length);
@@ -149,4 +196,82 @@ public sealed class ModbusTcpReconnectTests
await Should.ThrowAsync<Exception>(async () =>
await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken));
}
// --- STAB-3: per-op timeout + framing violations must be classified connection-fatal so the
// socket is torn down instead of left desynchronized. With auto-reconnect on, the transport
// transparently reconnects + resends once and returns the CORRECT response (no stale read);
// the reconnect is observable as a second accepted connection on the server. Before the fix
// the timeout OCE / InvalidDataException are not socket-level, so no teardown happens and the
// SendAsync throws (and the socket stays desynchronized forever). ---
private static readonly byte[] Fc03Qty1 = { 0x03, 0x00, 0x00, 0x00, 0x01 };
/// <summary>A per-op response timeout tears down the socket and (auto-reconnect) resends cleanly.</summary>
[Fact]
public async Task SendAsync_AfterResponseTimeout_TearsDownAndReconnects()
{
await using var server = new FlakeyModbusServer { StallFirstNResponses = 1 };
await using var transport = new ModbusTcpTransport(
"127.0.0.1", server.Port, TimeSpan.FromMilliseconds(300), autoReconnect: true);
await transport.ConnectAsync(TestContext.Current.CancellationToken);
var resp = await transport.SendAsync(unitId: 1, Fc03Qty1, TestContext.Current.CancellationToken);
resp[0].ShouldBe((byte)0x03); // correct FC, not stale bytes
resp.Length.ShouldBe(2 + 1 * 2); // FC + byteCount + qty*2 data
server.AcceptedConnectionCount.ShouldBe(2); // torn down + reconnected
}
/// <summary>A TxId-mismatch framing violation tears down the socket and (auto-reconnect) resends cleanly.</summary>
[Fact]
public async Task SendAsync_AfterTxIdMismatch_TearsDownSocket()
{
await using var server = new FlakeyModbusServer { WrongTxIdFirstNResponses = 1 };
await using var transport = new ModbusTcpTransport(
"127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: true);
await transport.ConnectAsync(TestContext.Current.CancellationToken);
var resp = await transport.SendAsync(unitId: 1, Fc03Qty1, TestContext.Current.CancellationToken);
resp[0].ShouldBe((byte)0x03);
server.AcceptedConnectionCount.ShouldBe(2);
}
/// <summary>A truncated-length framing violation tears down the socket and (auto-reconnect) resends cleanly.</summary>
[Fact]
public async Task SendAsync_AfterTruncatedHeader_TearsDownSocket()
{
await using var server = new FlakeyModbusServer { InvalidLengthFirstNResponses = 1 };
await using var transport = new ModbusTcpTransport(
"127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: true);
await transport.ConnectAsync(TestContext.Current.CancellationToken);
var resp = await transport.SendAsync(unitId: 1, Fc03Qty1, TestContext.Current.CancellationToken);
resp[0].ShouldBe((byte)0x03);
server.AcceptedConnectionCount.ShouldBe(2);
}
/// <summary>Caller cancellation propagates as OCE and must NOT tear down the socket (a legitimate shutdown is not a desync).</summary>
[Fact]
public async Task SendAsync_CallerCancellation_DoesNotTearDown()
{
await using var server = new FlakeyModbusServer { StallFirstNResponses = 1 };
await using var transport = new ModbusTcpTransport(
"127.0.0.1", server.Port, TimeSpan.FromSeconds(5), autoReconnect: true);
await transport.ConnectAsync(TestContext.Current.CancellationToken);
using var cts = new CancellationTokenSource();
var pending = transport.SendAsync(unitId: 1, Fc03Qty1, cts.Token);
await Task.Delay(150, TestContext.Current.CancellationToken); // let the request land + block on the response read
await cts.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(async () => await pending);
// The socket must still be usable — a subsequent transaction succeeds on the SAME connection
// (no reconnect), proving caller-cancel did not trip the desync teardown.
var resp = await transport.SendAsync(unitId: 1, Fc03Qty1, TestContext.Current.CancellationToken);
resp[0].ShouldBe((byte)0x03);
server.AcceptedConnectionCount.ShouldBe(1);
}
}
@@ -0,0 +1,63 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// CONV-4 (Modbus leg) — Modbus keys per-host resilience per SLAVE UNIT
/// (<c>host:port/unitN</c>). An equipment tag must carry its own <c>unitId</c> so multi-unit
/// equipment tags key their own breaker; <see cref="ModbusDriver.ResolveHost"/> must resolve
/// the equipment ref through the resolver rather than only matching authored names. Scope:
/// <c>unitId</c> ONLY — the parser's other strictness gaps are R2-11's.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusUnitIdResolveHostTests
{
private sealed class NoopTransport : IModbusTransport
{
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
=> Task.FromResult(new byte[] { 0x03, 0x00 });
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private static ModbusDriver NewDriver() => new(
new ModbusDriverOptions { Host = "10.0.0.1", Port = 502, UnitId = 1, Probe = new ModbusProbeOptions { Enabled = false } },
"modbus-1", _ => new NoopTransport());
/// <summary>The parser reads an optional unitId into the transient definition.</summary>
[Fact]
public void Parser_reads_optional_unitId()
{
var json = """{"region":"HoldingRegisters","address":0,"dataType":"Int16","unitId":7}""";
ModbusEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def.UnitId.ShouldBe((byte)7);
}
/// <summary>An absent unitId leaves the definition on the driver-level default (null override).</summary>
[Fact]
public void Parser_absent_unitId_is_null()
{
var json = """{"region":"HoldingRegisters","address":0,"dataType":"Int16"}""";
ModbusEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def.UnitId.ShouldBeNull();
}
/// <summary>An equipment ref carrying a unitId keys its own per-unit host name.</summary>
[Fact]
public void EquipmentTag_WithUnitId_ResolvesPerUnitHostName()
{
var drv = NewDriver();
var json = """{"region":"HoldingRegisters","address":0,"dataType":"Int16","unitId":7}""";
drv.ResolveHost(json).ShouldBe("10.0.0.1:502/unit7");
}
/// <summary>An equipment ref without a unitId keys the driver-level unit host name.</summary>
[Fact]
public void EquipmentTag_WithoutUnitId_KeysDriverDefaultUnit()
{
var drv = NewDriver();
var json = """{"region":"HoldingRegisters","address":0,"dataType":"Int16"}""";
drv.ResolveHost(json).ShouldBe("10.0.0.1:502/unit1");
}
}
@@ -91,8 +91,10 @@ public sealed class S7DiscoveryAndSubscribeTests
var h1 = await drv.SubscribeAsync(["T1"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
var h2 = await drv.SubscribeAsync(["T2"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
h1.DiagnosticId.ShouldStartWith("s7-sub-");
h2.DiagnosticId.ShouldStartWith("s7-sub-");
// Post poll-fork retirement (CONV-1) the subscription handle is the shared engine's
// PollSubscriptionHandle (`poll-sub-<id>`), not the retired S7SubscriptionHandle (`s7-sub-`).
h1.DiagnosticId.ShouldStartWith("poll-sub-");
h2.DiagnosticId.ShouldStartWith("poll-sub-");
h1.DiagnosticId.ShouldNotBe(h2.DiagnosticId);
await drv.UnsubscribeAsync(h1, TestContext.Current.CancellationToken);
@@ -0,0 +1,130 @@
using S7.Net;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using S7NetDataType = global::S7.Net.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Guards for retiring the bespoke S7 poll fork onto the shared <c>PollGroupEngine</c>
/// (CONV-1). The engine brings two fixes the fork lacked — structural array diffing (PERF-3)
/// and drain-before-CTS-dispose on unsubscribe (STAB-6-S7) — and keeps failure→health parity.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7PollEngineMigrationTests
{
// A fake that opens cleanly and serves a CONSTANT 6-byte block for array reads (three
// big-endian UInt16 words) — every ReadArrayAsync decodes a fresh ushort[3] with identical
// contents, so a reference-equality diff (the fork) refires every tick while a structural
// diff (the engine) fires only once.
private sealed class ConstantArrayFactory : IS7PlcFactory
{
public bool ReadThrows { get; set; }
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
=> new ConstantArrayPlc(this);
}
private sealed class ConstantArrayPlc(ConstantArrayFactory owner) : IS7Plc
{
public bool IsConnected { get; private set; }
public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; }
public void Close() => IsConnected = false;
public Task<object?> ReadAsync(string address, CancellationToken ct)
{
if (owner.ReadThrows) throw new PlcException(ErrorCode.ConnectionError, "poll failure");
return Task.FromResult<object?>((ushort)42);
}
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken ct)
{
if (owner.ReadThrows) throw new PlcException(ErrorCode.ConnectionError, "poll failure");
// Fresh array instance each call, identical contents: BE words 1, 2, 3.
var block = new byte[count];
for (var i = 0; i + 1 < block.Length; i += 2)
block[i + 1] = (byte)(i / 2 + 1);
return Task.FromResult(block);
}
public Task WriteAsync(string address, object value, CancellationToken ct) => Task.CompletedTask;
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken ct) => Task.CompletedTask;
public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask;
public void Dispose() => IsConnected = false;
}
private static S7DriverOptions ArrayOptions() => new()
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
Probe = new S7ProbeOptions { Enabled = false },
Tags = [new S7TagDefinition("Arr", "DB1.DBW0", S7DataType.UInt16, Writable: false, ArrayCount: 3)],
};
/// <summary>
/// PERF-3 regression: a subscribed array tag whose contents are identical across polls
/// (fresh instance each read) must fire only the initial change, not on every tick. The
/// fork's reference-equality diff refires every poll.
/// </summary>
[Fact]
public async Task ArrayTag_UnchangedBetweenPolls_DoesNotRefire()
{
var factory = new ConstantArrayFactory();
using var drv = new S7Driver(ArrayOptions(), "s7-arr-perf3", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new System.Collections.Concurrent.ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var h = await drv.SubscribeAsync(["Arr"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
await Task.Delay(500, TestContext.Current.CancellationToken); // several poll cycles
await drv.UnsubscribeAsync(h, TestContext.Current.CancellationToken);
events.Count.ShouldBe(1); // only the initial-data push
}
/// <summary>
/// STAB-6-S7 regression: rapidly subscribing + unsubscribing must not fault a poll loop by
/// disposing its CTS out from under an in-flight <c>Task.Delay</c>. Post-migration the engine
/// drains each loop before disposing its CTS. Asserts the whole churn completes cleanly and
/// the driver still serves reads afterward.
/// </summary>
[Fact]
public async Task RapidSubscribeUnsubscribe_NoObjectDisposedException()
{
var factory = new ConstantArrayFactory();
using var drv = new S7Driver(ArrayOptions(), "s7-rapid", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
for (var i = 0; i < 50; i++)
{
var h = await drv.SubscribeAsync(["Arr"], TimeSpan.FromMilliseconds(20), TestContext.Current.CancellationToken);
await drv.UnsubscribeAsync(h, TestContext.Current.CancellationToken);
}
// Driver still works after the churn — no wedged state.
var read = await drv.ReadAsync(["Arr"], TestContext.Current.CancellationToken);
read[0].StatusCode.ShouldBe(0u);
}
/// <summary>
/// Failure→health parity: a subscription polling a persistently-failing device degrades the
/// driver health (the fork's HandlePollFailure behaviour, now delivered by the engine's
/// onError→HandlePollError wiring + the read path's own status mapping).
/// </summary>
[Fact]
public async Task SustainedPollFailure_DegradesHealth()
{
var factory = new ConstantArrayFactory { ReadThrows = true };
using var drv = new S7Driver(ArrayOptions(), "s7-pollfail", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await drv.SubscribeAsync(["Arr"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(3);
while (drv.GetHealth().State == DriverState.Healthy && DateTime.UtcNow < deadline)
await Task.Delay(25, TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
}
@@ -118,6 +118,9 @@ internal class FakeTwinCATClient : ITwinCATClient
public bool ThrowOnAddNotification { get; set; }
/// <summary>Records the most recently-supplied <c>maxDelayMs</c> for Driver.TwinCAT-014 tests.</summary>
public int LastMaxDelayMs { get; private set; }
/// <summary>When set, <see cref="AddNotificationAsync"/> awaits this gate before creating the handle —
/// lets a test wedge a replay/register mid-flight (STAB-17 orphan race).</summary>
public TaskCompletionSource? AddNotificationGate { get; set; }
/// <summary>Simulates adding a notification for value changes.</summary>
/// <param name="symbolPath">The path to the symbol to watch.</param>
@@ -128,17 +131,20 @@ internal class FakeTwinCATClient : ITwinCATClient
/// <param name="onChange">The callback to invoke on value change.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that returns a notification handle.</returns>
public virtual Task<ITwinCATNotificationHandle> AddNotificationAsync(
public virtual async Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
int maxDelayMs, Action<string, object?> onChange, CancellationToken cancellationToken)
{
if (ThrowOnAddNotification)
throw Exception ?? new InvalidOperationException("fake AddNotification failure");
if (AddNotificationGate is { } gate)
await gate.Task.ConfigureAwait(false);
LastMaxDelayMs = maxDelayMs;
var reg = new FakeNotification(symbolPath, type, bitIndex, onChange, this);
Notifications.Add(reg);
return Task.FromResult<ITwinCATNotificationHandle>(reg);
return reg;
}
/// <summary>Fire a change event through the registered callback for <paramref name="symbolPath"/>.</summary>
@@ -0,0 +1,90 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// 05/STAB-8 — a dead TwinCAT device must not be hammered with a full connect attempt on every
/// data call. The per-device <see cref="ConnectionBackoff"/> gates the connect/create path;
/// the probe loop bypasses it (its own interval is the recovery cadence) and a successful
/// connect resets the window instantly so recovery is never delayed. No TC3 fixture exists —
/// the fake-client suite is authoritative.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATConnectBackoffTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static TwinCATDriverOptions Options(bool probeEnabled) => new()
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [new TwinCATTagDefinition("T", Host, "MAIN.T", TwinCATDataType.DInt)],
Probe = new TwinCATProbeOptions
{
Enabled = probeEnabled,
Interval = TimeSpan.FromMilliseconds(50),
Timeout = TimeSpan.FromMilliseconds(250),
},
EnableControllerBrowse = false,
};
/// <summary>Rapid data calls against a dead device attempt a single connect, then fail fast inside the backoff window.</summary>
[Fact]
public async Task DeadDevice_ConnectAttemptsFollowBackoffSchedule()
{
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient
{
ThrowOnConnect = true,
Exception = new InvalidOperationException("device down"),
},
};
var drv = new TwinCATDriver(Options(probeEnabled: false), "twincat-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
for (var i = 0; i < 6; i++)
await drv.ReadAsync(["T"], CancellationToken.None);
// One real connect attempt; the remaining five fail fast inside the 1 s window — no new
// client is created (the fake factory records one client per attempted connect).
factory.Clients.Count.ShouldBe(1);
}
/// <summary>The probe bypasses the backoff window and a successful connect resets it so the next data call succeeds immediately.</summary>
[Fact]
public async Task ProbeBypassesBackoff_AndSuccessResets()
{
var fail = true;
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient
{
ThrowOnConnect = fail,
Exception = new InvalidOperationException("device down"),
},
};
var drv = new TwinCATDriver(Options(probeEnabled: true), "twincat-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// First data call opens the backoff window on a dead device.
var down = await drv.ReadAsync(["T"], CancellationToken.None);
down[0].StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
// Device recovers. The data path is still inside the window, but the probe bypasses it and
// reconnects — resetting the window so the following data read succeeds with no residual delay.
fail = false;
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(3);
DataValueSnapshot? good = null;
while (DateTime.UtcNow < deadline)
{
var r = await drv.ReadAsync(["T"], CancellationToken.None);
if (r[0].StatusCode == TwinCATStatusMapper.Good) { good = r[0]; break; }
await Task.Delay(25, CancellationToken.None);
}
good.ShouldNotBeNull("probe-driven reconnect must reset the backoff so data reads recover");
}
}
@@ -0,0 +1,62 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// 05/STAB-9 — the poll engine's <c>onError</c> sink must be wired to the driver health
/// surface: a poll-loop reader failure degrades health while preserving
/// <see cref="DriverHealth.LastSuccessfulRead"/>, and must never downgrade a pre-existing
/// <see cref="DriverState.Faulted"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATPollErrorHealthTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
/// <summary>A poll reader failure degrades health, preserving the last successful read timestamp.</summary>
[Fact]
public async Task PollReaderFailure_DegradesHealth_PreservesLastSuccessfulRead()
{
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Probe = new TwinCATProbeOptions { Enabled = false },
EnableControllerBrowse = false,
}, "twincat-1", new FakeTwinCATClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var before = drv.GetHealth();
before.State.ShouldBe(DriverState.Healthy);
before.LastSuccessfulRead.ShouldNotBeNull();
drv.HandlePollError(new InvalidOperationException("poll boom"));
var after = drv.GetHealth();
after.State.ShouldBe(DriverState.Degraded);
after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
after.LastError.ShouldNotBeNull();
after.LastError.ShouldContain("poll boom");
}
/// <summary>A poll reader failure never downgrades a pre-existing Faulted state.</summary>
[Fact]
public async Task PollReaderFailure_NeverDowngradesFaulted()
{
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("not-a-valid-address")],
Probe = new TwinCATProbeOptions { Enabled = false },
EnableControllerBrowse = false,
}, "twincat-1", new FakeTwinCATClientFactory());
await Should.ThrowAsync<Exception>(() => drv.InitializeAsync("{}", CancellationToken.None));
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
drv.HandlePollError(new InvalidOperationException("poll boom"));
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
}
@@ -170,6 +170,200 @@ public sealed class TwinCATReconnectReplayTests
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
// ---- STAB-16 / STAB-17 / STAB-12 replay hardening ----
/// <summary>
/// STAB-16: a replay failure must not be silent — the registration is marked dead, a Bad
/// snapshot is pushed to subscribers (instead of the frozen-Good-stale value), and health
/// degrades preserving the last successful read.
/// </summary>
[Fact]
public async Task ReplayFailure_EmitsBadQuality_AndDegradesHealth()
{
var build = 0;
var factory = new FakeTwinCATClientFactory
{
// client1 normal; client2 (the reconnect target) fails AddNotification (replay) + reads.
Customise = () => new FakeTwinCATClient
{
ThrowOnAddNotification = ++build >= 2,
ThrowOnRead = build >= 2,
Exception = new InvalidOperationException("ADS notification quota exceeded"),
},
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)],
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken);
// Establish a last-successful-read on the healthy client1.
_ = await drv.ReadAsync(["Speed"], TestContext.Current.CancellationToken);
var lastGoodRead = drv.GetHealth().LastSuccessfulRead;
lastGoodRead.ShouldNotBeNull();
// Drop the wire; the next read reconnects onto client2, whose replay AddNotification fails.
factory.Clients[0].SimulateWireDrop();
_ = await drv.ReadAsync(["Speed"], TestContext.Current.CancellationToken);
// STAB-16: subscribers see the tag go Bad (not frozen Good), and health degraded while
// preserving the last successful read.
var bad = events.SingleOrDefault(e => e.FullReference == "Speed"
&& e.Snapshot.StatusCode == TwinCATStatusMapper.BadCommunicationError);
bad.ShouldNotBeNull("a failed replay must publish a Bad snapshot for the affected reference");
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastSuccessfulRead.ShouldBe(lastGoodRead);
}
/// <summary>
/// STAB-16: a registration whose replay failed (dead handle) is re-registered on the next
/// successful probe tick — a subsequent push then reaches OnDataChange with Good, and no
/// duplicate registration is created.
/// </summary>
[Fact]
public async Task ReplayFailure_RetriedOnNextSuccessfulProbeTick()
{
var build = 0;
var factory = new FakeTwinCATClientFactory
{
// Only the 2nd client (the reconnect target) fails its first replay; flipped off below.
Customise = () => new FakeTwinCATClient { ThrowOnAddNotification = ++build == 2 },
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)],
Probe = new TwinCATProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(40),
Timeout = TimeSpan.FromMilliseconds(100),
},
UseNativeNotifications = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients.Count >= 1, TimeSpan.FromSeconds(2));
// Drop the wire; the reconnect target (client2) fails its replay, leaving a dead registration.
factory.Clients[0].SimulateWireDrop();
_ = await drv.ReadAsync(["Speed"], TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients.Count >= 2, TimeSpan.FromSeconds(2));
var client2 = factory.Clients[1];
client2.Notifications.ShouldBeEmpty(); // replay failed — no live handle
// Device now accepts registrations again — the probe-tick retry must re-register the intent.
client2.ThrowOnAddNotification = false;
await WaitForAsync(() => client2.Notifications.Count == 1, TimeSpan.FromSeconds(3));
client2.Notifications.Count.ShouldBe(1); // re-registered exactly once (no duplicate)
// A fresh push on the recovered registration reaches OnDataChange with Good.
client2.FireNotification("MAIN.Speed", 4242);
await WaitForAsync(() => events.Any(e => e.FullReference == "Speed"
&& e.Snapshot.StatusCode == TwinCATStatusMapper.Good), TimeSpan.FromSeconds(2));
events.Any(e => e.FullReference == "Speed"
&& e.Snapshot.StatusCode == TwinCATStatusMapper.Good
&& Equals(e.Snapshot.Value, 4242)).ShouldBeTrue();
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>
/// STAB-17: unsubscribing WHILE a replay is mid-<c>AddNotificationAsync</c> must not leak a
/// live ADS notification. After the fresh handle is installed, an ownership re-check finds the
/// registration was unsubscribed and retracts (disposes) the fresh handle — no orphan.
/// </summary>
[Fact]
public async Task UnsubscribeDuringReplay_DisposesFreshHandle_NoOrphan()
{
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var build = 0;
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
var c = new FakeTwinCATClient();
if (++build == 2) c.AddNotificationGate = gate; // client2's replay blocks on the gate
return c;
},
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [new TwinCATTagDefinition("X", Host, "MAIN.X", TwinCATDataType.DInt)],
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken);
// Drop the wire; a read reconnects onto client2, whose replay AddNotification blocks on the gate.
factory.Clients[0].SimulateWireDrop();
var readTask = Task.Run(() => drv.ReadAsync(["X"], CancellationToken.None));
await WaitForAsync(() => factory.Clients.Count >= 2, TimeSpan.FromSeconds(2));
await Task.Delay(50); // let the replay reach the blocked AddNotification
// Unsubscribe mid-replay (gate-free) — removes the registration from the device registry.
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
// Release the gate: AddNotification completes, SwapHandle installs the fresh handle, and the
// ownership re-check must retract it (no owner) rather than leave a live orphan notification.
gate.SetResult();
await readTask;
await WaitForAsync(() => factory.Clients[1].Notifications.Count == 0, TimeSpan.FromSeconds(2));
factory.Clients[1].Notifications.ShouldBeEmpty("the fresh handle must be disposed — no orphan ADS notification");
}
/// <summary>
/// STAB-12 (compounded part): <c>RecycleClientAsync</c> must honor cancellation — it must
/// take the probe token into <c>ConnectGate.WaitAsync(ct)</c> rather than
/// <c>CancellationToken.None</c>, so a shutdown mid-recycle (or a connect wedged under the
/// gate) can't hang the probe loop uncancellably. Verified white-box: with the gate held and
/// a cancelled token, the call must throw promptly rather than block forever.
/// </summary>
[Fact]
public async Task RecycleClient_HonorsCancellation()
{
var (drv, _) = NewNativeDriver(
tags: new TwinCATTagDefinition("X", Host, "MAIN.X", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// Reach the single configured device + wedge its ConnectGate so a recycle must wait on it.
var devicesField = typeof(TwinCATDriver).GetField("_devices",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
var devicesObj = devicesField.GetValue(drv)!;
var values = (System.Collections.IEnumerable)devicesObj.GetType().GetProperty("Values")!.GetValue(devicesObj)!;
var device = (TwinCATDriver.DeviceState)values.Cast<object>().First();
await device.ConnectGate.WaitAsync(TestContext.Current.CancellationToken);
var recycle = typeof(TwinCATDriver).GetMethod("RecycleClientAsync",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
var task = (Task)recycle.Invoke(drv, [device, cts.Token])!;
// Must throw promptly (cancelled token), NOT hang on the held gate. The 2 s bound turns an
// uncancellable-wait regression into a failure rather than a hung suite.
await Should.ThrowAsync<OperationCanceledException>(async () => await task.WaitAsync(TimeSpan.FromSeconds(2)));
device.ConnectGate.Release();
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
@@ -0,0 +1,43 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// CONV-4 — <see cref="TwinCATDriver.ResolveHost"/> must resolve an equipment-tag reference
/// (raw TagConfig JSON) to its OWN device host so per-host breaker keys are correct for
/// equipment tags, not the first-device fallback.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATResolveHostTests
{
private const string DeviceA = "ads://5.23.91.23.1.1:851";
private const string DeviceB = "ads://5.23.91.23.1.2:851";
private static TwinCATDriver NewDriver() => new(
new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(DeviceA), new TwinCATDeviceOptions(DeviceB)],
Probe = new TwinCATProbeOptions { Enabled = false },
EnableControllerBrowse = false,
},
"twincat-1", new FakeTwinCATClientFactory());
/// <summary>An equipment-tag ref naming device B resolves to device B's host, not the first device.</summary>
[Fact]
public void ResolveHost_EquipmentTagRef_ReturnsItsOwnDeviceHost()
{
var drv = NewDriver();
var json = $$"""{"deviceHostAddress":"{{DeviceB}}","symbolPath":"MAIN.Speed","dataType":"DInt"}""";
drv.ResolveHost(json).ShouldBe(DeviceB);
}
/// <summary>An unresolvable ref keeps the current first-device fallback.</summary>
[Fact]
public void ResolveHost_UnknownRef_KeepsCurrentFallback()
{
var drv = NewDriver();
drv.ResolveHost("totally-unknown").ShouldBe(DeviceA);
}
}