b9fb0dd720
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.
274 lines
12 KiB
C#
274 lines
12 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using System.Threading;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
|
using Xunit.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes;
|
|
|
|
/// <summary>
|
|
/// Live dev-rig smoke test for the alarms-over-gateway pipeline.
|
|
/// Exercises <see cref="WnWrapAlarmConsumer"/> + <see cref="AlarmDispatcher"/> +
|
|
/// <see cref="MxAccessAlarmEventSink"/> end-to-end against the actual
|
|
/// AVEVA System Platform install: subscribes to
|
|
/// <c>\\<machine>\Galaxy!DEV</c>, waits for at least one alarm
|
|
/// transition (the dev rig's flip script writes
|
|
/// <c>TestMachine_001.TestAlarm001</c> every 10s), drains the proto
|
|
/// <c>OnAlarmTransitionEvent</c> from the queue, then ack-by-name's
|
|
/// it and verifies the ack registers as a subsequent
|
|
/// <see cref="AlarmTransitionKind.Acknowledge"/> transition.
|
|
///
|
|
/// Skip-gated; flip <c>Skip=null</c> on the dev rig with the flip
|
|
/// script running.
|
|
/// </summary>
|
|
public sealed class AlarmsLiveSmokeTests
|
|
{
|
|
private static readonly string SubscriptionExpression =
|
|
$@"\\{Environment.MachineName}\Galaxy!DEV";
|
|
private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(45);
|
|
private static readonly TimeSpan TransitionWaitTimeout = TimeSpan.FromSeconds(20);
|
|
|
|
private const string SessionId = "alarms-live-smoke";
|
|
|
|
private readonly ITestOutputHelper output;
|
|
private readonly Stopwatch elapsed = Stopwatch.StartNew();
|
|
private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
|
|
|
|
/// <summary>Initializes a new instance of the AlarmsLiveSmokeTests class.</summary>
|
|
/// <param name="output">Test output helper for logging.</param>
|
|
public AlarmsLiveSmokeTests(ITestOutputHelper output)
|
|
{
|
|
this.output = output;
|
|
}
|
|
|
|
/// <summary>Verifies the alarm pipeline raises and acknowledges alarms correctly.</summary>
|
|
[Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + the alarm flip script running. Verified working 2026-05-01.")]
|
|
public void Alarms_FullPipelineRoundTrip_RaisesAndAcknowledges()
|
|
{
|
|
Exception? threadException = null;
|
|
var done = new ManualResetEventSlim(false);
|
|
var thread = new Thread(() =>
|
|
{
|
|
try { RunSmoke(); }
|
|
catch (Exception ex) { threadException = ex; }
|
|
finally { done.Set(); }
|
|
});
|
|
thread.IsBackground = false;
|
|
thread.SetApartmentState(ApartmentState.STA);
|
|
thread.Start();
|
|
done.Wait();
|
|
thread.Join();
|
|
|
|
output.WriteLine($"Captured {log.Count} log line(s):");
|
|
while (log.TryDequeue(out string? line))
|
|
{
|
|
output.WriteLine(line);
|
|
}
|
|
|
|
if (threadException != null)
|
|
{
|
|
throw threadException;
|
|
}
|
|
}
|
|
|
|
private void RunSmoke()
|
|
{
|
|
Log($"Subscription expression: {SubscriptionExpression}");
|
|
Log($"Pump duration: {PumpDuration.TotalSeconds:F0}s; transition wait timeout: {TransitionWaitTimeout.TotalSeconds:F0}s");
|
|
|
|
MxAccessEventQueue queue = new MxAccessEventQueue();
|
|
// The consumer owns no internal timer; we drive PollOnce manually
|
|
// from the STA below (the wnwrap COM is ThreadingModel=Apartment,
|
|
// and this test doesn't run a Win32 message pump on its STA).
|
|
WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(
|
|
new WNWRAPCONSUMERLib.wwAlarmConsumerClass(),
|
|
maxAlarmsPerFetch: 1024);
|
|
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
|
|
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
|
|
|
|
Log("Constructed consumer + sink + dispatcher.");
|
|
dispatcher.Subscribe(SubscriptionExpression);
|
|
Log("Subscribe -> ok. Driving PollOnce manually from this STA...");
|
|
|
|
// The wnwrap COM object is ThreadingModel=Apartment. The consumer
|
|
// owns no internal timer, so we drive PollOnce manually here on the
|
|
// STA. Production hosting routes polls through the worker's
|
|
// StaRuntime.
|
|
|
|
// 1. Wait for the first transition (any kind), then keep waiting
|
|
// for one with kind=Raise so the alarm is currently Active when
|
|
// we try to ack. AVEVA rejects acks of cleared alarms with -55,
|
|
// so we have to time the ack against the flip script's 10s
|
|
// cadence.
|
|
OnAlarmTransitionEvent? raiseBody = null;
|
|
DateTime raiseDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
|
|
while (DateTime.UtcNow < raiseDeadline && raiseBody is null)
|
|
{
|
|
WorkerEvent? evt = WaitForTransition(queue, TransitionWaitTimeout, "raise", consumer);
|
|
if (evt is null) break;
|
|
OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition;
|
|
Log("Transition: " + DescribeTransition(body));
|
|
Assert.Equal(SessionId, evt.Event.SessionId);
|
|
if (body.TransitionKind == AlarmTransitionKind.Raise)
|
|
{
|
|
raiseBody = body;
|
|
}
|
|
}
|
|
Assert.NotNull(raiseBody);
|
|
Assert.False(string.IsNullOrEmpty(raiseBody!.AlarmFullReference));
|
|
Assert.Contains("Galaxy", raiseBody.AlarmFullReference);
|
|
|
|
// 2. Snapshot the active set + verify the captured alarm is there.
|
|
var snapshot = dispatcher.SnapshotActiveAlarms(out _);
|
|
Log($"SnapshotActiveAlarms count={snapshot.Count}");
|
|
foreach (var s in snapshot)
|
|
{
|
|
Log(" active: " + DescribeSnapshot(s));
|
|
}
|
|
Assert.NotEmpty(snapshot);
|
|
Assert.Contains(snapshot, s => s.AlarmFullReference == raiseBody.AlarmFullReference);
|
|
|
|
// 3. Ack-by-name using the captured reference. Parse the reference
|
|
// via the same convention the gateway dispatcher uses
|
|
// (Provider!Group.Tag where the tag may contain dots).
|
|
Assert.True(TryParseReference(
|
|
raiseBody.AlarmFullReference,
|
|
out string provider, out string group, out string alarmName),
|
|
$"Captured reference '{raiseBody.AlarmFullReference}' did not parse as Provider!Group.Tag.");
|
|
Log($"Ack target: provider='{provider}' group='{group}' name='{alarmName}'");
|
|
|
|
// Try the ack with real Windows identity. AVEVA's AlarmAckByName
|
|
// may reject synthetic operator strings; using the current process
|
|
// identity gives the alarm-history a recognizable principal.
|
|
string realUser = Environment.UserName;
|
|
string realNode = Environment.MachineName;
|
|
string realDomain = Environment.UserDomainName ?? string.Empty;
|
|
Log($"Ack identity: user='{realUser}' node='{realNode}' domain='{realDomain}'");
|
|
|
|
int rc = dispatcher.AcknowledgeByName(
|
|
alarmName: alarmName,
|
|
providerName: provider,
|
|
groupName: group,
|
|
ackComment: "alarms-live-smoke ack",
|
|
ackOperatorName: realUser,
|
|
ackOperatorNode: realNode,
|
|
ackOperatorDomain: realDomain,
|
|
ackOperatorFullName: realUser);
|
|
Log($"AcknowledgeByName(real identity) -> rc={rc}");
|
|
|
|
Assert.Equal(0, rc);
|
|
|
|
// 4. Wait for the post-ack transition. With the alarm flipping every
|
|
// 10s and the consumer polling every 500ms, the next state
|
|
// change should be either kind=Acknowledge (the ack we just
|
|
// sent registered as a state delta UnackAlm → AckAlm) or the
|
|
// flip script's next Clear (UnackAlm → UnackRtn).
|
|
WorkerEvent? second = WaitForTransition(queue, TransitionWaitTimeout, "post-ack", consumer);
|
|
Assert.NotNull(second);
|
|
OnAlarmTransitionEvent secondBody = second!.Event.OnAlarmTransition;
|
|
Log("Post-ack transition: " + DescribeTransition(secondBody));
|
|
Assert.NotEqual(AlarmTransitionKind.Unspecified, secondBody.TransitionKind);
|
|
|
|
// 5. Pump a little longer to confirm the consumer keeps reporting
|
|
// transitions on the 10s flip cadence.
|
|
DateTime deadline = DateTime.UtcNow + PumpDuration;
|
|
int additional = 0;
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
consumer.PollOnce();
|
|
if (queue.TryDequeue(out WorkerEvent? evt) && evt is not null)
|
|
{
|
|
additional++;
|
|
OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition;
|
|
Log($" +{additional}: " + DescribeTransition(body));
|
|
}
|
|
Thread.Sleep(500);
|
|
}
|
|
Log($"Pump completed; additional transitions captured: {additional}.");
|
|
}
|
|
|
|
private WorkerEvent? WaitForTransition(
|
|
MxAccessEventQueue queue,
|
|
TimeSpan timeout,
|
|
string label,
|
|
WnWrapAlarmConsumer consumer)
|
|
{
|
|
DateTime deadline = DateTime.UtcNow + timeout;
|
|
int pollCount = 0;
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
try
|
|
{
|
|
consumer.PollOnce();
|
|
pollCount++;
|
|
if (pollCount == 1) Log("First PollOnce returned without throw.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log($"PollOnce threw on poll #{pollCount + 1}: {ex.GetType().Name}: {ex.Message}");
|
|
if (ex is System.Runtime.InteropServices.COMException ce)
|
|
{
|
|
Log($" HResult=0x{(uint)ce.HResult:X8}");
|
|
}
|
|
throw;
|
|
}
|
|
if (queue.TryDequeue(out WorkerEvent? evt) && evt is not null)
|
|
{
|
|
if (evt.Event.Family == MxEventFamily.OnAlarmTransition)
|
|
{
|
|
return evt;
|
|
}
|
|
Log($"Skipped non-alarm event (family={evt.Event.Family}) while waiting for {label}.");
|
|
}
|
|
Thread.Sleep(500);
|
|
}
|
|
Log($"Timed out waiting for {label} transition after {timeout.TotalSeconds:F0}s (poll count={pollCount}).");
|
|
return null;
|
|
}
|
|
|
|
private static bool TryParseReference(
|
|
string reference,
|
|
out string provider,
|
|
out string group,
|
|
out string alarmName)
|
|
{
|
|
provider = group = alarmName = string.Empty;
|
|
if (string.IsNullOrWhiteSpace(reference)) return false;
|
|
int bang = reference.IndexOf('!');
|
|
if (bang <= 0 || bang == reference.Length - 1) return false;
|
|
string left = reference.Substring(0, bang);
|
|
string right = reference.Substring(bang + 1);
|
|
int dot = right.IndexOf('.');
|
|
if (dot <= 0 || dot == right.Length - 1) return false;
|
|
provider = left;
|
|
group = right.Substring(0, dot);
|
|
alarmName = right.Substring(dot + 1);
|
|
return true;
|
|
}
|
|
|
|
private static string DescribeTransition(OnAlarmTransitionEvent body)
|
|
{
|
|
return string.Format(
|
|
"kind={0} ref='{1}' source='{2}' type='{3}' severity={4} operator='{5}' comment='{6}' ts={7:o}",
|
|
body.TransitionKind, body.AlarmFullReference, body.SourceObjectReference,
|
|
body.AlarmTypeName, body.Severity, body.OperatorUser, body.OperatorComment,
|
|
body.TransitionTimestamp?.ToDateTime() ?? DateTime.MinValue);
|
|
}
|
|
|
|
private static string DescribeSnapshot(ActiveAlarmSnapshot s)
|
|
{
|
|
return string.Format(
|
|
"ref='{0}' state={1} severity={2} operator='{3}' comment='{4}' ts={5:o}",
|
|
s.AlarmFullReference, s.CurrentState, s.Severity, s.OperatorUser,
|
|
s.OperatorComment,
|
|
s.LastTransitionTimestamp?.ToDateTime() ?? DateTime.MinValue);
|
|
}
|
|
|
|
private void Log(string line)
|
|
{
|
|
log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
|
|
}
|
|
}
|