fix(twincat): review round 1 - PLC-not-in-RUN degrades, per-device health, retry backoff

- IMPORTANT: a successful probe records the PLC's ADS state; a PLC not in Run
  keeps the driver Degraded with 'PLC in {state}' while LastSuccessfulRead
  still advances (the device answered). RecordDeviceAnswered never restores
  Healthy while any device is out of Run.
- Health is per device, rolled up worst-of: a comms fault is remembered on the
  device it was seen on and only that device answering clears it (device A
  answering no longer clears device B's fault).
- Refused (non-comms) dead registrations back off 30 s doubling to 10 min and
  are re-armed by a symbol-version change or retried by the reconnect replay;
  comms-class failures keep the every-tick retry. A retry whose failure class
  changes republishes the tag's status, once.
- Bit-RMW gates are kept across generations and teardowns (never cleared).
- ShutdownAsync honours its CancellationToken: it stops waiting, finishes the
  bookkeeping and throws OperationCanceledException.
- Remarks on why a superseded InitializeAsync throws OperationCanceledException.
- REAL / LREAL values (scalar or array element) outside the target range are
  OverflowException -> BadOutOfRange instead of being written as Infinity.
- String / array write encoding factored into AdsTwinCATClient.PlanStringWrite
  / PlanArrayWrite; the fake runs them so a driver-level test covers the real
  encode path against a fake resolved symbol.
- Timing-fragile tests use condition waits / measured probe-tick windows; the
  dispose-during-shutdown test asserts ordering with a 1 s bound.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:47:24 -04:00
parent 8de5947e2d
commit f179829ca6
7 changed files with 608 additions and 56 deletions
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
@@ -142,6 +143,14 @@ internal class FakeTwinCATClient : ITwinCATClient
string symbolPath, TwinCATDataType type, int? bitIndex, object? value, CancellationToken ct)
{
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
if (type is TwinCATDataType.String or TwinCATDataType.WString
&& SymbolShapes.TryGetValue(symbolPath, out var shape))
{
// The real client's encode plan (resolve → CheckRead → codec at the PLC width).
var (planStatus, bytes) = AdsTwinCATClient.PlanStringWrite(type, shape, value, utf8: false);
if (planStatus != TwinCATStatusMapper.Good) return Task.FromResult(planStatus);
RawWrites[symbolPath] = bytes;
}
WriteLog.Add((symbolPath, type, bitIndex, value));
Values[symbolPath] = value;
var status = WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
@@ -161,11 +170,26 @@ internal class FakeTwinCATClient : ITwinCATClient
CancellationToken ct)
{
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
if (ArrayShapes.TryGetValue(symbolPath, out var array) && SymbolShapes.TryGetValue(symbolPath, out var shape))
{
// The real client's encode plan (CheckArray layout → TwinCATArrayCodec.Encode).
var (planStatus, bytes) = AdsTwinCATClient.PlanArrayWrite(type, elements, shape, array, utf8: false);
if (planStatus != TwinCATStatusMapper.Good) return Task.FromResult(planStatus);
RawWrites[symbolPath] = bytes;
}
ArrayWriteLog.Add((symbolPath, type, elements));
Values[symbolPath] = elements;
return Task.FromResult(WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good);
}
/// <summary>Resolved PLC symbol shapes; a string / array write to a symbol listed here runs the real client's
/// encode plan and lands its bytes in <see cref="RawWrites"/> (#739 fix round 1).</summary>
public Dictionary<string, TwinCATPlcSymbolShape> SymbolShapes { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Resolved PLC array shapes for <see cref="SymbolShapes"/> entries that are arrays.</summary>
public Dictionary<string, TwinCATPlcArrayShape> ArrayShapes { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>The raw bytes the encode plan produced, by symbol.</summary>
public ConcurrentDictionary<string, byte[]> RawWrites { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Raw bytes of bit-write parent words, by parent path (#739 H18). The byte count is the parent's width.</summary>
public Dictionary<string, byte[]> ParentWords { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Optional parent shapes; absent = an unsigned integer word as wide as its <see cref="ParentWords"/> entry.</summary>
@@ -205,10 +229,16 @@ internal class FakeTwinCATClient : ITwinCATClient
/// <returns>A task that returns the probe result.</returns>
public virtual Task<bool> ProbeAsync(CancellationToken ct)
{
Interlocked.Increment(ref _probeCount);
if (ThrowOnProbe) return Task.FromResult(false);
return Task.FromResult(ProbeResult);
}
private int _probeCount;
/// <summary>Gets how many times <see cref="ProbeAsync"/> ran — a measured window for absence assertions.</summary>
public int ProbeCount => Volatile.Read(ref _probeCount);
/// <summary>Releases unmanaged resources.</summary>
public virtual void Dispose()
{
@@ -224,7 +254,14 @@ internal class FakeTwinCATClient : ITwinCATClient
public bool ThrowOnAddNotification { get; set; }
/// <summary>Per-symbol exceptions <see cref="AddNotificationAsync"/> throws (e.g. a
/// <see cref="TwinCATTypeMismatchException"/> for one refused tag).</summary>
public Dictionary<string, Exception> AddNotificationErrors { get; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, Exception> AddNotificationErrors { get; } = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, int> _addAttempts = new(StringComparer.OrdinalIgnoreCase);
/// <summary>How many times <see cref="AddNotificationAsync"/> was called for <paramref name="symbolPath"/>.</summary>
/// <param name="symbolPath">The symbol path.</param>
/// <returns>The attempt count.</returns>
public int AttemptsFor(string symbolPath) => _addAttempts.TryGetValue(symbolPath, out var n) ? n : 0;
/// <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 —
@@ -245,6 +282,7 @@ internal class FakeTwinCATClient : ITwinCATClient
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, TimeSpan cycleTime,
int maxDelayMs, Action<string, object?> onChange, CancellationToken cancellationToken)
{
_addAttempts.AddOrUpdate(symbolPath, 1, (_, n) => n + 1);
if (ThrowOnAddNotification)
throw Exception ?? new InvalidOperationException("fake AddNotification failure");
if (AddNotificationErrors.TryGetValue(symbolPath, out var perSymbol))
@@ -50,7 +50,9 @@ public sealed class TwinCATHealthContractTests
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
drv.GetHealth().LastSuccessfulRead.ShouldBe(initStamp);
await Task.Delay(300, TestContext.Current.CancellationToken);
// Condition, not a fixed delay: the clock only has to move past the stamp (the Degraded state means
// the notification is not inside the Healthy throttle window).
await WaitForAsync(() => DateTime.UtcNow > initStamp!.Value, TimeSpan.FromSeconds(2));
fake.FireNotification("MAIN.Speed", 7);
var after = drv.GetHealth();
@@ -80,8 +80,10 @@ public sealed class TwinCATLifecycleTests
var shutdown = drv.ShutdownAsync(CancellationToken.None);
var sw = Stopwatch.StartNew();
await drv.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(250));
// Ordering is the real assertion: dispose finished while the first shutdown was still joining the
// wedged probe. The elapsed bound is generous (the runtime allows 250 ms; a loaded CI box gets 1 s).
shutdown.IsCompleted.ShouldBeFalse("the positive control: the first shutdown really was still joining the probe");
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1));
wedge.SetResult();
await shutdown.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
@@ -109,15 +111,18 @@ public sealed class TwinCATLifecycleTests
await Should.ThrowAsync<OperationCanceledException>(() => abandoned.WaitAsync(TimeSpan.FromSeconds(5)));
drv.DeviceCount.ShouldBe(0);
await Task.Delay(150, TestContext.Current.CancellationToken); // several probe intervals
factory.Clients.ShouldBeEmpty("no probe loop of the overtaken generation may be running");
factory.Clients.ShouldBeEmpty();
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
// Positive control + measured window: a fresh generation's probe creates exactly one client and
// ticks several times; an overtaken-generation probe loop (same interval, started earlier) would have
// created a second client within that window.
drv.BeforeCommitForTest = null;
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
drv.DeviceCount.ShouldBe(1);
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
factory.Clients.Count.ShouldBe(1);
await WaitForAsync(() => factory.Clients.Count >= 1 && factory.Clients[0].ProbeCount >= 4, TimeSpan.FromSeconds(3));
factory.Clients[0].ProbeCount.ShouldBeGreaterThanOrEqualTo(4);
factory.Clients.Count.ShouldBe(1, "no probe loop of the overtaken generation may be running");
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
@@ -145,7 +150,7 @@ public sealed class TwinCATLifecycleTests
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
var abandoned = drv.InitializeAsync("{}", CancellationToken.None); // joins the wedged generation-1 probe
await Task.Delay(50, TestContext.Current.CancellationToken);
await WaitForAsync(() => drv.DeviceCount == 1 && factory.Clients.Count >= 2, TimeSpan.FromSeconds(3)); // gen 2 committed + probing
var sw = Stopwatch.StartNew();
await drv.ShutdownAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
sw.Elapsed.ShouldBeLessThan(TwinCATDriver.ShutdownBudget + TimeSpan.FromMilliseconds(500));
@@ -157,7 +162,15 @@ public sealed class TwinCATLifecycleTests
drv.DeviceCount.ShouldBe(0);
await WaitForAsync(() => factory.Clients.All(c => c.DisposeCount > 0 || !c.IsConnected), TimeSpan.FromSeconds(2));
var settled = factory.Clients.Count;
await Task.Delay(150, TestContext.Current.CancellationToken);
// Measured window: a sibling driver on the same probe interval ticks 4 times; any surviving probe
// loop of the torn-down driver would have built a new client in that time.
var witnessFactory = new FakeTwinCATClientFactory();
var witness = NewDriver(witnessFactory);
await witness.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => witnessFactory.Clients.Count >= 1 && witnessFactory.Clients[0].ProbeCount >= 4, TimeSpan.FromSeconds(3));
witnessFactory.Clients[0].ProbeCount.ShouldBeGreaterThanOrEqualTo(4);
await witness.ShutdownAsync(TestContext.Current.CancellationToken);
factory.Clients.Count.ShouldBe(settled, "no probe loop may still be creating clients");
factory.Clients.ShouldAllBe(c => c.DisposeCount > 0 || !c.IsConnected);
}
@@ -231,20 +244,33 @@ public sealed class TwinCATLifecycleTests
var factory = new FakeTwinCATClientFactory();
var drv = NewDriver(factory, probe: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(400));
// Run for at least 300 ms of overlap AND until the reader has seen a Good read mid-churn (positive
// control), bounded at 10 s — a condition, not a fixed window, so a loaded machine cannot starve it.
var good = 0;
var reads = 0;
var inits = 0;
var minUntil = DateTime.UtcNow.AddMilliseconds(300);
var deadline = DateTime.UtcNow.AddSeconds(10);
bool Done() => (DateTime.UtcNow > minUntil && Volatile.Read(ref good) > 0 && Volatile.Read(ref inits) > 10)
|| DateTime.UtcNow > deadline;
var reader = Task.Run(async () =>
{
while (!stop.IsCancellationRequested)
while (!Done())
{
var r = await drv.ReadAsync(["Speed"], CancellationToken.None);
Interlocked.Increment(ref reads);
if (r[0].StatusCode == TwinCATStatusMapper.Good) Interlocked.Increment(ref good);
}
}, TestContext.Current.CancellationToken);
while (!stop.IsCancellationRequested)
while (!Done())
{
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
Interlocked.Increment(ref inits);
await Task.Yield(); // let the reader in between generations
}
await reader; // throws if any read threw
good.ShouldBeGreaterThan(0);
inits.ShouldBeGreaterThan(10);
}
/// <summary>Notification-queue drops are reported once per probe tick that sees new drops.</summary>
@@ -255,14 +281,14 @@ public sealed class TwinCATLifecycleTests
var log = new TwinCATCapturingLogger();
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, log: log);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => fake.IsConnected, TimeSpan.FromSeconds(2));
await Task.Delay(100, TestContext.Current.CancellationToken);
log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(0); // no drops, no warning
await WaitForAsync(() => fake.ProbeCount >= 3, TimeSpan.FromSeconds(3));
log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(0); // 3 ticks, no drops, no warning
fake.DroppedNotificationCount = 5;
await WaitForAsync(() => log.Warnings.Any(w => w.Contains("dropped 5")), TimeSpan.FromSeconds(2));
await Task.Delay(100, TestContext.Current.CancellationToken);
log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(1);
var ticks = fake.ProbeCount;
await WaitForAsync(() => fake.ProbeCount >= ticks + 3, TimeSpan.FromSeconds(3));
log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(1); // 3 more ticks, no new drops, no repeat
fake.DroppedNotificationCount = 8;
await WaitForAsync(() => log.Warnings.Any(w => w.Contains("dropped 3")), TimeSpan.FromSeconds(2));
@@ -87,7 +87,8 @@ public sealed class TwinCATReadWarningTests
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
fake.ReadStatuses["MAIN.Typo"] = TwinCATStatusMapper.BadNodeIdUnknown;
await Task.Delay(300, TestContext.Current.CancellationToken);
var deadline = DateTime.UtcNow.AddSeconds(2);
while (DateTime.UtcNow <= initStamp && DateTime.UtcNow < deadline) await Task.Delay(5, TestContext.Current.CancellationToken);
_ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
var afterRefusal = drv.GetHealth();
@@ -0,0 +1,294 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Shouldly;
using TwinCAT.TypeSystem;
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>
/// #739 review fix round 1: the PLC-not-in-RUN health ruling, refused-registration retry backoff,
/// per-device worst-of health, RMW gates surviving teardown, a cancellable shutdown, the real
/// STRING / array encode path through the client planning helpers, and REAL range checks.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATReviewFixRound1Tests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private const string HostB = "ads://5.23.91.24.1.1:851";
private static TwinCATProbeOptions FastProbe => new()
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(30),
Timeout = TimeSpan.FromMilliseconds(100),
};
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, TwinCATProbeOptions probe, bool native,
params TwinCATTagDefinition[] tags) =>
new(new TwinCATDriverOptions
{
Devices = [.. tags.Select(t => t.DeviceHostAddress).Append(Host).Distinct().Select(h => new TwinCATDeviceOptions(h))],
RawTags = TwinCATRawTags.Entries(tags),
Probe = probe,
UseNativeNotifications = native,
}, "drv-1", factory);
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(10);
}
// ---- IMPORTANT: a PLC not in RUN leaves the driver Degraded ----
/// <summary>
/// A probe that answers with ADS state Stop writes Degraded "PLC in Stop" and still advances
/// LastSuccessfulRead (the device answered); back in Run the driver is Healthy (positive control).
/// </summary>
[Fact]
public async Task A_PLC_not_in_Run_is_Degraded_and_Run_restores_Healthy()
{
var fake = new FakeTwinCATClient { AdsStateText = "Stop" };
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: false,
new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
await WaitForAsync(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(3));
var stopped = drv.GetHealth();
stopped.State.ShouldBe(DriverState.Degraded);
stopped.LastError.ShouldNotBeNull().ShouldContain("PLC in Stop");
await WaitForAsync(() => drv.GetHealth().LastSuccessfulRead > initStamp, TimeSpan.FromSeconds(3));
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp);
// A Good read while the PLC is stopped must not paper over it.
_ = await drv.ReadAsync(["Speed"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
fake.AdsStateText = "Run";
await WaitForAsync(() => drv.GetHealth().State == DriverState.Healthy, TimeSpan.FromSeconds(3));
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
// ---- MINOR 1: refused dead registrations back off; a class change republishes ----
/// <summary>
/// A REFUSED dead registration is not re-attempted every probe tick; a comms-failed one is (positive
/// control: it recovers). A symbol-version-changed signal re-arms the refused one at once.
/// </summary>
[Fact]
public async Task Refused_registrations_back_off_while_comms_failures_retry_each_tick()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.T"] = new TwinCATTypeMismatchException("MAIN.T", "declared DInt, PLC STRING");
fake.AddNotificationErrors["MAIN.C"] = new InvalidOperationException("transient");
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: true,
new TwinCATTagDefinition("T", Host, "MAIN.T", TwinCATDataType.DInt),
new TwinCATTagDefinition("C", Host, "MAIN.C", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.SubscribeAsync(["T", "C"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
await WaitForAsync(() => fake.AttemptsFor("MAIN.C") >= 3, TimeSpan.FromSeconds(3));
fake.AttemptsFor("MAIN.C").ShouldBeGreaterThanOrEqualTo(3); // comms class: every tick
fake.AttemptsFor("MAIN.T").ShouldBe(1); // refusal: backed off
fake.AddNotificationErrors.TryRemove("MAIN.C", out _);
await WaitForAsync(() => fake.Notifications.Any(n => n.SymbolPath == "MAIN.C"), TimeSpan.FromSeconds(3));
fake.Notifications.ShouldContain(n => n.SymbolPath == "MAIN.C");
fake.FireSymbolVersionChanged();
await WaitForAsync(() => fake.AttemptsFor("MAIN.T") >= 2, TimeSpan.FromSeconds(3));
fake.AttemptsFor("MAIN.T").ShouldBe(2);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>When a retry fails with a different failure class the tag's status is republished, once.</summary>
[Fact]
public async Task A_retry_whose_failure_class_changes_republishes_the_status()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.X"] = new InvalidOperationException("router hiccup");
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: true,
new TwinCATTagDefinition("X", Host, "MAIN.X", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
events.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
await WaitForAsync(() => fake.AttemptsFor("MAIN.X") >= 3, TimeSpan.FromSeconds(3));
events.Count.ShouldBe(1, "a repeat of the same failure class publishes nothing");
fake.AddNotificationErrors["MAIN.X"] = new TwinCATNotificationRefusedException(
"MAIN.X", "symbol gone", TwinCATStatusMapper.BadNodeIdUnknown);
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(3));
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
// ---- MINOR 2: per-device worst-of health ----
/// <summary>
/// A comms fault on device B is not cleared by device A answering; B answering clears it
/// (positive control).
/// </summary>
[Fact]
public async Task One_devices_answer_does_not_clear_another_devices_comms_fault()
{
var clients = new ConcurrentDictionary<int, FakeTwinCATClient>();
var build = 0;
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
var c = new FakeTwinCATClient();
clients[Interlocked.Increment(ref build)] = c;
return c;
},
};
var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", HostB, "MAIN.B", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.ReadAsync(["A"], TestContext.Current.CancellationToken); // client 1 = device A
_ = await drv.ReadAsync(["B"], TestContext.Current.CancellationToken); // client 2 = device B
clients[2].ReadStatuses["MAIN.B"] = TwinCATStatusMapper.BadTimeout;
_ = await drv.ReadAsync(["B"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
_ = await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "device A answering says nothing about device B");
clients[2].ReadStatuses.Remove("MAIN.B");
_ = await drv.ReadAsync(["B"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
// ---- MINOR 3: RMW gates are kept across generations ----
/// <summary>The per-parent RMW gate survives a teardown (a write still in flight keeps the same gate).</summary>
[Fact]
public async Task Bit_RMW_gates_survive_a_shutdown()
{
var fake = new FakeTwinCATClient { ParentWords = { ["MAIN.Flags"] = [0, 0] } };
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("Bit", Host, "MAIN.Flags.1", TwinCATDataType.Bool));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.WriteAsync([new WriteRequest("Bit", true)], TestContext.Current.CancellationToken);
var gate = drv.BitRmwGateForTest(Host, "MAIN.Flags").ShouldNotBeNull();
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.WriteAsync([new WriteRequest("Bit", false)], TestContext.Current.CancellationToken);
drv.BitRmwGateForTest(Host, "MAIN.Flags").ShouldBeSameAs(gate);
}
// ---- MINOR 4: ShutdownAsync honours its token ----
/// <summary>
/// A cancelled ShutdownAsync stops waiting on a probe wedged in connect and throws
/// OperationCanceledException; the teardown itself still happened (tables emptied).
/// </summary>
[Fact]
public async Task Shutdown_honours_its_cancellation_token()
{
var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { BlockConnect = wedge } };
var drv = NewDriver(factory, FastProbe, native: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
var sw = Stopwatch.StartNew();
await Should.ThrowAsync<OperationCanceledException>(() => drv.ShutdownAsync(cts.Token));
sw.Elapsed.ShouldBeLessThan(TwinCATDriver.ProbeJoinTimeout, "the wait ended on the token, not the join bound");
drv.DeviceCount.ShouldBe(0);
wedge.SetResult();
}
// ---- MINOR 7: the real STRING / array encode path through the client planning helpers ----
/// <summary>
/// A STRING write through the driver lands as NUL-padded Windows-1252 bytes at the PLC symbol's width;
/// an over-long value is BadOutOfRange and a WSTRING symbol under a String tag BadTypeMismatch.
/// </summary>
[Fact]
public async Task String_writes_go_through_the_client_encode_plan()
{
var fake = new FakeTwinCATClient();
fake.SymbolShapes["MAIN.Name"] = TwinCATPlcSymbolShape.Classify("STRING(4)", DataTypeCategory.String, 5);
fake.SymbolShapes["MAIN.Wide"] = TwinCATPlcSymbolShape.Classify("WSTRING(4)", DataTypeCategory.String, 10);
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("Name", Host, "MAIN.Name", TwinCATDataType.String),
new TwinCATTagDefinition("Wide", Host, "MAIN.Wide", TwinCATDataType.String));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[
new WriteRequest("Name", "äb"),
new WriteRequest("Name", "toolong"),
new WriteRequest("Wide", "x"),
], TestContext.Current.CancellationToken);
results.Select(r => r.StatusCode).ShouldBe(
[TwinCATStatusMapper.Good, TwinCATStatusMapper.BadOutOfRange, TwinCATStatusMapper.BadTypeMismatch]);
fake.RawWrites["MAIN.Name"].ShouldBe(new byte[] { 0xE4, (byte)'b', 0, 0, 0 });
fake.RawWrites.ShouldNotContainKey("MAIN.Wide");
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>An array write through the driver lands as the codec's bytes at the PLC element stride.</summary>
[Fact]
public async Task Array_writes_go_through_the_client_encode_plan()
{
var fake = new FakeTwinCATClient();
var element = TwinCATPlcSymbolShape.Classify("INT", DataTypeCategory.Primitive, 2);
fake.SymbolShapes["MAIN.Buf"] = TwinCATPlcSymbolShape.Classify("ARRAY [0..3] OF INT", DataTypeCategory.Array, 8);
fake.ArrayShapes["MAIN.Buf"] = new TwinCATPlcArrayShape([4], 8, element);
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("Buf", Host, "MAIN.Buf", TwinCATDataType.Int, ArrayLength: 2),
new TwinCATTagDefinition("BufAsReal", Host, "MAIN.Buf", TwinCATDataType.Real, ArrayLength: 2));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[
new WriteRequest("Buf", new[] { 1, -2 }),
new WriteRequest("BufAsReal", new[] { 1.0, 2.0 }),
], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
fake.RawWrites["MAIN.Buf"].ShouldBe(new byte[] { 0x01, 0x00, 0xFE, 0xFF }); // leading sub-range only
results[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
}
// ---- MINOR 8: REAL / LREAL range ----
/// <summary>A REAL (scalar or array element) beyond float range is BadOutOfRange, never written as Infinity.</summary>
[Fact]
public async Task Out_of_range_REAL_values_are_BadOutOfRange()
{
Should.Throw<OverflowException>(() => AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, 1e300));
Should.Throw<OverflowException>(() => AdsTwinCATClient.ConvertForWrite(TwinCATDataType.LReal, "1e400"));
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, 3.5).ShouldBe(3.5f);
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, float.PositiveInfinity).ShouldBe(float.PositiveInfinity);
float.IsNaN((float)AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, double.NaN)).ShouldBeTrue();
var fake = new FakeTwinCATClient();
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("Arr", Host, "MAIN.Arr", TwinCATDataType.Real, ArrayLength: 2));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var result = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1.0, 1e300 })], TestContext.Current.CancellationToken);
result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
fake.ArrayWriteLog.ShouldBeEmpty();
}
}