diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 6879a972d..3d58971c9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -491,6 +491,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS /// () means the same thing on both sides. private readonly TimeProvider _writeClock; + /// + /// Test seam: the PostStop shutdown budget every real (non-stub) child is spawned with; null = + /// . A test that parks a stopped child in its + /// PostStop to observe the retired-but-not-terminated window raises it, so the window cannot close under + /// load before the test has looked (lmxopcua#828). + /// + private readonly TimeSpan? _childShutdownTimeout; + /// Whether any redundancy snapshot has reached this host — the HistoryUpdate gate stays /// closed until one has (see ). private bool _redundancySnapshotReceived; @@ -738,6 +746,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS /// Ask is derived (). Null ⇒ . /// The clock the write bounds (and the host's write Ask) run on, shared with every /// child. Null ⇒ . + /// Test seam: each real child's PostStop shutdown budget; null ⇒ + /// (lmxopcua#828). /// The Akka.NET used to spawn this actor. public static Props Props( IDbContextFactory? dbFactory, @@ -776,7 +786,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS NativeAlarmAckPreflightView? ackPreflightView = null, DriverWriteBounds? writeBounds = null, TimeProvider? writeClock = null, - IScriptRunner? scriptedAlarmRunner = null) => + IScriptRunner? scriptedAlarmRunner = null, + TimeSpan? childShutdownTimeout = null) => // WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an // expression tree. Six IActorRef? parameters and several interface-typed ones mean a // mis-ordered argument is usually type-compatible and therefore compiles clean, then binds @@ -790,7 +801,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS alarmStateStore, telemetryHub, primaryRoleSource, historizationRoleView, sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff, commsRecoveryAuditDelay, sharesAlarmQueueWithLocal, applyOutcomeTimeout, captureWhilePrimaryUnreachable, historyUpdateRoleView, - scripting, ackPreflightView, writeBounds, writeClock, scriptedAlarmRunner)); + scripting, ackPreflightView, writeBounds, writeClock, scriptedAlarmRunner, childShutdownTimeout)); /// Initializes a new DriverHostActor with the specified dependencies. /// Database context factory for configuration database access. @@ -885,6 +896,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS /// Ask is derived (). Null ⇒ . /// The clock the write bounds (and the host's write Ask) run on, shared with every /// child. Null ⇒ . + /// Test seam: each real child's PostStop shutdown budget; null ⇒ + /// (lmxopcua#828). public DriverHostActor( IDbContextFactory? dbFactory, CommonsNodeId localNode, @@ -922,8 +935,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS NativeAlarmAckPreflightView? ackPreflightView = null, DriverWriteBounds? writeBounds = null, TimeProvider? writeClock = null, - IScriptRunner? scriptedAlarmRunner = null) + IScriptRunner? scriptedAlarmRunner = null, + TimeSpan? childShutdownTimeout = null) { + _childShutdownTimeout = childShutdownTimeout; _scriptedAlarmRunner = scriptedAlarmRunner; _writeBounds = writeBounds ?? DriverWriteBounds.Default; _writeClock = writeClock ?? TimeProvider.System; @@ -3957,7 +3972,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS clusterId: clusterId, invoker: invoker, writeTimeout: _writeBounds.Timeout, - writeClock: _writeClock), + writeClock: _writeClock, + shutdownTimeout: _childShutdownTimeout), actorName); child.Tell(new DriverInstanceActor.InitializeRequested(spec.DriverConfig)); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorSourceRemovedTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorSourceRemovedTests.cs index 4cf7ca254..0eab7f6f0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorSourceRemovedTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorSourceRemovedTests.cs @@ -47,7 +47,8 @@ public sealed class DriverHostActorSourceRemovedTests : RuntimeActorTestBase /// Spawns a host over a real mux with two enabled drivers, waits for both children to subscribe, /// registers a replay-opting holder (so the mux retains both refs) and publishes one value per driver. - private void ArrangeTwoDriversWithRetainedValues() + /// Each child's PostStop shutdown budget (null = the production 5 s). + private void ArrangeTwoDriversWithRetainedValues(TimeSpan? childShutdownTimeout = null) { _db = NewInMemoryDbFactory(); var revA = V3ArtifactSeeder.SeedV3Deployment(_db, RevA, BothDrivers, NoRefs, enabled: true); @@ -61,7 +62,8 @@ public sealed class DriverHostActorSourceRemovedTests : RuntimeActorTestBase localRoles: new HashSet { "driver" }, dependencyMux: _mux, virtualTagEvaluator: NullVirtualTagEvaluator.Instance, - virtualTagHostOverride: CreateTestProbe().Ref)); + virtualTagHostOverride: CreateTestProbe().Ref, + childShutdownTimeout: childShutdownTimeout)); V3ArtifactSeeder.Dispatch(_host, _coordinator, revA, RevA); AwaitAssert(() => _factory.Created.Count.ShouldBe(2), PresenceBudget); // The spawn hands a child the PREVIOUS apply's set (empty on the first deploy) and the bulk push then @@ -137,28 +139,51 @@ public sealed class DriverHostActorSourceRemovedTests : RuntimeActorTestBase /// Review I-1: a burst the removed child sent after the eviction (it stops asynchronously, so it keeps /// publishing through the rest of the apply) must be dropped by the host, not forwarded to re-retain the value. + /// + /// lmxopcua#828: the window this test needs — the child RETIRED by the host but not yet TERMINATED — is held + /// open by parking the child in its PostStop on . PostStop + /// abandons a shutdown after its budget (5 s in production), the child then terminates, the host forgets it on + /// Terminated, and a burst sent AS it afterwards is forwarded — so under a loaded suite run (more than + /// 5 s from the stop to the host processing the burst) the test failed for a reason that is not a production + /// path (a terminated actor cannot send). Reproduced deterministically by sleeping 6 s before the burst. The + /// budget is therefore raised for this test, and the premise is a checked positive control: the child must + /// still be alive when the fence has passed, or the absence assertion below would mean nothing. + /// [Fact] public void A_late_burst_from_a_removed_child_does_not_re_retain_its_value() { - ArrangeTwoDriversWithRetainedValues(); + ArrangeTwoDriversWithRetainedValues(childShutdownTimeout: TimeSpan.FromMinutes(5)); var oldChild = ChildOf("DI-1"); + var watcher = CreateTestProbe(); + watcher.Watch(oldChild); // Hold the removed child in its PostStop so it has NOT terminated when the stale burst lands; a terminated // actor cannot send, and the host forgets a retired child on Terminated. var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); Driver("DI-1").ShutdownGate = gate; + try + { + var revB = V3ArtifactSeeder.SeedV3Deployment(_db, RevB, [("DI-2", "Modbus2", "dev2", "flow", "Double", false)], NoRefs, enabled: true); + V3ArtifactSeeder.Dispatch(_host, _coordinator, revB, RevB); + // The child really was stopped (it is parked in PostStop now), so the burst below is a RETIRED child's. + AwaitAssert(() => Driver("DI-1").ShutdownCount.ShouldBe(1), PresenceBudget, cancellationToken: TestContext.Current.CancellationToken); - var revB = V3ArtifactSeeder.SeedV3Deployment(_db, RevB, [("DI-2", "Modbus2", "dev2", "flow", "Double", false)], NoRefs, enabled: true); - V3ArtifactSeeder.Dispatch(_host, _coordinator, revB, RevB); + // The stale burst, sent AS the stopped child. + _host.Tell(ValuePublishes.Single("DI-1", Path1, 11.0, OpcUaStatus.Good, DateTime.UtcNow), oldChild); + // Positive control / ordering fence: a live driver's value sent after it is delivered, so the stale burst + // has been processed by the host by the time we look. + Driver("DI-2").FireDataChange(Path2, 23.0); + AwaitHolder(Path2, 23.0); + // Premise: the burst came from a retired child that had NOT terminated (it is still parked on the gate). + watcher.HasMessages.ShouldBeFalse("the removed child terminated before the fence; the test premise is void"); - // The stale burst, sent AS the stopped child. - _host.Tell(ValuePublishes.Single("DI-1", Path1, 11.0, OpcUaStatus.Good, DateTime.UtcNow), oldChild); - // Positive control / ordering fence: a live driver's value sent after it is delivered, so the stale burst - // has been processed by the host by the time we look. - Driver("DI-2").FireDataChange(Path2, 23.0); - AwaitHolder(Path2, 23.0); - - Replayed().Keys.ToArray().ShouldBe(new[] { Path2 }, "the removed child's late burst re-retained its last Good"); - gate.TrySetResult(); + Replayed().Keys.ToArray().ShouldBe(new[] { Path2 }, "the removed child's late burst re-retained its last Good"); + } + finally + { + gate.TrySetResult(); + } + // And the gate really was what held it: released, the child terminates. + watcher.ExpectTerminated(oldChild, PresenceBudget, cancellationToken: TestContext.Current.CancellationToken); } /// Review m-3: a config edit respawns the driver (stop + spawn); the OLD object's value is evicted, and