fix(runtime): #869 review round 3 — key a native-ack revert to the ack that failed

A pending local ack can outlive its own delivery (sources that send no
Acknowledge back; a Clear now only carries it through), so a later failed
re-ack — or a stale Galaxy confirmation timing out after the alarm re-occurred
and was acked again — could revert an ack it does not own. MarkAcknowledged
now issues a sequence (0 when the ack changed nothing), carried on
RouteAlarmAck and echoed on AlarmAckFailed; TryRevertAcknowledge reverts only
on a matching sequence.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 13:25:58 -05:00
parent 5001e40690
commit b421c7ca91
7 changed files with 110 additions and 19 deletions
@@ -83,6 +83,25 @@ public sealed class DriverInstanceActorAckConfirmationTests : RuntimeActorTestBa
router.ExpectMsg<DriverInstanceActor.AlarmAckFailed>(Timeout, cancellationToken: TestContext.Current.CancellationToken).ConditionNodeId.ShouldBe("n-bad");
}
/// <summary>
/// lmxopcua#869 review — the failure echoes the routed ack's <see cref="DriverInstanceActor.RouteAlarmAck.AckSequence"/>,
/// which is what lets the host revert only the acknowledge that failed (keyed in
/// <c>NativeAlarmProjector.TryRevertAcknowledge(nodeId, ackSequence, …)</c>).
/// </summary>
[Fact]
public void A_failed_confirmation_echoes_the_ack_sequence()
{
var driver = new ConfirmingDriver();
var actor = SpawnConnected(driver);
driver.Confirmation = () => Task.FromException(new DriverOperationRefusedException("unconfirmed"));
var router = CreateTestProbe();
actor.Tell(Ack("n-seq") with { AckSequence = 42 }, router.Ref);
router.ExpectMsg<DriverInstanceActor.AlarmAckFailed>(Timeout, cancellationToken: TestContext.Current.CancellationToken)
.AckSequence.ShouldBe(42L);
}
private static DriverInstanceActor.RouteAlarmAck Ack(string conditionNodeId) =>
new("Tank.Hi", "c", "alice", ConditionNodeId: conditionNodeId);
@@ -630,6 +630,32 @@ public class NativeAlarmProjectorTests
sut.TryRevertAcknowledge("n1", out _).ShouldBeFalse("an Acknowledge transition states the ack — authoritative");
}
/// <summary>
/// lmxopcua#869 review — a pending ack can outlive its own delivery (a source that sends no Acknowledge back,
/// across a Clear that only carried it), so a revert is keyed to the failed ack's sequence: a later re-ack that
/// changed nothing (sequence 0) or a stale earlier ack's failure does not undo it. Positive control: the
/// matching sequence does.
/// </summary>
[Fact]
public void A_revert_only_undoes_the_acknowledge_that_failed()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
var first = sut.MarkAcknowledged("n1");
first.ShouldBeGreaterThan(0);
sut.Project("n1", Evt(AlarmTransitionKind.Clear)); // first ack delivered; nothing settles it
var reAck = sut.MarkAcknowledged("n1"); // a re-ack of the acked, cleared condition
reAck.ShouldBe(0L, "it changed nothing, so its failure has nothing to undo");
sut.TryRevertAcknowledge("n1", reAck, out _).ShouldBeFalse("a later re-ack's failure must not revert the first");
sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "again")); // a new occurrence (un-acks)
var second = sut.MarkAcknowledged("n1");
sut.TryRevertAcknowledge("n1", first, out _).ShouldBeFalse("a stale earlier ack's failure must not revert the newer one");
sut.TryRevertAcknowledge("n1", second, out var reverted).ShouldBeTrue();
reverted!.Acknowledged.ShouldBeFalse();
}
private static AlarmEventArgs Evt(
AlarmTransitionKind kind,
AlarmSeverity sev = AlarmSeverity.High,