using Akka.Actor; using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// The repo's first driver-child restart suite (archreview 03/S16, 03/U6): what a child obtained /// by each of the three incarnation routes is actually handed. /// /// /// /// The falsification bar, and why this file exists at all. An actor-restart test must assert a /// FUNCTION the new incarnation performs — it subscribed, it re-initialised — never that the /// incarnation EXISTS. Existence is exactly what /// DriverHostActorReconcileTests.RestartDriver_respawns_the_child_without_an_actor_name_collision /// asserts (no name collision, factory created a second driver, one child remains), and that test /// passes green against the 03/S16 defect: a respawned child that connects and then reads nothing /// forever. It is kept, because its own subject — the InvalidActorNameException — is real; it /// is simply the weaker half of a pair, and this file is the other half. /// /// /// The three incarnation routes, and what re-provisions each. (1) Deploy reconcile — /// ApplyDriverSpecs → SpawnChild, then the apply's bulk desired-subscription push. /// (2) Operator Restart — HandleRestartDriver stops the child and calls /// SpawnChild with the cached spec; before 03/S16 that was the whole story, and the new child /// started with an EMPTY desired set that nothing re-sent until the next deployment (the #488 /// periodic reconcile early-returns on an empty set, so it is structurally inert here). /// (3) Supervision restart — Akka's default decider recreates the actor object around the /// SAME driver instance without going through SpawnChild at all, so nothing re-sent /// InitializeRequested either and the child sat in Connecting forever around a driver /// its own PostStop had already shut down. A fourth route, Force Reconnect, re-uses the /// live actor instance and needs nothing — a verified negative, pinned here so a future edit to the /// restart path cannot break the path that was already correct. /// /// /// The supervision crash seam, and why the obvious ones do not work. The seam used is the /// driver's own OnDataChange carrying a NULL Snapshot /// (): DataChangeEventArgs is /// a positional record with no null validation, and DriverInstanceActor.OnDataChangeForward /// dereferences msg.Snapshot unguarded on the ACTOR thread, so it NREs inside the child and /// the default decider Restarts it. Rejected alternatives, each for a specific reason: a throwing /// IDriverHealthPublisher (PublishHealthSnapshot wraps the publish in a /// catch (Exception), so it never escapes); a throwing SubscribeAsync (also swallowed — /// the stub already models it via SubscribeFailuresBeforeSuccess); Kill.Instance (the /// default decider Stops on ActorKilledException, a different directive and therefore a /// different code path); and an internal "throw this" test message (a production surface added /// purely for a test). The fault is arbitrary by design — what is under test is the RECOVERY. If /// DataChangeEventArgs ever gains null validation, the fallback is an explicit /// OneForOneStrategy(Directive.Restart) on the host plus an internal test-only throw message. /// /// public sealed class DriverHostActorRestartTests : RuntimeActorTestBase { private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64)); private static readonly NodeId TestNode = NodeId.Parse("node-1:4053"); private const string DriverId = "DI-1"; private const string SpeedPath = "Plant/Modbus/dev1/speed"; private const string TempPath = "Plant/Modbus/dev1/temp"; private const string AlarmXPath = "Plant/Modbus/dev1/alarmX"; private const string AlarmYPath = "Plant/Modbus/dev1/alarmY"; /// TagConfig marking a raw tag as alarm-bearing (parsed by TagConfigIntent). private const string AlarmConfig = """{"alarm":{"alarmType":"AlarmCondition","severity":500}}"""; /// Seeds + applies a v3 deployment whose single Modbus driver is ENABLED, so a real child is /// spawned around a , and waits until that child has connected /// and established its first subscription. private (IActorRef Host, RecordingSubscribableDriverFactory Factory) DeployOneDriver( IDbContextFactory db, (string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags, IReadOnlyDictionary? tagConfigByTag = null) { var factory = new RecordingSubscribableDriverFactory("Modbus"); var deploymentId = V3ArtifactSeeder.SeedV3Deployment( db, RevA, rawTags, Array.Empty<(string, string, string, string, string?)>(), enabled: true, tagConfigByTag: tagConfigByTag); var (host, _, _) = V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, factory); return (host, factory); } /// /// The 03/S16 falsification. An operator's Restart is supposed to recover a sick driver; before /// the fix it silently killed it — the respawned child came up with _desiredRefs empty, and the /// only SetDesiredSubscriptions sender in the repo was the apply's bulk push, so the driver /// subscribed to nothing until the next deployment dispatch. /// /// Deliberately NOT the pass criterion: that a second driver was created, or that one child /// remains. Both are true against the defect. /// /// [Fact] public void Operator_restart_re_subscribes_the_respawned_child() { var db = NewInMemoryDbFactory(); var (host, factory) = DeployOneDriver(db, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) }); AwaitAssert( () => { factory.Created.Count.ShouldBe(1); factory.Created[0].SubscribeCount.ShouldBe(1); factory.Created[0].LastSubscribedRefs.ShouldBe(new[] { SpeedPath }); }, duration: PresenceBudget); host.Tell(new RestartDriver("c1", DriverId, "tester", Guid.NewGuid())); AwaitAssert( () => { factory.Created.Count.ShouldBe(2, "the restart did not respawn the child around a new driver"); factory.Created[1].SubscribeCount.ShouldBeGreaterThanOrEqualTo( 1, "the RESPAWNED child never subscribed — it is Connected and reading nothing, which is the " + "03/S16 defect (an operator pressed Restart to recover a driver and silently killed it)"); factory.Created[1].LastSubscribedRefs.ShouldBe(new[] { SpeedPath }); }, duration: PresenceBudget); } /// /// The value refs and the alarm refs ride in ONE SetDesiredSubscriptions but are applied by two /// different child code paths (the value set self-Tells Subscribe; the alarm set runs /// SubscribeDesiredAlarms against the IAlarmSource feed, which stays gated shut until it /// is called). Sending the message is therefore not evidence that both halves landed, so the alarm /// half is pinned separately. /// [Fact] public void Operator_restart_re_subscribes_alarm_refs_too() { var db = NewInMemoryDbFactory(); var (host, factory) = DeployOneDriver( db, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false), (DriverId, "Modbus", "dev1", "temp", "Float", false), }, // An alarm-bearing raw tag is EXCLUDED from the value set and carried in the alarm set instead. new Dictionary { ["temp"] = """{"alarm":{"alarmType":"AlarmCondition","severity":500}}""", }); AwaitAssert( () => { factory.Created.Count.ShouldBe(1); factory.Created[0].SubscribeAlarmsCount.ShouldBeGreaterThanOrEqualTo(1); factory.Created[0].LastSubscribedAlarmRefs.ShouldBe(new[] { TempPath }); }, duration: PresenceBudget); host.Tell(new RestartDriver("c1", DriverId, "tester", Guid.NewGuid())); AwaitAssert( () => { factory.Created.Count.ShouldBe(2); factory.Created[1].SubscribeAlarmsCount.ShouldBeGreaterThanOrEqualTo( 1, "the respawned child never re-subscribed its alarm refs — the IAlarmSource feed stays " + "gated shut, so no native alarm transition can ever reach the host again"); factory.Created[1].LastSubscribedAlarmRefs.ShouldBe(new[] { TempPath }); // The value half must survive the alarm half being present. factory.Created[1].LastSubscribedRefs.ShouldBe(new[] { SpeedPath }); }, duration: PresenceBudget); } /// /// The ordering guard for the spawn-time send. ApplyDriverSpecs (which spawns) runs BEFORE /// ApplyDesiredSubscriptions (which rebuilds the authority), so a child spawned by a fresh /// deploy is momentarily handed the PREVIOUS apply's set. That transient must never be acted on: it /// is superseded by the bulk push on the same sender→receiver pair (Akka guarantees per-pair /// ordering) while the child is still Connecting, where SetDesiredSubscriptions only /// STORES; the subscribe happens on the Connected entry, from whatever was stored last. /// /// Revision B swaps the driver's tag, so A's and B's sets are distinguishable. Note the raw tags /// are folded into the merged DriverConfig, so a tag-set change DOES change the driver /// spec and therefore respawns the child (#516) — which is precisely the arrangement under test: /// a brand-new child, spawned while the authority still holds A, must end up subscribed to B. /// /// /// What is asserted is CONVERGENCE, not inertness, and the difference was measured rather /// than assumed. The child runs concurrently with the rest of the apply handler, so a driver /// whose InitializeAsync completes instantly (this stub does) can reach Connected and /// subscribe the transient stale set BEFORE the bulk push arrives — observed here as /// SubscribeCount == 2. Akka's per-pair ordering still guarantees the final state is B's, /// and HandleSubscribeAsync drops the prior subscription before establishing the new one, /// so the cost is one extra round-trip on a respawning deploy. Pinning the count would pin a /// race; the final refs are the invariant. /// /// [Fact] public void A_child_spawned_by_a_fresh_deploy_ends_with_the_NEW_refs() { var db = NewInMemoryDbFactory(); var (host, factory) = DeployOneDriver(db, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) }); AwaitAssert( () => factory.Created[0].LastSubscribedRefs.ShouldBe(new[] { SpeedPath }), duration: PresenceBudget); var revB = V3ArtifactSeeder.SeedV3Deployment( db, RevB, new[] { (DriverId, "Modbus", "dev1", "temp", "Float", false) }, Array.Empty<(string, string, string, string, string?)>(), enabled: true); host.Tell(new Commons.Messages.Deploy.DispatchDeployment(revB, RevB, CorrelationId.NewId())); AwaitAssert( () => { factory.Created.Count.ShouldBe(2, "the tag-set change re-spawns the child (#516 config respawn)"); factory.Created[1].LastSubscribedRefs.ShouldBe( new[] { TempPath }, "the newly-spawned child is on the PREVIOUS revision's refs — the spawn-time set was not superseded"); }, duration: PresenceBudget); // A Restart on top of that must hand the new child B's set, not A's — i.e. the authority really is // repopulated WHOLE on every apply rather than accumulated. host.Tell(new RestartDriver("c1", DriverId, "tester", Guid.NewGuid())); AwaitAssert( () => { factory.Created.Count.ShouldBe(3); factory.Created[2].LastSubscribedRefs.ShouldBe(new[] { TempPath }); }, duration: PresenceBudget); } /// /// The empty-set semantics are load-bearing and must not degrade into "skip the send": an EMPTY value /// set is what tells the child to Unsubscribe and drop a stale handle, so it must be SENT. /// /// The subject is a driver whose only raw tag is alarm-bearing: alarm tags are excluded from the /// value set and carried in the alarm one, so its value half is genuinely empty while its alarm /// half is not — and both halves ride in ONE SetDesiredSubscriptions. That is what makes /// the absence assertion non-vacuous: SubscribeAlarmsCount >= 1 proves the message /// ARRIVED, and SubscribeCount == 0 then proves the empty value half was honoured rather /// than the whole send being skipped. Asserting only "it did not subscribe" would pass equally /// against a build that sent the respawned child nothing at all — the 03/S16 defect itself. /// /// [Fact] public void Restart_of_a_driver_with_an_empty_value_set_still_sends_the_set() { var db = NewInMemoryDbFactory(); var (host, factory) = DeployOneDriver( db, new[] { (DriverId, "Modbus", "dev1", "temp", "Float", false) }, new Dictionary { ["temp"] = """{"alarm":{"alarmType":"AlarmCondition","severity":500}}""", }); AwaitAssert( () => { factory.Created.Count.ShouldBe(1); factory.Created[0].SubscribeAlarmsCount.ShouldBeGreaterThanOrEqualTo(1); }, duration: PresenceBudget); host.Tell(new RestartDriver("c1", DriverId, "tester", Guid.NewGuid())); AwaitAssert( () => { factory.Created.Count.ShouldBe(2); var respawned = factory.Created[1]; respawned.InitializeCount.ShouldBe(1, "the respawned child was never initialised"); // Positive control: the SetDesiredSubscriptions genuinely reached the respawned child. respawned.SubscribeAlarmsCount.ShouldBeGreaterThanOrEqualTo( 1, "the respawned child received no desired set at all — the absence below would be vacuous"); respawned.LastSubscribedAlarmRefs.ShouldBe(new[] { TempPath }); respawned.SubscribeCount.ShouldBe( 0, "an EMPTY value set must route to Unsubscribe / no-op, never to a spurious subscribe"); }, duration: PresenceBudget); } /// /// Review finding F1 — the alarm subscription must FOLLOW a changed desired set, not latch. /// The value side converges by construction (HandleSubscribeAsync drops the prior subscription /// before establishing the new one, and StoreDesiredSubscriptions is a full replace), but the /// alarm side is guarded by _alarmSubscriptionHandle is not null in BOTH /// SubscribeDesiredAlarms and HandleSubscribeAlarmsAsync — so once a handle exists it /// was never re-established for the life of the connection. /// /// That became reachable when every spawn started carrying a desired set: a tag-set edit forces a /// respawn (raw tags fold into the merged DriverConfig, #516), and a fast-initialising /// driver connects and subscribes the transient spawn-time set — the PREVIOUS revision's alarm /// refs — before the bulk push lands. The bulk push then early-returns, latching the old set for /// the connection's life. Drivers that FILTER on sourceNodeIds (OPC UA Client, AB CIP, /// FOCAS) would silently never deliver the newly-added alarm. /// /// [Fact] public void A_deploy_that_widens_the_alarm_set_re_subscribes_the_new_refs() { var db = NewInMemoryDbFactory(); var (host, factory) = DeployOneDriver( db, new[] { (DriverId, "Modbus", "dev1", "alarmX", "Float", false) }, new Dictionary { ["alarmX"] = AlarmConfig }); AwaitAssert( () => { factory.Created.Count.ShouldBe(1); factory.Created[0].LastSubscribedAlarmRefs.ShouldBe(new[] { AlarmXPath }, ignoreOrder: true); }, duration: PresenceBudget); // Revision B WIDENS the alarm set: X stays, Y is added. var revB = V3ArtifactSeeder.SeedV3Deployment( db, RevB, new[] { (DriverId, "Modbus", "dev1", "alarmX", "Float", false), (DriverId, "Modbus", "dev1", "alarmY", "Float", false), }, Array.Empty<(string, string, string, string, string?)>(), enabled: true, tagConfigByTag: new Dictionary { ["alarmX"] = AlarmConfig, ["alarmY"] = AlarmConfig, }); host.Tell(new Commons.Messages.Deploy.DispatchDeployment(revB, RevB, CorrelationId.NewId())); AwaitAssert( () => { var current = factory.Created[^1]; current.LastSubscribedAlarmRefs.ShouldBe( new[] { AlarmXPath, AlarmYPath }, ignoreOrder: true, "the driver is still subscribed to the PREVIOUS revision's alarm refs — the alarm " + "subscription latched on the transient spawn-time set and never followed the new one"); }, duration: PresenceBudget); } /// /// The repo's first genuine Akka SUPERVISION-restart test (03/U6). Not a stop, not a stub: the /// driver raises a malformed OnDataChange, the actor NREs on its own thread, and Akka's default /// decider Restarts the child in place. /// /// A restart is strictly worse than the operator-Restart case 03/S16 names. It recreates the actor /// object around the SAME driver instance without going through SpawnChild, so nothing /// re-sends InitializeRequested — while PreRestart has already run PostStop, /// which called ShutdownAsync on that very driver. The child therefore sat in /// Connecting forever around a shut-down driver, with its desired set gone too, and /// silently: Akka logs the fault, nothing logs the non-recovery. /// /// /// The assertions are FUNCTIONS of the new incarnation — it re-initialised, it re-subscribed the /// deployed refs — never that it exists. Created.Count == 1 is what distinguishes this from /// the operator path: a restart must NOT produce a second driver object. /// /// [Fact] public void Supervision_restart_reinitialises_and_re_subscribes_the_child() { var db = NewInMemoryDbFactory(); var (_, factory) = DeployOneDriver(db, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) }); AwaitAssert( () => { factory.Created.Count.ShouldBe(1); factory.Created[0].SubscribeCount.ShouldBe(1); }, duration: PresenceBudget); var driver = factory.Created[0]; driver.InitializeCount.ShouldBe(1); // The crash. Subscribing is what attaches the actor's OnDataChange handler, so this only reaches the // actor thread because the arrangement above got as far as a live subscription. driver.FireMalformedDataChange(SpeedPath); // FIRST: prove the crash seam actually tripped supervision, before asserting anything about the // recovery. PreRestart runs PostStop, which calls ShutdownAsync on the driver — so a raised // ShutdownCount is the evidence that a restart happened at all. Without this the assertions below // would be ambiguous: "the child was not re-provisioned" and "the child never crashed" look // identical from the outside, and a seam that silently stopped working would read as a passing // regression test forever. AwaitAssert( () => driver.ShutdownCount.ShouldBeGreaterThanOrEqualTo( 1, "the crash seam did not trip supervision — nothing below is testing a restart"), duration: PresenceBudget); AwaitAssert( () => { factory.Created.Count.ShouldBe( 1, "a supervision restart must re-use the same driver object, not respawn the child"); driver.InitializeCount.ShouldBe( 2, "the restarted child was never re-initialised — it is sitting in Connecting around a " + "driver its own PostStop already shut down (03/S6, the worse half of 03/S16)"); driver.SubscribeCount.ShouldBeGreaterThanOrEqualTo( 2, "the restarted child never re-subscribed — its desired set died with the old instance"); driver.LastSubscribedRefs.ShouldBe(new[] { SpeedPath }); }, duration: PresenceBudget); } /// /// Review finding F2 — a reprovision must be routed by INCARNATION, not just by driver id. /// ReprovisionRequested is delivered asynchronously, so one can still be in flight from an old /// incarnation when HandleRestartDriver has already stopped that child and swapped in a new /// one under the same DriverInstanceId. Routing on the id alone delivers it to the NEW child, /// which is in Connecting around a healthy driver: it triggers a second, concurrent /// InitializeAsync that races the very dispose-before-overwrite the #527 teardown exists to /// serialise (ModbusDriver._transport, MqttDriver._connection). /// /// A stale sender is simulated directly — a probe standing in for the dead incarnation — because /// provoking the real race would be timing-dependent. The assertion is an ABSENCE, so it carries /// a positive control: the current child is proven alive and provisioned first, and its /// InitializeCount must then not move. /// /// [Fact] public void A_reprovision_from_a_stale_incarnation_is_ignored() { var db = NewInMemoryDbFactory(); // The child must be in CONNECTING for this defect to bite: InitializeRequested is unhandled in the // Connected behaviour, so an already-connected child would swallow the stale reprovision for the // wrong reason and the test would pass vacuously. The gated init holds it in Connecting. var factory = new RecordingSubscribableDriverFactory("Modbus", gateInitialize: true); var deploymentId = V3ArtifactSeeder.SeedV3Deployment( db, RevA, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) }, Array.Empty<(string, string, string, string, string?)>(), enabled: true); var (host, _, _) = V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, factory); // Positive control: the current child is alive and has begun its one and only initialise. AwaitAssert( () => { factory.Created.Count.ShouldBe(1); factory.Created[0].InitializeCount.ShouldBe(1); }, duration: PresenceBudget); var driver = factory.Created[0]; // A ReprovisionRequested that did NOT come from the child the host currently holds. var staleIncarnation = CreateTestProbe(); host.Tell( new DriverInstanceActor.ReprovisionRequested(DriverId, "NullReferenceException"), staleIncarnation.Ref); // Round-trip a message the host must answer, so the reprovision above is provably processed before // the absence is asserted — otherwise "nothing happened yet" would pass for "it was ignored". var coordinator = CreateTestProbe(); host.Tell(new Commons.Messages.Fleet.GetDiagnostics(CorrelationId.NewId()), coordinator.Ref); coordinator.ExpectMsg(PresenceBudget); driver.InitializeCount.ShouldBe( 1, "a reprovision from a dead incarnation reached the CURRENT child and started a second, " + "concurrent InitializeAsync on a driver that was already connected"); } /// /// Review finding F3 - a crash-looping driver must not become a reconnect storm. There is no /// SupervisorStrategy anywhere under src/Server, so the default decider restarts a /// faulting child immediately and without limit. That was survivable while a restart did /// nothing; the reprovision hook now makes every one of them do /// ShutdownAsync + InitializeAsync + SubscribeAsync, so a driver that faults on /// every value would hammer its device as fast as it can crash. /// /// The reprovision is therefore rate-limited per driver. Crucially it is deferred, not /// dropped - the last request must still be honoured, or a child whose final crash fell /// inside the window would be left un-provisioned forever, which is the very failure this whole /// plan exists to close. Two genuine back-to-back supervision restarts: the first reprovision /// applies immediately, the second is held and then lands. /// /// [Fact] public void A_second_reprovision_inside_the_backoff_window_is_deferred_not_dropped() { var db = NewInMemoryDbFactory(); // Generous relative to the in-memory step time below, so "inside the window" is a decision rather // than a race, while still far under the presence budget the deferred landing waits on. var backoff = TimeSpan.FromSeconds(3); var factory = new RecordingSubscribableDriverFactory("Modbus"); var deploymentId = V3ArtifactSeeder.SeedV3Deployment( db, RevA, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) }, Array.Empty<(string, string, string, string, string?)>(), enabled: true); V3ArtifactSeeder.SpawnHostAndApply( this, db, TestNode, deploymentId, RevA, factory, reprovisionBackoff: backoff); AwaitAssert( () => { factory.Created.Count.ShouldBe(1); factory.Created[0].SubscribeCount.ShouldBe(1); }, duration: PresenceBudget); var driver = factory.Created[0]; // Crash 1: the first reprovision has no prior stamp, so it applies immediately. driver.FireMalformedDataChange(SpeedPath); AwaitAssert( () => { driver.InitializeCount.ShouldBe(2); driver.SubscribeCount.ShouldBe(2, "the re-subscribe must land before the second crash is armed"); }, duration: PresenceBudget); // Crash 2, immediately after - well inside the window. driver.FireMalformedDataChange(SpeedPath); AwaitAssert( () => driver.ShutdownCount.ShouldBeGreaterThanOrEqualTo( 2, "the second crash seam did not trip supervision - nothing below is testing a backoff"), duration: PresenceBudget); driver.InitializeCount.ShouldBe( 2, "the second reprovision was applied immediately - the backoff is not rate-limiting anything"); // ...but it must still land once the window elapses. AwaitAssert( () => driver.InitializeCount.ShouldBe( 3, "the deferred reprovision was DROPPED, not deferred"), duration: PresenceBudget); } /// /// The _children.Keys arm of the desired-set authority's union. A driver with NO authored tags /// is absent from both the value- and alarm-ref maps, so keying the authority off those maps alone /// would leave it with no entry at all — and "absent" must not be allowed to differ from "empty", /// because an empty set is what tells a child to Unsubscribe and drop a stale handle. /// /// Asserted the only way it is observable: the tagless child is initialised (so it is provably /// alive and was provisioned) and never subscribes, while its tag-owning sibling in the same /// deployment does — the sibling is the positive control proving the apply's push pass actually /// ran, so "it did not subscribe" cannot pass for "nothing happened". /// /// [Fact] public void A_running_driver_with_no_authored_tags_is_handed_an_empty_set() { var db = NewInMemoryDbFactory(); var factory = new RecordingSubscribableDriverFactory("Modbus"); var deploymentId = V3ArtifactSeeder.SeedV3Deployment( db, RevA, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) }, Array.Empty<(string, string, string, string, string?)>(), enabled: true, taglessDrivers: new[] { "DI-TAGLESS" }); V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, factory); AwaitAssert( () => { factory.Created.Count.ShouldBe(2); // Positive control: the sibling that DOES have a tag subscribed, so the push pass ran. factory.Created.Single(d => d.DriverInstanceId == DriverId) .SubscribeCount.ShouldBe(1); factory.Created.Single(d => d.DriverInstanceId == "DI-TAGLESS") .InitializeCount.ShouldBe(1); }, duration: PresenceBudget); factory.Created.Single(d => d.DriverInstanceId == "DI-TAGLESS") .SubscribeCount.ShouldBe( 0, "a driver with no authored tags must be handed an EMPTY set, never a spurious subscribe"); } /// /// The verified negative, pinned so this plan's edits to the restart paths cannot break the one that /// was already correct. Force Reconnect (ReconnectDriver) Tells ForceReconnect to the /// SAME child actor: the instance survives, so _desiredRefs/_desiredAlarmRefs are intact /// and the reconnect's Connected entry re-applies them by itself. Nothing needs re-provisioning, and /// no new driver object is created. /// /// /// The budget here is deliberately wider than : /// SpawnChild gives the child the production DefaultReconnectInterval (10 s) and the /// retry is a periodic timer, so the reconnect cannot land sooner than that. A budget is spent only /// when the assertion fails, so widening it costs nothing on the happy path. /// [Fact] public void Force_reconnect_keeps_the_desired_set_and_re_subscribes() { var db = NewInMemoryDbFactory(); var (host, factory) = DeployOneDriver(db, new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) }); AwaitAssert( () => { factory.Created.Count.ShouldBe(1); factory.Created[0].SubscribeCount.ShouldBe(1); }, duration: PresenceBudget); var driver = factory.Created[0]; host.Tell(new ReconnectDriver("c1", DriverId, "tester", Guid.NewGuid())); AwaitAssert( () => { factory.Created.Count.ShouldBe(1, "Force Reconnect must NOT respawn the child"); driver.SubscribeCount.ShouldBeGreaterThanOrEqualTo( 2, "the reconnected driver never re-subscribed its (surviving) desired set"); driver.LastSubscribedRefs.ShouldBe(new[] { SpeedPath }); }, duration: TimeSpan.FromSeconds(30)); } }