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:
+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