fix(opcua): #855 review 3 — plain refresh skips removed conditions; per-hop failure isolation

- Both plain ConditionRefresh paths (Server object and folder) skip a
  condition a deploy removed or replaced between the off-Lock walk and the
  queueing (the SDK base did not either; only the cut path checked).
- A failure delivering at one notifier hop is logged and the walk continues
  to the remaining hops; a failure deciding the Server-object items withholds
  only them.
- Test: a condition removed during a plain refresh (folder + Server object).

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 12:51:03 -05:00
parent f775625805
commit 7783fadae1
2 changed files with 72 additions and 2 deletions
@@ -489,6 +489,49 @@ public sealed class FolderConditionRefreshWireTests
after.ShouldBeEmpty("and none of its deferred events follows");
}
/// <summary>
/// #855 review 3: the plain refresh path (nothing deferred — the only path production takes) collects live
/// conditions off <c>Lock</c> and queues them afterwards; a condition removed in between must not be replayed.
/// </summary>
/// <param name="serverObject">Refresh a Server-object item instead of a folder item.</param>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task A_condition_removed_during_a_plain_refresh_is_not_replayed(bool serverObject)
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Refresh855RemovePlain", encrypted: false, ct);
rig.KeyRoles = [];
rig.MaterialiseNative();
rig.Enforce(false, [ServerEventRig.Key()], KeyEquipmentGrant(EqA));
using var session = await rig.ConnectAsync(anonymous: false, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
var item = RecordedEvents.AddEventItem(subscription, serverObject ? ObjectIds.Server : ServerEventRig.UnsNodeId(session, EqA));
await subscription.ApplyChangesAsync(ct);
rig.Project(native: true, Active("seed"));
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "seed"));
var removed = 0;
void Remove(bool cut, bool _)
{
if (!cut && Interlocked.Exchange(ref removed, 1) == 0)
rig.Nm.RemoveAlarmConditionNode(ServerEventRig.RawAlarmPath, AddressSpaceRealm.Raw).ShouldBeTrue();
}
if (serverObject) rig.Nm.ServerObjectRefreshProbe = Remove;
else rig.Nm.DirectRefreshProbe = Remove;
await session.CallAsync(ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh, ct, subscription.Id);
await RecordedEvents.WaitUntilAsync(() => events.HasType(item, ObjectTypeIds.RefreshEndEventType));
rig.Nm.ServerObjectRefreshProbe = null;
rig.Nm.DirectRefreshProbe = null;
Volatile.Read(ref removed).ShouldBe(1, "the plain path ran");
Window(events.Of(item)).Replayed.ShouldBeEmpty("the condition was removed before the replay was queued");
}
/// <summary>
/// #855 review: a delivery that fails inline (here: a hop that throws) is over, not suspended — it must not be booked
/// as a deferral episode (which moved the episode a concurrent plain refresh checks, and cleared cuts under the