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:
Joseph Doherty
2026-10-08 17:13:54 -04:00
parent 454775c67c
commit 8f5b265db8
4 changed files with 499 additions and 50 deletions
@@ -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; } = [];