using System.Text.Json; using Akka.Actor; using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// lmxopcua#777: 's write Ask is derived from the configured write timeout /// () rather than a fixed 8 s, so a write the child is still entitled to run /// is never reported failed by the Ask — the node would be reverted while the device could still take the value. /// /// Drives a real apply (a real child), then routes a held write with the /// host and the child on ONE manual clock: the host's Ask and the child's bounds both run on it, so stepping /// the clock shows which one answers first. /// /// public sealed class DriverHostActorWriteAskTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-write-ask-test"); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); private const string RawNodeId = "ns=2;s=Plant/Modbus/dev1/speed"; /// The value the gated driver holds; every other value is written at once. private const double HeldValue = 99.0; /// /// A write still inside a 9 s write timeout is not cut off at 8 s: the host waits for the child, which /// answers with the device's real outcome. (Mutation-checked: a fixed 8 s Ask on the same clock fails this /// with "write timeout" at the 8.5 s step.) /// [Fact] public void A_write_within_a_9s_write_timeout_is_not_failed_by_the_host_Ask() { var clock = new ManualTimeProvider(); var driver = new GatedWritableDriver(); var host = SpawnHostAndApply(driver, new DriverWriteBounds(TimeSpan.FromSeconds(9)), clock); var asker = RouteHeldWrite(host, driver, clock); clock.Advance(TimeSpan.FromSeconds(8.5)); asker.ExpectNoMsg(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken); driver.Release(); var result = asker.ExpectMsg(Timeout, cancellationToken: TestContext.Current.CancellationToken); result.Success.ShouldBeTrue(result.Reason); } /// /// A token-ignoring write under a 9 s write timeout is answered by the CHILD (abandoned BadTimeout at /// 9 s + its grace), not by the host's Ask: the Ask covers the child's whole bound. Under the fixed 8 s Ask /// this read "write timeout" from the Ask at 8 s while the device still had the write. /// [Fact] public void A_token_ignoring_write_is_answered_by_the_child_before_the_host_Ask() { var clock = new ManualTimeProvider(); var driver = new GatedWritableDriver { IgnoreCancellation = true }; var bounds = new DriverWriteBounds(TimeSpan.FromSeconds(9)); var host = SpawnHostAndApply(driver, bounds, clock); var asker = RouteHeldWrite(host, driver, clock); clock.Advance(bounds.Timeout); asker.ExpectNoMsg(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken); clock.Advance(bounds.AbandonGrace); var result = asker.ExpectMsg(Timeout, cancellationToken: TestContext.Current.CancellationToken); result.Success.ShouldBeFalse(); result.Reason!.ShouldContain("800A0000", customMessage: "the child's BadTimeout, not the host Ask's \"write timeout\""); result.Reason!.ShouldContain("within 9000 ms"); bounds.RunBound.ShouldBeLessThan(bounds.HostAsk); } /// /// The mechanism the host's Ask relies on: with an Infinite Akka timeout, the Ask's deadline is the write /// clock's token alone — it does not complete a tick before , and it /// does complete (cancelled, which the host reports as "write timeout") at it, for a child that never answers. /// [Fact] public void An_Ask_with_an_Infinite_timeout_ends_exactly_at_its_clock_deadline() { var clock = new ManualTimeProvider(); var bounds = new DriverWriteBounds(TimeSpan.FromSeconds(9)); var silentChild = CreateTestProbe(); using var deadline = new CancellationTokenSource(bounds.HostAsk, clock); var ask = silentChild.Ref.Ask( new DriverInstanceActor.WriteAttribute("x", 1), System.Threading.Timeout.InfiniteTimeSpan, deadline.Token); clock.Advance(bounds.HostAsk - TimeSpan.FromMilliseconds(1)); Thread.Sleep(200); ask.IsCompleted.ShouldBeFalse("the Ask ended before its clock deadline"); clock.Advance(TimeSpan.FromMilliseconds(1)); AwaitCondition(() => ask.IsCompleted, Timeout, TimeSpan.FromMilliseconds(20), cancellationToken: TestContext.Current.CancellationToken); ask.IsCompletedSuccessfully.ShouldBeFalse(); } /// Routes the held write once the host accepts writes (a warm-up write proves the child is Connected /// and the reverse map is pushed), then waits until it is parked in the driver with every bound armed. private Akka.TestKit.TestProbe RouteHeldWrite(IActorRef host, GatedWritableDriver driver, ManualTimeProvider clock) { var lastReason = "(no reply)"; AwaitAssert( () => { var warm = CreateTestProbe(); host.Tell(new DriverHostActor.RouteNodeWrite(RawNodeId, 1.0, AddressSpaceRealm.Raw), warm.Ref); var r = warm.ExpectMsg(TimeSpan.FromSeconds(1)); lastReason = r.Reason ?? "(none)"; r.Success.ShouldBeTrue($"host kept rejecting the warm-up write; last reason: {lastReason}"); }, duration: Timeout, interval: TimeSpan.FromMilliseconds(50)); var asker = CreateTestProbe(); host.Tell(new DriverHostActor.RouteNodeWrite(RawNodeId, HeldValue, AddressSpaceRealm.Raw), asker.Ref); AwaitCondition(() => driver.HeldEntered, Timeout, TimeSpan.FromMilliseconds(20)); // The host's Ask deadline + the child's token cancel + the child's abandon bound. AwaitCondition(() => clock.ArmedTimers == 3, Timeout, TimeSpan.FromMilliseconds(20)); return asker; } private IActorRef SpawnHostAndApply(GatedWritableDriver driver, DriverWriteBounds bounds, ManualTimeProvider clock) { var db = NewInMemoryDbFactory(); var deploymentId = SeedDeployment(db); var coordinator = CreateTestProbe(); var host = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, driverFactory: new SingleDriverFactory(driver), localRoles: new HashSet { "driver" }, writeBounds: bounds, writeClock: clock)); host.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); return host; } /// One enabled Modbus driver "drv-1" with one ReadWrite tag, RawPath Plant/Modbus/dev1/speed. private static DeploymentId SeedDeployment(IDbContextFactory db) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, DriverInstances = new[] { new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true }, }, Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } }, TagGroups = Array.Empty(), Tags = new[] { new { TagId = "t-speed", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "speed", DataType = "Double", AccessLevel = 1, TagConfig = "{}" }, }, UnsAreas = Array.Empty(), UnsLines = Array.Empty(), Equipment = Array.Empty(), UnsTagReferences = Array.Empty(), }); var id = DeploymentId.NewId(); using var ctx = db.CreateDbContext(); ctx.Deployments.Add(new Deployment { DeploymentId = id.Value, RevisionHash = RevA.Value, Status = DeploymentStatus.Sealed, CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = artifact, }); ctx.SaveChanges(); return id; } private sealed class SingleDriverFactory(GatedWritableDriver driver) : IDriverFactory { public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => driverType == "Modbus" ? driver.Bind(driverInstanceId, driverType) : null; public IReadOnlyCollection SupportedTypes => new[] { "Modbus" }; } /// Writes only once released (honouring its token unless /// ); every other value is written at once. private sealed class GatedWritableDriver : IDriver, IWritable { private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); private int _heldEntered; public bool IgnoreCancellation { get; init; } public bool HeldEntered => Volatile.Read(ref _heldEntered) == 1; public string DriverInstanceId { get; private set; } = string.Empty; public string DriverType { get; private set; } = string.Empty; public GatedWritableDriver Bind(string id, string type) { DriverInstanceId = id; DriverType = type; return this; } public void Release() => _release.TrySetResult(); public async Task> WriteAsync(IReadOnlyList writes, CancellationToken cancellationToken) { if (writes.Any(w => w.Value is HeldValue)) { Volatile.Write(ref _heldEntered, 1); if (IgnoreCancellation) await _release.Task; else await _release.Task.WaitAsync(cancellationToken); } return writes.Select(_ => new WriteResult(OpcUaStatus.Good)).ToList(); } public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; public Task ShutdownAsync(CancellationToken cancellationToken) { _release.TrySetResult(); return Task.CompletedTask; } public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); } }