fix(alarms): SDK timed-unshelve timer is a no-op; commented re-ack comment is durable (#597)

Review R1 M2: since #597 the SDK timer fires at real expiry, and OnTimedUnshelve routed an operator-style
Unshelve by 'system' from both pair nodes, pre-empting the engine's AutoUnshelve. The engine owns expiry,
so the callback now routes nothing. Integration test: an engine stand-in that acts on a routed Unshelve and
expires late, plus a wrapped OnTimedUnshelve (positive control: the SDK timer fired).
Review R1 m1: Part9StateMachine.ApplyAcknowledge records a commented re-ack as AddComment (persisted and
replicated) instead of a NoOp; an uncommented re-ack is still a no-op.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 07:47:28 -04:00
parent 8a9bf23048
commit 83e9ffd27a
7 changed files with 140 additions and 71 deletions
@@ -263,14 +263,18 @@ public sealed class ConditionMethodEventTests
/// <summary>
/// A scripted TimedShelve expires: the engine's expiry comes back as an Unshelved re-projection, which
/// reports exactly one unshelve event after the one shelve event.
/// reports exactly one unshelve event after the one shelve event — and the SDK's own expiry timer routes
/// nothing (review R1 M2).
/// </summary>
/// <remarks>
/// The expiry is the engine's (computed from the command's <c>UnshelveAtUtc</c>). Since Gitea #597 the node
/// is shelved for the real duration, so the SDK's own timer fires at the same instant — but it only routes
/// an Unshelve back to the engine (ignored by this stand-in, a no-op on an unshelved engine alarm) and
/// changes no node state, so it adds no event. The stand-in engine here schedules its expiry from the real
/// <c>UnshelveAtUtc</c> the method handler computed.
/// is shelved for the real duration, so the SDK's own timer fires at that instant. It used to route an
/// operator-style <c>Unshelve</c> by <c>system</c>, which — because the real engine only checks expiry every
/// 5 s — pre-empted the engine's <c>AutoUnshelve</c> on every expiry. This stand-in engine therefore
/// (a) expires LATE, like the real one, so the SDK timer demonstrably fires first; and (b) ACTS on a routed
/// <c>Unshelve</c> as the real engine would, recording who unshelved it. The wrapped
/// <c>OnTimedUnshelve</c> is the positive control: the SDK timer did fire, and still nothing was routed and
/// the unshelve on record is the engine's own.
/// </remarks>
[Fact]
public async Task A_scripted_timed_shelve_expires_with_exactly_one_unshelve_event()
@@ -283,21 +287,47 @@ public sealed class ConditionMethodEventTests
rig.MaterialiseScripted();
var current = PreState("TimedShelve", native: false) with { Message = "base" };
Task? expiry = null;
var routed = new System.Collections.Concurrent.ConcurrentQueue<string>();
string? unshelvedBy = null;
var engineLag = TimeSpan.FromMilliseconds(1500); // the real engine's 5 s shelving-check granularity
rig.Nm.AlarmCommandRouter = cmd =>
{
routed.Enqueue(cmd.Operation);
if (cmd.Operation == "Unshelve")
{
// What the real engine does with a routed Unshelve: an operator-style unshelve.
unshelvedBy ??= $"Unshelve:{cmd.User}";
current = current with { Shelving = AlarmShelvingKind.Unshelved, UnshelveAtUtc = null };
rig.ProjectLater(native: false, current);
return;
}
if (cmd.Operation != "TimedShelve") return;
current = current with { Shelving = AlarmShelvingKind.Timed };
// The real engine's projection carries its expiry, so the re-projection keeps the SDK timer armed
// for the real remaining time (without it, it would re-arm for MaxTimeShelved and never fire here).
current = current with { Shelving = AlarmShelvingKind.Timed, UnshelveAtUtc = cmd.UnshelveAtUtc };
rig.ProjectLater(native: false, current);
var unshelveAt = cmd.UnshelveAtUtc!.Value;
expiry = Task.Run(async () =>
{
var wait = unshelveAt - DateTime.UtcNow;
var wait = unshelveAt + engineLag - DateTime.UtcNow;
if (wait > TimeSpan.Zero) await Task.Delay(wait);
current = current with { Shelving = AlarmShelvingKind.Unshelved };
if (current.Shelving != AlarmShelvingKind.Timed) return; // ApplyShelvingCheck: Timed only
unshelvedBy ??= "AutoUnshelve:system";
current = current with { Shelving = AlarmShelvingKind.Unshelved, UnshelveAtUtc = null };
rig.Project(native: false, current);
});
};
// Count the SDK's own expiry-timer firings without changing what the production handler does.
var condition = rig.Nm.TryGetAlarmCondition(ScriptedAlarmId)!;
var sdkTimerFired = 0;
var production = condition.OnTimedUnshelve!;
condition.OnTimedUnshelve = (context, c) =>
{
Interlocked.Increment(ref sdkTimerFired);
return production(context, c);
};
using var session = await rig.ConnectAsync(anonymous: false, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
@@ -315,6 +345,10 @@ public sealed class ConditionMethodEventTests
rig.Project(false, current = current with { Message = "sentinel" });
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
Volatile.Read(ref sdkTimerFired).ShouldBeGreaterThanOrEqualTo(1, "the SDK's timed-unshelve timer fired");
routed.ShouldNotContain("Unshelve", "the SDK timer must not route an operator-style Unshelve");
unshelvedBy.ShouldBe("AutoUnshelve:system", "the engine's own expiry owns the unshelve");
var shelving = events.ConditionEvents(item, "base").Select(e => e[RecordedEvents.ShelvingField].Value).ToList();
shelving.ShouldBe(
[ObjectIds.ShelvedStateMachineType_Unshelved, ObjectIds.ShelvedStateMachineType_TimedShelved,
@@ -268,19 +268,15 @@ public sealed class AlarmCommandRouterTests : IDisposable
await host.DisposeAsync();
}
/// <summary>OnTimedUnshelve fires with the SDK's system context (no session, no user identity) —
/// the real SDK path when a TimedShelve duration expires. The gate must NOT veto: the result must be
/// Good, the router must be invoked exactly once with Operation == "Unshelve", and no UnshelveAtUtc is
/// carried.
/// <para>
/// Regression (OpcUaServer-007): the routed command's <see cref="AlarmCommand.User"/> must be a
/// NON-EMPTY system user (<c>"system"</c>). The engine's <c>Part9StateMachine.ApplyUnshelve</c> rejects
/// a null/whitespace user with <c>ArgumentException("User required.")</c>; that exception is swallowed
/// downstream, so an empty user made the timed auto-unshelve SILENTLY no-op — a TimedShelve would never
/// auto-expire. Asserting a non-empty user here pins the value the state machine requires.
/// </para></summary>
/// <summary>OnTimedUnshelve fires with the SDK's system context (no session, no user identity) when the
/// SDK's own TimedShelve timer expires. It must return Good and route NOTHING (review R1 M2, Gitea #597):
/// the scripted engine owns timed-shelve expiry (its shelving check records an "AutoUnshelve" by
/// "system"), and since #597 arms the SDK timer for the real remaining time, an "Unshelve" routed from
/// here pre-empted that check on every expiry, from both pair nodes, as an operator-style unshelve.
/// <para>Positive control: on the same condition and router, a client Unshelve (OnShelve with
/// <c>shelving:false</c>) DOES route — so the empty capture is not a disconnected router.</para></summary>
[Fact]
public async Task OnTimedUnshelve_with_system_context_returns_good_and_routes_unshelve()
public async Task OnTimedUnshelve_with_system_context_returns_good_and_routes_nothing()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
@@ -299,14 +295,13 @@ public sealed class AlarmCommandRouterTests : IDisposable
var result = condition.OnTimedUnshelve!(ctx, condition);
result.ShouldBe(ServiceResult.Good);
captured.Count.ShouldBe(1);
var cmd = captured[0];
cmd.AlarmId.ShouldBe("alm-tu"); // == ScriptedAlarmId / condition NodeId identifier
cmd.Operation.ShouldBe("Unshelve");
// System-initiated (no client principal), but the user MUST be non-empty so ApplyUnshelve accepts it.
cmd.User.ShouldNotBeNullOrWhiteSpace();
cmd.User.ShouldBe("system");
cmd.UnshelveAtUtc.ShouldBeNull();
captured.ShouldBeEmpty();
// Positive control: the router is live for a genuine operator Unshelve on the same condition.
var operatorCtx = SessionContext(server, "grace", OpcUaDataPlaneRoles.AlarmAck);
condition.OnShelve!(operatorCtx, condition, shelving: false, oneShot: false, shelvingTime: 0.0)
.ShouldBe(ServiceResult.Good);
captured.Select(c => c.Operation).ShouldBe(["Unshelve"]);
await host.DisposeAsync();
}