fix(opcuaclient): native-alarm ConditionRefresh + transition table, branch, dedup and thread-safety fixes (#619 re-review)
Re-review of 3de2cc3b/454775c6: - AlarmTransitions could not reach (inactive, unacked): (active,acked)->(inactive,unacked) emitted Clear (host stayed acked) and a first-seen cleared-unacked condition emitted nothing. The table is now total; an exhaustive test replays every (prior, target) pair through the projector's semantics. - The per-RawPath read-compute-emit was a lock-free TryGetValue/set across SDK publish threads; it now runs under one lock with the projection and emission. - Condition BRANCH events (non-null BranchId) were projected (flipping the trunk) and remembered as the ack target (acking the branch); they are dropped. BranchId is select clause 9. - EventId is copied, not aliased from the decoded notification. - An unchanged state re-emitted Unspecified; it now emits nothing unless message/severity changed (an Unspecified projects from the host projector's default after a redeploy reset it). - Exact upstream severity rides OpcUaSeverity (#626) instead of being re-bucketed. - ShutdownAsync no longer forgets the last-told state: a reinit re-subscribes the same set and the refresh would otherwise re-raise every acked alarm. A CHANGED set (host rebuilt) forgets it. ConditionRefresh: called after the alarm event item is created, after an in-place reconnect (subscriptions found on the new session by display name, since a recreate clones them) and on RefreshRequired. Refresh markers are never projected. BadNotSupported/BadMethodInvalid/... logs once. Wire test: a condition already active before subscribe is reported Raise; a release+resubscribe refresh re-raises nothing (falsified by disabling the refresh call). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -225,13 +225,196 @@ public sealed class OpcUaClientAlarmTests
|
||||
[InlineData(false, false, false, true, new[] { AlarmTransitionKind.Acknowledge })]
|
||||
[InlineData(true, false, false, true, new[] { AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge })]
|
||||
[InlineData(true, true, true, false, new[] { AlarmTransitionKind.Retrigger })]
|
||||
[InlineData(true, false, true, false, new[] { AlarmTransitionKind.Unspecified })]
|
||||
[InlineData(true, false, true, false, new AlarmTransitionKind[0])]
|
||||
public void AlarmTransitions_move_prior_to_target(
|
||||
bool priorActive, bool priorAcked, bool active, bool acked, AlarmTransitionKind[] expected)
|
||||
{
|
||||
OpcUaClientDriver.AlarmTransitions((priorActive, priorAcked), (active, acked)).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---- #619 re-review: the transition table must reach EVERY target from EVERY prior. ----
|
||||
|
||||
/// <summary>The host projector's semantics, restated: the only thing the driver's kinds act upon.</summary>
|
||||
private static (bool Active, bool Acked) Simulate((bool Active, bool Acked) s, AlarmTransitionKind k) => k switch
|
||||
{
|
||||
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
|
||||
AlarmTransitionKind.Acknowledge => (s.Active, true),
|
||||
AlarmTransitionKind.Clear => (false, s.Acked),
|
||||
_ => s,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Exhaustive over all 16 (prior, target) pairs: replaying the emitted kinds through the projector's
|
||||
/// semantics lands on the target, and an unchanged state emits nothing. The table this replaced reached
|
||||
/// (inactive, acked) from (active, acked) → (inactive, UNACKED) wrongly, and could not express a
|
||||
/// cleared-but-unacked condition first seen by a ConditionRefresh at all.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AlarmTransitions_reach_every_target_from_every_prior()
|
||||
{
|
||||
bool[] b = [false, true];
|
||||
foreach (var pa in b) foreach (var pk in b) foreach (var ta in b) foreach (var tk in b)
|
||||
{
|
||||
var kinds = OpcUaClientDriver.AlarmTransitions((pa, pk), (ta, tk));
|
||||
var s = (pa, pk);
|
||||
foreach (var k in kinds) s = Simulate(s, k);
|
||||
s.ShouldBe((ta, tk), $"prior ({pa},{pk}) -> target ({ta},{tk}) via [{string.Join(",", kinds)}]");
|
||||
if ((pa, pk) == (ta, tk)) kinds.ShouldBeEmpty();
|
||||
kinds.ShouldNotContain(AlarmTransitionKind.Unspecified);
|
||||
}
|
||||
}
|
||||
|
||||
private static VariantCollection ConditionFields(
|
||||
bool active, bool acked, string message = "tripped", ushort severity = 700, byte[]? eventId = null,
|
||||
NodeId? branchId = null, NodeId? eventType = null)
|
||||
{
|
||||
var f = EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond");
|
||||
f[0] = new Variant(eventId ?? new byte[] { 1 });
|
||||
if (eventType is not null) f[1] = new Variant(eventType);
|
||||
f[3] = new Variant(new LocalizedText(message));
|
||||
f[4] = new Variant(severity);
|
||||
f.Add(new Variant(active));
|
||||
f.Add(new Variant(acked));
|
||||
f.Add(branchId is null ? Variant.Null : new Variant(branchId));
|
||||
return f;
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, string> TripIndex = new() { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
|
||||
|
||||
private static List<AlarmEventArgs> Record(OpcUaClientDriver drv)
|
||||
{
|
||||
var seen = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (seen) seen.Add(e); };
|
||||
return seen;
|
||||
}
|
||||
|
||||
/// <summary>The remembered EventId is a COPY — the decoded notification's array is not ours to alias.</summary>
|
||||
[Fact]
|
||||
public void ProjectEvent_copies_the_EventId()
|
||||
{
|
||||
var raw = new byte[] { 7, 8, 9 };
|
||||
OpcUaClientDriver.AlarmAckTarget? target = null;
|
||||
OpcUaClientDriver.ProjectEvent(ConditionFields(true, false, eventId: raw), TripIndex, new FakeAlarmHandle(),
|
||||
n => n.ToString(), (_, t) => target = t);
|
||||
|
||||
raw[0] = 0;
|
||||
target!.Value.EventId.ShouldBe(new byte[] { 7, 8, 9 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A condition BRANCH event (BranchId non-null) reports a previous state of the condition and carries its
|
||||
/// own EventId — projecting it would flip the trunk's state, and remembering it would ack the branch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ProjectEvent_ignores_a_condition_branch_event()
|
||||
{
|
||||
var remembered = false;
|
||||
OpcUaClientDriver.ProjectEvent(
|
||||
ConditionFields(false, false, branchId: new NodeId(Guid.NewGuid(), 3)), TripIndex, new FakeAlarmHandle(),
|
||||
n => n.ToString(), (_, _) => remembered = true)
|
||||
.ShouldBeNull();
|
||||
remembered.ShouldBeFalse();
|
||||
|
||||
// Positive control: the same event with a null BranchId (the trunk) projects.
|
||||
OpcUaClientDriver.ProjectEvent(ConditionFields(false, false), TripIndex, new FakeAlarmHandle(),
|
||||
n => n.ToString(), (_, _) => { })
|
||||
.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>RefreshStart/RefreshEnd are markers around a ConditionRefresh, never alarms — even pass-through.</summary>
|
||||
[Fact]
|
||||
public void Refresh_markers_are_never_projected_as_alarms()
|
||||
{
|
||||
foreach (var type in new[] { ObjectTypeIds.RefreshStartEventType, ObjectTypeIds.RefreshEndEventType, ObjectTypeIds.RefreshRequiredEventType })
|
||||
{
|
||||
var fields = EventFields("i=2253", "i=0");
|
||||
fields[1] = new Variant(type);
|
||||
OpcUaClientDriver.ProjectEvent(fields, new Dictionary<string, string>(), new FakeAlarmHandle(),
|
||||
n => n.ToString(), (_, _) => { }).ShouldBeNull($"{type} is a refresh marker");
|
||||
}
|
||||
OpcUaClientDriver.IsRefreshRequired(EventFieldsOfType(ObjectTypeIds.RefreshRequiredEventType)).ShouldBeTrue();
|
||||
OpcUaClientDriver.IsRefreshRequired(EventFieldsOfType(ObjectTypeIds.RefreshEndEventType)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static VariantCollection EventFieldsOfType(NodeId type)
|
||||
{
|
||||
var f = EventFields("i=2253", "i=0");
|
||||
f[1] = new Variant(type);
|
||||
return f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A ConditionRefresh re-delivers every retained condition's CURRENT state. One the host was already told
|
||||
/// must emit nothing — no re-raise (which would un-ack an acked alarm) and no Unspecified (which a host
|
||||
/// projector reset by a redeploy would project from its inactive default).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_refresh_of_an_already_reported_state_emits_nothing()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-dedup");
|
||||
var seen = Record(drv);
|
||||
var handle = new FakeAlarmHandle();
|
||||
|
||||
drv.RouteAlarmEvent(ConditionFields(true, false), TripIndex, handle);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||
seen.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
|
||||
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle); // the refresh copy
|
||||
seen.Count.ShouldBe(2);
|
||||
|
||||
// Positive control: a genuine message change on an unchanged state still reaches the host.
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true, message: "worse"), TripIndex, handle);
|
||||
seen.Count.ShouldBe(3);
|
||||
seen[2].Kind.ShouldBe(AlarmTransitionKind.Unspecified);
|
||||
seen[2].Message.ShouldBe("worse");
|
||||
}
|
||||
|
||||
/// <summary>The exact upstream severity rides OpcUaSeverity (#626) so the host does not re-bucket it.</summary>
|
||||
[Fact]
|
||||
public void The_exact_upstream_severity_is_carried()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-sev");
|
||||
var seen = Record(drv);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, false, severity: 650), TripIndex, new FakeAlarmHandle());
|
||||
seen.Single().OpcUaSeverity.ShouldBe(650);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A driver re-initialise (comms-loss watchdog, Force Reconnect) re-subscribes the SAME set over nodes the
|
||||
/// host did not rebuild, so the last-told state must survive it — or the refresh after it would re-raise
|
||||
/// every acked active alarm (un-ack). A CHANGED set means the host rebuilt (nodes recreated inactive), so
|
||||
/// there the state is forgotten and the refresh re-raises from scratch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Last_told_state_survives_a_reinit_of_the_same_set_but_not_a_set_change()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-reinit");
|
||||
var seen = Record(drv);
|
||||
var handle = new FakeAlarmHandle();
|
||||
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1"]);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||
seen.Count.ShouldBe(2);
|
||||
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1"]);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||
seen.Count.ShouldBe(2, "same set after a reinit: the refresh copy is a duplicate");
|
||||
|
||||
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1", "Plant/opc/plc/Trip2"]);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||
seen.Skip(2).Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
|
||||
}
|
||||
|
||||
/// <summary>A server without ConditionRefresh is tolerated; anything else is a real failure.</summary>
|
||||
[Fact]
|
||||
public void ConditionRefresh_unsupported_status_codes()
|
||||
{
|
||||
OpcUaClientDriver.IsConditionRefreshUnsupported(StatusCodes.BadNotSupported).ShouldBeTrue();
|
||||
OpcUaClientDriver.IsConditionRefreshUnsupported(StatusCodes.BadMethodInvalid).ShouldBeTrue();
|
||||
OpcUaClientDriver.IsConditionRefreshUnsupported(StatusCodes.BadNotImplemented).ShouldBeTrue();
|
||||
OpcUaClientDriver.IsConditionRefreshUnsupported(StatusCodes.BadTimeout).ShouldBeFalse();
|
||||
}
|
||||
|
||||
private sealed class FakeAlarmHandle : IAlarmSubscriptionHandle
|
||||
{
|
||||
/// <summary>Gets the diagnostic identifier for this alarm handle.</summary>
|
||||
|
||||
+62
@@ -253,6 +253,68 @@ public sealed class OpcUaClientDriverWireTests
|
||||
"the writable variable and the read-only one must classify differently");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #619 — ConditionRefresh over the wire: a condition already active+unacked BEFORE the driver subscribes is
|
||||
/// reported (Raise, keyed by the RawPath) right after subscribe, not only on its next transition. A second
|
||||
/// subscribe of the same set refreshes again and must emit nothing (no re-raise); a positive control then
|
||||
/// proves the feed is still live by acknowledging upstream and seeing exactly the Acknowledge arrive.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_condition_already_active_before_subscribe_is_reported_by_ConditionRefresh()
|
||||
{
|
||||
const string AlarmRef = "Sim/dev1/TempHi";
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await StartRigAsync("drv-alarm-refresh", ct);
|
||||
rig.MaterialiseNative();
|
||||
rig.Project(native: true, new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true,
|
||||
Enabled: true, AlarmShelvingKind.Unshelved, 800, "already hot"));
|
||||
|
||||
await using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrl = rig.EndpointUrl,
|
||||
RawTags = [Tag(AlarmRef, ServerEventRig.RawNsu(ServerEventRig.RawAlarmPath))],
|
||||
SecurityPolicy = OpcUaSecurityPolicy.None,
|
||||
SecurityMode = OpcUaSecurityMode.None,
|
||||
AuthType = OpcUaAuthType.Username,
|
||||
Username = "apikey",
|
||||
Password = ServerEventRig.KeyPassword,
|
||||
AutoAcceptCertificates = true,
|
||||
TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform,
|
||||
Timeout = TimeSpan.FromSeconds(10),
|
||||
SessionTimeout = TimeSpan.FromSeconds(30),
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(1),
|
||||
}, "wire-alarm-refresh");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
var seen = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (seen) seen.Add(e); };
|
||||
AlarmEventArgs[] Seen() { lock (seen) return [.. seen]; }
|
||||
|
||||
var first = await drv.SubscribeAlarmsAsync([AlarmRef], ct);
|
||||
await RecordedEvents.WaitUntilAsync(() => Seen().Any(e => e.Kind == AlarmTransitionKind.Raise));
|
||||
|
||||
var raise = Seen().Single(e => e.Kind == AlarmTransitionKind.Raise);
|
||||
raise.ConditionId.ShouldBe(AlarmRef);
|
||||
raise.Message.ShouldBe("already hot");
|
||||
raise.OpcUaSeverity.ShouldBe(800);
|
||||
Seen().ShouldAllBe(e => e.ConditionId == AlarmRef, "refresh markers must never surface as alarms");
|
||||
|
||||
// Same set again, released first exactly as DriverInstanceActor does → a fresh subscription and another
|
||||
// ConditionRefresh; the identical state must not re-raise.
|
||||
var before = Seen().Length;
|
||||
await drv.UnsubscribeAlarmsAsync(first, ct);
|
||||
await drv.SubscribeAlarmsAsync([AlarmRef], ct);
|
||||
await Task.Delay(1500, ct); // let the second refresh's events land before the control transition
|
||||
|
||||
// Positive control: an upstream acknowledge arrives as exactly one Acknowledge, after which nothing else.
|
||||
rig.Project(native: true, new AlarmConditionSnapshot(Active: true, Acknowledged: true, Confirmed: true,
|
||||
Enabled: true, AlarmShelvingKind.Unshelved, 800, "already hot"));
|
||||
await RecordedEvents.WaitUntilAsync(() => Seen().Any(e => e.Kind == AlarmTransitionKind.Acknowledge));
|
||||
await Task.Delay(500, ct);
|
||||
Seen().Skip(before).Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Acknowledge],
|
||||
"the second refresh re-delivered an unchanged state and must have emitted nothing");
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
|
||||
|
||||
Reference in New Issue
Block a user