fix(alarms): atomic snapshot+truncation read; direct tests for the flag plumbing (review)

Review found AlarmDispatcher.SnapshotActiveAlarms reading the snapshot and the
truncation verdict through two independent lock acquisitions, defended by a
comment claiming read-order made a race "widen only, never narrow". That claim
was false: a not-truncated -> truncated poll landing between the two reads pairs
a stale false with a capped snapshot, which is exactly the false all-clear the
feature exists to prevent. It was safe only because AlarmCommandHandler
STA-serializes consumer calls — an accident of the call graph, not an invariant.

Made the invariant structural instead of documented. IMxAccessAlarmConsumer now
exposes ONE accessor, `IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(
out bool truncated)`, which implementations must satisfy from a single
acquisition of the lock guarding the retained snapshot — mirroring the write
side, where FoldFetch already updates snapshot and verdict together. The
separate LastSnapshotTruncated property is gone from every layer, so there is no
second read left to pair badly. `out` over a result struct follows the file's
established idiom (FoldFetch, ParseSnapshotXml).

The same threading applies one level up: IAlarmCommandHandler.QueryActive now
carries `out bool snapshotTruncated`, so MxAccessCommandExecutor stamps the reply
payload from the value the records were stamped with rather than reading the
state a second time.

Direct tests for the three hops that were only covered end-to-end:
- AlarmDispatcherTests: truncated consumer snapshot stamps FromTruncatedSnapshot
  on every mapped record, with a complete-snapshot control, plus an assertion
  that the independent per-record Degraded flag is not dragged along.
- AlarmCommandHandlerTests: the verdict delegates through the dispatcher
  (Theory over both values), and survives a prefix filter that removes every
  record — the case the per-record flag cannot cover.
- AlarmCommandExecutorTests: the reply payload's SnapshotTruncated comes from the
  handler (Theory over both values), including the zero-record case.
The WnWrapAlarmConsumer truncation tests now assert through
SnapshotActiveAlarms(out ...) rather than an internal field, because the pairing
is the contract.

Also: GatewayAlarmMonitor's _snapshotTruncated comment now says "as of the last
full reconcile" rather than implying it tracks the current _alarms contents,
which live transitions keep moving via ApplyTransition between passes.

Detection heuristic still untouched (fetchedRecordCount >= maxAlarmsPerFetch);
no @COUNT parsing, per docs/AlarmProbeFindings.md. Still additive gateway
metadata about our fetch mechanics, not MXAccess behavior — not a parity
deviation, and no event is synthesized.

Gateway: NonWindows.slnx builds clean (0 warnings); ~Alarm filter 107/107 pass.
Worker + Worker.Tests are windev-gated; the signature change was reviewed by
inspection across all 7 IMxAccessAlarmConsumer implementers, all 3
IAlarmCommandHandler implementers, and every call site.
This commit is contained in:
Joseph Doherty
2026-08-17 04:39:33 -04:00
parent 7ec0b3594c
commit b9fb0dd720
18 changed files with 376 additions and 154 deletions
@@ -128,8 +128,8 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
/// <summary>
/// COM-free construction, for exercising the retained-snapshot state
/// machine (<see cref="FoldFetch"/> / <see cref="LastSnapshotTruncated"/>
/// / <see cref="SnapshotActiveAlarms"/>) on a machine without AVEVA
/// machine (<see cref="FoldFetch"/> / <see cref="SnapshotActiveAlarms"/>)
/// on a machine without AVEVA
/// installed. <see cref="Subscribe"/> throws and <see cref="PollOnce"/>
/// no-ops on an instance built this way — both need the wnwrap coclass,
/// which cannot be instantiated on the macOS/Linux test matrix. Internal
@@ -372,8 +372,11 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
/// <see cref="PollOnce"/>. Erring toward "still active" keeps the feed
/// at-least-once: a stale entry is repaired by the next sub-cap poll,
/// whereas a dropped one is broadcast as a Clear that never happened.
/// The snapshot and <paramref name="truncated"/> are produced under one
/// <c>syncRoot</c> acquisition, the same one <see cref="FoldFetch"/>
/// writes them both under, so no poll can interleave between them.
/// </remarks>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms()
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
{
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
lock (syncRoot)
@@ -387,22 +390,11 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
active.Add(record);
}
}
truncated = lastSnapshotTruncated;
return active;
}
}
/// <inheritdoc />
/// <remarks>
/// Read without the disposed guard <see cref="SnapshotActiveAlarms"/>
/// carries: this is degraded-status metadata a reply builder stamps
/// alongside a snapshot, and throwing from it would fail a query whose
/// snapshot half succeeded.
/// </remarks>
public bool LastSnapshotTruncated
{
get { lock (syncRoot) { return lastSnapshotTruncated; } }
}
/// <summary>
/// Sink for the rate-limited truncated-fetch warning. Defaults to
/// <see cref="Console.Error"/>, the stream
@@ -463,10 +455,11 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
/// <summary>
/// Folds one fetch into the retained state under a single lock: the
/// transition diff, the snapshot merge/replace, and the truncation
/// verdict move together. Splitting them would let a concurrent
/// <see cref="SnapshotActiveAlarms"/> / <see cref="LastSnapshotTruncated"/>
/// pair read a capped snapshot alongside the previous poll's "complete"
/// verdict — precisely the false all-clear the signal exists to prevent.
/// verdict move together. This is the write half of the pairing
/// <see cref="SnapshotActiveAlarms"/> reads; splitting either half would
/// let a reader see a capped snapshot alongside the previous poll's
/// "complete" verdict — precisely the false all-clear the signal exists
/// to prevent.
/// The verdict is replaced, never latched: a sub-cap fetch is complete
/// and restores absence authority, so leaving the flag set would strand
/// the operator banner on after a single burst.