diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index ce5e3c63..f6560a28 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -158,18 +158,46 @@ condition's state?") — mirroring the native OT semantic: guard's job) — it contributes `Good`, so scripted conditions don't flash `Bad` at every deploy. The first actually-`Bad` published value flips the bucket and annotates. -**Coverage boundary (#478 as shipped).** Scripted quality tracks input tags whose driver **publishes a -data change carrying a Bad/Uncertain `StatusCode`** (e.g. an OpcUaClient input forwarding a server's -per-item Bad). It does **not** yet cover a driver **comms loss**: a poll driver (Modbus/S7) whose device -goes unreachable emits only `ConnectivityChanged` and goes *silent* on the value feed (see -`DriverInstanceActor.Reconnecting`), so the scripted engine keeps the last-known Good value and the -condition stays `Good`. Bridging driver connectivity into scripted inputs — the symmetric of the native -`OnDriverConnectivityChanged` path above, plus resolving the null-value/cold-start asymmetry (a runtime -`Bad` with a null value is currently indistinguishable from cold start and contributes `Good`) — is tracked -as the Layer-4 follow-up (#481). +**Comms loss (Layer 4, #481).** #478 covered input tags whose driver **publishes a data change carrying a +Bad/Uncertain `StatusCode`** (e.g. an OpcUaClient input forwarding a server's per-item Bad). It did not +cover a driver **comms loss**: a poll driver (Modbus/S7) whose device goes unreachable emits only +`ConnectivityChanged` and goes *silent* on the value feed (see `DriverInstanceActor.Reconnecting`), so the +engine kept the last-known Good value, `WorstInputStatus` stayed Good — and worse, `AreInputsReady` stayed +true, so the predicate went on evaluating a stale value after the source had died. + +`DriverHostActor.FanCommsLossToDependencyMux` closes it, symmetrically with the native +`OnDriverConnectivityChanged` path above: on `ConnectivityChanged(false)` it pushes a synthetic +`BadNoCommunication` with a **null value** into the dependency mux for every value ref the driver owns, +resolved from the existing `_nodeIdByDriverRef` map. Alarm-bearing tags are deliberately excluded — a +native condition is not a mux value, and its comms quality goes through the Part 9 annotation instead. + +Two things worth knowing before changing this: + +- **Nothing is pushed on reconnect, deliberately** — publishing Good would assert a value quality the host + has not observed. Recovery rides the driver's own republish: the `Connected → Reconnecting` edge calls + `DetachSubscription`, so the reconnect's `ResubscribeDesired` builds a *fresh* subscription whose + `PollGroupEngine` initial push runs with `forceRaise: true` and re-publishes every tag with its real + quality. That detach is load-bearing — without it the poll engine would compare against its retained + `LastValues`, see an unchanged value, raise nothing, and strand those refs Bad forever. +- **Virtual tags computed from a comms-lost input now go Bad too.** That follows from using the shared mux + and is the correct reading of "this input's source is unreachable", but it is a behaviour change beyond + scripted alarms. + +**Cold start vs. a driver reporting Bad (#478 Finding 3, closed by #481).** `WorstInputStatus` used to skip +every input whose *value* was null as "no data yet". A comms-lost driver produces exactly that shape — Bad +with no value — so the skip was hiding the case that matters most. The upstream source now stamps a +never-pushed path `BadWaitingForInitialData` and nothing else does, so the engine skips *that specific +code* rather than every null. Deleting the null-skip outright would have been the obvious fix and is wrong +in the other direction: it makes every not-yet-published input drag its condition Bad at deploy, which is +what the skip existed to prevent. Both failure modes are pinned — see the engine-test pair below, each of +which goes red under the opposite mistake. Guards: `ScriptedAlarmEngineTests` (transition carries `Uncertain`; `Bad` input with no transition emits -`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; unchanged bucket emits nothing), +`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; unchanged bucket emits nothing; plus the #481 +pair — a pushed Bad carrying no value counts, an input never pushed does not), +`DriverHostActorLiveValueTests` (comms loss pushes Bad for every ref the driver owns, reconnect pushes +nothing, another driver's loss pushes nothing), +`DependencyMuxTagUpstreamSourceTests.The_engine_agrees_with_this_source_on_the_never_pushed_status_code`, `ScriptedAlarmSourceTests.QualityChanged_emission_raises_no_alarm_event`, `DependencyMuxActorTests.Publish_quality_is_forwarded_on_DependencyValueChanged`, and `ScriptedAlarmHostActorTests` (`Bad_quality_dependency_publishes_AlarmQualityUpdate_and_no_alerts`, diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs index 250953d9..ff3582fd 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs @@ -32,6 +32,17 @@ namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; /// public sealed class ScriptedAlarmEngine : IDisposable { + /// + /// OPC UA Part 4 BadWaitingForInitialData — the status the upstream source stamps on a + /// path nothing has ever pushed to, and the marker uses to tell + /// genuine cold start apart from a driver reporting Bad. + /// Inlined rather than taken from Commons.OpcUa.OpcUaStatus because this project + /// deliberately does not reference Commons — the engine is pure evaluation logic over + /// Core.Abstractions. The value is fixed by the OPC UA spec, so the duplication cannot + /// drift; DependencyMuxTagUpstreamSourceTests pins the two agreeing. + /// + private const uint StatusNeverPushed = 0x80320000u; + private readonly ITagUpstreamSource _upstream; private readonly IAlarmStateStore _store; private readonly ScriptLoggerFactory _loggerFactory; @@ -736,19 +747,27 @@ public sealed class ScriptedAlarmEngine : IDisposable WorstInputStatusCode: worstInputStatus); /// Worst OPC UA StatusCode across a refilled read cache — the entry with the highest severity - /// bits (top 2). An input with no value yet (null snapshot/value — the cold-start placeholder, or a - /// not-yet-published upstream) is NOT a quality signal: it means "no data", which the - /// guard already handles by holding the condition. Counting it as Bad here - /// would flash every scripted condition Bad at deploy until the first push and would flood the quality - /// path with load-time annotations, so unread inputs are skipped (contribute Good). Empty / all-unread - /// cache ⇒ Good (0). + /// bits (top 2). An input that has never been pushed is NOT a quality signal: it means "no data", which + /// the guard already handles by holding the condition. Counting it would + /// flash every scripted condition Bad at deploy until the first push and flood the quality path with + /// load-time annotations, so it is skipped (contributes Good). Empty / all-unpushed cache ⇒ Good (0). + /// "Never pushed" is a specific status code, not "the value is null" (#481, closing #478 + /// Finding 3). This used to skip every entry whose value was null, which made a genuine runtime Bad + /// indistinguishable from cold start — and that is exactly the shape a comms-lost driver produces, so + /// the case the skip was hiding was the case that matters most. The upstream source now stamps a + /// never-pushed path and nothing else does, so the two are tellable + /// apart. A null snapshot is still skipped: that is a cache the refill did not populate, not an + /// observation. private static uint WorstInputStatus(IReadOnlyDictionary cache) { uint worst = 0u; var worstSeverity = 0u; foreach (var kv in cache) { - if (kv.Value is null || kv.Value.Value is null) continue; // no data yet — not a quality signal + // No entry at all — nothing was observed, so there is nothing to report on. + if (kv.Value is null) continue; + // Observed as "no data has ever arrived" — the cold-start placeholder, not a fault. + if (kv.Value.StatusCode == StatusNeverPushed) continue; var status = kv.Value.StatusCode; var severity = status >> 30; if (severity > worstSeverity) 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 87cd2fdc..950909b7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -1009,9 +1009,24 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// alerts row is published here — driver comms health has its own status surface /// (); a row per condition would be alarm-fatigue. /// + /// + /// #481 (Layer 4) — the same transition also reaches SCRIPTED alarms, via the dependency + /// mux. #478 gave a scripted condition the worst quality of its input tags, but only for + /// inputs whose driver actually publishes a Bad data change. A poll driver whose device + /// goes unreachable publishes nothing at all — it emits this signal and goes silent — so the + /// engine kept its last Good value, WorstInputStatus stayed Good, and worse, + /// AreInputsReady stayed true, so the predicate went on evaluating a stale value after + /// the source had died. closes that by pushing a + /// synthetic Bad for every value ref the driver owns. + /// /// private void OnDriverConnectivityChanged(DriverInstanceActor.ConnectivityChanged msg) { + // #481: the mux fan-out is independent of the OPC UA sink — a node with scripted alarms but no + // publish actor (the mux-only test/dev path) must still see comms loss. Done before the early + // return for exactly that reason. + if (!msg.Connected) FanCommsLossToDependencyMux(msg.DriverInstanceId); + if (_opcUaPublishActor is null) return; // #519: name the actual cause. This is a connectivity transition, so the Bad case is @@ -1038,6 +1053,68 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers _localNode, msg.DriverInstanceId, msg.Connected ? "connected" : "disconnected", annotated, statusCode); } + /// + /// #481 (Layer 4) — push a synthetic into the dependency + /// mux for every value ref the just-disconnected driver owns, so scripted alarms and virtual tags see + /// the source die instead of holding its last Good value indefinitely. + /// + /// + /// + /// The ref set already existed. #481 asked for a driver → all-tag-refs map to be built; + /// is exactly that, keyed (DriverInstanceId, RawPath) and + /// rebuilt from the composition's RawTags ∪ UnsReferenceVariables on every apply. + /// Alarm-bearing tags live in the separate and are + /// deliberately absent here — a native condition is not a mux value, and its comms quality is + /// annotated by the caller through the Part 9 path instead. + /// + /// + /// Value null, not the last-known value. The honest statement is "there is no + /// current value", and the engine's AreInputsReady gate holds the predicate on any Bad + /// regardless, so no script observes the null. It does mean the Bad must survive + /// WorstInputStatus, which until #481 skipped every null-valued entry as cold start — + /// hence the paired change stamping never-pushed paths BadWaitingForInitialData so the two + /// are tellable apart. Without that half this fan-out would be silently inert. + /// + /// + /// Nothing is pushed on reconnect, deliberately. Publishing Good would assert a value + /// quality this actor has not observed. Recovery rides the driver's own republish: + /// DriverInstanceActor calls DetachSubscription on the Connected → Reconnecting + /// edge, which nulls the subscription handle, so the reconnect's ResubscribeDesired builds + /// a fresh subscription whose PollGroupEngine initial push runs with + /// forceRaise: true and re-publishes every tag with its real quality. That detach is what + /// makes recovery work: without it the poll engine would compare against its retained + /// LastValues, see an unchanged Good value, raise nothing, and leave these refs Bad + /// forever — the value-dedup trap that bit the v3 Batch 4 virtual-tag reassert. + /// + /// + /// Ungated by redundancy role, for the same reason as the native-condition annotation above: a + /// Secondary must keep its scripted-alarm evaluation coherent for failover. The mux fan-out + /// reaches only refs something is actually subscribed to, so a driver whose tags feed no script + /// and no virtual tag costs one dictionary scan and no delivery. + /// + /// + /// The driver child that just reported comms loss. + private void FanCommsLossToDependencyMux(string driverInstanceId) + { + if (_dependencyMux is null) return; + + var ts = DateTime.UtcNow; + var pushed = 0; + foreach (var (driverId, rawPath) in _nodeIdByDriverRef.Keys) + { + if (driverId != driverInstanceId) continue; + + _dependencyMux.Tell(new DriverInstanceActor.AttributeValuePublished( + driverInstanceId, rawPath, Value: null, OpcUaStatus.BadNoCommunication, ts)); + pushed++; + } + + if (pushed > 0) + _log.Debug( + "DriverHost {Node}: driver {Driver} disconnected — pushed BadNoCommunication for {Count} mux ref(s)", + _localNode, driverInstanceId, pushed); + } + private void ForwardNativeAlarm(DriverInstanceActor.AttributeAlarmPublished msg) { if (_opcUaPublishActor is null) return; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs index b58e6d7d..d0734f45 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Collections.Immutable; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; @@ -26,11 +27,20 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; /// public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource { - // OPC UA Part 4 StatusCode for an outright Bad value (severity 10, bit 31 set). The - // codebase has no shared Core.Abstractions constant — concrete producers (GalaxyDriver, - // the Wonderware historian client) inline this same 0x80000000u, and the engine's own - // AreInputsReady gate tests exactly this bit — so an "unknown path" snapshot uses it too. - private const uint StatusBad = 0x80000000u; + // The status an "unknown path" snapshot carries — a path nothing has ever pushed to. + // + // This is deliberately BadWaitingForInitialData rather than the bare Bad (0x80000000u) it used + // to be, and the distinction is load-bearing (#481, closing #478 Finding 3). Both codes are Bad, + // so the engine's AreInputsReady gate treats them identically and the cold-start hold is + // unchanged. What changed is that "no data has ever arrived" is now tellable apart from "a + // driver pushed Bad", which previously looked identical whenever the pushed Bad carried a null + // value — the shape a comms-lost driver produces. WorstInputStatus keys off exactly this code to + // decide which of the two it is looking at. + // + // The code is also the honest one: Part 4 defines BadWaitingForInitialData as precisely this + // state, and it is what a freshly materialised OPC UA variable already reports before its first + // publish. + private const uint StatusNeverPushed = OpcUaStatus.BadWaitingForInitialData; // Intentionally never pruned: cold-start semantics depend on retained last values so // ReadTag can answer synchronously for any path that has ever been pushed. @@ -90,9 +100,9 @@ public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource // No value has been pushed for this path yet — return a Bad-quality placeholder so // the engine's cold-start guard holds the prior condition rather than NRE-ing on a - // null value cast. + // null value cast. See StatusNeverPushed for why the specific code matters. var now = _clock(); - return new DataValueSnapshot(Value: null, StatusCode: StatusBad, SourceTimestampUtc: null, ServerTimestampUtc: now); + return new DataValueSnapshot(Value: null, StatusCode: StatusNeverPushed, SourceTimestampUtc: null, ServerTimestampUtc: now); } // No-replay contract: a new subscriber does NOT receive a synthetic initial diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs index 6ad0a3fc..61316199 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs @@ -38,11 +38,23 @@ public sealed class FakeUpstream : ITagUpstreamSource } } - /// Reads the current value of a tag, or returns a bad-status snapshot if not set. + /// + /// OPC UA BadWaitingForInitialData — the status a never-pushed path reports, matching what + /// the production DependencyMuxTagUpstreamSource stamps. + /// This used to be 0x80340000 (BadNotConnected), a different Bad code from the + /// one production uses. That drift was invisible while WorstInputStatus skipped every + /// null-valued entry — any Bad code behaved the same. #481 made the specific code load-bearing (it + /// is how cold start is told apart from a driver reporting Bad), so a fake disagreeing with + /// production here would let every alarm with an unset input report Bad in tests and Good in the + /// real runtime, or vice versa. + /// + public const uint StatusNeverPushed = 0x80320000u; + + /// Reads the current value of a tag, or returns a never-pushed snapshot if not set. /// The tag path to read. public DataValueSnapshot ReadTag(string path) => _values.TryGetValue(path, out var v) ? v - : new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow); + : new DataValueSnapshot(null, StatusNeverPushed, null, DateTime.UtcNow); /// Subscribes an observer to tag changes for the given path. /// The tag path to subscribe to. diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs index 1748070a..12786bb0 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs @@ -1103,6 +1103,75 @@ public sealed class ScriptedAlarmEngineTests events.ShouldNotContain(e => e.Emission == EmissionKind.QualityChanged); } + // ------------------------------------------------------------------------- + // #481 — cold start vs. a driver reporting Bad (closes #478 Finding 3) + // ------------------------------------------------------------------------- + + /// + /// A driver reporting Bad with no value counts as Bad quality. + /// This is the hole #478 Finding 3 recorded. WorstInputStatus skipped every entry whose + /// value was null as "no data yet", which is indistinguishable from a real fault when the fault + /// carries no value — and a null-valued Bad is exactly what a comms-lost driver produces, so the + /// case being skipped was the case that mattered most. Before the fix this test sees no + /// QualityChanged at all. + /// + [Fact] + public async Task A_pushed_Bad_carrying_no_value_counts_as_Bad_quality() + { + var up = new FakeUpstream(); + up.Set("Temp", 50); // Good to start, so the bucket has somewhere to move from + using var eng = Build(up, out _); + await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")], + TestContext.Current.CancellationToken); + + var events = new List(); + eng.OnEvent += (_, e) => events.Add(e); + + // The shape DriverHostActor pushes on ConnectivityChanged(false): Bad, no value. + up.Push("Temp", null, 0x80310000u); // BadNoCommunication + await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged)); + + var q = events.First(e => e.Emission == EmissionKind.QualityChanged); + (q.WorstInputStatusCode >> 30).ShouldBeGreaterThanOrEqualTo(2u); + // The sub-code survives, so an operator can tell comms loss from any other fault. + q.WorstInputStatusCode.ShouldBe(0x80310000u); + } + + /// + /// …and the protection the old null-skip existed for still holds: an input nothing has ever pushed + /// does NOT drag the condition Bad. + /// The falsifying control for the test above. Deleting the null-skip outright — the obvious + /// way to make a null-valued Bad count — would also make every not-yet-published input count, which + /// is what the skip was there to prevent: every scripted condition flashing Bad at deploy until its + /// first push, flooding the quality path with load-time annotations. Both tests pass only if the + /// engine keys off the never-pushed status code specifically. + /// + [Fact] + public async Task An_input_that_was_never_pushed_does_not_drag_the_condition_Bad() + { + var up = new FakeUpstream(); + up.Set("Temp", 50); + // "Spare" is never Set or Pushed, so the engine's cache refill resolves it through ReadTag and + // gets the never-pushed placeholder — value null, Bad severity, the same shape a genuine fault has. + using var eng = Build(up, out _); + + // Subscribed BEFORE LoadAsync, deliberately. The quality bucket is tracked per alarm and only a + // CHANGE emits, so the load-time evaluation is the one that would flash Bad — subscribing after it + // makes this assertion vacuous, and the first draft of this test was exactly that: it passed + // against the broken behaviour because the flash had already happened by the time it was listening. + var events = new List(); + eng.OnEvent += (_, e) => events.Add(e); + + await eng.LoadAsync( + [Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100 || (int?)ctx.GetTag("Spare").Value == 1;""")], + TestContext.Current.CancellationToken); + + up.Push("Temp", 60, 0u); + await Task.Delay(150, TestContext.Current.CancellationToken); + + events.ShouldNotContain(e => e.Emission == EmissionKind.QualityChanged); + } + private static async Task WaitForAsync(Func cond, int timeoutMs = 2000) { var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs index a445fe22..df113425 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs @@ -151,6 +151,94 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase .FullReference.ShouldBe("Plant/Modbus/dev1/nope"); } + // ---- #481 (Layer 4): comms loss reaches the dependency mux ------------------------------------ + // + // #478 gave a scripted condition the worst quality of its inputs, but only for inputs whose driver + // PUBLISHES a Bad data change. A poll driver whose device goes unreachable publishes nothing — it + // raises ConnectivityChanged(false) and goes silent — so the engine held its last Good value and + // went on evaluating it. These pin the synthetic Bad that closes that hole. + + /// Comms loss pushes a Bad for every value ref the driver owns — not one, and not the + /// alarm-bearing ones (those take the Part 9 quality path instead). + [Fact] + public void Driver_comms_loss_pushes_BadNoCommunication_into_the_mux_for_every_ref_it_owns() + { + var db = NewInMemoryDbFactory(); + var deploymentId = SeedV3Deployment(db, RevA, + rawTags: new[] + { + (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false), + (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "temp", DataType: "Double", Writable: false), + }, + unsRefs: Array.Empty<(string, string, string, string, string?)>()); + + var (actor, _, mux) = SpawnHostAndApply(db, deploymentId); + + actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false)); + + var pushes = new[] + { + mux.ExpectMsg(TimeSpan.FromSeconds(5)), + mux.ExpectMsg(TimeSpan.FromSeconds(5)), + }; + + pushes.Select(p => p.FullReference).ShouldBe( + new[] { "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1/temp" }, ignoreOrder: true); + + foreach (var p in pushes) + { + // BadNoCommunication, not the bare Bad: #519's reasoning applies here too — the sub-code + // names the actual cause and reaches a client through the scripted condition's quality. + p.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication); + // Null rather than the last-known value: the honest statement is "no current value". The + // engine's AreInputsReady gate holds the predicate on any Bad, so no script sees the null. + p.Value.ShouldBeNull(); + p.DriverInstanceId.ShouldBe("drv-1"); + } + + mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + } + + /// Reconnect pushes NOTHING — and this is the falsifying control for the test above, since + /// both drive the same handler over the same populated map and only one may produce traffic. + [Fact] + public void Driver_reconnect_pushes_nothing_into_the_mux() + { + // Publishing Good here would assert a value quality the host has not observed. Recovery instead + // rides the driver's own republish: the Connected -> Reconnecting edge calls DetachSubscription, + // so the reconnect builds a FRESH subscription whose PollGroupEngine initial push runs with + // forceRaise: true and re-publishes every tag with its real quality. Without that detach the poll + // engine would compare against retained LastValues, see an unchanged value, raise nothing, and + // strand these refs Bad forever — the value-dedup trap from the v3 Batch 4 virtual-tag reassert. + var db = NewInMemoryDbFactory(); + var deploymentId = SeedV3Deployment(db, RevA, + rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) }, + unsRefs: Array.Empty<(string, string, string, string, string?)>()); + + var (actor, _, mux) = SpawnHostAndApply(db, deploymentId); + + actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: true)); + + mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + } + + /// Comms loss for a driver this host knows nothing about pushes nothing — the fan-out is + /// scoped by DriverInstanceId, so one dead device cannot Bad-out another driver's tags. + [Fact] + public void Comms_loss_for_another_driver_pushes_nothing_into_the_mux() + { + var db = NewInMemoryDbFactory(); + var deploymentId = SeedV3Deployment(db, RevA, + rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) }, + unsRefs: Array.Empty<(string, string, string, string, string?)>()); + + var (actor, _, mux) = SpawnHostAndApply(db, deploymentId); + + actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-OTHER", Connected: false)); + + mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + } + /// Spawns the host with publish + mux probes, dispatches the deployment, and waits for the /// Applied ACK so the map build in PushDesiredSubscriptions has completed before the test publishes a /// value. A VirtualTag-host probe is injected so the real host isn't spawned. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs index d26b5381..d020d118 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs @@ -1,5 +1,6 @@ using Shouldly; using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; @@ -40,6 +41,36 @@ public sealed class DependencyMuxTagUpstreamSourceTests snap.StatusCode.ShouldNotBe(0u); // Timestamp comes from the injected clock. snap.ServerTimestampUtc.ShouldBe(fixedNow); + + // #481: the SPECIFIC code is load-bearing, not merely "some Bad". ScriptedAlarmEngine skips + // exactly this code when computing worst-of-input quality, which is how it tells a path nothing + // has ever pushed to apart from a driver reporting Bad with no value — the shape a comms-lost + // driver produces, and previously indistinguishable from cold start. Any other Bad code here + // would make every not-yet-published input drag its condition Bad at deploy. + snap.StatusCode.ShouldBe(OpcUaStatus.BadWaitingForInitialData); + } + + /// + /// The engine's private copy of the never-pushed sentinel agrees with the one this source stamps. + /// + /// + /// Core.ScriptedAlarms deliberately does not reference Commons, so the engine inlines + /// the constant rather than sharing . The value is fixed by the OPC UA spec + /// so it cannot legitimately drift, but a typo in either copy would silently un-wire #481: the + /// engine would stop recognising the placeholder and every cold-start input would report Bad. Read + /// by reflection because the engine's copy is private — deliberately, since nothing outside the + /// engine should key off it. + /// + [Fact] + public void The_engine_agrees_with_this_source_on_the_never_pushed_status_code() + { + var engineConst = typeof(Core.ScriptedAlarms.ScriptedAlarmEngine) + .GetField("StatusNeverPushed", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static) + .ShouldNotBeNull("ScriptedAlarmEngine.StatusNeverPushed is gone or was renamed — #481's cold-start " + + "vs. reported-Bad distinction is wired through it, so this guard must be repointed, not deleted"); + + engineConst.GetRawConstantValue().ShouldBe( + new DependencyMuxTagUpstreamSource().ReadTag("never/pushed").StatusCode); } [Fact]